From e425b05c34ccd3e835cc1ca98137dc1138e78447 Mon Sep 17 00:00:00 2001 From: Jay Gaikar Date: Tue, 19 May 2026 15:59:11 +0530 Subject: [PATCH] feat: add batch export support --- package.json | 1 + src/components/BatchExportPanel.tsx | 175 +++++++++++++++++++ src/components/DownloadResult.tsx | 139 +++++++++++++--- src/components/ExportOverlay.tsx | 24 ++- src/components/VideoEditor.tsx | 60 +++++-- src/hooks/useVideoEditor.ts | 250 +++++++++++++++++++++++++++- src/lib/ffmpeg.ts | 228 ++++++++++++++++--------- src/lib/types.ts | 13 +- 8 files changed, 758 insertions(+), 132 deletions(-) create mode 100644 src/components/BatchExportPanel.tsx diff --git a/package.json b/package.json index 67c82d5a..465cffdd 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "dependencies": { "@ffmpeg/ffmpeg": "^0.12.10", "@ffmpeg/util": "^0.12.2", + "fflate": "^0.8.2", "lottie-react": "^2.4.0", "lottie-web": "^5.12.2", "lucide-react": "^0.469.0", 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 51fedaad..904d61e1 100644 --- a/src/components/DownloadResult.tsx +++ b/src/components/DownloadResult.tsx @@ -1,18 +1,57 @@ "use client"; +import { useState } from "react"; import { ExportResult } from "@/lib/types"; import { formatBytes } from "@/lib/ffmpeg"; -import { Download, RotateCcw } from "lucide-react"; +import { Download, RotateCcw, Archive } from "lucide-react"; +import { zipSync } from "fflate"; import LottiePlayer from "./LottiePlayer"; import successAnim from "@/lib/lottie/success.json"; interface Props { - result: ExportResult; + result?: ExportResult | null; + batchResults?: ExportResult[] | null; onReset: () => void; } -export default function DownloadResult({ result, onReset }: Props) { - const filename = `reframe_${result.width}x${result.height}.${result.format}`; +function downloadBlob(blob: Blob, filename: string) { + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + a.rel = "noopener"; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); +} + +export default function DownloadResult({ result, batchResults, onReset }: Props) { + const [zipBusy, setZipBusy] = useState(false); + + const isBatch = Boolean(batchResults && batchResults.length > 0); + const items = isBatch ? batchResults! : result ? [result] : []; + + const handleZip = async () => { + if (!batchResults?.length) return; + setZipBusy(true); + try { + const files: Record = {}; + for (const r of batchResults) { + const name = r.filename ?? `reframe_${r.width}x${r.height}.${r.format}`; + const buf = await fetch(r.blobUrl).then((res) => res.arrayBuffer()); + files[name] = new Uint8Array(buf); + } + const zipped = zipSync(files, { level: 6 }); + downloadBlob(new Blob([zipped], { type: "application/zip" }), "reframe_batch_export.zip"); + } catch (e) { + console.error("zip failed:", e); + } finally { + setZipBusy(false); + } + }; + + if (items.length === 0) return null; return (
@@ -21,34 +60,84 @@ export default function DownloadResult({ result, onReset }: Props) {
-

Export complete

-

Ready to download

+

+ {isBatch ? "Batch export complete" : "Export complete"} +

+

+ {isBatch ? `${items.length} files ready` : "Ready to download"} +

-
-
-

Resolution

-

{result.width} x {result.height}

-
-
-

File size

-

{formatBytes(result.size)}

+ {!isBatch && result && ( +
+
+

Resolution

+

{result.width} x {result.height}

+
+
+

File size

+

{formatBytes(result.size)}

+
-
+ )} + + {isBatch && ( +
    + {items.map((r) => { + const name = r.filename ?? `reframe_${r.width}x${r.height}.${r.format}`; + return ( +
  • +
    +

    {name}

    +

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

    +
    + + + +
  • + ); + })} +
+ )} + +
+ {!isBatch && result && ( + + + Download {result.format.toUpperCase()} + + )} + + {isBatch && batchResults && batchResults.length > 1 && ( + + )} -
- - - Download {result.format.toUpperCase()} - + )}
); diff --git a/src/components/VideoEditor.tsx b/src/components/VideoEditor.tsx index 64d33929..884bec71 100644 --- a/src/components/VideoEditor.tsx +++ b/src/components/VideoEditor.tsx @@ -3,7 +3,7 @@ import { useVideoEditor } from "@/hooks/useVideoEditor"; import FileUpload from "./FileUpload"; import VideoPreview from "./VideoPreview"; -import PresetSelector from "./PresetSelector"; +import BatchExportPanel from "./BatchExportPanel"; import FramingControl from "./FramingControl"; import TrimControl from "./TrimControl"; import RotateControl from "./RotateControl"; @@ -12,7 +12,7 @@ import ExportSettings from "./ExportSettings"; import ExportOverlay from "./ExportOverlay"; import DownloadResult from "./DownloadResult"; import { - Layers, Crop, Scissors, RotateCw, Volume2, + Crop, Scissors, RotateCw, Volume2, SlidersHorizontal, Zap, AlertTriangle, Github } from "lucide-react"; @@ -44,15 +44,25 @@ function Section({ icon, title, children, delay = 0 }: SectionProps) { export default function VideoEditor() { const { file, duration, recipe, status, progress, - result, error, updateRecipe, - handleFileSelect, handleExport, reset, + result, batchResults, error, updateRecipe, + batchMode, batchPresetIds, batchProgress, + setBatchMode, toggleBatchPreset, + handleFileSelect, handleExport, cancelExport, reset, + acknowledgeCancelled, } = useVideoEditor(); const isProcessing = status === "loading-engine" || status === "exporting"; + const exportBlocked = + !file || isProcessing || (batchMode && batchPresetIds.length < 2); return (
- +
@@ -114,18 +124,40 @@ export default function VideoEditor() {
)} - {status === "done" && result && ( + {status === "done" && (result || (batchResults && batchResults.length > 0)) && (
- + +
+ )} + + {status === "cancelled" && ( +
+ +
+

Export cancelled

+

No files were exported.

+ +
)}
-
} title="Output size"> - -
+
} title="Framing" delay={100}> @@ -134,18 +166,18 @@ export default function VideoEditor() {
diff --git a/src/hooks/useVideoEditor.ts b/src/hooks/useVideoEditor.ts index 89856b98..6569c124 100644 --- a/src/hooks/useVideoEditor.ts +++ b/src/hooks/useVideoEditor.ts @@ -1,8 +1,20 @@ "use client"; -import { useState, useCallback } from "react"; -import { EditRecipe, ExportResult, ExportStatus, DEFAULT_RECIPE } from "@/lib/types"; -import { loadFFmpeg, exportVideo } from "@/lib/ffmpeg"; +import { useState, useCallback, useRef, useEffect } from "react"; +import { + EditRecipe, + ExportResult, + ExportStatus, + BatchExportProgress, + DEFAULT_RECIPE, +} from "@/lib/types"; +import { + loadFFmpeg, + exportVideo, + terminateFFmpegEngine, + buildExportFilename, +} from "@/lib/ffmpeg"; +import { PRESETS } from "@/lib/presets"; function getVideoDuration(file: File): Promise { return new Promise((resolve) => { @@ -21,6 +33,41 @@ function getVideoDuration(file: File): Promise { }); } +function isAbortLikeError(err: unknown): boolean { + if (err instanceof DOMException && err.name === "AbortError") return true; + if (err instanceof Error) { + if (err.name === "AbortError") return true; + // FFmpeg.wasm: "Message # 0 was aborted" + 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]; +} + export function useVideoEditor() { const [file, setFile] = useState(null); const [duration, setDuration] = useState(0); @@ -28,53 +75,230 @@ 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 [batchMode, setBatchMode] = useState(false); + const [batchPresetIds, setBatchPresetIds] = useState(() => + defaultBatchPresetIds(DEFAULT_RECIPE.preset) + ); + const [batchProgress, setBatchProgress] = useState(null); + + const exportAbortRef = useRef(null); + const recipeRef = useRef(recipe); + const batchPresetIdsRef = useRef(batchPresetIds); + const resultRef = useRef(result); + const batchResultsRef = useRef(batchResults); + + useEffect(() => { + recipeRef.current = recipe; + }, [recipe]); + useEffect(() => { + batchPresetIdsRef.current = batchPresetIds; + }, [batchPresetIds]); + useEffect(() => { + resultRef.current = result; + }, [result]); + useEffect(() => { + batchResultsRef.current = batchResults; + }, [batchResults]); const updateRecipe = useCallback((patch: Partial) => { setRecipe((prev) => ({ ...prev, ...patch })); }, []); + const setBatchModeWrapped = useCallback((enabled: boolean) => { + if (enabled) { + setBatchMode(true); + setBatchPresetIds((prev) => + prev.length >= 2 ? prev : defaultBatchPresetIds(recipeRef.current.preset) + ); + } else { + setBatchMode(false); + const first = batchPresetIdsRef.current[0]; + if (first) setRecipe((r) => ({ ...r, preset: first })); + } + }, []); + + 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]; + }); + }, []); + const handleFileSelect = useCallback(async (selectedFile: File) => { + revokeResultUrls(batchResultsRef.current); + if (resultRef.current?.blobUrl) URL.revokeObjectURL(resultRef.current.blobUrl); + setFile(selectedFile); setResult(null); + setBatchResults(null); setStatus("idle"); setError(null); + setBatchProgress(null); setRecipe((prev) => ({ ...prev, trimStart: 0, trimEnd: null })); const dur = await getVideoDuration(selectedFile); setDuration(dur); }, []); + const cancelExport = useCallback(() => { + exportAbortRef.current?.abort(); + terminateFFmpegEngine(); + }, []); + const handleExport = useCallback(async () => { if (!file) return; + const currentRecipe = recipeRef.current; + const ids = batchPresetIdsRef.current; + + if (batchMode && ids.length < 2) { + setError("Select at least two presets for batch export."); + setStatus("error"); + return; + } + + exportAbortRef.current?.abort(); + + const controller = new AbortController(); + exportAbortRef.current = controller; + const { signal } = controller; + + revokeResultUrls(batchResultsRef.current); + if (resultRef.current?.blobUrl) URL.revokeObjectURL(resultRef.current.blobUrl); + setResult(null); + setBatchResults(null); + setError(null); + setBatchProgress(null); + + const completed: ExportResult[] = []; + try { setStatus("loading-engine"); setProgress(0); - setError(null); - setResult(null); - const ffmpeg = await loadFFmpeg(); + const ffmpeg = await loadFFmpeg(signal); setStatus("exporting"); - const exportResult = await exportVideo(ffmpeg, file, recipe, setProgress); - setResult(exportResult); + if (!batchMode) { + const exportResult = await exportVideo(ffmpeg, file, currentRecipe, setProgress, { signal }); + const filename = buildExportFilename( + currentRecipe.preset, + exportResult.width, + exportResult.height, + exportResult.format + ); + setResult({ + ...exportResult, + filename, + presetId: currentRecipe.preset, + }); + setStatus("done"); + return; + } + + for (let i = 0; i < ids.length; i++) { + if (signal.aborted) break; + + const presetId = ids[i]; + const recipeForJob: EditRecipe = { ...currentRecipe, preset: presetId }; + const dims = + presetId === "custom" + ? { w: currentRecipe.customWidth, h: currentRecipe.customHeight } + : (() => { + const p = PRESETS.find((x) => x.id === presetId); + return { w: p?.width ?? 0, h: p?.height ?? 0 }; + })(); + const filename = buildExportFilename( + presetId, + Math.round((dims.w || 1920) / 2) * 2, + Math.round((dims.h || 1080) / 2) * 2, + "mp4" + ); + + setBatchProgress({ + current: i + 1, + total: ids.length, + filename, + }); + setProgress(0); + + const exportResult = await exportVideo(ffmpeg, file, recipeForJob, setProgress, { signal }); + const outFilename = buildExportFilename( + presetId, + exportResult.width, + exportResult.height, + exportResult.format + ); + + completed.push({ + ...exportResult, + filename: outFilename, + presetId, + }); + } + + if (signal.aborted) { + if (completed.length > 0) { + setBatchResults(completed); + setStatus("done"); + } else { + setStatus("cancelled"); + } + return; + } + + setBatchResults(completed); setStatus("done"); } catch (err) { + if (isAbortLikeError(err)) { + if (completed.length > 0) { + setBatchResults(completed); + setStatus("done"); + } else { + setStatus("cancelled"); + } + return; + } console.error("export failed:", err); + if (batchMode && completed.length > 0) { + setBatchResults(completed); + } setError(err instanceof Error ? err.message : "something went wrong"); setStatus("error"); + } finally { + setBatchProgress(null); + if (exportAbortRef.current === controller) { + exportAbortRef.current = null; + } } - }, [file, recipe]); + }, [file, batchMode]); const reset = useCallback(() => { + revokeResultUrls(batchResultsRef.current); + if (resultRef.current?.blobUrl) URL.revokeObjectURL(resultRef.current.blobUrl); + exportAbortRef.current?.abort(); + terminateFFmpegEngine(); + setFile(null); setDuration(0); setRecipe(DEFAULT_RECIPE); setStatus("idle"); setProgress(0); setResult(null); + setBatchResults(null); setError(null); + setBatchProgress(null); + setBatchMode(false); + setBatchPresetIds(defaultBatchPresetIds(DEFAULT_RECIPE.preset)); + }, []); + + const acknowledgeCancelled = useCallback(() => { + setStatus("idle"); }, []); return { @@ -84,10 +308,18 @@ export function useVideoEditor() { status, progress, result, + batchResults, error, + batchMode, + batchPresetIds, + batchProgress, updateRecipe, + setBatchMode: setBatchModeWrapped, + toggleBatchPreset, handleFileSelect, handleExport, + cancelExport, reset, + acknowledgeCancelled, }; } diff --git a/src/lib/ffmpeg.ts b/src/lib/ffmpeg.ts index 26ceb5c1..efe3bf7b 100644 --- a/src/lib/ffmpeg.ts +++ b/src/lib/ffmpeg.ts @@ -1,5 +1,6 @@ import { FFmpeg } from "@ffmpeg/ffmpeg"; import { fetchFile, toBlobURL } from "@ffmpeg/util"; +import type { ProgressEventCallback } from "@ffmpeg/ffmpeg"; import { EditRecipe, ExportResult } from "./types"; import { getPresetById } from "./presets"; @@ -8,14 +9,36 @@ const CORE_BASE_URL = let ffmpegInstance: FFmpeg | null = null; -export async function loadFFmpeg(): Promise { +export function terminateFFmpegEngine(): void { + if (!ffmpegInstance) return; + try { + ffmpegInstance.terminate(); + } catch { + /* worker may already be gone */ + } + ffmpegInstance = null; +} + +export async function loadFFmpeg(signal?: AbortSignal): Promise { if (ffmpegInstance) return ffmpegInstance; const ffmpeg = new FFmpeg(); - await ffmpeg.load({ - coreURL: await toBlobURL(`${CORE_BASE_URL}/ffmpeg-core.js`, "text/javascript"), - wasmURL: await toBlobURL(`${CORE_BASE_URL}/ffmpeg-core.wasm`, "application/wasm"), - }); + try { + await ffmpeg.load( + { + coreURL: await toBlobURL(`${CORE_BASE_URL}/ffmpeg-core.js`, "text/javascript"), + wasmURL: await toBlobURL(`${CORE_BASE_URL}/ffmpeg-core.wasm`, "application/wasm"), + }, + { signal } + ); + } catch (err) { + try { + ffmpeg.terminate(); + } catch { + /* noop */ + } + throw err; + } ffmpegInstance = ffmpeg; return ffmpeg; @@ -71,12 +94,36 @@ function buildAudioTrimFilter(recipe: EditRecipe): string { return `atrim=start=${recipe.trimStart}:end=${end},asetpts=PTS-STARTPTS`; } +export type ExportVideoOptions = { + signal?: AbortSignal; +}; + +export function buildExportFilename( + presetId: string, + _width: number, + _height: number, + format: "mp4" | "webm" +): string { + return `${presetId}.${format}`; +} + +async function safeDeleteFile(ffmpeg: FFmpeg, path: string): Promise { + try { + await ffmpeg.deleteFile(path); + } catch { + /* file may not exist if export failed mid-flight */ + } +} + export async function exportVideo( ffmpeg: FFmpeg, file: File, recipe: EditRecipe, - onProgress: (percent: number) => void + onProgressPercent: (percent: number) => void, + options?: ExportVideoOptions ): Promise { + const { signal } = options ?? {}; + let targetW: number, targetH: number; if (recipe.preset === "custom") { targetW = recipe.customWidth; @@ -87,94 +134,115 @@ export async function exportVideo( targetH = preset?.height ?? 1080; } - // dimensions must be even for libx264 targetW = Math.round(targetW / 2) * 2; targetH = Math.round(targetH / 2) * 2; const ext = file.name.split(".").pop() ?? "mp4"; - const inputName = `input.${ext}`; - const outputName = "output.mp4"; - - await ffmpeg.writeFile(inputName, await fetchFile(file)); - - ffmpeg.on("progress", ({ progress }) => { - onProgress(Math.min(99, Math.round(progress * 100))); - }); - - const vf = buildVideoFilter(recipe, targetW, targetH); - const audioTrim = buildAudioTrimFilter(recipe); - const audioSpeed = buildAudioFilter(recipe.speed); - const afParts = [audioTrim, audioSpeed].filter(Boolean); - const af = afParts.join(","); + const jobId = crypto.randomUUID().slice(0, 10); + const inputName = `in_${jobId}.${ext}`; + const outputName = `out_${jobId}.mp4`; + const webmOutput = `out_${jobId}.webm`; - const args = ["-i", inputName]; - if (vf) args.push("-vf", vf); - - if (!recipe.keepAudio) { - args.push("-an"); - } else if (af) { - args.push("-af", af); - } - - args.push( - "-c:v", "libx264", - "-crf", String(recipe.quality), - "-preset", "medium", - "-movflags", "+faststart" - ); - - if (recipe.keepAudio) { - args.push("-c:a", "aac", "-b:a", "128k"); - } - - args.push(outputName); - - const exitCode = await ffmpeg.exec(args); - - // fall back to webm if libx264 isnt available - if (exitCode !== 0) { - const webmOutput = "output.webm"; - const fallbackArgs = [ - "-i", inputName, - ...(vf ? ["-vf", vf] : []), - ...(recipe.keepAudio ? (af ? ["-af", af] : []) : ["-an"]), - "-c:v", "libvpx-vp9", - "-crf", String(recipe.quality), - ...(recipe.keepAudio ? ["-c:a", "libopus"] : []), - webmOutput, - ]; - - const fallbackCode = await ffmpeg.exec(fallbackArgs); - if (fallbackCode !== 0) throw new Error("Export failed"); + const onProgress: ProgressEventCallback = ({ progress }) => { + onProgressPercent(Math.min(99, Math.round(progress * 100))); + }; - const data = await ffmpeg.readFile(webmOutput); - const blob = new Blob([new Uint8Array(data as Uint8Array)], { type: "video/webm" }); - await ffmpeg.deleteFile(inputName); - await ffmpeg.deleteFile(webmOutput); + ffmpeg.on("progress", onProgress); + + try { + await ffmpeg.writeFile(inputName, await fetchFile(file), { signal }); + + const vf = buildVideoFilter(recipe, targetW, targetH); + const audioTrim = buildAudioTrimFilter(recipe); + const audioSpeed = buildAudioFilter(recipe.speed); + const afParts = [audioTrim, audioSpeed].filter(Boolean); + const af = afParts.join(","); + + const args = ["-i", inputName]; + if (vf) args.push("-vf", vf); + + if (!recipe.keepAudio) { + args.push("-an"); + } else if (af) { + args.push("-af", af); + } + + args.push( + "-c:v", + "libx264", + "-crf", + String(recipe.quality), + "-preset", + "medium", + "-movflags", + "+faststart" + ); - onProgress(100); + if (recipe.keepAudio) { + args.push("-c:a", "aac", "-b:a", "128k"); + } + + args.push(outputName); + + const exitCode = await ffmpeg.exec(args, undefined, { signal }); + + if (exitCode !== 0) { + const fallbackArgs = [ + "-i", + inputName, + ...(vf ? ["-vf", vf] : []), + ...(recipe.keepAudio ? (af ? ["-af", af] : []) : ["-an"]), + "-c:v", + "libvpx-vp9", + "-crf", + String(recipe.quality), + ...(recipe.keepAudio ? ["-c:a", "libopus"] : []), + webmOutput, + ]; + + const fallbackCode = await ffmpeg.exec(fallbackArgs, undefined, { signal }); + if (fallbackCode !== 0) throw new Error("Export failed"); + + const data = await ffmpeg.readFile(webmOutput, undefined, { signal }); + const blob = new Blob([new Uint8Array(data as Uint8Array)], { type: "video/webm" }); + await safeDeleteFile(ffmpeg, inputName); + await safeDeleteFile(ffmpeg, webmOutput); + + onProgressPercent(100); + return { + blobUrl: URL.createObjectURL(blob), + size: blob.size, + width: targetW, + height: targetH, + format: "webm", + }; + } + + const data = await ffmpeg.readFile(outputName, undefined, { signal }); + const blob = new Blob([new Uint8Array(data as Uint8Array)], { type: "video/mp4" }); + await safeDeleteFile(ffmpeg, inputName); + await safeDeleteFile(ffmpeg, outputName); + + onProgressPercent(100); return { blobUrl: URL.createObjectURL(blob), size: blob.size, width: targetW, height: targetH, - format: "webm", + format: "mp4", }; + } catch (err) { + await safeDeleteFile(ffmpeg, inputName); + await safeDeleteFile(ffmpeg, outputName); + await safeDeleteFile(ffmpeg, webmOutput); + throw err; + } finally { + try { + ffmpeg.off("progress", onProgress); + } catch { + /* noop */ + } } - - const data = await ffmpeg.readFile(outputName); - const blob = new Blob([new Uint8Array(data as Uint8Array)], { type: "video/mp4" }); - await ffmpeg.deleteFile(inputName); - await ffmpeg.deleteFile(outputName); - - onProgress(100); - return { - blobUrl: URL.createObjectURL(blob), - size: blob.size, - width: targetW, - height: targetH, - format: "mp4", - }; } export function formatBytes(bytes: number): string { diff --git a/src/lib/types.ts b/src/lib/types.ts index 726d1120..6905c445 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -17,6 +17,9 @@ export interface ExportResult { width: number; height: number; format: "mp4" | "webm"; + /** Present for batch exports — suggested download filename */ + filename?: string; + presetId?: string; } export type ExportStatus = @@ -24,7 +27,15 @@ export type ExportStatus = | "loading-engine" | "exporting" | "done" - | "error"; + | "error" + | "cancelled"; + +/** 1-based `current` index for batch queue UI */ +export interface BatchExportProgress { + current: number; + total: number; + filename: string; +} export const SPEED_STEPS = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 2, 4] as const;