diff --git a/src/components/BatchExportPanel.tsx b/src/components/BatchExportPanel.tsx new file mode 100644 index 00000000..6781f51f --- /dev/null +++ b/src/components/BatchExportPanel.tsx @@ -0,0 +1,175 @@ +"use client"; + +import { PRESETS } from "@/lib/presets"; +import { EditRecipe } from "@/lib/types"; +import PresetSelector from "./PresetSelector"; +import { Layers, SquareStack } from "lucide-react"; + +interface Props { + recipe: EditRecipe; + onRecipeChange: (patch: Partial) => void; + batchMode: boolean; + onBatchModeChange: (enabled: boolean) => void; + batchPresetIds: string[]; + onToggleBatchPreset: (presetId: string) => void; +} + +export default function BatchExportPanel({ + recipe, + onRecipeChange, + batchMode, + onBatchModeChange, + batchPresetIds, + onToggleBatchPreset, +}: Props) { + const selectable = PRESETS.filter((p) => p.id !== "custom"); + + return ( +
+
+
+ +
+

Batch export

+

+ Export the same edit to multiple sizes, one after another. +

+
+
+ +
+ + {!batchMode ? ( +
+
+ + + +

+ Output size +

+
+
+ +
+ ) : ( +
+

+ Select presets (2+) +

+
+ {selectable.map((preset) => { + const checked = batchPresetIds.includes(preset.id); + return ( + + ); + })} + + +
+ + {batchPresetIds.includes("custom") && ( +
+
+ + onRecipeChange({ customWidth: Number(e.target.value) })} + className="w-full text-sm px-3 py-1.5 border border-[var(--border)] rounded-md bg-[var(--bg)] font-heading focus:outline-none focus:ring-2 focus:ring-film-400 transition-shadow" + /> +
+ x +
+ + onRecipeChange({ customHeight: Number(e.target.value) })} + className="w-full text-sm px-3 py-1.5 border border-[var(--border)] rounded-md bg-[var(--bg)] font-heading focus:outline-none focus:ring-2 focus:ring-film-400 transition-shadow" + /> +
+
+ )} + + {batchPresetIds.length < 2 && ( +

+ Choose at least two presets to run a batch. +

+ )} +
+ )} +
+ ); +} diff --git a/src/components/DownloadResult.tsx b/src/components/DownloadResult.tsx index 35bb9460..602b97ef 100644 --- a/src/components/DownloadResult.tsx +++ b/src/components/DownloadResult.tsx @@ -3,7 +3,8 @@ import { useState, useEffect } from "react"; import { ExportResult } from "@/lib/types"; import { formatBytes } from "@/lib/utils"; -import { Download, RotateCcw, Share2, AlertCircle } from "lucide-react"; +import { Download, RotateCcw, Share2, AlertCircle, Archive } from "lucide-react"; +import JSZip from "jszip"; import LottiePlayer from "./LottiePlayer"; import successAnim from "@/lib/lottie/success.json"; import { cn } from "@/lib/utils"; @@ -12,33 +13,133 @@ const SHARE_TWEET_TEXT = "I just edited my video with @reframevideo — free browser-based video editor! Check it out: https://github.com/magic-peach/reframe"; interface Props { - result: ExportResult; + result?: ExportResult | null; + batchResults?: ExportResult[] | null; onReset: () => void; soundOnCompletion: boolean; } -export default function DownloadResult({ result, onReset, soundOnCompletion }: Props) { - const defaultName = `reframe_${result.width}x${result.height}`; +export default function DownloadResult({ + result, + batchResults, + onReset, + soundOnCompletion, +}: Props) { + const isBatch = Boolean(batchResults && batchResults.length > 0); + const [zipBusy, setZipBusy] = useState(false); + const defaultName = result ? `reframe_${result.width}x${result.height}` : ""; const [name, setName] = useState(defaultName); const invalidCharRegex = /[<>:"/\\|?*]/; const isValid = !invalidCharRegex.test(name) && name.trim().length > 0; - const filename = `${name.trim() || "untitled"}.${result.format}`; - + const filename = result ? `${name.trim() || "untitled"}.${result.format}` : ""; const shareHref = `https://x.com/intent/tweet?text=${encodeURIComponent(SHARE_TWEET_TEXT)}`; useEffect(() => { - if (soundOnCompletion) { + if (soundOnCompletion && (result || isBatch)) { const audio = new Audio("/sounds/export-complete.mp3"); audio.play().catch(console.error); } - }, [soundOnCompletion]); + }, [soundOnCompletion, result, isBatch]); + + useEffect(() => { + if (result) setName(`reframe_${result.width}x${result.height}`); + }, [result]); + const handleReset = () => { if (window.confirm("This will clear the current video and all settings. Continue?")) { onReset(); } }; + const handleZip = async () => { + if (!batchResults?.length) return; + setZipBusy(true); + try { + const zip = new JSZip(); + for (const r of batchResults) { + const fileName = r.filename ?? `reframe_${r.width}x${r.height}.${r.format}`; + const buf = await fetch(r.blobUrl).then((res) => res.arrayBuffer()); + zip.file(fileName, buf); + } + const blob = await zip.generateAsync({ type: "blob" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = "reframe_batch_export.zip"; + a.click(); + URL.revokeObjectURL(url); + } catch (e) { + console.error("zip failed:", e); + } finally { + setZipBusy(false); + } + }; + + if (isBatch && batchResults) { + return ( +
+
+
+ +
+
+

Batch export complete

+

{batchResults.length} files ready

+
+
+
    + {batchResults.map((r) => { + const fileName = r.filename ?? `reframe_${r.width}x${r.height}.${r.format}`; + return ( +
  • +
    +

    {fileName}

    +

    + {r.width}×{r.height} · {formatBytes(r.size)} · {r.format} +

    +
    + + + +
  • + ); + })} +
+
+ {batchResults.length > 1 && ( + + )} + +
+
+ ); + } + + if (!result) return null; + return (
@@ -110,7 +211,7 @@ export default function DownloadResult({ result, onReset, soundOnCompletion }: P if (!isValid) e.preventDefault(); }} > -
); -} \ No newline at end of file +} diff --git a/src/components/VideoEditor.tsx b/src/components/VideoEditor.tsx index f89872d5..cd14b02f 100644 --- a/src/components/VideoEditor.tsx +++ b/src/components/VideoEditor.tsx @@ -6,7 +6,7 @@ import { useVideoEditor } from "@/hooks/useVideoEditor"; import FileUpload from "./FileUpload"; import VideoPreview from "./VideoPreview"; import ThumbnailStrip from "./ThumbnailStrip"; -import PresetSelector from "./PresetSelector"; +import BatchExportPanel from "./BatchExportPanel"; import FramingControl from "./FramingControl"; import TrimControl from "./TrimControl"; import RotateControl from "./RotateControl"; @@ -50,9 +50,12 @@ function Section({ icon, title, children, delay = 0 }: SectionProps) { export default function VideoEditor() { const { - file, duration, recipe, status, progress, - result, error, updateRecipe, + file, duration, recipe, status, progress, + result, batchResults, error, updateRecipe, + batchMode, batchPresetIds, batchProgress, + setBatchMode, toggleBatchPreset, handleFileSelect, fileError, handleExport, cancelExport, reset, resetSettings, + acknowledgeCancelled, videoRef, seekTo, overlayFile, setOverlayFile, @@ -75,6 +78,8 @@ export default function VideoEditor() { }, [status]); const isProcessing = status === "loading-engine" || status === "exporting"; + const exportBlocked = + !file || isProcessing || (batchMode && batchPresetIds.length < 2); const videoSrc = useMemo( () => (file ? URL.createObjectURL(file) : null), @@ -89,7 +94,12 @@ export default function VideoEditor() { return (
- +
@@ -305,9 +315,31 @@ export default function VideoEditor() {
)} - {status === "done" && result && ( + {status === "done" && (result || (batchResults && batchResults.length > 0)) && (
- + +
+ )} + + {status === "cancelled" && ( +
+ +
+

Export cancelled

+

No files were exported.

+ +
)}
@@ -317,16 +349,22 @@ export default function VideoEditor() { isProcessing && "pointer-events-none opacity-50" )}>
-
} title="Output size"> - {recommendedPreset && ( -
-

- We detected a {recommendedPreset.label.replace(/\s/g, "")} video → Recommended: {recommendedPreset.platform.split("·")[0].trim()} ({recommendedPreset.label.replace(/\s/g, "")}) -

-
- )} - -
+ {recommendedPreset && !batchMode && ( +
+

+ We detected a {recommendedPreset.label.replace(/\s/g, "")} video → Recommended:{" "} + {recommendedPreset.platform.split("·")[0].trim()} ({recommendedPreset.label.replace(/\s/g, "")}) +

+
+ )} +
} title="Framing" delay={100}> @@ -347,19 +385,19 @@ export default function VideoEditor() { id="export-button" type="button" onClick={handleExport} - disabled={!file || isProcessing} - aria-label='Export video' - aria-disabled={!file || isProcessing ? "true" : undefined} + disabled={exportBlocked} + aria-label="Export video" + aria-disabled={exportBlocked ? "true" : undefined} className={cn( "w-full flex items-center justify-center gap-3 py-5 rounded-xl", "font-display text-2xl tracking-widest transition-all duration-200", - file && !isProcessing + !exportBlocked ? "bg-film-600 hover:bg-film-700 hover:scale-[1.01] text-white shadow-lg shadow-film-200 active:scale-[0.98] cursor-pointer" : "bg-[var(--border)] text-[var(--muted)] opacity-40 cursor-not-allowed" )} > - - {isProcessing ? "PROCESSING" : "EXPORT"} + + {isProcessing ? "PROCESSING" : batchMode ? "BATCH EXPORT" : "EXPORT"}
diff --git a/src/hooks/useVideoEditor.ts b/src/hooks/useVideoEditor.ts index a2283128..ec084193 100644 --- a/src/hooks/useVideoEditor.ts +++ b/src/hooks/useVideoEditor.ts @@ -1,13 +1,60 @@ "use client"; import { useState, useCallback, useEffect, useRef, useMemo } from "react"; -import { EditRecipe, ExportResult, ExportStatus, MAX_FILE_SIZE, OverlayPosition } from "@/lib/types"; +import { + EditRecipe, + ExportResult, + ExportStatus, + BatchExportProgress, + MAX_FILE_SIZE, + OverlayPosition, +} from "@/lib/types"; import { DEFAULT_RECIPE, SPEED_STEPS } from "@/lib/constants"; -import { getPresetById } from "@/lib/presets"; -import { loadFFmpeg, exportVideo, terminateFFmpeg, FFmpegLoadError } from "@/lib/ffmpeg"; +import { getPresetById, PRESETS } from "@/lib/presets"; +import { + loadFFmpeg, + exportVideo, + terminateFFmpeg, + FFmpegLoadError, + buildExportFilename, +} from "@/lib/ffmpeg"; import { suggestPreset } from "@/lib/presetSuggestion"; -const DEFAULT_TITLE = "Reframe — Resize, trim, and export videos in your browser"; +function isAbortLikeError(err: unknown): boolean { + if (err instanceof DOMException && err.name === "AbortError") return true; + if (err instanceof Error) { + if (err.name === "AbortError") return true; + if (/aborted/i.test(err.message)) return true; + } + return false; +} + +function revokeResultUrls(results: ExportResult[] | null | undefined) { + results?.forEach((r) => { + try { + URL.revokeObjectURL(r.blobUrl); + } catch { + /* noop */ + } + }); +} + +function defaultBatchPresetIds(currentPreset: string): string[] { + const ids = new Set(); + ids.add(currentPreset); + for (const p of PRESETS) { + if (p.id === "custom") continue; + if (ids.size >= 2) break; + if (!ids.has(p.id)) ids.add(p.id); + } + if (ids.size < 2) { + const fallback = PRESETS.find((p) => p.id !== "custom")?.id ?? "vertical-9-16"; + ids.add(fallback); + } + return [...ids]; +} + +const DEFAULT_TITLE = "Reframe ? Resize, trim, and export videos in your browser"; export function extractMetadata(file: File): Promise<{ width: number; height: number; duration: number }> { return new Promise((resolve, reject) => { @@ -131,8 +178,14 @@ export function useVideoEditor() { const [status, setStatus] = useState("idle"); const [progress, setProgress] = useState(0); const [result, setResult] = useState(null); + const [batchResults, setBatchResults] = useState(null); const [error, setError] = useState(null); const [fileError, setFileError] = useState(""); + const [batchMode, setBatchMode] = useState(false); + const [batchPresetIds, setBatchPresetIds] = useState(() => + defaultBatchPresetIds(DEFAULT_RECIPE.preset) + ); + const [batchProgress, setBatchProgress] = useState(null); const exportAbortControllerRef = useRef(null); const exportCancelledRef = useRef(false); const videoRef = useRef(null); @@ -151,6 +204,32 @@ export function useVideoEditor() { setRecipe((prev) => ({ ...prev, ...patch })); }, []); + const setBatchModeWrapped = useCallback((enabled: boolean) => { + if (enabled) { + setBatchMode(true); + setBatchPresetIds((prev) => + prev.length >= 2 ? prev : defaultBatchPresetIds(recipe.preset) + ); + } else { + setBatchMode(false); + setBatchPresetIds((prev) => { + const first = prev[0]; + if (first) setRecipe((r) => ({ ...r, preset: first })); + return prev; + }); + } + }, [recipe.preset]); + + const toggleBatchPreset = useCallback((presetId: string) => { + setBatchPresetIds((prev) => { + if (prev.includes(presetId)) { + if (prev.length <= 1) return prev; + return prev.filter((id) => id !== presetId); + } + return [...prev, presetId]; + }); + }, []); + useEffect(() => { try { const saved = localStorage.getItem("reframe-settings"); @@ -190,7 +269,11 @@ export function useVideoEditor() { }, [videoMetadata]); const handleFileSelect = useCallback(async (selectedFile: File) => { + revokeResultUrls(batchResults); + if (result?.blobUrl) URL.revokeObjectURL(result.blobUrl); setResult(null); + setBatchResults(null); + setBatchProgress(null); setStatus("idle"); setError(null); setFile(null); @@ -266,48 +349,146 @@ export function useVideoEditor() { return; } + if (batchMode && batchPresetIds.length < 2) { + setError("Select at least two presets for batch export."); + setStatus("error"); + return; + } + const abortController = new AbortController(); exportAbortControllerRef.current = abortController; exportCancelledRef.current = false; + revokeResultUrls(batchResults); + if (result?.blobUrl) URL.revokeObjectURL(result.blobUrl); + setResult(null); + setBatchResults(null); + setError(null); + setBatchProgress(null); + + const musicOpts = { + file: musicFile, + musicVolume, + originalAudioVolume, + loopMusic, + }; + const overlayOpts = { + file: overlayFile, + position: overlayPosition, + size: overlaySize, + opacity: overlayOpacity, + }; + + const completed: ExportResult[] = []; + try { setStatus("loading-engine"); setProgress(0); - setError(null); - if (result?.blobUrl) URL.revokeObjectURL(result.blobUrl); - setResult(null); const ffmpeg = await loadFFmpeg(abortController.signal); if (exportCancelledRef.current) return; setStatus("exporting"); - const exportResult = await exportVideo( - ffmpeg, - file, - recipe, - setProgress, - abortController.signal, - { - file: musicFile, - musicVolume, - originalAudioVolume, - loopMusic, - }, - { - file: overlayFile, - position: overlayPosition, - size: overlaySize, - opacity: overlayOpacity, + if (!batchMode) { + const exportResult = await exportVideo( + ffmpeg, + file, + recipe, + setProgress, + abortController.signal, + musicOpts, + overlayOpts + ); + if (exportCancelledRef.current) return; + + const filename = buildExportFilename( + recipe.preset, + exportResult.width, + exportResult.height, + exportResult.format + ); + setResult({ ...exportResult, filename, presetId: recipe.preset }); + setStatus("done"); + return; + } + + for (let i = 0; i < batchPresetIds.length; i++) { + if (abortController.signal.aborted || exportCancelledRef.current) break; + + const presetId = batchPresetIds[i]; + const recipeForJob: EditRecipe = { ...recipe, preset: presetId }; + const preset = getPresetById(presetId); + const w = + presetId === "custom" + ? recipe.customWidth + : (preset?.width ?? 1920); + const h = + presetId === "custom" + ? recipe.customHeight + : (preset?.height ?? 1080); + const labelFilename = buildExportFilename( + presetId, + Math.round(w / 2) * 2, + Math.round(h / 2) * 2, + recipe.format + ); + + setBatchProgress({ + current: i + 1, + total: batchPresetIds.length, + filename: labelFilename, + }); + setProgress(0); + + const exportResult = await exportVideo( + ffmpeg, + file, + recipeForJob, + setProgress, + abortController.signal, + musicOpts, + overlayOpts + ); + + const filename = buildExportFilename( + presetId, + exportResult.width, + exportResult.height, + exportResult.format + ); + completed.push({ ...exportResult, filename, presetId }); + } + + if (exportCancelledRef.current || abortController.signal.aborted) { + if (completed.length > 0) { + setBatchResults(completed); + setStatus("done"); + } else { + setStatus("cancelled"); } - ); - if (exportCancelledRef.current) return; + return; + } - setResult(exportResult); + setBatchResults(completed); setStatus("done"); - } catch (err) { + } catch (err) { if (exportCancelledRef.current) return; + if (isAbortLikeError(err)) { + if (completed.length > 0) { + setBatchResults(completed); + setStatus("done"); + } else { + setStatus("cancelled"); + } + return; + } + + if (batchMode && completed.length > 0) { + setBatchResults(completed); + } + console.error("export failed:", err); if (err instanceof FFmpegLoadError) { setError(err.message); @@ -321,11 +502,12 @@ export function useVideoEditor() { setStatus("error"); } finally { + setBatchProgress(null); if (exportAbortControllerRef.current === abortController) { exportAbortControllerRef.current = null; } } - }, [file, recipe, result, status, overlayFile, overlayPosition, overlaySize, overlayOpacity, duration]); + }, [file, recipe, result, batchResults, batchMode, batchPresetIds, status, musicFile, musicVolume, originalAudioVolume, loopMusic, overlayFile, overlayPosition, overlaySize, overlayOpacity, duration]); useEffect(() => { @@ -403,7 +585,12 @@ export function useVideoEditor() { }, []); + const acknowledgeCancelled = useCallback(() => { + setStatus("idle"); + }, []); + const reset = useCallback(() => { + revokeResultUrls(batchResults); if (result?.blobUrl) URL.revokeObjectURL(result.blobUrl); setFile(null); setVideoMetadata(null); @@ -412,8 +599,12 @@ export function useVideoEditor() { setStatus("idle"); setProgress(0); setResult(null); + setBatchResults(null); + setBatchProgress(null); + setBatchMode(false); + setBatchPresetIds(defaultBatchPresetIds(DEFAULT_RECIPE.preset)); setError(null); - }, [result]); + }, [result, batchResults]); useEffect(() => { if (process.env.NODE_ENV !== "development") return; @@ -445,15 +636,22 @@ export function useVideoEditor() { status, progress, result, + batchResults, error, + batchMode, + batchPresetIds, + batchProgress, videoRef, seekTo, updateRecipe, + setBatchMode: setBatchModeWrapped, + toggleBatchPreset, handleFileSelect, fileError, handleExport, cancelExport, reset, + acknowledgeCancelled, resetSettings, musicFile, setMusicFile, @@ -473,4 +671,4 @@ export function useVideoEditor() { setOverlayOpacity, recommendedPreset, }; -} \ No newline at end of file +} diff --git a/src/lib/ffmpeg.ts b/src/lib/ffmpeg.ts index beaeed1b..cdd32ccd 100644 --- a/src/lib/ffmpeg.ts +++ b/src/lib/ffmpeg.ts @@ -80,6 +80,17 @@ export function terminateFFmpeg() { ffmpegInstance = null; } +export const terminateFFmpegEngine = terminateFFmpeg; + +export function buildExportFilename( + presetId: string, + _width: number, + _height: number, + format: "mp4" | "webm" | "mkv" +): string { + return `${presetId}.${format}`; +} + function buildSessionId(): string { if (typeof crypto !== "undefined" && "randomUUID" in crypto) { return crypto.randomUUID(); @@ -428,4 +439,4 @@ export async function exportVideo( export function formatBytes(bytes: number): string { if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; -} \ No newline at end of file +} diff --git a/src/lib/types.ts b/src/lib/types.ts index bf167094..c7930520 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -43,6 +43,8 @@ export interface ExportResult { width: number; height: number; format: "mp4" | "webm" | "mkv"; + filename?: string; + presetId?: string; } export type ExportStatus = @@ -50,7 +52,14 @@ export type ExportStatus = | "loading-engine" | "exporting" | "done" - | "error"; + | "error" + | "cancelled"; + +export interface BatchExportProgress { + current: number; + total: number; + filename: string; +} export const SPEED_STEPS = [ 0.25,