diff --git a/docs/DATADOG.md b/docs/DATADOG.md
index 38f011b21..72e99746e 100644
--- a/docs/DATADOG.md
+++ b/docs/DATADOG.md
@@ -43,9 +43,10 @@ its own, so we can see which browser and device combinations fail.
| Event | When | Properties |
|---|---|---|
| `export_started` | Export clicked and settings validated | `format`, `resolution`, `input_size`, `clip_length`, `has_text`, `has_music`, `has_image_overlay`, `is_retry`, `retry_after` (retries only: the previous failure's category) |
-| `export_succeeded` | Terminal | the context above, plus `engine`, `durationMs` (click to done), `encode_ms`, `output_size` |
+| `export_succeeded` | Terminal | the context above, plus `engine`, `fell_back` (true after an engine fallback), `durationMs` (click to done), `encode_ms`, `output_size` |
| `export_failed` | Terminal | the context, plus `engine`, `durationMs`, `stage` (`engine` / `encode`), `category` |
| `export_cancelled` | Terminal: the user cancelled | the context, plus `engine`, `durationMs`, `stage` |
+| `engine_fallback` | The multi-threaded engine stalled; the same export restarts automatically on the single-threaded engine. Not terminal: the attempt's outcome follows | the context, plus `from: multi`, `to: single`, `stall_reason` |
| `ffmpeg_load_success` | A worker loaded the engine (once per worker) | `engine` (`multi` / `single`, what actually loaded), `engine_requested`, `single_reason` (`not_isolated` / `after_stall` / `mt_load_failed`, single-threaded only), `load_number` (`first` / `repeat`), `durationMs` |
| `ffmpeg_load_failure` | A worker failed to load (not sent when the user cancelled) | `engine_requested`, `load_number`, `category` (`network` / `integrity` / `wasm` / `worker_script` / `unknown`), `durationMs` |
| `export` (duration vital) | With `export_succeeded`; encoding time only | the context, plus `engine` |
diff --git a/src/components/ExportOverlay.tsx b/src/components/ExportOverlay.tsx
index bcc93210f..baf6c3f96 100644
--- a/src/components/ExportOverlay.tsx
+++ b/src/components/ExportOverlay.tsx
@@ -11,6 +11,8 @@ interface Props {
status: ExportStatus;
progress: number;
exportStartedAt?: number | null;
+ /** One line about a change mid-export, e.g. the engine fallback. */
+ notice?: string | null;
onCancel?: () => void;
}
@@ -21,7 +23,7 @@ function formatElapsed(ms: number): string {
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
}
-export default function ExportOverlay({ status, progress, exportStartedAt, onCancel }: Props) {
+export default function ExportOverlay({ status, progress, exportStartedAt, notice, onCancel }: Props) {
const visible = status === "loading-engine" || status === "exporting";
const previousFocusRef = useRef(null);
const focusAnchorRef = useRef(null);
@@ -124,6 +126,11 @@ export default function ExportOverlay({ status, progress, exportStartedAt, onCan
? "Downloading the video engine. This only happens once."
: "Processing your video locally."}
+ {notice && (
+
+ {notice}
+
+ )}
Do not close or refresh this tab
diff --git a/src/components/VideoEditor.tsx b/src/components/VideoEditor.tsx
index cfd582c57..17637669f 100644
--- a/src/components/VideoEditor.tsx
+++ b/src/components/VideoEditor.tsx
@@ -285,6 +285,7 @@ export default function VideoEditor() {
result,
error,
exportStartedAt,
+ exportNotice,
updateRecipe,
handleFileSelect,
fileError,
@@ -425,6 +426,7 @@ export default function VideoEditor() {
status={status}
progress={progress}
exportStartedAt={exportStartedAt}
+ notice={exportNotice}
onCancel={cancelExport}
/>
diff --git a/src/hooks/tests/exportTelemetry.hook.test.ts b/src/hooks/tests/exportTelemetry.hook.test.ts
index 1b09798e0..891b96a0d 100644
--- a/src/hooks/tests/exportTelemetry.hook.test.ts
+++ b/src/hooks/tests/exportTelemetry.hook.test.ts
@@ -89,6 +89,23 @@ describe("export reliability telemetry in the editor", () => {
expect(JSON.stringify(rum.addAction.mock.calls)).not.toContain("holiday-clip");
});
+ it("an automatic fallback to the single-threaded engine is one attempt, with a notice", async () => {
+ const { result } = await editorWithFile();
+ vi.mocked(exportVideo).mockImplementationOnce(async (...args) => {
+ const onEngineFallback = args[7] as (reason: "no_activity") => void;
+ onEngineFallback("no_activity");
+ return okResult;
+ });
+ await act(() => result.current.handleExport());
+ expect(lifecycle().map((e) => e.name)).toEqual(["export_started", "export_succeeded"]);
+ const fallback = rum.addAction.mock.calls.find(([name]) => name === "engine_fallback");
+ expect(fallback?.[1]).toMatchObject({ stall_reason: "no_activity", from: "multi", to: "single" });
+ expect(lifecycle()[1]!.ctx).toMatchObject({ engine: "single", fell_back: true });
+ expect(result.current.exportNotice).toMatch(/single-threaded engine/);
+ // The editor passes its known duration, so the export doesn't re-read it.
+ expect(vi.mocked(exportVideo).mock.calls[0]![6]).toBe(12);
+ });
+
it("a cancel followed by a late rejection is one cancellation, not a failure", async () => {
const { result } = await editorWithFile();
let reject!: (e: unknown) => void;
diff --git a/src/hooks/useVideoEditor.ts b/src/hooks/useVideoEditor.ts
index 020d334a8..8594bf189 100644
--- a/src/hooks/useVideoEditor.ts
+++ b/src/hooks/useVideoEditor.ts
@@ -314,6 +314,8 @@ export function useVideoEditor() {
const [error, setError] = useState(null);
const [fileError, setFileError] = useState("");
const [exportStartedAt, setExportStartedAt] = useState(null);
+ /** Shown during an export when something changed mid-way (engine fallback). */
+ const [exportNotice, setExportNotice] = useState(null);
const exportAbortControllerRef = useRef(null);
const exportCancelledRef = useRef(false);
/** The export attempt in progress, which reports its single outcome. */
@@ -725,6 +727,7 @@ export function useVideoEditor() {
try {
setStatus("loading-engine");
setProgress(0);
+ setExportNotice(null);
setError(null);
setExportStartedAt(null);
if (result?.blobUrl) URL.revokeObjectURL(result.blobUrl);
@@ -754,6 +757,12 @@ export function useVideoEditor() {
position: overlayPosition,
size: overlaySize,
opacity: overlayOpacity,
+ },
+ duration,
+ (reason) => {
+ // Same export, same edits, on the slower but sturdier engine.
+ attempt.engineFallback(reason);
+ setExportNotice("Switched to the single-threaded engine and started again. This takes longer.");
}
);
if (exportCancelledRef.current) return;
@@ -1120,6 +1129,7 @@ export function useVideoEditor() {
status,
progress,
exportStartedAt,
+ exportNotice,
result,
error,
videoRef,
diff --git a/src/lib/exportEstimate.ts b/src/lib/exportEstimate.ts
index bc4d335bd..aee5aa342 100644
--- a/src/lib/exportEstimate.ts
+++ b/src/lib/exportEstimate.ts
@@ -55,7 +55,7 @@ function resolutionMultiplier(width: number, height: number): number {
// ---------------------------------------------------------------------------
// Format overhead factor
-// MP4 and MKV are close; WebM (VP9) tends to produce slightly smaller files
+// MP4 and MKV are close; WebM (VP8) tends to produce slightly smaller files
// at the same CRF, so we apply a small discount.
// ---------------------------------------------------------------------------
function formatFactor(format: string | undefined): number {
diff --git a/src/lib/exportTelemetry.ts b/src/lib/exportTelemetry.ts
index 6d0397d0e..2076e8fff 100644
--- a/src/lib/exportTelemetry.ts
+++ b/src/lib/exportTelemetry.ts
@@ -7,9 +7,9 @@
* Event names and properties are documented in docs/DATADOG.md.
*/
import { failureCategory, trackAction, trackVital } from "./telemetry";
+import type { EngineMode } from "./ffmpegArgs";
-/** Which FFmpeg core the worker loaded. */
-export type EngineMode = "multi" | "single";
+export type { EngineMode };
export type ExportStage = "engine" | "encode";
@@ -22,6 +22,9 @@ export type ExportFailureCategory =
| "network"
| "encode_error";
+/** Which watchdog rule stopped a stalled export (see ExportStalledError). */
+export type StallReason = "no_activity" | "no_progress" | "overrun";
+
export type EngineLoadFailureCategory = "network" | "integrity" | "wasm" | "worker_script" | "unknown";
/** Why a load ended up single-threaded. */
@@ -86,6 +89,7 @@ export class ExportAttempt {
private readonly startedAt = now();
private encodeStartedAt: number | null = null;
private engine: EngineMode | "none" = "none";
+ private fellBack = false;
constructor(
private readonly context: ExportContext,
@@ -109,10 +113,23 @@ export class ExportAttempt {
this.encodeStartedAt = now();
}
+ /**
+ * The multi-threaded engine stalled and this same attempt is being retried
+ * on the single-threaded one. The attempt keeps going: its one outcome is
+ * reported when the retry ends, with engine "single" and fell_back true.
+ */
+ engineFallback(reason: StallReason): void {
+ if (this.settled) return;
+ trackAction("engine_fallback", { ...this.context, from: "multi", to: "single", stall_reason: reason });
+ this.engine = "single";
+ this.fellBack = true;
+ }
+
private common() {
return {
...this.context,
engine: this.engine,
+ fell_back: this.fellBack,
is_retry: this.retryOf !== null,
durationMs: Math.round(now() - this.startedAt),
};
diff --git a/src/lib/ffmpeg.ts b/src/lib/ffmpeg.ts
index 497646131..c347cba01 100644
--- a/src/lib/ffmpeg.ts
+++ b/src/lib/ffmpeg.ts
@@ -1,8 +1,10 @@
import { EditRecipe, ExportResult, BackgroundMusicOptions, ImageOverlayOptions, MAX_FILE_SIZE } from "./types";
-import { getPresetById } from "./presets";
-import { buildTextFilter } from "./text-overlay";
import { trackTiming } from "./telemetry";
-import { startEngineLoad, type EngineMode } from "./exportTelemetry";
+import { startEngineLoad, type StallReason } from "./exportTelemetry";
+import type { EngineMode } from "./ffmpegArgs";
+
+// The command line itself is built in ffmpegArgs.ts and run by the worker.
+export { buildVideoFilter, buildAudioFilter } from "./ffmpegArgs";
export class FFmpegLoadError extends Error {
name = "FFmpegLoadError";
@@ -12,6 +14,29 @@ export class FFmpegLoadError extends Error {
}
}
+/** The watchdog stopped an export that wasn't going to finish. */
+export class ExportStalledError extends Error {
+ name = "ExportStalledError";
+ constructor(
+ readonly mode: EngineMode,
+ readonly reason: StallReason = "no_activity"
+ ) {
+ super(
+ mode === "multi"
+ ? "The export stopped responding. Press Retry to try again with the single-threaded engine, which is slower but more reliable."
+ : "The export stopped responding. Press Retry to try again, or try a shorter clip or a lower resolution."
+ );
+ }
+}
+
+/**
+ * FFmpeg prints a stats line about every 0.5 s while encoding and the worker
+ * forwards a heartbeat at most every 2 s, so a minute of silence means the
+ * run is wedged (the multi-threaded core's thread-pool deadlock looked
+ * exactly like this: stuck at 0% forever). Fail instead of spinning.
+ */
+export const STALL_TIMEOUT_MS = 60_000;
+
type SerializedFile = {
name: string;
@@ -31,7 +56,8 @@ type WorkerExportRequest = {
overlayOptions?: ImageOverlayOptions;
};
-type WorkerLoadResponse = { type: "ready"; mode?: EngineMode };
+type WorkerLoadResponse = { type: "ready"; mode: EngineMode };
+type WorkerHeartbeatResponse = { type: "heartbeat" };
type WorkerProgressResponse = { type: "progress"; percent: number };
type WorkerResultResponse = {
type: "result";
@@ -48,6 +74,7 @@ type WorkerCancelledResponse = { type: "cancelled"; id?: string };
type WorkerResponse =
| WorkerLoadResponse
+ | WorkerHeartbeatResponse
| WorkerProgressResponse
| WorkerResultResponse
| WorkerErrorResponse
@@ -63,10 +90,16 @@ let pendingExport: {
reject: (reason: unknown) => void;
} | null = null;
let pendingProgress: ((percent: number) => void) | null = null;
-
+/** Called on any sign of life from the worker during an export. */
+let pendingActivity: (() => void) | null = null;
/** The core the current worker loaded, once it is ready. */
let engineMode: EngineMode | null = null;
-/** Telemetry for the engine load in progress, if any (one outcome per worker). */
+/**
+ * Set after a multi-threaded export stalls: later workers in this page
+ * session load the single-threaded core even though the page is isolated.
+ */
+let preferSingleThread = false;
+/** Telemetry for the load in progress, if any. */
let engineLoad: ReturnType | null = null;
function createWorker(): Worker {
@@ -96,12 +129,30 @@ function createWorker(): Worker {
return ffmpegWorker;
}
+/**
+ * Wraps a progress callback so it only fires when the rounded percentage
+ * changes. FFmpeg reports progress per encoded frame; forwarding every event
+ * re-rendered the editor hundreds of times per export for no visible change.
+ */
+export function dedupeProgress(onProgress?: (percent: number) => void) {
+ if (!onProgress) return undefined;
+ let last = -1;
+ return (percent: number) => {
+ const rounded = Math.max(0, Math.min(100, Math.round(percent)));
+ if (rounded === last) return;
+ last = rounded;
+ onProgress(rounded);
+ };
+}
+
function resetWorker() {
// A load that never finished was abandoned (cancelled), not failed.
engineLoad?.abandoned();
engineLoad = null;
- engineMode = null;
+ ffmpegWorker?.terminate();
ffmpegWorker = null;
+ engineMode = null;
+ pendingActivity = null;
workerReady = null;
workerReadyResolve = null;
workerReadyReject = null;
@@ -123,7 +174,13 @@ function handleWorkerMessage(event: MessageEvent) {
return;
}
+ if (data.type === "heartbeat") {
+ pendingActivity?.();
+ return;
+ }
+
if (data.type === "progress") {
+ pendingActivity?.();
pendingProgress?.(data.percent);
return;
}
@@ -158,13 +215,14 @@ function handleWorkerMessage(event: MessageEvent) {
return;
}
- const loadError = new FFmpegLoadError(data.message);
- if (workerReadyReject) engineLoad?.failed(loadError);
+ // An error without a matching id means the worker itself failed. Settle
+ // whatever is waiting on it — an export left pending here used to keep
+ // the UI on "Exporting" forever.
+ const error = new FFmpegLoadError(data.message);
+ if (workerReadyReject) engineLoad?.failed(error);
engineLoad = null;
- workerReadyReject?.(loadError);
- workerReady = null;
- workerReadyResolve = null;
- workerReadyReject = null;
+ workerReadyReject?.(error);
+ pendingExport?.reject(error);
resetWorker();
return;
}
@@ -202,14 +260,20 @@ export async function loadFFmpeg(
// 2. Use the captured flag to securely trigger the worker's internal load phase
if (isFirstLoad) {
const isolated = typeof crossOriginIsolated !== "undefined" && crossOriginIsolated;
- engineLoad = startEngineLoad(isolated ? "multi" : "single", isolated ? null : "not_isolated");
- ffmpegWorker!.postMessage({ type: "load" });
+ engineLoad = startEngineLoad(
+ isolated && !preferSingleThread ? "multi" : "single",
+ !isolated ? "not_isolated" : preferSingleThread ? "after_stall" : null
+ );
+ ffmpegWorker!.postMessage({ type: "load", singleThread: preferSingleThread });
}
- pendingProgress = onProgress ?? null;
+ pendingProgress = dedupeProgress(onProgress) ?? null;
+ // A load can't be interrupted inside the worker, so cancelling one
+ // discards the worker; the next attempt starts a fresh one (the core files
+ // come back from the HTTP cache).
if (signal?.aborted) {
- ffmpegWorker?.postMessage({ type: "cancel" });
+ terminateFFmpeg();
throw new DOMException("Aborted", "AbortError");
}
@@ -218,8 +282,7 @@ export async function loadFFmpeg(
};
const onAbort = () => {
- ffmpegWorker?.postMessage({ type: "cancel" });
- workerReadyReject?.(new DOMException("Aborted", "AbortError"));
+ terminateFFmpeg();
cleanup();
};
@@ -227,7 +290,7 @@ export async function loadFFmpeg(
try {
// ffmpeg_load_success / ffmpeg_load_failure are reported once per worker
- // by the message handlers, however many callers wait on the same load.
+ // by handleWorkerMessage, however many callers wait on the same load.
await workerReady;
if (isFirstLoad) trackTiming("ffmpeg_ready");
} finally {
@@ -241,17 +304,47 @@ function cancelPendingExport(reason?: unknown) {
pendingExport = null;
}
pendingProgress = null;
+ pendingActivity = null;
}
+/**
+ * Exports `file` with `recipe`. If the multi-threaded engine stalls, the same
+ * export (same file and edits) is retried once on the single-threaded engine,
+ * and `onEngineFallback` is told why before the retry starts.
+ */
export async function exportVideo(
file: File,
recipe: EditRecipe,
onProgress: (percent: number) => void,
signal?: AbortSignal,
musicOptions?: BackgroundMusicOptions,
- overlayOptions?: ImageOverlayOptions
+ overlayOptions?: ImageOverlayOptions,
+ knownDuration?: number,
+ onEngineFallback?: (reason: StallReason) => void
): Promise {
- await loadFFmpeg(signal, onProgress);
+ try {
+ return await exportOnce(file, recipe, onProgress, signal, musicOptions, overlayOptions, knownDuration);
+ } catch (err) {
+ if (!(err instanceof ExportStalledError) || err.mode !== "multi" || signal?.aborted) throw err;
+ // The stall set preferSingleThread, so this loads the single-threaded core.
+ // The file is read again: its first copy was transferred to the old worker.
+ onEngineFallback?.(err.reason);
+ onProgress(0);
+ return exportOnce(file, recipe, onProgress, signal, musicOptions, overlayOptions, knownDuration);
+ }
+}
+
+async function exportOnce(
+ file: File,
+ recipe: EditRecipe,
+ onProgress: (percent: number) => void,
+ signal?: AbortSignal,
+ musicOptions?: BackgroundMusicOptions,
+ overlayOptions?: ImageOverlayOptions,
+ /** Duration already read by the editor; skips a second metadata load. */
+ knownDuration?: number
+): Promise {
+ await loadFFmpeg(signal);
if (!ffmpegWorker) {
throw new Error("FFmpeg worker is not available.");
@@ -300,7 +393,7 @@ export async function exportVideo(
? { ...overlayOptions, file: null }
: undefined;
- pendingProgress = onProgress;
+ pendingProgress = dedupeProgress(onProgress) ?? null;
const exportPromise = new Promise((resolve, reject) => {
pendingExport = { id: sessionId, resolve, reject };
@@ -318,6 +411,21 @@ export async function exportVideo(
};
signal?.addEventListener("abort", onAbort, { once: true });
+ const mode = engineMode ?? "single";
+ let lastActivity = Date.now();
+ pendingActivity = () => {
+ lastActivity = Date.now();
+ };
+ const watchdog = setInterval(() => {
+ if (Date.now() - lastActivity < STALL_TIMEOUT_MS) return;
+ clearInterval(watchdog);
+ if (mode === "multi") preferSingleThread = true;
+ const stalled = new ExportStalledError(mode);
+ cancelPendingExport(stalled);
+ // The worker is wedged and can't be told to stop; discard it.
+ resetWorker();
+ }, 5_000);
+
const transfers: Transferable[] = [arrayBuffer];
if (musicFilePayload) transfers.push(musicFilePayload.data);
if (overlayFilePayload) transfers.push(overlayFilePayload.data);
@@ -328,7 +436,10 @@ export async function exportVideo(
id: sessionId,
file: filePayload,
recipe,
- videoDuration: await getVideoDuration(file),
+ videoDuration:
+ knownDuration && Number.isFinite(knownDuration) && knownDuration > 0
+ ? knownDuration
+ : await getVideoDuration(file),
musicFile: musicFilePayload,
musicOptions: sanitizedMusicOptions,
overlayFile: overlayFilePayload,
@@ -340,6 +451,8 @@ export async function exportVideo(
try {
return await exportPromise;
} finally {
+ clearInterval(watchdog);
+ pendingActivity = null;
signal?.removeEventListener("abort", onAbort);
}
}
@@ -347,30 +460,52 @@ export async function exportVideo(
async function getVideoDuration(file: File): Promise {
return new Promise((resolve) => {
const video = document.createElement("video");
- video.preload = "metadata";
- video.onloadedmetadata = () => {
- URL.revokeObjectURL(video.src);
- resolve(video.duration);
+ const url = URL.createObjectURL(file);
+ const finish = (value: number) => {
+ clearTimeout(timer);
+ URL.revokeObjectURL(url);
+ video.removeAttribute("src");
+ resolve(Number.isFinite(value) ? value : 0);
};
- video.onerror = () => resolve(0);
- video.src = URL.createObjectURL(file);
+ // 0 makes the worker omit -t, so a stalled metadata read degrades safely.
+ const timer = setTimeout(() => finish(0), 5000);
+ video.preload = "metadata";
+ video.onloadedmetadata = () => finish(video.duration);
+ video.onerror = () => finish(0);
+ video.src = url;
});
}
-/** The core the worker loaded ("multi" or "single"), or null before it is ready. */
-export function getEngineMode(): EngineMode | null {
- return engineMode;
-}
-
export function terminateFFmpeg() {
if (ffmpegWorker) {
ffmpegWorker.postMessage({ type: "terminate" });
ffmpegWorker.terminate();
}
+ // Settle a load that is still in flight so its caller never hangs.
+ workerReadyReject?.(new DOMException("Export cancelled", "AbortError"));
cancelPendingExport(new DOMException("Export cancelled", "AbortError"));
resetWorker();
}
+/** Test/diagnostic helper: whether a worker or export is currently held. */
+export function getFFmpegClientState() {
+ return {
+ hasWorker: ffmpegWorker !== null,
+ hasPendingExport: pendingExport !== null,
+ hasPendingProgress: pendingProgress !== null,
+ };
+}
+
+/** The loaded core ("multi" | "single"), or null before the engine is ready. */
+export function getEngineMode(): EngineMode | null {
+ return engineMode;
+}
+
+/** Test helper: forget the single-thread preference set by a stall. */
+export function resetEnginePreferenceForTests() {
+ preferSingleThread = false;
+}
+
function buildSessionId(): string {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
return crypto.randomUUID();
@@ -398,268 +533,6 @@ function buildSessionId(): string {
return `${timestamp}-${randomPart}${counterPart}`;
}
-export function buildVideoFilter(recipe: EditRecipe, targetW: number, targetH: number): string {
- const filters: string[] = [];
-
- if (recipe.trimStart > 0 || recipe.trimEnd !== null) {
- // Only use trim filter with precise bounds to avoid scanning entire file
- // If trimEnd is null, use duration parameter instead of large placeholder value
- if (recipe.trimEnd !== null) {
- filters.push(`trim=start=${recipe.trimStart}:end=${recipe.trimEnd}`);
- } else if (recipe.trimStart > 0) {
- // When only trimStart is set, let FFmpeg infer end (don't use 999999 placeholder)
- filters.push(`trim=start=${recipe.trimStart}`);
- }
- }
-
- if (recipe.stabilization) {
- filters.push("deshake");
- }
-
- if (recipe.rotate === 90) {
- filters.push("transpose=1");
- } else if (recipe.rotate === 180) {
- filters.push("transpose=1,transpose=1");
- } else if (recipe.rotate === 270) {
- filters.push("transpose=2");
- }
-
- if (recipe.framing === "fit") {
- filters.push(
- `scale=${targetW}:${targetH}:force_original_aspect_ratio=decrease`,
- `pad=${targetW}:${targetH}:(ow-iw)/2:(oh-ih)/2:color=black`
- );
- } else {
- filters.push(
- `scale=${targetW}:${targetH}:force_original_aspect_ratio=increase`,
- `crop=${targetW}:${targetH}`
- );
- }
- if (recipe.sharpness !== 0) {
- filters.push(`unsharp=5:5:${recipe.sharpness}:5:5:0.0`);
- }
-
- // Normalize timestamps only when needed — trim or speed change both
- // require a clean 0-based timeline to produce correct output duration.
- if (recipe.trimStart > 0 || recipe.trimEnd !== null || recipe.speed !== 1) {
- filters.push("setpts=PTS-STARTPTS");
- }
-
- if (recipe.speed !== 1) {
- const pts = (1 / recipe.speed).toFixed(4);
- filters.push(`setpts=${pts}*PTS`);
- }
-
- if (recipe.denoise) {
- filters.push("hqdn3d=1.5:1.5:6:6");
- }
-
- const needsEq =
- recipe.brightness !== 0 ||
- recipe.contrast !== 1 ||
- recipe.saturation !== 1;
-
- if (needsEq) {
- filters.push(
- `eq=brightness=${recipe.brightness}:contrast=${recipe.contrast}:saturation=${recipe.saturation}`
- );
- }
-
- // Add text overlays
- const textOverlays = recipe.textOverlays || [];
- textOverlays.forEach((overlay) => {
- filters.push(buildTextFilter(overlay, targetW, targetH));
- });
-
- return filters.join(",");
-}
-
-export function buildAudioFilter(recipe: EditRecipe): string {
- if (recipe.speed <= 0) return "";
- const filters: string[] = [];
-
- let remaining = recipe.speed;
- while (remaining < 0.5) {
- filters.push("atempo=0.5");
- remaining /= 0.5;
- }
-
- while (remaining > 2.0) {
- filters.push("atempo=2.0");
- remaining /= 2.0;
- }
-
- if (Math.abs(remaining - 1.0) > 0.001) {
- filters.push(`atempo=${Number(remaining.toFixed(4))}`);
- }
-
- if (recipe.volume !== undefined && recipe.volume !== 100) {
- filters.push(`volume=${(recipe.volume / 100).toFixed(2)}`);
- }
-
- if (recipe.normalizeAudio) filters.push("loudnorm=I=-14:TP=-1.5:LRA=11");
-
- return filters.join(",");
-}
-
-function buildAudioTrimFilter(recipe: EditRecipe): string {
- if (recipe.trimStart === 0 && recipe.trimEnd === null) return "";
-
- // Avoid scanning entire audio with large placeholder values
- // Use precise trim bounds when available
- let trimFilter = `atrim=start=${recipe.trimStart}`;
- if (recipe.trimEnd !== null) {
- trimFilter += `:end=${recipe.trimEnd}`;
- }
- // asetpts normalizes timestamps after trim for correct stream positioning
- return `${trimFilter},asetpts=PTS-STARTPTS`;
-}
-
-function buildArguments(
- recipe: EditRecipe,
- format: "mp4" | "webm" | "mkv" | "gif",
- outputName: string,
- inputName: string,
- targetW: number,
- targetH: number,
- hasMusicTrack: boolean,
- musicInputName: string,
- musicOptions: BackgroundMusicOptions | undefined,
- hasOverlay: boolean,
- overlayInputName: string,
- overlayOptions: ImageOverlayOptions | undefined,
- hasOriginalAudio: boolean,
- videoDuration: number
-): string[] {
- const vf = buildVideoFilter(recipe, targetW, targetH);
- const audioTrim = hasOriginalAudio ? buildAudioTrimFilter(recipe) : "";
- const audioSpeed = hasOriginalAudio ? buildAudioFilter(recipe) : "";
- const afParts = [audioTrim, audioSpeed].filter(Boolean);
- const af = afParts.join(",");
-
- const musicIdx = 1;
- const overlayIdx = hasMusicTrack ? 2 : 1;
-
- const args: string[] = [];
- args.push("-i", inputName);
- if (hasMusicTrack) {
- if (musicOptions!.loopMusic) args.push("-stream_loop", "-1");
- args.push("-i", musicInputName);
- }
- if (hasOverlay) {
- args.push("-i", overlayInputName);
- }
-
- const needsFilterComplex = hasOverlay || hasMusicTrack;
- const shouldKeepAudio = recipe.keepAudio && (hasOriginalAudio || hasMusicTrack);
-
- if (needsFilterComplex) {
- const filterParts: string[] = [];
- let videoOut = "[0:v]";
-
- if (vf) {
- filterParts.push(`[0:v]${vf}[vbase]`);
- videoOut = "[vbase]";
- }
-
-if (hasOverlay) {
- const scaledW = overlayOptions!.size;
- const alpha = (overlayOptions!.opacity / 100).toFixed(2);
- const posMap: Record = {
- "top-left": "20:20",
- "top-right": "main_w-w-20:20",
- "bottom-left": "20:main_h-h-20",
- "bottom-right": "main_w-w-20:main_h-h-20",
- };
-
-interface PositionCoords {
- x: number;
- y: number;
- }
-
- const pos = typeof overlayOptions?.position === "string"
- ? (posMap[overlayOptions.position] ?? "main_w-w-20:main_h-h-20")
- : overlayOptions?.position
- ? `(main_w)*${(overlayOptions.position as PositionCoords).x}/100:(main_h)*${(overlayOptions.position as PositionCoords).y}/100`
- : "main_w-w-20:main_h-h-20";
-
- filterParts.push(`[${overlayIdx}:v]scale=${scaledW}:-2,format=rgba,colorchannelmixer=aa=${alpha}[logo]`);
- filterParts.push(`${videoOut}[logo]overlay=${pos}[vout]`);
- videoOut = "[vout]";
-}
-
- let audioOut = "";
- if (shouldKeepAudio) {
- if (hasMusicTrack) {
- const musicVol = (musicOptions!.musicVolume / 100).toFixed(2);
- if (hasOriginalAudio) {
- const origVol = (musicOptions!.originalAudioVolume / 100).toFixed(2);
- const origChain = afParts.length > 0
- ? `[0:a]${afParts.join(",")},volume=${origVol}[orig]`
- : `[0:a]volume=${origVol}[orig]`;
- filterParts.push(origChain);
- filterParts.push(`[${musicIdx}:a]volume=${musicVol}[music]`);
- filterParts.push(`[orig][music]amix=inputs=2:duration=first:dropout_transition=0[aout]`);
- audioOut = "[aout]";
- } else {
- filterParts.push(`[${musicIdx}:a]volume=${musicVol}[aout]`);
- audioOut = "[aout]";
- }
- } else if (hasOriginalAudio && af) {
- filterParts.push(`[0:a]${af}[aout]`);
- audioOut = "[aout]";
- }
- }
-
- if (filterParts.length > 0) {
- args.push("-filter_complex", filterParts.join(";"));
- }
- args.push("-map", videoOut === "[0:v]" ? "0:v" : videoOut);
-
- if (!shouldKeepAudio) {
- args.push("-an");
- } else if (audioOut) {
- args.push("-map", audioOut);
- } else if (hasOriginalAudio) {
- args.push("-map", "0:a");
- }
- } else {
- if (vf) args.push("-vf", vf);
- if (!shouldKeepAudio) {
- args.push("-an");
- } else if (af && hasOriginalAudio) {
- args.push("-af", af);
- }
- }
-
- if (format === "webm") {
- args.push(
- "-c:v", "libvpx-vp9",
- "-b:v", "0",
- "-crf", String(recipe.quality),
- "-cpu-used", "4",
- "-deadline", "realtime"
- );
- if (shouldKeepAudio) args.push("-c:a", "libopus");
- } else if (format === "mkv") {
- args.push("-c:v", "libx264", "-crf", String(recipe.quality), "-preset", "ultrafast");
- if (shouldKeepAudio) args.push("-c:a", "aac", "-b:a", "128k");
- } else {
- args.push("-c:v", "libx264", "-crf", String(recipe.quality), "-preset", "ultrafast", "-movflags", "+faststart");
- if (shouldKeepAudio) args.push("-c:a", "aac", "-b:a", "128k");
- }
-
- // Add explicit output duration to prevent slight duration overshoot
- // caused by encoder/filter pipeline frame flush at stream end,
- // and to prevent infinite loops when adding looped audio to silent videos.
- const sourceDuration = (recipe.trimEnd ?? videoDuration) - recipe.trimStart;
- const outputDuration = sourceDuration / recipe.speed;
- args.push("-t", outputDuration.toFixed(6));
-
- args.push(outputName);
- return args;
-}
-
export function formatBytes(bytes: number): string {
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
diff --git a/src/lib/ffmpeg.worker.ts b/src/lib/ffmpeg.worker.ts
index 52fbed3e0..72ed5ee5e 100644
--- a/src/lib/ffmpeg.worker.ts
+++ b/src/lib/ffmpeg.worker.ts
@@ -2,7 +2,7 @@ import { FFmpeg } from "@ffmpeg/ffmpeg";
import { toBlobURL } from "@ffmpeg/util";
import { EditRecipe, BackgroundMusicOptions, ImageOverlayOptions } from "./types";
import { getPresetById } from "./presets";
-import { buildTextFilter } from "./text-overlay";
+import { buildArguments, buildGifArguments, resolveThreadCount, type EngineMode } from "./ffmpegArgs";
const CORE_BASE_URL = "https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.10/dist/umd";
// MT core aligned to the same version and distribution format as CORE_BASE_URL.
@@ -15,13 +15,17 @@ const CORE_BASE_URL = "https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.10/dist/um
const MT_CORE_BASE_URL = "https://cdn.jsdelivr.net/npm/@ffmpeg/core-mt@0.12.10/dist/umd";
// Keys are full URLs so hashes for the non-MT and MT packages never collide.
-// MT hashes are omitted intentionally: fetchWithIntegrity falls back to a plain
-// fetch when no hash entry exists, which is safe for a trusted CDN origin and
-// avoids the previous bug where a wrong hash caused a hard IntegrityError.
-// Add MT-specific sha384 entries here once they are verified against the CDN.
-const SRI_HASHES: Record = {
- [`${CORE_BASE_URL}/ffmpeg-core.js`]: "sha384-sKfkiFtvUk+vexk+0EUhEh366190/4WpgUAsUvaxEfyg7+E1Zt5Y5hrsU808g8Q9",
- [`${CORE_BASE_URL}/ffmpeg-core.wasm`]: "sha384-U1VDhkPYrM3wTCT4/vjSpSsKqG/UjljYrYCI4hBSJ02svbCkxuCi6U6u/peg5vpW",
+// Every engine file is integrity-checked, so a tampered CDN response fails to
+// load instead of running. Each hash was computed from the npm tarball (itself
+// verified against the registry's sha512 `dist.integrity`) and matched against
+// what jsDelivr serves (2026-10-22, docs/SECURITY-PIPELINE.md). When bumping
+// @ffmpeg/core*, recompute all of them the same way.
+export const SRI_HASHES: Record = {
+ [`${CORE_BASE_URL}/ffmpeg-core.js`]: "sha384-sKfkiFtvUk+vexk+0EUhEh366190/4WpgUAsUvaxEfyg7+E1Zt5Y5hrsU808g8Q9",
+ [`${CORE_BASE_URL}/ffmpeg-core.wasm`]: "sha384-U1VDhkPYrM3wTCT4/vjSpSsKqG/UjljYrYCI4hBSJ02svbCkxuCi6U6u/peg5vpW",
+ [`${MT_CORE_BASE_URL}/ffmpeg-core.js`]: "sha384-91GpZ3Ow5AIa5wmICIKrQaOAmJPc0sPFNaeuZKtD1Dx7Xr/F8kfGZHM++VOj308v",
+ [`${MT_CORE_BASE_URL}/ffmpeg-core.wasm`]: "sha384-IXnr5PE2UFcQ5DvI5LyubPqmMF46EkyIMlbdn4CNQR1iQ8/2irEkyhDFnVDxv4f/",
+ [`${MT_CORE_BASE_URL}/ffmpeg-core.worker.js`]: "sha384-h19AXK35916sCbdJrzAeYw2kl/zEt4B6PTy94TTPup7kgO7r/7mwb43Orcu+xTW1",
};
type SerializedFile = {
@@ -42,7 +46,11 @@ type ExportRequest = {
overlayOptions?: ImageOverlayOptions;
};
-type LoadRequest = { type: "load" };
+type LoadRequest = {
+ type: "load";
+ /** Load the single-threaded core even when cross-origin isolated (after a stall). */
+ singleThread?: boolean;
+};
type CancelRequest = { type: "cancel" };
@@ -52,8 +60,9 @@ type WorkerCommand = LoadRequest | ExportRequest | CancelRequest | TerminateRequ
type ProgressPayload = { type: "progress"; percent: number };
-/** `mode`: which core loaded, multi-threaded (cross-origin isolated) or single. */
-type ReadyPayload = { type: "ready"; mode: "multi" | "single" };
+type ReadyPayload = { type: "ready"; mode: EngineMode };
+
+type HeartbeatPayload = { type: "heartbeat" };
type ResultPayload = {
type: "result";
@@ -70,305 +79,80 @@ type ErrorPayload = { type: "error"; id?: string; message: string };
type CancelledPayload = { type: "cancelled"; id?: string };
-type WorkerResponse = ProgressPayload | ReadyPayload | ResultPayload | ErrorPayload | CancelledPayload;
+type WorkerResponse = ProgressPayload | ReadyPayload | HeartbeatPayload | ResultPayload | ErrorPayload | CancelledPayload;
let ffmpeg: FFmpeg | null = null;
let ffmpegLoaded = false;
-let engineMode: "multi" | "single" = "single";
+/** Which core actually loaded — decides the thread flags, not crossOriginIsolated alone. */
+let engineMode: EngineMode = "single";
let activeExportAbortController: AbortController | null = null;
let activeExportId: string | null = null;
+/** Liveness signal for the client's stall watchdog (see ffmpeg.ts). */
+const HEARTBEAT_INTERVAL_MS = 2000;
+let lastHeartbeat = 0;
+function heartbeat() {
+ const now = Date.now();
+ if (now - lastHeartbeat < HEARTBEAT_INTERVAL_MS) return;
+ lastHeartbeat = now;
+ postMessage({ type: "heartbeat" });
+}
+
async function fetchWithIntegrity(url: string, mimeType: string): Promise {
// Look up by full URL so non-MT and MT hashes never collide when filenames match.
const integrity = SRI_HASHES[url];
- // Fallback to standard fetch if SRI is missing (Prevents ffmpeg-core.worker.js from crashing the thread)
- if (!integrity) {
- const response = await fetch(url, { credentials: "omit" });
- const blob = new Blob([await response.arrayBuffer()], { type: mimeType });
- return URL.createObjectURL(blob);
- }
+ // Never fetch an engine file unverified.
+ if (!integrity) throw new Error(`No integrity hash for engine file: ${url}`);
const response = await fetch(url, { integrity, credentials: "omit" });
+ if (!response.ok) throw new Error(`Failed to fetch engine file (HTTP ${response.status})`);
const blob = new Blob([await response.arrayBuffer()], { type: mimeType });
return URL.createObjectURL(blob);
}
-function buildVideoFilter(recipe: EditRecipe, targetW: number, targetH: number): string {
- const filters: string[] = [];
-
- if (recipe.trimStart > 0 || recipe.trimEnd !== null) {
- const end = recipe.trimEnd !== null ? recipe.trimEnd : 999999;
- filters.push(`trim=start=${recipe.trimStart}:end=${end}`);
- }
-
- if (recipe.stabilization) {
- filters.push("deshake");
- }
-
- if (recipe.rotate === 90) {
- filters.push("transpose=1");
- } else if (recipe.rotate === 180) {
- filters.push("transpose=1,transpose=1");
- } else if (recipe.rotate === 270) {
- filters.push("transpose=2");
- }
-
- if (recipe.framing === "fit") {
- filters.push(
- `scale=${targetW}:${targetH}:force_original_aspect_ratio=decrease`,
- `pad=${targetW}:${targetH}:(ow-iw)/2:(oh-ih)/2:color=black`
- );
- } else {
- filters.push(
- `scale=${targetW}:${targetH}:force_original_aspect_ratio=increase`,
- `crop=${targetW}:${targetH}`
- );
- }
-
- if (recipe.trimStart > 0 || recipe.trimEnd !== null || recipe.speed !== 1) {
- filters.push("setpts=PTS-STARTPTS");
- }
-
- if (recipe.speed !== 1) {
- const pts = (1 / recipe.speed).toFixed(4);
- filters.push(`setpts=${pts}*PTS`);
- }
-
- if (recipe.denoise) {
- filters.push("hqdn3d=1.5:1.5:6:6");
- }
-
- const needsEq =
- recipe.brightness !== 0 ||
- recipe.contrast !== 1 ||
- recipe.saturation !== 1;
-
- if (needsEq) {
- filters.push(
- `eq=brightness=${recipe.brightness}:contrast=${recipe.contrast}:saturation=${recipe.saturation}`
- );
- }
-
- const textOverlays = recipe.textOverlays || [];
- textOverlays.forEach((overlay) => {
- filters.push(buildTextFilter(overlay, targetW, targetH));
- });
-
- return filters.join(",");
-}
-
-function buildAudioFilter(speed: number, normalizeAudio: boolean): string {
- if (speed <= 0) return "";
- const filters: string[] = [];
-
- let remaining = speed;
- while (remaining < 0.5) {
- filters.push("atempo=0.5");
- remaining /= 0.5;
- }
-
- while (remaining > 2.0) {
- filters.push("atempo=2.0");
- remaining /= 2.0;
- }
-
- if (Math.abs(remaining - 1.0) > 0.001) {
- filters.push(`atempo=${Number(remaining.toFixed(4))}`);
- }
-
- if (normalizeAudio) filters.push("loudnorm=I=-14:TP=-1.5:LRA=11");
-
- return filters.join(",");
-}
-
-function buildAudioTrimFilter(recipe: EditRecipe): string {
- if (recipe.trimStart === 0 && recipe.trimEnd === null) return "";
- const end = recipe.trimEnd !== null ? recipe.trimEnd : 999999;
- return `atrim=start=${recipe.trimStart}:end=${end},asetpts=PTS-STARTPTS`;
-}
-
-function buildArguments(
- recipe: EditRecipe,
- format: "mp4" | "webm" | "mkv" | "gif",
- outputName: string,
- inputName: string,
- targetW: number,
- targetH: number,
- hasMusicTrack: boolean,
- musicInputName: string,
- musicOptions: BackgroundMusicOptions | undefined,
- hasOverlay: boolean,
- overlayInputName: string,
- overlayOptions: ImageOverlayOptions | undefined,
- hasOriginalAudio: boolean,
- videoDuration: number
-): string[] {
- const vf = buildVideoFilter(recipe, targetW, targetH);
- const audioTrim = hasOriginalAudio ? buildAudioTrimFilter(recipe) : "";
- const audioSpeed = hasOriginalAudio ? buildAudioFilter(recipe.speed, recipe.normalizeAudio ?? false) : "";
- const afParts = [audioTrim, audioSpeed].filter(Boolean);
- const af = afParts.join(",");
-
- const musicIdx = 1;
- const overlayIdx = hasMusicTrack ? 2 : 1;
-
- const args: string[] = [];
- args.push("-i", inputName);
- if (hasMusicTrack) {
- if (musicOptions!.loopMusic) args.push("-stream_loop", "-1");
- args.push("-i", musicInputName);
- }
- if (hasOverlay) {
- args.push("-i", overlayInputName);
- }
-
- const needsFilterComplex = hasOverlay || hasMusicTrack;
- const shouldKeepAudio = recipe.keepAudio && (hasOriginalAudio || hasMusicTrack);
-
- if (needsFilterComplex) {
- const filterParts: string[] = [];
- let videoOut = "[0:v]";
-
- if (vf) {
- filterParts.push(`[0:v]${vf}[vbase]`);
- videoOut = "[vbase]";
- }
-
-if (hasOverlay) {
- const scaledW = overlayOptions!.size;
- const alpha = (overlayOptions!.opacity / 100).toFixed(2);
- const posMap: Record = {
- "top-left": "20:20",
- "top-right": "W-w-20:20",
- "bottom-left": "20:H-h-20",
- "bottom-right": "W-w-20:H-h-20",
- };
-
-interface PositionCoords {
- x: number;
- y: number;
- }
-
- const pos = typeof overlayOptions?.position === "string"
- ? (posMap[overlayOptions.position] ?? "W-w-20:H-h-20")
- : overlayOptions?.position
- ? `(W-w)*${(overlayOptions.position as PositionCoords).x}/100:(H-h)*${(overlayOptions.position as PositionCoords).y}/100`
- : "W-w-20:H-h-20";
-
- filterParts.push(`[${overlayIdx}:v]scale=${scaledW}:-2,format=rgba,colorchannelmixer=aa=${alpha}[logo]`);
- filterParts.push(`${videoOut}[logo]overlay=${pos}[vout]`);
- videoOut = "[vout]";
-}
-
- let audioOut = "";
- if (shouldKeepAudio) {
- if (hasMusicTrack) {
- const musicVol = (musicOptions!.musicVolume / 100).toFixed(2);
- if (hasOriginalAudio) {
- const origVol = (musicOptions!.originalAudioVolume / 100).toFixed(2);
- const origChain = afParts.length > 0
- ? `[0:a]${afParts.join(",")},volume=${origVol}[orig]`
- : `[0:a]volume=${origVol}[orig]`;
- filterParts.push(origChain);
- filterParts.push(`[${musicIdx}:a]volume=${musicVol}[music]`);
- filterParts.push(`[orig][music]amix=inputs=2:duration=first:dropout_transition=0[aout]`);
- audioOut = "[aout]";
- } else {
- filterParts.push(`[${musicIdx}:a]volume=${musicVol}[aout]`);
- audioOut = "[aout]";
- }
- } else if (hasOriginalAudio && af) {
- filterParts.push(`[0:a]${af}[aout]`);
- audioOut = "[aout]";
- }
- }
-
- if (filterParts.length > 0) {
- args.push("-filter_complex", filterParts.join(";"));
- }
- args.push("-map", videoOut === "[0:v]" ? "0:v" : videoOut);
-
- if (!shouldKeepAudio) {
- args.push("-an");
- } else if (audioOut) {
- args.push("-map", audioOut);
- } else if (hasOriginalAudio) {
- args.push("-map", "0:a");
- }
- } else {
- if (vf) args.push("-vf", vf);
- if (!shouldKeepAudio) {
- args.push("-an");
- } else if (af && hasOriginalAudio) {
- args.push("-af", af);
- }
- }
-
- if (format === "webm") {
- args.push(
- "-c:v", "libvpx-vp9",
- "-b:v", "0",
- "-crf", String(recipe.quality),
- "-cpu-used", "4",
- "-deadline", "realtime"
- );
- if (shouldKeepAudio) args.push("-c:a", "libopus");
- } else if (format === "mkv") {
- args.push("-c:v", "libx264", "-crf", String(recipe.quality), "-preset", "ultrafast");
- if (shouldKeepAudio) args.push("-c:a", "aac", "-b:a", "128k");
- } else {
- args.push("-c:v", "libx264", "-crf", String(recipe.quality), "-preset", "ultrafast", "-movflags", "+faststart");
- if (shouldKeepAudio) args.push("-c:a", "aac", "-b:a", "128k");
- }
-
- if (recipe.speed !== 1) {
- const sourceDuration = (recipe.trimEnd ?? videoDuration) - recipe.trimStart;
- // Skip -t when sourceDuration <= 0 (happens when video metadata fails to
- // load and videoDuration falls back to 0). Passing -t 0 to FFmpeg produces
- // a zero-length output file; omitting it lets FFmpeg encode the full stream.
- if (sourceDuration > 0) {
- const outputDuration = sourceDuration / recipe.speed;
- args.push("-t", outputDuration.toFixed(6));
- }
- }
-
- args.push(outputName);
- return args;
-}
-
-async function loadCore(onProgress?: (percent: number) => void): Promise {
- if (ffmpegLoaded) {
- onProgress?.(100);
- return;
- }
-
+async function loadCoreFrom(mode: EngineMode): Promise {
ffmpeg = new FFmpeg();
-
- const isIsolated = typeof self !== "undefined" && self.crossOriginIsolated;
- const baseURL = isIsolated ? MT_CORE_BASE_URL : CORE_BASE_URL;
- engineMode = isIsolated ? "multi" : "single";
-
- const handleProgress = ({ progress }: { progress: number }) => {
- onProgress?.(Math.round(progress * 100));
- };
-
- ffmpeg.on("progress", handleProgress);
-
+ const baseURL = mode === "multi" ? MT_CORE_BASE_URL : CORE_BASE_URL;
try {
await ffmpeg.load({
coreURL: await fetchWithIntegrity(`${baseURL}/ffmpeg-core.js`, "text/javascript"),
wasmURL: await fetchWithIntegrity(`${baseURL}/ffmpeg-core.wasm`, "application/wasm"),
- ...(isIsolated && {
+ ...(mode === "multi" && {
workerURL: await fetchWithIntegrity(`${baseURL}/ffmpeg-core.worker.js`, "text/javascript"),
}),
});
+ } catch (error) {
+ ffmpeg.terminate();
+ ffmpeg = null;
+ throw error;
+ }
+ engineMode = mode;
+ ffmpegLoaded = true;
+}
- ffmpegLoaded = true;
- onProgress?.(100);
- } finally {
- ffmpeg.off("progress", handleProgress);
+async function loadCore(singleThread = false): Promise {
+ if (ffmpegLoaded) return;
+
+ // The multi-threaded core needs SharedArrayBuffer, which only exists when
+ // the page is cross-origin isolated (COOP + COEP headers).
+ const canUseMulti = !singleThread && typeof self !== "undefined" && self.crossOriginIsolated;
+ if (!canUseMulti) {
+ await loadCoreFrom("single");
+ return;
}
+ try {
+ await loadCoreFrom("multi");
+ } catch {
+ // e.g. the MT files failed to download or instantiate. The single-threaded
+ // core is slower but works everywhere, so fall back rather than fail.
+ await loadCoreFrom("single");
+ }
+}
+
+/** ffmpeg.exec rejects with a plain string when the wasm core traps. */
+function errorMessage(error: unknown): string {
+ return error instanceof Error ? error.message : String(error);
}
function serializeFileBuffer(file: SerializedFile): Uint8Array {
@@ -451,52 +235,64 @@ async function runExport(request: ExportRequest): Promise {
}
const videoDuration = request.videoDuration;
+ const threads = resolveThreadCount(engineMode, self.navigator?.hardwareConcurrency);
+ const signal = activeExportAbortController?.signal;
+ // FFmpeg emits progress per frame; post only when the whole percentage
+ // changes so the main thread isn't flooded with identical updates.
+ let lastPercent = -1;
const handleProgress = ({ progress }: { progress: number }) => {
if (activeExportId !== sessionId) return;
- postMessage({ type: "progress", percent: Math.min(99, Math.round(progress * 100)) });
+ heartbeat();
+ const percent = Math.max(0, Math.min(99, Math.round(progress * 100)));
+ if (percent === lastPercent) return;
+ lastPercent = percent;
+ postMessage({ type: "progress", percent });
+ };
+
+ // FFmpeg prints a stats line about twice a second while it works, so log
+ // output doubles as a heartbeat even when progress can't be computed.
+ let missingAudioDetected = false;
+ const handleLog = ({ message }: { message: string }) => {
+ heartbeat();
+ const msg = message.toLowerCase();
+ if (
+ msg.includes("matches no streams") ||
+ msg.includes("specifier '0:a'") ||
+ msg.includes("input pad 0 on filter src")
+ ) {
+ missingAudioDetected = true;
+ }
};
- let logListener: ((event: { message: string }) => void) | null = null;
ffmpeg.on("progress", handleProgress);
+ ffmpeg.on("log", handleLog);
+ lastHeartbeat = 0;
+ heartbeat();
+
+ const read = async (name: string) => {
+ const data = await ffmpeg!.readFile(name, undefined, { signal });
+ return (data as Uint8Array).buffer as ArrayBuffer;
+ };
try {
if (recipe.format === "gif") {
- const vf = buildVideoFilter(recipe, targetW, targetH);
- const vfWithPalette = vf ? `${vf},palettegen` : "palettegen";
- const vfWithPaletteUse = vf
- ? `[0:v]${vf}[x];[x][1:v]paletteuse`
- : "[0:v][1:v]paletteuse";
-
- const gifDurationArgs = recipe.speed !== 1
- ? (() => {
- const sourceDuration = (recipe.trimEnd ?? videoDuration) - recipe.trimStart;
- // Skip -t when sourceDuration <= 0 (metadata load failure fallback)
- // to avoid producing a zero-length GIF.
- if (sourceDuration <= 0) return [];
- const outputDuration = sourceDuration / recipe.speed;
- return ["-t", outputDuration.toFixed(6)];
- })()
- : [];
-
- const pass1Code = await ffmpeg.exec(
- ["-i", inputName, "-vf", vfWithPalette, ...gifDurationArgs, "-y", paletteName],
- undefined,
- { signal: activeExportAbortController?.signal }
- );
+ const gif = buildGifArguments({
+ recipe,
+ inputName,
+ paletteName,
+ outputName,
+ targetW,
+ targetH,
+ videoDuration,
+ threads,
+ });
+ const pass1Code = await ffmpeg.exec(gif.palette, undefined, { signal });
if (pass1Code !== 0) throw new Error("GIF palette generation failed");
-
- const pass2Code = await ffmpeg.exec(
- ["-i", inputName, "-i", paletteName, "-lavfi", vfWithPaletteUse, ...gifDurationArgs, "-y", outputName],
- undefined,
- { signal: activeExportAbortController?.signal }
- );
+ const pass2Code = await ffmpeg.exec(gif.render, undefined, { signal });
if (pass2Code !== 0) throw new Error("GIF export failed");
- const data = await ffmpeg.readFile(outputName, undefined, {
- signal: activeExportAbortController?.signal,
- });
- const payload = (data as Uint8Array).buffer as ArrayBuffer;
+ const payload = await read(outputName);
return {
type: "result",
id: sessionId,
@@ -509,90 +305,38 @@ async function runExport(request: ExportRequest): Promise {
};
}
- let missingAudioDetected = false;
- const logListener = ({ message }: { message: string }) => {
- const msg = message.toLowerCase();
- if (
- msg.includes("matches no streams") ||
- msg.includes("specifier '0:a'") ||
- msg.includes("input pad 0 on filter src")
- ) {
- missingAudioDetected = true;
- }
- };
- ffmpeg.on("log", logListener);
-
- let args = buildArguments(
- recipe,
- recipe.format,
- outputName,
- inputName,
- targetW,
- targetH,
- hasMusicTrack,
- musicInputName,
- request.musicOptions,
- hasOverlay,
- overlayInputName,
- request.overlayOptions,
- true,
- videoDuration
- );
-
- let exitCode = await ffmpeg.exec(args, undefined, {
- signal: activeExportAbortController?.signal,
- });
-
- if (exitCode !== 0 && missingAudioDetected) {
- missingAudioDetected = false;
- args = buildArguments(
+ const argsFor = (format: "mp4" | "webm" | "mkv", output: string, hasOriginalAudio: boolean) =>
+ buildArguments({
recipe,
- recipe.format,
- outputName,
+ format,
inputName,
+ outputName: output,
targetW,
targetH,
- hasMusicTrack,
- musicInputName,
- request.musicOptions,
- hasOverlay,
- overlayInputName,
- request.overlayOptions,
- false,
- videoDuration
- );
- exitCode = await ffmpeg.exec(args, undefined, {
- signal: activeExportAbortController?.signal,
+ videoDuration,
+ hasOriginalAudio,
+ music: hasMusicTrack ? { inputName: musicInputName, options: request.musicOptions! } : undefined,
+ overlay: hasOverlay ? { inputName: overlayInputName, options: request.overlayOptions! } : undefined,
+ threads,
});
+
+ let exitCode = await ffmpeg.exec(argsFor(recipe.format, outputName, true), undefined, { signal });
+
+ if (exitCode !== 0 && missingAudioDetected) {
+ // Retry without the original audio track (the source has none).
+ exitCode = await ffmpeg.exec(argsFor(recipe.format, outputName, false), undefined, { signal });
}
if (exitCode !== 0) {
- args = buildArguments(
- recipe,
- "webm",
- fallbackOutputName,
- inputName,
- targetW,
- targetH,
- hasMusicTrack,
- musicInputName,
- request.musicOptions,
- hasOverlay,
- overlayInputName,
- request.overlayOptions,
- !missingAudioDetected,
- videoDuration
+ // Last resort: WebM (VP8), for inputs the H.264 path rejects.
+ const fallbackCode = await ffmpeg.exec(
+ argsFor("webm", fallbackOutputName, !missingAudioDetected),
+ undefined,
+ { signal }
);
-
- const fallbackCode = await ffmpeg.exec(args, undefined, {
- signal: activeExportAbortController?.signal,
- });
if (fallbackCode !== 0) throw new Error("Export failed");
- const data = await ffmpeg.readFile(fallbackOutputName, undefined, {
- signal: activeExportAbortController?.signal,
- });
- const payload = (data as Uint8Array).buffer as ArrayBuffer;
+ const payload = await read(fallbackOutputName);
return {
type: "result",
id: sessionId,
@@ -605,10 +349,7 @@ async function runExport(request: ExportRequest): Promise {
};
}
- const data = await ffmpeg.readFile(outputName, undefined, {
- signal: activeExportAbortController?.signal,
- });
- const payload = (data as Uint8Array).buffer as ArrayBuffer;
+ const payload = await read(outputName);
return {
type: "result",
id: sessionId,
@@ -620,8 +361,10 @@ async function runExport(request: ExportRequest): Promise {
format: recipe.format,
};
} finally {
+ // Both listeners must come off: the worker outlives each export, and a
+ // leaked log listener used to accumulate on every run.
ffmpeg.off("progress", handleProgress);
- if (logListener) ffmpeg.off("log", logListener);
+ ffmpeg.off("log", handleLog);
for (const path of cleanupFiles) {
await removeFile(path);
}
@@ -656,10 +399,10 @@ async function handleCommand(message: WorkerCommand) {
switch (message.type) {
case "load": {
try {
- await loadCore();
+ await loadCore(message.singleThread);
postMessage({ type: "ready", mode: engineMode });
} catch (error) {
- postMessage({ type: "error", message: (error as Error).message });
+ postMessage({ type: "error", message: errorMessage(error) });
}
return;
}
@@ -687,7 +430,7 @@ async function handleCommand(message: WorkerCommand) {
if (activeExportAbortController?.signal.aborted) {
postMessage({ type: "cancelled", id: message.id });
} else {
- postMessage({ type: "error", id: message.id, message: (error as Error).message });
+ postMessage({ type: "error", id: message.id, message: errorMessage(error) });
}
} finally {
activeExportAbortController = null;
@@ -713,6 +456,6 @@ async function handleCommand(message: WorkerCommand) {
self.addEventListener("message", (event) => {
handleCommand(event.data as WorkerCommand).catch((error) => {
- postMessage({ type: "error", message: (error as Error).message });
+ postMessage({ type: "error", message: errorMessage(error) });
});
});
diff --git a/src/lib/ffmpegArgs.ts b/src/lib/ffmpegArgs.ts
new file mode 100644
index 000000000..2a0499c14
--- /dev/null
+++ b/src/lib/ffmpegArgs.ts
@@ -0,0 +1,370 @@
+import { EditRecipe, BackgroundMusicOptions, ImageOverlayOptions, OverlayPosition } from "./types";
+import { buildTextFilter } from "./text-overlay";
+
+// The FFmpeg command line for every export is built here and only here.
+// ffmpeg.worker.ts runs these commands; ffmpeg.ts re-exports the filter
+// builders for tests. There used to be a second copy in ffmpeg.ts that tests
+// exercised while the worker ran its own — sharpness, volume and the `-t` fix
+// all landed in the copy that never ran. Keep this module free of DOM/worker
+// APIs so it can be imported from both sides.
+
+export type OutputFormat = "mp4" | "webm" | "mkv" | "gif";
+export type EngineMode = "multi" | "single";
+
+/**
+ * Upper bound on threads per FFmpeg stage (decode, filter, encode) for the
+ * multi-threaded core. @ffmpeg/core-mt pre-spawns a pool of 32 pthread
+ * workers; a thread beyond the pool needs the FFmpeg worker's event loop to
+ * start, but that loop is blocked inside `exec`, so the run deadlocks at 0%.
+ * With "auto" threading x264/libvpx and the decoder each size themselves from
+ * the CPU count and together exceed the pool on many-core machines. Measured
+ * on an 11-core M3 Pro (Chrome 155): auto hung, 4 completed ~4× faster than
+ * the single-threaded core.
+ */
+export const MT_THREAD_CAP = 4;
+
+/** Threads to assume when the browser doesn't report its core count. */
+export const UNKNOWN_CORES_THREADS = 2;
+
+/**
+ * Threads to request per stage: min(MT_THREAD_CAP, hardwareConcurrency || 2),
+ * or 0 ("don't pass any thread flags") for the single-threaded core.
+ */
+export function resolveThreadCount(mode: EngineMode, hardwareConcurrency?: number): number {
+ if (mode !== "multi") return 0;
+ const cores =
+ hardwareConcurrency && Number.isFinite(hardwareConcurrency) && hardwareConcurrency > 0
+ ? Math.floor(hardwareConcurrency)
+ : UNKNOWN_CORES_THREADS;
+ return Math.max(1, Math.min(MT_THREAD_CAP, cores));
+}
+
+/** Decoder thread flag, placed before an `-i`. */
+function inputThreadArgs(threads: number): string[] {
+ return threads > 0 ? ["-threads", String(threads)] : [];
+}
+
+/** Encoder and filter-graph thread flags, placed before the output name. */
+function outputThreadArgs(threads: number, hasFilterComplex: boolean): string[] {
+ if (threads <= 0) return [];
+ const args = ["-threads", String(threads), "-filter_threads", String(threads)];
+ if (hasFilterComplex) args.push("-filter_complex_threads", String(threads));
+ return args;
+}
+
+/**
+ * Explicit output duration. Stops encoder flush overshoot and, with looped
+ * background music (`-stream_loop -1`) on a silent video, an export that
+ * never ends. Omitted when the duration is unknown (metadata read failed):
+ * `-t 0` would produce an empty file.
+ */
+export function outputDurationArgs(recipe: EditRecipe, videoDuration: number): string[] {
+ const sourceDuration = (recipe.trimEnd ?? videoDuration) - recipe.trimStart;
+ if (!(sourceDuration > 0) || !(recipe.speed > 0)) return [];
+ return ["-t", (sourceDuration / recipe.speed).toFixed(6)];
+}
+
+export function buildVideoFilter(recipe: EditRecipe, targetW: number, targetH: number): string {
+ const filters: string[] = [];
+
+ if (recipe.trimStart > 0 || recipe.trimEnd !== null) {
+ // Precise bounds only; an open end lets FFmpeg infer it instead of a
+ // large placeholder that makes it scan the whole file.
+ if (recipe.trimEnd !== null) {
+ filters.push(`trim=start=${recipe.trimStart}:end=${recipe.trimEnd}`);
+ } else if (recipe.trimStart > 0) {
+ filters.push(`trim=start=${recipe.trimStart}`);
+ }
+ }
+
+ if (recipe.stabilization) {
+ filters.push("deshake");
+ }
+
+ if (recipe.rotate === 90) {
+ filters.push("transpose=1");
+ } else if (recipe.rotate === 180) {
+ filters.push("transpose=1,transpose=1");
+ } else if (recipe.rotate === 270) {
+ filters.push("transpose=2");
+ }
+
+ if (recipe.framing === "fit") {
+ filters.push(
+ `scale=${targetW}:${targetH}:force_original_aspect_ratio=decrease`,
+ `pad=${targetW}:${targetH}:(ow-iw)/2:(oh-ih)/2:color=black`
+ );
+ } else {
+ filters.push(
+ `scale=${targetW}:${targetH}:force_original_aspect_ratio=increase`,
+ `crop=${targetW}:${targetH}`
+ );
+ }
+ if (recipe.sharpness !== 0) {
+ filters.push(`unsharp=5:5:${recipe.sharpness}:5:5:0.0`);
+ }
+
+ // Normalize timestamps only when needed — trim or speed change both
+ // require a clean 0-based timeline to produce correct output duration.
+ if (recipe.trimStart > 0 || recipe.trimEnd !== null || recipe.speed !== 1) {
+ filters.push("setpts=PTS-STARTPTS");
+ }
+
+ if (recipe.speed !== 1) {
+ const pts = (1 / recipe.speed).toFixed(4);
+ filters.push(`setpts=${pts}*PTS`);
+ }
+
+ if (recipe.denoise) {
+ filters.push("hqdn3d=1.5:1.5:6:6");
+ }
+
+ const needsEq =
+ recipe.brightness !== 0 ||
+ recipe.contrast !== 1 ||
+ recipe.saturation !== 1;
+
+ if (needsEq) {
+ filters.push(
+ `eq=brightness=${recipe.brightness}:contrast=${recipe.contrast}:saturation=${recipe.saturation}`
+ );
+ }
+
+ const textOverlays = recipe.textOverlays || [];
+ textOverlays.forEach((overlay) => {
+ filters.push(buildTextFilter(overlay, targetW, targetH));
+ });
+
+ return filters.join(",");
+}
+
+export function buildAudioFilter(recipe: EditRecipe): string {
+ if (recipe.speed <= 0) return "";
+ const filters: string[] = [];
+
+ let remaining = recipe.speed;
+ while (remaining < 0.5) {
+ filters.push("atempo=0.5");
+ remaining /= 0.5;
+ }
+
+ while (remaining > 2.0) {
+ filters.push("atempo=2.0");
+ remaining /= 2.0;
+ }
+
+ if (Math.abs(remaining - 1.0) > 0.001) {
+ filters.push(`atempo=${Number(remaining.toFixed(4))}`);
+ }
+
+ if (recipe.volume !== undefined && recipe.volume !== 100) {
+ filters.push(`volume=${(recipe.volume / 100).toFixed(2)}`);
+ }
+
+ if (recipe.normalizeAudio) filters.push("loudnorm=I=-14:TP=-1.5:LRA=11");
+
+ return filters.join(",");
+}
+
+export function buildAudioTrimFilter(recipe: EditRecipe): string {
+ if (recipe.trimStart === 0 && recipe.trimEnd === null) return "";
+ let trimFilter = `atrim=start=${recipe.trimStart}`;
+ if (recipe.trimEnd !== null) {
+ trimFilter += `:end=${recipe.trimEnd}`;
+ }
+ return `${trimFilter},asetpts=PTS-STARTPTS`;
+}
+
+const OVERLAY_POSITIONS: Record = {
+ "top-left": "20:20",
+ "top-right": "W-w-20:20",
+ "bottom-left": "20:H-h-20",
+ "bottom-right": "W-w-20:H-h-20",
+};
+
+export interface ExportArgsOptions {
+ recipe: EditRecipe;
+ format: Exclude;
+ inputName: string;
+ outputName: string;
+ targetW: number;
+ targetH: number;
+ videoDuration: number;
+ hasOriginalAudio: boolean;
+ music?: { inputName: string; options: BackgroundMusicOptions };
+ overlay?: { inputName: string; options: ImageOverlayOptions };
+ /** From resolveThreadCount; 0 for the single-threaded core. */
+ threads: number;
+}
+
+export function buildArguments({
+ recipe,
+ format,
+ inputName,
+ outputName,
+ targetW,
+ targetH,
+ videoDuration,
+ hasOriginalAudio,
+ music,
+ overlay,
+ threads,
+}: ExportArgsOptions): string[] {
+ const vf = buildVideoFilter(recipe, targetW, targetH);
+ const audioTrim = hasOriginalAudio ? buildAudioTrimFilter(recipe) : "";
+ const audioSpeed = hasOriginalAudio ? buildAudioFilter(recipe) : "";
+ const afParts = [audioTrim, audioSpeed].filter(Boolean);
+ const af = afParts.join(",");
+
+ const musicIdx = 1;
+ const overlayIdx = music ? 2 : 1;
+
+ const args: string[] = [];
+ args.push(...inputThreadArgs(threads), "-i", inputName);
+ if (music) {
+ if (music.options.loopMusic) args.push("-stream_loop", "-1");
+ args.push("-i", music.inputName);
+ }
+ if (overlay) {
+ args.push("-i", overlay.inputName);
+ }
+
+ const needsFilterComplex = Boolean(overlay || music);
+ const shouldKeepAudio = recipe.keepAudio && (hasOriginalAudio || Boolean(music));
+ let usesFilterComplex = false;
+
+ if (needsFilterComplex) {
+ const filterParts: string[] = [];
+ let videoOut = "[0:v]";
+
+ if (vf) {
+ filterParts.push(`[0:v]${vf}[vbase]`);
+ videoOut = "[vbase]";
+ }
+
+ if (overlay) {
+ const alpha = (overlay.options.opacity / 100).toFixed(2);
+ filterParts.push(`[${overlayIdx}:v]scale=${overlay.options.size}:-2,format=rgba,colorchannelmixer=aa=${alpha}[logo]`);
+ const pos = OVERLAY_POSITIONS[overlay.options.position] ?? OVERLAY_POSITIONS["bottom-right"];
+ filterParts.push(`${videoOut}[logo]overlay=${pos}[vout]`);
+ videoOut = "[vout]";
+ }
+
+ let audioOut = "";
+ if (shouldKeepAudio) {
+ if (music) {
+ const musicVol = (music.options.musicVolume / 100).toFixed(2);
+ if (hasOriginalAudio) {
+ const origVol = (music.options.originalAudioVolume / 100).toFixed(2);
+ const origChain = afParts.length > 0
+ ? `[0:a]${af},volume=${origVol}[orig]`
+ : `[0:a]volume=${origVol}[orig]`;
+ filterParts.push(origChain);
+ filterParts.push(`[${musicIdx}:a]volume=${musicVol}[music]`);
+ filterParts.push(`[orig][music]amix=inputs=2:duration=first:dropout_transition=0[aout]`);
+ audioOut = "[aout]";
+ } else {
+ filterParts.push(`[${musicIdx}:a]volume=${musicVol}[aout]`);
+ audioOut = "[aout]";
+ }
+ } else if (hasOriginalAudio && af) {
+ filterParts.push(`[0:a]${af}[aout]`);
+ audioOut = "[aout]";
+ }
+ }
+
+ if (filterParts.length > 0) {
+ args.push("-filter_complex", filterParts.join(";"));
+ usesFilterComplex = true;
+ }
+ args.push("-map", videoOut === "[0:v]" ? "0:v" : videoOut);
+
+ if (!shouldKeepAudio) {
+ args.push("-an");
+ } else if (audioOut) {
+ args.push("-map", audioOut);
+ } else if (hasOriginalAudio) {
+ args.push("-map", "0:a");
+ }
+ } else {
+ if (vf) args.push("-vf", vf);
+ if (!shouldKeepAudio) {
+ args.push("-an");
+ } else if (af && hasOriginalAudio) {
+ args.push("-af", af);
+ }
+ }
+
+ if (format === "webm") {
+ // VP8 + Vorbis, not VP9 + Opus: in @ffmpeg/core 0.12.10 the libvpx-vp9
+ // and libopus encoders trap ("memory access out of bounds") on every
+ // setting tried, and hang the multi-threaded core, so WebM exports could
+ // never succeed. VP8's CRF mode needs a bitrate ceiling. Realtime
+ // keeps WebM about as fast as MP4 (measured: 9 s vs 21–32 s with
+ // "good" for a 6 s 1080p clip), as the old VP9 settings intended.
+ args.push(
+ "-c:v", "libvpx",
+ "-crf", String(Math.min(63, Math.max(4, recipe.quality))),
+ "-b:v", "8M",
+ "-deadline", "realtime",
+ "-cpu-used", "8"
+ );
+ if (shouldKeepAudio) args.push("-c:a", "libvorbis", "-q:a", "4");
+ } else if (format === "mkv") {
+ args.push("-c:v", "libx264", "-crf", String(recipe.quality), "-preset", "ultrafast");
+ if (shouldKeepAudio) args.push("-c:a", "aac", "-b:a", "128k");
+ } else {
+ args.push("-c:v", "libx264", "-crf", String(recipe.quality), "-preset", "ultrafast", "-movflags", "+faststart");
+ if (shouldKeepAudio) args.push("-c:a", "aac", "-b:a", "128k");
+ }
+
+ args.push(...outputThreadArgs(threads, usesFilterComplex));
+ args.push(...outputDurationArgs(recipe, videoDuration));
+ args.push(outputName);
+ return args;
+}
+
+export interface GifArgsOptions {
+ recipe: EditRecipe;
+ inputName: string;
+ paletteName: string;
+ outputName: string;
+ targetW: number;
+ targetH: number;
+ videoDuration: number;
+ threads: number;
+}
+
+/** Two-pass GIF: palettegen, then paletteuse. */
+export function buildGifArguments({
+ recipe,
+ inputName,
+ paletteName,
+ outputName,
+ targetW,
+ targetH,
+ videoDuration,
+ threads,
+}: GifArgsOptions): { palette: string[]; render: string[] } {
+ const vf = buildVideoFilter(recipe, targetW, targetH);
+ const vfWithPalette = vf ? `${vf},palettegen` : "palettegen";
+ const vfWithPaletteUse = vf ? `[0:v]${vf}[x];[x][1:v]paletteuse` : "[0:v][1:v]paletteuse";
+ // The trim filter already bounds the clip; -t is only needed when speed
+ // changes the output length.
+ const durationArgs = recipe.speed !== 1 ? outputDurationArgs(recipe, videoDuration) : [];
+
+ return {
+ palette: [
+ ...inputThreadArgs(threads), "-i", inputName,
+ "-vf", vfWithPalette,
+ ...durationArgs, ...outputThreadArgs(threads, false),
+ "-y", paletteName,
+ ],
+ render: [
+ ...inputThreadArgs(threads), "-i", inputName, "-i", paletteName,
+ "-lavfi", vfWithPaletteUse,
+ ...durationArgs, ...outputThreadArgs(threads, true),
+ "-y", outputName,
+ ],
+ };
+}
diff --git a/src/lib/tests/exportTelemetry.test.ts b/src/lib/tests/exportTelemetry.test.ts
index 52916ac3c..fa7f9ab0b 100644
--- a/src/lib/tests/exportTelemetry.test.ts
+++ b/src/lib/tests/exportTelemetry.test.ts
@@ -43,6 +43,10 @@ const ALLOWED_KEYS = new Set([
"category",
"load_number",
"single_reason",
+ "fell_back",
+ "from",
+ "to",
+ "stall_reason",
]);
let rum: { addAction: ReturnType; addDurationVital: ReturnType };
@@ -118,6 +122,10 @@ describe("export attempt outcomes", () => {
a.failed(new Error("x"));
new ExportAttempt(context).cancelled();
new ExportAttempt(context).succeeded("25-100MB");
+ const fb = new ExportAttempt(context);
+ fb.encoding("multi");
+ fb.engineFallback("no_activity");
+ fb.succeeded("<25MB");
const load = startEngineLoad("multi");
load.failed(new Error("Failed to fetch engine file (HTTP 503)"));
startEngineLoad("single").succeeded("single");
@@ -132,6 +140,25 @@ describe("export attempt outcomes", () => {
});
});
+describe("engine fallback", () => {
+ it("reports the fallback with its reason, and the attempt's one outcome on the single-threaded engine", () => {
+ const attempt = new ExportAttempt(context);
+ attempt.encoding("multi");
+ attempt.engineFallback("no_activity");
+ expect(attempt.succeeded("<25MB")).toBe(true);
+ expect(names()).toEqual(["export_started", "engine_fallback", "export_succeeded"]);
+ expect(events()[1]!.ctx).toMatchObject({ from: "multi", to: "single", stall_reason: "no_activity", format: "mp4" });
+ expect(events()[2]!.ctx).toMatchObject({ engine: "single", fell_back: true });
+ });
+
+ it("does nothing once the attempt has an outcome", () => {
+ const attempt = new ExportAttempt(context);
+ attempt.cancelled();
+ attempt.engineFallback("no_activity");
+ expect(names()).toEqual(["export_started", "export_cancelled"]);
+ });
+});
+
describe("failure categories", () => {
it.each([
[new ExportStalledError("multi"), "encode", "timeout"],
diff --git a/src/lib/tests/ffmpegArgs.test.ts b/src/lib/tests/ffmpegArgs.test.ts
new file mode 100644
index 000000000..2e4665057
--- /dev/null
+++ b/src/lib/tests/ffmpegArgs.test.ts
@@ -0,0 +1,172 @@
+import { describe, it, expect } from "vitest";
+import {
+ buildArguments,
+ buildGifArguments,
+ outputDurationArgs,
+ resolveThreadCount,
+ MT_THREAD_CAP,
+ UNKNOWN_CORES_THREADS,
+ type ExportArgsOptions,
+} from "../ffmpegArgs";
+import { DEFAULT_RECIPE } from "../constants";
+import type { EditRecipe } from "../types";
+
+const recipe = (overrides: Partial = {}): EditRecipe => ({ ...DEFAULT_RECIPE, ...overrides });
+
+const opts = (overrides: Partial = {}): ExportArgsOptions => ({
+ recipe: recipe(),
+ format: "mp4",
+ inputName: "input.mp4",
+ outputName: "output.mp4",
+ targetW: 1280,
+ targetH: 720,
+ videoDuration: 6,
+ hasOriginalAudio: true,
+ threads: 0,
+ ...overrides,
+});
+
+/** Value following `flag`, at every occurrence. */
+const valuesOf = (args: string[], flag: string) =>
+ args.flatMap((a, i) => (a === flag ? [args[i + 1]] : []));
+
+describe("resolveThreadCount", () => {
+ it("never passes thread flags to the single-threaded core", () => {
+ expect(resolveThreadCount("single", 16)).toBe(0);
+ expect(resolveThreadCount("single")).toBe(0);
+ });
+
+ it("caps the multi-threaded core at MT_THREAD_CAP however many cores there are", () => {
+ expect(MT_THREAD_CAP).toBe(4);
+ expect(resolveThreadCount("multi", 11)).toBe(4);
+ expect(resolveThreadCount("multi", 64)).toBe(4);
+ });
+
+ it("is min(4, hardwareConcurrency || 2)", () => {
+ expect(resolveThreadCount("multi", 2)).toBe(2);
+ expect(resolveThreadCount("multi", 1)).toBe(1);
+ expect(resolveThreadCount("multi", 3)).toBe(3);
+ expect(UNKNOWN_CORES_THREADS).toBe(2);
+ expect(resolveThreadCount("multi", 0)).toBe(2);
+ expect(resolveThreadCount("multi", undefined)).toBe(2);
+ expect(resolveThreadCount("multi", NaN)).toBe(2);
+ });
+});
+
+describe("buildArguments", () => {
+ it("single-threaded: no -threads / -filter_threads anywhere", () => {
+ const args = buildArguments(opts({ threads: 0 }));
+ expect(args).not.toContain("-threads");
+ expect(args).not.toContain("-filter_threads");
+ expect(args).not.toContain("-filter_complex_threads");
+ });
+
+ it("multi-threaded: caps decoder, filter and encoder threads", () => {
+ const args = buildArguments(opts({ threads: 4 }));
+ // Decoder flag must precede -i to apply to the input.
+ expect(args.indexOf("-threads")).toBeLessThan(args.indexOf("-i"));
+ expect(valuesOf(args, "-threads")).toEqual(["4", "4"]);
+ expect(valuesOf(args, "-filter_threads")).toEqual(["4"]);
+ expect(args.at(-1)).toBe("output.mp4");
+ });
+
+ it("adds -filter_complex_threads only when a filter graph is used", () => {
+ const withOverlay = buildArguments(
+ opts({
+ threads: 4,
+ overlay: { inputName: "logo.png", options: { file: null, position: "top-left", size: 120, opacity: 80 } },
+ })
+ );
+ expect(withOverlay).toContain("-filter_complex");
+ expect(valuesOf(withOverlay, "-filter_complex_threads")).toEqual(["4"]);
+ expect(buildArguments(opts({ threads: 4 }))).not.toContain("-filter_complex_threads");
+ });
+
+ it("applies sharpness and volume (these used to be dropped by the worker's copy)", () => {
+ const args = buildArguments(opts({ recipe: recipe({ sharpness: 2, volume: 150 }) }));
+ expect(valuesOf(args, "-vf")[0]).toContain("unsharp=5:5:2:5:5:0.0");
+ expect(valuesOf(args, "-af")[0]).toContain("volume=1.50");
+ });
+
+ it("always bounds output duration when it is known, and omits it when not", () => {
+ expect(valuesOf(buildArguments(opts()), "-t")).toEqual(["6.000000"]);
+ expect(valuesOf(buildArguments(opts({ recipe: recipe({ trimStart: 1, trimEnd: 5, speed: 2 }) })), "-t")).toEqual([
+ "2.000000",
+ ]);
+ expect(buildArguments(opts({ videoDuration: 0 }))).not.toContain("-t");
+ });
+
+ it("positions an image overlay inside the frame", () => {
+ const args = buildArguments(
+ opts({ overlay: { inputName: "logo.png", options: { file: null, position: "bottom-right", size: 100, opacity: 50 } } })
+ );
+ const graph = valuesOf(args, "-filter_complex")[0]!;
+ expect(graph).toContain("overlay=W-w-20:H-h-20");
+ expect(graph).toContain("colorchannelmixer=aa=0.50");
+ });
+
+ it("mixes background music with the original audio and loops it when asked", () => {
+ const args = buildArguments(
+ opts({
+ music: { inputName: "music.mp3", options: { file: null, musicVolume: 50, originalAudioVolume: 100, loopMusic: true } },
+ })
+ );
+ expect(args.slice(0, 6)).toEqual(["-i", "input.mp4", "-stream_loop", "-1", "-i", "music.mp3"]);
+ expect(valuesOf(args, "-filter_complex")[0]).toContain("amix=inputs=2");
+ expect(valuesOf(args, "-map")).toEqual(["[vbase]", "[aout]"]);
+ });
+
+ it("drops audio when the source has none", () => {
+ expect(buildArguments(opts({ hasOriginalAudio: false }))).toContain("-an");
+ });
+
+ it("uses VP8/Vorbis for WebM (VP9 and Opus crash in @ffmpeg/core 0.12.10)", () => {
+ const args = buildArguments(opts({ format: "webm", outputName: "out.webm" }));
+ expect(valuesOf(args, "-c:v")).toEqual(["libvpx"]);
+ expect(valuesOf(args, "-c:a")).toEqual(["libvorbis"]);
+ expect(args).not.toContain("libvpx-vp9");
+ expect(args).not.toContain("libopus");
+ expect(valuesOf(args, "-b:v")).toEqual(["8M"]);
+ });
+});
+
+describe("buildGifArguments", () => {
+ const gif = (threads: number, overrides: Partial = {}) =>
+ buildGifArguments({
+ recipe: recipe({ format: "gif", ...overrides }),
+ inputName: "in.mp4",
+ paletteName: "palette.png",
+ outputName: "out.gif",
+ targetW: 480,
+ targetH: 270,
+ videoDuration: 6,
+ threads,
+ });
+
+ it("runs palettegen then paletteuse", () => {
+ const { palette, render } = gif(0);
+ expect(valuesOf(palette, "-vf")[0]).toMatch(/palettegen$/);
+ expect(valuesOf(render, "-lavfi")[0]).toMatch(/paletteuse$/);
+ expect(palette).not.toContain("-threads");
+ });
+
+ it("caps threads in both passes on the multi-threaded core", () => {
+ const { palette, render } = gif(4);
+ expect(valuesOf(palette, "-threads")).toEqual(["4", "4"]);
+ expect(valuesOf(render, "-threads")).toEqual(["4", "4"]);
+ expect(valuesOf(render, "-filter_complex_threads")).toEqual(["4"]);
+ });
+
+ it("only adds -t when speed changes the length", () => {
+ expect(gif(0).render).not.toContain("-t");
+ expect(valuesOf(gif(0, { speed: 2 }).render, "-t")).toEqual(["3.000000"]);
+ });
+});
+
+describe("outputDurationArgs", () => {
+ it("guards against zero, negative and invalid durations", () => {
+ expect(outputDurationArgs(recipe(), 0)).toEqual([]);
+ expect(outputDurationArgs(recipe({ trimStart: 8 }), 6)).toEqual([]);
+ expect(outputDurationArgs(recipe({ speed: 0 }), 6)).toEqual([]);
+ });
+});
diff --git a/src/lib/tests/ffmpegClient.test.ts b/src/lib/tests/ffmpegClient.test.ts
new file mode 100644
index 000000000..7054b38d7
--- /dev/null
+++ b/src/lib/tests/ffmpegClient.test.ts
@@ -0,0 +1,269 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import {
+ loadFFmpeg,
+ exportVideo,
+ terminateFFmpeg,
+ dedupeProgress,
+ getFFmpegClientState,
+ getEngineMode,
+ resetEnginePreferenceForTests,
+ FFmpegLoadError,
+ ExportStalledError,
+ STALL_TIMEOUT_MS,
+} from "../ffmpeg";
+import { DEFAULT_RECIPE } from "../constants";
+
+/**
+ * Scripted stand-in for the FFmpeg worker. Each test decides how the worker
+ * answers "load" and "export"; every instance is recorded so tests can check
+ * that terminated workers are never reused.
+ */
+type Script = {
+ onLoad?: (w: FakeWorker) => void;
+ onExport?: (w: FakeWorker, id: string) => void;
+};
+let script: Script = {};
+const workers: FakeWorker[] = [];
+
+class FakeWorker {
+ onmessage: ((e: MessageEvent) => void) | null = null;
+ onerror: ((e: ErrorEvent) => void) | null = null;
+ posted: { type: string; id?: string; singleThread?: boolean }[] = [];
+ terminated = false;
+ constructor() {
+ workers.push(this);
+ }
+ postMessage(msg: { type: string; id?: string; singleThread?: boolean }) {
+ this.posted.push(msg);
+ if (msg.type === "load") queueMicrotask(() => (script.onLoad ?? ((w) => w.emit({ type: "ready", mode: "single" })))(this));
+ if (msg.type === "export") queueMicrotask(() => script.onExport?.(this, msg.id!));
+ }
+ terminate() {
+ this.terminated = true;
+ }
+ emit(data: unknown) {
+ if (!this.terminated) this.onmessage?.({ data } as MessageEvent);
+ }
+}
+
+const result = (id: string) => ({
+ type: "result",
+ id,
+ data: new ArrayBuffer(8),
+ mimeType: "video/mp4",
+ size: 8,
+ width: 1920,
+ height: 1080,
+ format: "mp4",
+});
+
+const file = () => new File([new Uint8Array(16)], "clip.mp4", { type: "video/mp4" });
+const run = (signal?: AbortSignal, onProgress = vi.fn()) =>
+ exportVideo(file(), { ...DEFAULT_RECIPE }, onProgress, signal, undefined, undefined, 6);
+
+beforeEach(() => {
+ script = {};
+ workers.length = 0;
+ vi.stubGlobal("Worker", FakeWorker);
+ vi.stubGlobal("URL", Object.assign(URL, { createObjectURL: vi.fn(() => "blob:out"), revokeObjectURL: vi.fn() }));
+});
+
+afterEach(() => {
+ terminateFFmpeg();
+ resetEnginePreferenceForTests();
+ vi.useRealTimers();
+ vi.unstubAllGlobals();
+});
+
+describe("dedupeProgress", () => {
+ it("only forwards changes of the rounded percentage", () => {
+ const cb = vi.fn();
+ const p = dedupeProgress(cb)!;
+ [0, 0.2, 0.4, 1, 1.3, 1, 2, 2, 150].forEach(p);
+ expect(cb.mock.calls.map((c) => c[0])).toEqual([0, 1, 2, 100]);
+ });
+});
+
+describe("FFmpeg client lifecycle", () => {
+ it("loads once and reuses the worker across repeated exports", async () => {
+ script.onExport = (w, id) => w.emit(result(id));
+ await loadFFmpeg();
+ const a = await run();
+ const b = await run();
+ expect(a.size).toBe(8);
+ expect(b.size).toBe(8);
+ expect(workers).toHaveLength(1);
+ expect(workers[0]!.posted.filter((m) => m.type === "load")).toHaveLength(1);
+ expect(getFFmpegClientState()).toMatchObject({ hasPendingExport: false, hasPendingProgress: false });
+ });
+
+ it("does not accumulate abort listeners over repeated exports", async () => {
+ script.onExport = (w, id) => w.emit(result(id));
+ const controller = new AbortController();
+ const add = vi.spyOn(controller.signal, "addEventListener");
+ const remove = vi.spyOn(controller.signal, "removeEventListener");
+ for (let i = 0; i < 5; i++) await run(controller.signal);
+ expect(add.mock.calls.length).toBeGreaterThan(0);
+ expect(remove.mock.calls.length).toBe(add.mock.calls.length);
+ });
+
+ it("forwards de-duplicated progress for the active export", async () => {
+ script.onExport = (w, id) => {
+ [0, 0, 10, 10, 10, 55].forEach((percent) => w.emit({ type: "progress", percent }));
+ w.emit(result(id));
+ };
+ const onProgress = vi.fn();
+ await loadFFmpeg();
+ await run(undefined, onProgress);
+ expect(onProgress.mock.calls.map((c) => c[0])).toEqual([0, 10, 55]);
+ });
+
+ it("cancelling rejects with AbortError and tells the worker to stop", async () => {
+ script.onExport = () => {}; // never finishes on its own
+ const controller = new AbortController();
+ await loadFFmpeg();
+ const pending = run(controller.signal);
+ await new Promise((r) => setTimeout(r, 0));
+ controller.abort();
+ await expect(pending).rejects.toMatchObject({ name: "AbortError" });
+ expect(workers[0]!.posted.some((m) => m.type === "cancel")).toBe(true);
+ expect(getFFmpegClientState().hasPendingExport).toBe(false);
+ });
+
+ it("a worker crash fails the export, and the next export gets a fresh worker", async () => {
+ script.onExport = (w) => w.onerror?.({ message: "boom" } as ErrorEvent);
+ await loadFFmpeg();
+ await expect(run()).rejects.toBeInstanceOf(FFmpegLoadError);
+ expect(getFFmpegClientState().hasWorker).toBe(false);
+
+ script.onExport = (w, id) => w.emit(result(id));
+ const ok = await run();
+ expect(ok.size).toBe(8);
+ expect(workers).toHaveLength(2);
+ });
+
+ it("a failed engine load can be retried", async () => {
+ script.onLoad = (w) => w.emit({ type: "error", message: "Failed to fetch" });
+ await expect(loadFFmpeg()).rejects.toBeInstanceOf(FFmpegLoadError);
+
+ script.onLoad = undefined;
+ script.onExport = (w, id) => w.emit(result(id));
+ await loadFFmpeg();
+ await expect(run()).resolves.toMatchObject({ format: "mp4" });
+ });
+
+ it("terminating during engine load settles the load instead of hanging", async () => {
+ script.onLoad = () => {}; // load never completes
+ const loading = loadFFmpeg();
+ await new Promise((r) => setTimeout(r, 0));
+ terminateFFmpeg();
+ await expect(loading).rejects.toMatchObject({ name: "AbortError" });
+ expect(workers[0]!.terminated).toBe(true);
+ expect(getFFmpegClientState()).toEqual({ hasWorker: false, hasPendingExport: false, hasPendingProgress: false });
+ });
+
+ it("export results own a blob URL that dispose() revokes", async () => {
+ script.onExport = (w, id) => w.emit(result(id));
+ const res = await run();
+ res.dispose();
+ expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:out");
+ });
+
+ it("records which core the worker actually loaded", async () => {
+ script.onLoad = (w) => w.emit({ type: "ready", mode: "multi" });
+ expect(getEngineMode()).toBeNull();
+ await loadFFmpeg();
+ expect(getEngineMode()).toBe("multi");
+ });
+
+ it("a worker error without an export id fails the pending export instead of hanging it", async () => {
+ script.onExport = (w) => w.emit({ type: "error", message: "unexpected" });
+ await loadFFmpeg();
+ await expect(run()).rejects.toBeInstanceOf(FFmpegLoadError);
+ expect(workers[0]!.terminated).toBe(true);
+ expect(getFFmpegClientState()).toEqual({ hasWorker: false, hasPendingExport: false, hasPendingProgress: false });
+ });
+
+ it("cancelling during engine load discards the half-loaded worker", async () => {
+ script.onLoad = () => {}; // never finishes
+ const controller = new AbortController();
+ const loading = loadFFmpeg(controller.signal);
+ await new Promise((r) => setTimeout(r, 0));
+ controller.abort();
+ await expect(loading).rejects.toMatchObject({ name: "AbortError" });
+ expect(workers[0]!.terminated).toBe(true);
+
+ script.onLoad = undefined;
+ script.onExport = (w, id) => w.emit(result(id));
+ await expect(run()).resolves.toMatchObject({ format: "mp4" });
+ expect(workers).toHaveLength(2);
+ });
+
+ it("heartbeats keep a slow export alive", async () => {
+ vi.useFakeTimers();
+ let worker!: FakeWorker;
+ let jobId = "";
+ script.onExport = (w, id) => {
+ worker = w;
+ jobId = id;
+ };
+ const pending = run();
+ for (let t = 0; t < STALL_TIMEOUT_MS * 2; t += 10_000) {
+ await vi.advanceTimersByTimeAsync(10_000);
+ worker?.emit({ type: "heartbeat" });
+ }
+ worker.emit(result(jobId));
+ await expect(pending).resolves.toMatchObject({ size: 8 });
+ });
+
+ it("a stalled multi-threaded export is retried automatically on the single-threaded core", async () => {
+ vi.useFakeTimers();
+ // First worker: multi-threaded and wedged. Second: single-threaded and fine.
+ script.onLoad = (w) => w.emit({ type: "ready", mode: workers.length === 1 ? "multi" : "single" });
+ script.onExport = (w, id) => {
+ if (workers.length > 1) w.emit(result(id));
+ };
+ const onFallback = vi.fn();
+ const onProgress = vi.fn();
+ const recipe = { ...DEFAULT_RECIPE, trimStart: 1, trimEnd: 4, speed: 2 };
+ const pending = exportVideo(file(), recipe, onProgress, undefined, undefined, undefined, 6, onFallback);
+ await vi.advanceTimersByTimeAsync(STALL_TIMEOUT_MS + 5_000);
+ await expect(pending).resolves.toMatchObject({ format: "mp4" });
+
+ expect(onFallback).toHaveBeenCalledWith("no_activity");
+ expect(workers[0]!.terminated).toBe(true);
+ expect(workers[1]!.posted.find((m) => m.type === "load")).toMatchObject({ singleThread: true });
+ // The retry is the same export: same edits.
+ const exports = workers.flatMap((w) => w.posted).filter((m) => m.type === "export") as unknown as { recipe: unknown }[];
+ expect(exports).toHaveLength(2);
+ expect(exports[1]!.recipe).toEqual(exports[0]!.recipe);
+ expect(exports[1]!.recipe).toMatchObject({ trimStart: 1, trimEnd: 4, speed: 2 });
+ expect(onProgress).toHaveBeenCalledWith(0);
+ });
+
+ it("if the single-threaded retry stalls too, the export fails with a Retry message", async () => {
+ vi.useFakeTimers();
+ script.onLoad = (w) => w.emit({ type: "ready", mode: workers.length === 1 ? "multi" : "single" });
+ script.onExport = () => {};
+ const onFallback = vi.fn();
+ const pending = exportVideo(file(), { ...DEFAULT_RECIPE }, vi.fn(), undefined, undefined, undefined, 6, onFallback);
+ const assertion = expect(pending).rejects.toMatchObject({ name: "ExportStalledError", mode: "single" });
+ await vi.advanceTimersByTimeAsync((STALL_TIMEOUT_MS + 5_000) * 2);
+ await assertion;
+ expect(onFallback).toHaveBeenCalledTimes(1);
+ await expect(pending).rejects.toThrow(/Press Retry/);
+ });
+
+ it("does not retry a single-threaded stall or a cancelled export", async () => {
+ vi.useFakeTimers();
+ script.onExport = () => {}; // default load is single-threaded
+ const onFallback = vi.fn();
+ const pending = exportVideo(file(), { ...DEFAULT_RECIPE }, vi.fn(), undefined, undefined, undefined, 6, onFallback);
+ const assertion = expect(pending).rejects.toMatchObject({ mode: "single" });
+ await vi.advanceTimersByTimeAsync(STALL_TIMEOUT_MS + 5_000);
+ await assertion;
+ expect(onFallback).not.toHaveBeenCalled();
+ expect(workers).toHaveLength(1);
+ });
+});
+
diff --git a/src/lib/tests/ffmpegWorker.test.ts b/src/lib/tests/ffmpegWorker.test.ts
new file mode 100644
index 000000000..05ba9a0ef
--- /dev/null
+++ b/src/lib/tests/ffmpegWorker.test.ts
@@ -0,0 +1,220 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { DEFAULT_RECIPE } from "../constants";
+import type { EditRecipe } from "../types";
+
+// Drives the real worker module (the code that runs in production) against a
+// fake FFmpeg, so command-line fixes are tested where they execute rather
+// than in a copy.
+
+const h = vi.hoisted(() => {
+ type Listener = (event: { message?: string; progress?: number }) => void;
+ class FakeFFmpeg {
+ static instances: FakeFFmpeg[] = [];
+ static loadImpl: (config: Record) => Promise = async () => {};
+ static execImpl: ((args: string[]) => Promise) | null = null;
+ listeners = { progress: new Set(), log: new Set() };
+ config: Record = {};
+ execCalls: string[][] = [];
+ deleted: string[] = [];
+ files = new Map();
+ terminated = false;
+ constructor() {
+ FakeFFmpeg.instances.push(this);
+ }
+ on(event: "progress" | "log", fn: Listener) {
+ this.listeners[event].add(fn);
+ }
+ off(event: "progress" | "log", fn: Listener) {
+ this.listeners[event].delete(fn);
+ }
+ async load(config: Record) {
+ this.config = config;
+ await FakeFFmpeg.loadImpl(config);
+ }
+ async writeFile(name: string, data: Uint8Array) {
+ this.files.set(name, data);
+ }
+ async exec(args: string[]) {
+ this.execCalls.push(args);
+ if (FakeFFmpeg.execImpl) return FakeFFmpeg.execImpl(args);
+ this.listeners.log.forEach((fn) => fn({ message: "frame= 1 fps=0.0 time=00:00:00.04" }));
+ this.listeners.progress.forEach((fn) => fn({ progress: 0.5 }));
+ this.files.set(args.at(-1)!, new Uint8Array([1, 2, 3]));
+ return 0;
+ }
+ async readFile(name: string) {
+ return this.files.get(name)!;
+ }
+ async deleteFile(name: string) {
+ this.deleted.push(name);
+ }
+ terminate() {
+ this.terminated = true;
+ }
+ }
+ return { FakeFFmpeg };
+});
+
+vi.mock("@ffmpeg/ffmpeg", () => ({ FFmpeg: h.FakeFFmpeg }));
+vi.mock("@ffmpeg/util", () => ({ toBlobURL: vi.fn() }));
+
+const posted: { type: string; [k: string]: unknown }[] = [];
+let handler: ((event: MessageEvent) => void) | null = null;
+
+async function startWorker({ isolated, cores = 11 }: { isolated: boolean; cores?: number }) {
+ Object.defineProperty(window, "crossOriginIsolated", { configurable: true, value: isolated });
+ Object.defineProperty(navigator, "hardwareConcurrency", { configurable: true, value: cores });
+ const add = vi.spyOn(self, "addEventListener");
+ vi.resetModules();
+ await import("../ffmpeg.worker");
+ const call = add.mock.calls.find(([type]) => type === "message")!;
+ handler = call[1] as (event: MessageEvent) => void;
+ add.mockRestore();
+}
+
+const send = (data: unknown) => handler!({ data } as MessageEvent);
+const waitFor = (type: string) => vi.waitFor(() => expect(posted.some((m) => m.type === type)).toBe(true));
+const ffmpeg = () => h.FakeFFmpeg.instances.at(-1)!;
+
+function exportRequest(recipe: Partial = {}) {
+ return {
+ type: "export",
+ id: "job1",
+ file: { name: "My holiday.mp4", type: "video/mp4", data: new ArrayBuffer(16) },
+ recipe: { ...DEFAULT_RECIPE, ...recipe },
+ videoDuration: 6,
+ };
+}
+
+beforeEach(() => {
+ posted.length = 0;
+ h.FakeFFmpeg.instances.length = 0;
+ h.FakeFFmpeg.loadImpl = async () => {};
+ h.FakeFFmpeg.execImpl = null;
+ vi.stubGlobal("postMessage", vi.fn((msg) => posted.push(msg)));
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async () => ({ ok: true, status: 200, arrayBuffer: async () => new ArrayBuffer(1) }))
+ );
+ URL.createObjectURL = vi.fn(() => "blob:core");
+});
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+ if (handler) self.removeEventListener("message", handler as EventListener);
+ handler = null;
+});
+
+describe("ffmpeg.worker", () => {
+ it("isolated: loads the MT core and caps threads in the command it runs", async () => {
+ await startWorker({ isolated: true, cores: 16 });
+ send({ type: "load" });
+ await waitFor("ready");
+ expect(posted.find((m) => m.type === "ready")).toMatchObject({ mode: "multi" });
+ expect(ffmpeg().config.coreURL).toBeDefined();
+ expect(ffmpeg().config.workerURL).toBeDefined();
+ expect(vi.mocked(fetch).mock.calls[0]![0]).toContain("@ffmpeg/core-mt@");
+ // Every engine file, including the pthread worker script, is fetched
+ // with a Subresource Integrity hash.
+ expect(vi.mocked(fetch).mock.calls.map(([url, init]) => [String(url).split("/").pop(), Boolean((init as RequestInit)?.integrity)])).toEqual([
+ ["ffmpeg-core.js", true],
+ ["ffmpeg-core.wasm", true],
+ ["ffmpeg-core.worker.js", true],
+ ]);
+
+ send(exportRequest({ sharpness: 2, volume: 50 }));
+ await waitFor("result");
+ const args = ffmpeg().execCalls[0]!;
+ const threads = args.flatMap((a, i) => (a === "-threads" ? [args[i + 1]] : []));
+ expect(threads).toEqual(["4", "4"]);
+ // Settings the old in-worker copy silently ignored:
+ expect(args.join(" ")).toContain("unsharp=5:5:2");
+ expect(args.join(" ")).toContain("volume=0.50");
+ });
+
+ it("not isolated: loads the single-threaded core and passes no thread flags", async () => {
+ await startWorker({ isolated: false });
+ send({ type: "load" });
+ await waitFor("ready");
+ expect(posted.find((m) => m.type === "ready")).toMatchObject({ mode: "single" });
+ expect(ffmpeg().config.workerURL).toBeUndefined();
+ expect(vi.mocked(fetch).mock.calls[0]![0]).toContain("@ffmpeg/core@");
+ expect(vi.mocked(fetch).mock.calls.every(([, init]) => Boolean((init as RequestInit)?.integrity))).toBe(true);
+
+ send(exportRequest());
+ await waitFor("result");
+ expect(ffmpeg().execCalls[0]).not.toContain("-threads");
+ });
+
+ it("falls back to the single-threaded core if the MT core fails to load", async () => {
+ await startWorker({ isolated: true });
+ h.FakeFFmpeg.loadImpl = async (config) => {
+ if (config.workerURL) throw new Error("MT instantiate failed");
+ };
+ send({ type: "load" });
+ await waitFor("ready");
+ expect(posted.find((m) => m.type === "ready")).toMatchObject({ mode: "single" });
+ expect(h.FakeFFmpeg.instances[0]!.terminated).toBe(true);
+
+ send(exportRequest());
+ await waitFor("result");
+ expect(ffmpeg().execCalls[0]).not.toContain("-threads");
+ });
+
+ it("honours singleThread on load even when isolated", async () => {
+ await startWorker({ isolated: true });
+ send({ type: "load", singleThread: true });
+ await waitFor("ready");
+ expect(posted.find((m) => m.type === "ready")).toMatchObject({ mode: "single" });
+ });
+
+ it("reports a failed engine download instead of loading garbage", async () => {
+ await startWorker({ isolated: false });
+ vi.mocked(fetch).mockResolvedValue({ ok: false, status: 404, arrayBuffer: async () => new ArrayBuffer(0) } as Response);
+ send({ type: "load" });
+ await waitFor("error");
+ expect(posted.find((m) => m.type === "error")?.message).toMatch(/HTTP 404/);
+ });
+
+ it("removes its listeners and temp files after every export, and sends heartbeats", async () => {
+ await startWorker({ isolated: false });
+ send({ type: "load" });
+ await waitFor("ready");
+ for (let i = 0; i < 3; i++) {
+ posted.length = 0;
+ send({ ...exportRequest(), id: `job${i}` });
+ await waitFor("result");
+ }
+ expect(ffmpeg().listeners.log.size).toBe(0);
+ expect(ffmpeg().listeners.progress.size).toBe(0);
+ expect(ffmpeg().deleted).toEqual(expect.arrayContaining(["input_job2.mp4", "output_job2.mp4"]));
+ expect(posted.some((m) => m.type === "heartbeat")).toBe(true);
+ // Temp names come from the job id, never the user's file name.
+ expect([...ffmpeg().files.keys()].some((name) => name.includes("holiday"))).toBe(false);
+ });
+
+ it("reports a wasm trap (a rejected string) with its text, not 'undefined'", async () => {
+ await startWorker({ isolated: false });
+ send({ type: "load" });
+ await waitFor("ready");
+ h.FakeFFmpeg.execImpl = () => Promise.reject("RuntimeError: memory access out of bounds");
+ send(exportRequest());
+ await waitFor("error");
+ expect(posted.find((m) => m.type === "error")).toMatchObject({
+ id: "job1",
+ message: "RuntimeError: memory access out of bounds",
+ });
+ });
+
+ it("GIF exports use the shared two-pass builder with thread caps", async () => {
+ await startWorker({ isolated: true });
+ send({ type: "load" });
+ await waitFor("ready");
+ send(exportRequest({ format: "gif" }));
+ await waitFor("result");
+ const [palette, render] = ffmpeg().execCalls;
+ expect(palette!.join(" ")).toContain("palettegen");
+ expect(render!.join(" ")).toContain("paletteuse");
+ expect(render).toContain("-filter_complex_threads");
+ });
+});