From 8f1ecccb4d15b2ebca7fd1ebf477b619670a7a34 Mon Sep 17 00:00:00 2001
From: Akanksha Trehun
Date: Tue, 3 Nov 2026 13:00:22 +0530
Subject: [PATCH 1/6] fix(ffmpeg): stop multi-threaded exports from hanging at
100%
Production (Netlify) is cross-origin isolated, so every browser loads the
multi-threaded core. With FFmpeg choosing its own thread count, core-mt's
32-worker pool deadlocks and every export sits at "Exporting 100%" forever.
Reproduced locally with COOP/COEP for MP4, WebM, portrait and no-audio
inputs; the single-threaded core was unaffected.
Ports the export hardening from the earlier branch (33b2c5a, bf26cf1):
- src/lib/ffmpegArgs.ts: one command builder used by the worker (the old
copy in ffmpeg.ts was dead code); decoder, filter and encoder threads
capped at 4 on the multi-threaded core only
- stall watchdog with worker heartbeats: 60 s without activity fails the
export with ExportStalledError, and later attempts use the
single-threaded core
- WebM uses VP8 + Vorbis: libvpx-vp9 and libopus trap in @ffmpeg/core
0.12.10
- SRI hashes for the multi-threaded core files; falls back to the
single-threaded core if core-mt fails to load
- id-less worker errors, cancel during engine load, and string rejections
from wasm traps no longer leave the editor stuck
Co-Authored-By: Claude Opus 5.5
---
src/lib/exportEstimate.ts | 2 +-
src/lib/exportTelemetry.ts | 4 +-
src/lib/ffmpeg.ts | 433 ++++++++---------------
src/lib/ffmpeg.worker.ts | 543 ++++++++---------------------
src/lib/ffmpegArgs.ts | 364 +++++++++++++++++++
src/lib/tests/ffmpegArgs.test.ts | 169 +++++++++
src/lib/tests/ffmpegClient.test.ts | 237 +++++++++++++
src/lib/tests/ffmpegWorker.test.ts | 220 ++++++++++++
8 files changed, 1272 insertions(+), 700 deletions(-)
create mode 100644 src/lib/ffmpegArgs.ts
create mode 100644 src/lib/tests/ffmpegArgs.test.ts
create mode 100644 src/lib/tests/ffmpegClient.test.ts
create mode 100644 src/lib/tests/ffmpegWorker.test.ts
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..497712492 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";
diff --git a/src/lib/ffmpeg.ts b/src/lib/ffmpeg.ts
index 497646131..7eb543615 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 } 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,22 @@ export class FFmpegLoadError extends Error {
}
}
+/** The engine stopped reporting activity mid-export (see STALL_TIMEOUT_MS). */
+export class ExportStalledError extends Error {
+ name = "ExportStalledError";
+ constructor(readonly mode: EngineMode) {
+ super("Export stopped responding");
+ }
+}
+
+/**
+ * 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 +49,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 +67,7 @@ type WorkerCancelledResponse = { type: "cancelled"; id?: string };
type WorkerResponse =
| WorkerLoadResponse
+ | WorkerHeartbeatResponse
| WorkerProgressResponse
| WorkerResultResponse
| WorkerErrorResponse
@@ -63,10 +83,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 +122,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 +167,13 @@ function handleWorkerMessage(event: MessageEvent) {
return;
}
+ if (data.type === "heartbeat") {
+ pendingActivity?.();
+ return;
+ }
+
if (data.type === "progress") {
+ pendingActivity?.();
pendingProgress?.(data.percent);
return;
}
@@ -158,13 +208,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 +253,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 +275,7 @@ export async function loadFFmpeg(
};
const onAbort = () => {
- ffmpegWorker?.postMessage({ type: "cancel" });
- workerReadyReject?.(new DOMException("Aborted", "AbortError"));
+ terminateFFmpeg();
cleanup();
};
@@ -227,7 +283,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,6 +297,7 @@ function cancelPendingExport(reason?: unknown) {
pendingExport = null;
}
pendingProgress = null;
+ pendingActivity = null;
}
export async function exportVideo(
@@ -249,9 +306,11 @@ export async function exportVideo(
onProgress: (percent: number) => void,
signal?: AbortSignal,
musicOptions?: BackgroundMusicOptions,
- overlayOptions?: ImageOverlayOptions
+ overlayOptions?: ImageOverlayOptions,
+ /** Duration already read by the editor; skips a second metadata load. */
+ knownDuration?: number
): Promise {
- await loadFFmpeg(signal, onProgress);
+ await loadFFmpeg(signal);
if (!ffmpegWorker) {
throw new Error("FFmpeg worker is not available.");
@@ -300,7 +359,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 +377,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 +402,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 +417,8 @@ export async function exportVideo(
try {
return await exportPromise;
} finally {
+ clearInterval(watchdog);
+ pendingActivity = null;
signal?.removeEventListener("abort", onAbort);
}
}
@@ -347,30 +426,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 +499,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..09f623a51
--- /dev/null
+++ b/src/lib/ffmpegArgs.ts
@@ -0,0 +1,364 @@
+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 request per stage; 0 means "don't pass any thread flags". */
+export function resolveThreadCount(mode: EngineMode, hardwareConcurrency?: number): number {
+ if (mode !== "multi") return 0;
+ const cores =
+ hardwareConcurrency && Number.isFinite(hardwareConcurrency) && hardwareConcurrency > 0
+ ? Math.floor(hardwareConcurrency)
+ : MT_THREAD_CAP;
+ 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/ffmpegArgs.test.ts b/src/lib/tests/ffmpegArgs.test.ts
new file mode 100644
index 000000000..d354ddc59
--- /dev/null
+++ b/src/lib/tests/ffmpegArgs.test.ts
@@ -0,0 +1,169 @@
+import { describe, it, expect } from "vitest";
+import {
+ buildArguments,
+ buildGifArguments,
+ outputDurationArgs,
+ resolveThreadCount,
+ MT_THREAD_CAP,
+ 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("uses fewer threads on small devices and falls back to the cap when unknown", () => {
+ expect(resolveThreadCount("multi", 2)).toBe(2);
+ expect(resolveThreadCount("multi", 1)).toBe(1);
+ expect(resolveThreadCount("multi", 0)).toBe(4);
+ expect(resolveThreadCount("multi", undefined)).toBe(4);
+ expect(resolveThreadCount("multi", NaN)).toBe(4);
+ });
+});
+
+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..5cc3c2cda
--- /dev/null
+++ b/src/lib/tests/ffmpegClient.test.ts
@@ -0,0 +1,237 @@
+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 fails, and the retry loads the single-threaded core", async () => {
+ vi.useFakeTimers();
+ script.onLoad = (w) => w.emit({ type: "ready", mode: "multi" });
+ script.onExport = () => {}; // wedged: no progress, no heartbeat
+ const pending = run();
+ const assertion = expect(pending).rejects.toBeInstanceOf(ExportStalledError);
+ await vi.advanceTimersByTimeAsync(STALL_TIMEOUT_MS + 5_000);
+ await assertion;
+ await expect(pending).rejects.toMatchObject({ mode: "multi" });
+ expect(workers[0]!.terminated).toBe(true);
+ expect(getFFmpegClientState().hasWorker).toBe(false);
+
+ script.onLoad = undefined;
+ script.onExport = (w, id) => w.emit(result(id));
+ await expect(run()).resolves.toMatchObject({ format: "mp4" });
+ expect(workers[1]!.posted.find((m) => m.type === "load")).toMatchObject({ singleThread: true });
+ });
+});
+
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");
+ });
+});
From e85c63c3dd445fafb242fbb177ec12f67848e326 Mon Sep 17 00:00:00 2001
From: Akanksha Trehun
Date: Tue, 3 Nov 2026 13:40:11 +0530
Subject: [PATCH 2/6] fix(export): unknown durations, stuck-progress watchdog,
e2e export tests
- Live-recorded files (MediaRecorder screen recordings) report an Infinity
duration; seek to the end to find the real one (src/lib/mediaDuration.ts),
in the editor and the export. Trim works on them again.
- ffmpegArgs: time values are repaired before use (withSafeTimes), -t only
for a finite positive length, -shortest for looped music when the length
is unknown, and a final check that no argument carries Infinity or NaN.
An infinite speed no longer hangs the audio filter builder.
- Watchdog: besides 60 s of silence, stop exports whose encoded time stops
changing for 120 s while FFmpeg keeps logging (stuck at 100%), or that run
far past the expected length. The worker reports encoded time and computes
progress from it. The message says what happened and to press Retry.
- Telemetry: unknown_duration failure category and stall_reason.
- scripts/make-fixtures.sh (test/fixtures/, gitignored), a static server with
COOP/COEP (scripts/serve-isolated.mjs), Playwright export tests on the
multi- and single-threaded engines, and a Tests workflow running Vitest and
Playwright in CI.
Co-Authored-By: Claude Opus 5.5
---
.github/workflows/test.yml | 62 ++++++++
.gitignore | 7 +
bun.lock | 7 +
docs/DATADOG.md | 13 +-
.../datadog/export-reliability.dashboard.json | 52 ++++++-
package.json | 1 +
playwright.config.ts | 44 ++++++
scripts/make-fixtures.sh | 60 ++++++++
scripts/serve-isolated.mjs | 64 +++++++++
src/hooks/tests/useVideoEditor.test.ts | 1 +
src/hooks/tests/useVideoEditorProject.test.ts | 1 +
src/hooks/useVideoEditor.ts | 19 ++-
src/lib/exportTelemetry.ts | 27 +++-
src/lib/ffmpeg.ts | 99 ++++++++++---
src/lib/ffmpeg.worker.ts | 40 ++++--
src/lib/ffmpegArgs.ts | 87 ++++++++---
src/lib/mediaDuration.ts | 45 ++++++
src/lib/tests/exportTelemetry.test.ts | 36 +++--
src/lib/tests/ffmpegArgs.test.ts | 88 ++++++++++++
src/lib/tests/ffmpegClient.test.ts | 100 +++++++++++++
src/lib/tests/mediaDuration.test.ts | 53 +++++++
test/e2e/export.spec.ts | 135 ++++++++++++++++++
22 files changed, 977 insertions(+), 64 deletions(-)
create mode 100644 .github/workflows/test.yml
create mode 100644 playwright.config.ts
create mode 100755 scripts/make-fixtures.sh
create mode 100644 scripts/serve-isolated.mjs
create mode 100644 src/lib/mediaDuration.ts
create mode 100644 src/lib/tests/mediaDuration.test.ts
create mode 100644 test/e2e/export.spec.ts
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
new file mode 100644
index 000000000..319617403
--- /dev/null
+++ b/.github/workflows/test.yml
@@ -0,0 +1,62 @@
+name: Tests
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: tests-${{ github.event.pull_request.number || github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ unit:
+ name: Unit tests (Vitest)
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ steps:
+ - uses: actions/checkout@v6
+ - uses: oven-sh/setup-bun@v2
+ - run: bun install --frozen-lockfile
+ - run: bun run test --run
+
+ e2e:
+ name: Export end-to-end (Playwright)
+ runs-on: ubuntu-latest
+ timeout-minutes: 45
+ steps:
+ - uses: actions/checkout@v6
+ - uses: oven-sh/setup-bun@v2
+ - run: bun install --frozen-lockfile
+
+ # ffmpeg makes the input fixtures and ffprobe checks the exported files.
+ - name: Install ffmpeg
+ run: sudo apt-get update && sudo apt-get install -y --no-install-recommends ffmpeg
+
+ - name: Make video fixtures
+ run: scripts/make-fixtures.sh --small
+
+ # Telemetry off: CI must never report to Datadog.
+ - name: Build the static site
+ run: bun run build
+ env:
+ NEXT_PUBLIC_DATADOG_ENV: "off"
+
+ - name: Install Chromium for Playwright
+ run: bunx playwright install --with-deps chromium
+
+ # Served with COOP/COEP (multi-threaded core, as in production) and
+ # without (single-threaded core); see playwright.config.ts.
+ - name: Run Playwright
+ run: bunx playwright test
+
+ - name: Upload Playwright report
+ if: failure()
+ uses: actions/upload-artifact@v4
+ with:
+ name: playwright-results
+ path: test-results/
+ retention-days: 7
diff --git a/.gitignore b/.gitignore
index 1d982f0b4..cc717af46 100644
--- a/.gitignore
+++ b/.gitignore
@@ -50,3 +50,10 @@ chromatic.log
# Local Netlify folder
.netlify
+
+# generated video fixtures (scripts/make-fixtures.sh)
+/test/fixtures/
+
+# playwright
+/test-results/
+/playwright-report/
diff --git a/bun.lock b/bun.lock
index c91ebf99a..ffc20a3a5 100644
--- a/bun.lock
+++ b/bun.lock
@@ -22,6 +22,7 @@
"wasm-feature-detect": "^1.8.0",
},
"devDependencies": {
+ "@playwright/test": "1.64.1",
"@storybook/addon-a11y": "^10.5.10",
"@storybook/addon-docs": "^10.5.10",
"@storybook/addon-themes": "^10.5.10",
@@ -566,6 +567,8 @@
"@oxc-resolver/binding-win32-x64-msvc": ["@oxc-resolver/binding-win32-x64-msvc@11.21.2", "", { "os": "win32", "cpu": "x64" }, "sha512-VPoCAhKvCQTlG7vxqaBXcmuvbh77BfnGXj8g0pbvVXpm1F/R8rDVwqIWcEbMzrI1JvJlm8v7T9uMVQb6UctMRg=="],
+ "@playwright/test": ["@playwright/test@1.64.1", "", { "dependencies": { "playwright": "1.64.1" }, "bin": { "playwright": "cli.js" } }, "sha512-IaYGml/nSOiZWROfPtuIL7neuu9qDOZ3TNvNljUIrhDMxiGTgPjzj/g9p9RxpS2tSepPDRY+UwAaEeiKtUvIAg=="],
+
"@pmmmwh/react-refresh-webpack-plugin": ["@pmmmwh/react-refresh-webpack-plugin@0.5.17", "", { "dependencies": { "ansi-html": "^0.0.9", "core-js-pure": "^3.23.3", "error-stack-parser": "^2.0.6", "html-entities": "^2.1.0", "loader-utils": "^2.0.4", "schema-utils": "^4.2.0", "source-map": "^0.7.3" }, "peerDependencies": { "@types/webpack": "4.x || 5.x", "react-refresh": ">=0.10.0 <1.0.0", "sockjs-client": "^1.4.0", "type-fest": ">=0.17.0 <5.0.0", "webpack": ">=4.43.0 <6.0.0", "webpack-dev-server": "3.x || 4.x || 5.x", "webpack-hot-middleware": "2.x", "webpack-plugin-serve": "0.x || 1.x" }, "optionalPeers": ["@types/webpack", "sockjs-client", "type-fest", "webpack-dev-server", "webpack-hot-middleware", "webpack-plugin-serve"] }, "sha512-tXDyE1/jzFsHXjhRZQ3hMl0IVhYe5qula43LDWIhVfjp9G/nT5OQY5AORVOrkEGAUltBJOfOWeETbmhm6kHhuQ=="],
"@rolldown/binding-android-arm-eabi": ["@rolldown/binding-android-arm-eabi@1.2.6", "", { "os": "android", "cpu": "arm" }, "sha512-b+jTcARdTiFLI6jB4a5XjTm0RWd6KcRfQj/I2356fxUZemiho9zQLxo0RtCuMDAyKcLo6cEltkgbQp6d1+sjjQ=="],
@@ -1626,6 +1629,10 @@
"pkg-dir": ["pkg-dir@7.0.0", "", { "dependencies": { "find-up": "^6.3.0" } }, "sha512-Ie9z/WINcxxLp27BKOCHGde4ITq9UklYKDzVo1nhk5sqGEXU3FpkwP5GM2voTGJkGd9B3Otl+Q4uwSOeSUtOBA=="],
+ "playwright": ["playwright@1.64.1", "", { "dependencies": { "playwright-core": "1.64.1" }, "bin": { "playwright": "cli.js" } }, "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA=="],
+
+ "playwright-core": ["playwright-core@1.64.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ=="],
+
"possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="],
"postcss": ["postcss@8.5.26", "", { "dependencies": { "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ=="],
diff --git a/docs/DATADOG.md b/docs/DATADOG.md
index 38f011b21..5bbea22c3 100644
--- a/docs/DATADOG.md
+++ b/docs/DATADOG.md
@@ -44,7 +44,7 @@ its own, so we can see which browser and device combinations fail.
|---|---|---|
| `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_failed` | Terminal | the context, plus `engine`, `durationMs`, `stage` (`engine` / `encode`), `category` |
+| `export_failed` | Terminal | the context, plus `engine`, `durationMs`, `stage` (`engine` / `encode`), `category`, and `stall_reason` for stalls |
| `export_cancelled` | Terminal: the user cancelled | the context, plus `engine`, `durationMs`, `stage` |
| `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` |
@@ -68,12 +68,21 @@ as part of the schema.
| Category | Meaning |
|---|---|
| `engine_load` | The engine never became ready (any failure in the `engine` stage) |
-| `timeout` | No progress for 60 s; the next attempt uses the single-threaded engine |
+| `timeout` | The stall watchdog stopped the export (see `stall_reason`); the next attempt uses the single-threaded engine |
| `unsupported_format` | FFmpeg couldn't decode the input |
| `out_of_memory` | An allocation failed, or a `RangeError` |
| `encoder_crash` | A WASM trap in the encoder (`memory access out of bounds`, `unreachable`) |
| `network` | A network error after the engine started |
| `encode_error` | Any other FFmpeg failure |
+| `unknown_duration` | The browser couldn't tell how long the clip is (`clip_length: unknown`), and the export then stalled or failed. Usually a live recording with no duration header that the seek-to-end fallback couldn't resolve |
+
+`export_failed.stall_reason` (stalls only):
+
+| Reason | Meaning |
+|---|---|
+| `no_activity` | No heartbeat or log line for 60 s: the engine is wedged (the multi-threaded thread-pool deadlock looked like this) |
+| `no_progress` | FFmpeg kept logging, but the encoded time didn't change for 120 s ("stuck at 100%") |
+| `overrun` | Encoding ran past 1.25 × the expected length + 15 s, for example looped music that never stops |
### Guarantees (covered by tests)
diff --git a/docs/datadog/export-reliability.dashboard.json b/docs/datadog/export-reliability.dashboard.json
index 3ebee75c5..53b2a31b9 100644
--- a/docs/datadog/export-reliability.dashboard.json
+++ b/docs/datadog/export-reliability.dashboard.json
@@ -1,5 +1,5 @@
{
- "title": "Reframe — export reliability",
+ "title": "Reframe \u2014 export reliability",
"description": "Export outcomes from Reframe's browser RUM events (src/lib/exportTelemetry.ts). Success rate = export_succeeded / (export_succeeded + export_failed); cancellations are excluded. Treat groups with few finished exports as noise. Setup and facets: docs/DATADOG.md.",
"layout_type": "ordered",
"template_variables": [
@@ -1009,6 +1009,56 @@
}
]
}
+ },
+ {
+ "definition": {
+ "title": "Stalled exports by reason and engine",
+ "type": "query_table",
+ "requests": [
+ {
+ "response_format": "scalar",
+ "queries": [
+ {
+ "data_source": "rum",
+ "name": "stalled",
+ "indexes": [
+ "*"
+ ],
+ "compute": {
+ "aggregation": "count"
+ },
+ "search": {
+ "query": "@type:action service:reframe $env @action.target.name:export_failed @context.category:(timeout OR unknown_duration)"
+ },
+ "group_by": [
+ {
+ "facet": "@context.stall_reason",
+ "limit": 3,
+ "sort": {
+ "aggregation": "count",
+ "order": "desc"
+ }
+ },
+ {
+ "facet": "@context.engine",
+ "limit": 3,
+ "sort": {
+ "aggregation": "count",
+ "order": "desc"
+ }
+ }
+ ]
+ }
+ ],
+ "formulas": [
+ {
+ "formula": "stalled",
+ "alias": "Stalled exports"
+ }
+ ]
+ }
+ ]
+ }
}
]
}
diff --git a/package.json b/package.json
index 8cf6b3057..5037164c9 100644
--- a/package.json
+++ b/package.json
@@ -31,6 +31,7 @@
"wasm-feature-detect": "^1.8.0"
},
"devDependencies": {
+ "@playwright/test": "1.64.1",
"@storybook/addon-a11y": "^10.5.10",
"@storybook/addon-docs": "^10.5.10",
"@storybook/addon-themes": "^10.5.10",
diff --git a/playwright.config.ts b/playwright.config.ts
new file mode 100644
index 000000000..533b7f532
--- /dev/null
+++ b/playwright.config.ts
@@ -0,0 +1,44 @@
+import { defineConfig, devices } from "@playwright/test";
+
+// End-to-end export tests against the production build (out/), served with
+// the same cross-origin isolation headers as Netlify.
+//
+// scripts/make-fixtures.sh --small
+// NEXT_PUBLIC_DATADOG_ENV=off bun run build
+// bunx playwright test
+//
+// Two projects: "multi-threaded" (COOP/COEP set, so @ffmpeg/core-mt loads,
+// as on the live site) and "single-threaded" (no isolation headers).
+export default defineConfig({
+ testDir: "test/e2e",
+ // Each test runs real FFmpeg.wasm encodes; one worker keeps CPU contention
+ // from making timings meaningless.
+ workers: 1,
+ fullyParallel: false,
+ timeout: 240_000,
+ expect: { timeout: 15_000 },
+ retries: process.env.CI ? 1 : 0,
+ reporter: process.env.CI ? [["list"], ["junit", { outputFile: "test-results/e2e-junit.xml" }]] : "list",
+ use: {
+ ...devices["Desktop Chrome"],
+ acceptDownloads: true,
+ trace: "retain-on-failure",
+ },
+ projects: [
+ { name: "multi-threaded", use: { baseURL: "http://127.0.0.1:4173" } },
+ { name: "single-threaded", use: { baseURL: "http://127.0.0.1:4174" } },
+ ],
+ webServer: [
+ {
+ command: "node scripts/serve-isolated.mjs 4173",
+ url: "http://127.0.0.1:4173",
+ reuseExistingServer: !process.env.CI,
+ },
+ {
+ command: "node scripts/serve-isolated.mjs 4174",
+ url: "http://127.0.0.1:4174",
+ env: { ISOLATED: "0" },
+ reuseExistingServer: !process.env.CI,
+ },
+ ],
+});
diff --git a/scripts/make-fixtures.sh b/scripts/make-fixtures.sh
new file mode 100755
index 000000000..495a63284
--- /dev/null
+++ b/scripts/make-fixtures.sh
@@ -0,0 +1,60 @@
+#!/usr/bin/env bash
+# Recreates the video fixtures used by the end-to-end export tests.
+# Output goes to test/fixtures/ (gitignored). Needs the ffmpeg CLI
+# (macOS: `brew install ffmpeg`, Ubuntu: `sudo apt-get install ffmpeg`).
+#
+# scripts/make-fixtures.sh # all fixtures
+# scripts/make-fixtures.sh --small # everything except the ~300 MB clip
+set -euo pipefail
+
+cd "$(dirname "$0")/.."
+OUT=test/fixtures
+mkdir -p "$OUT"
+
+command -v ffmpeg >/dev/null || { echo "ffmpeg not found; install it first." >&2; exit 1; }
+
+ff() { ffmpeg -hide_banner -loglevel error -y "$@"; }
+
+# A moving test pattern with a tone, so trims, speed changes and audio edits
+# are visible in the output.
+video() { echo "testsrc2=size=$1:rate=30:duration=$2"; }
+tone() { echo "sine=frequency=440:sample_rate=48000:duration=$1"; }
+
+echo "normal.mp4: 1280x720 H.264/AAC, 10 s"
+ff -f lavfi -i "$(video 1280x720 10)" -f lavfi -i "$(tone 10)" \
+ -c:v libx264 -pix_fmt yuv420p -preset veryfast -c:a aac -b:a 128k \
+ -movflags +faststart -shortest "$OUT/normal.mp4"
+
+# Browser screen recordings (MediaRecorder) are written as a live stream: the
+# WebM header has no duration and there are no cues, so .duration is
+# Infinity until the whole file is read. `-live 1` produces the same layout.
+echo "no-duration.webm: 1280x720 VP8/Opus, 6 s, no duration in the container"
+ff -f lavfi -i "$(video 1280x720 6)" -f lavfi -i "$(tone 6)" \
+ -c:v libvpx -b:v 1M -deadline realtime -cpu-used 8 -c:a libopus \
+ -f webm -live 1 -shortest "$OUT/no-duration.webm"
+
+echo "portrait.mp4: 720x1280 H.264/AAC, 6 s"
+ff -f lavfi -i "$(video 720x1280 6)" -f lavfi -i "$(tone 6)" \
+ -c:v libx264 -pix_fmt yuv420p -preset veryfast -c:a aac -b:a 128k \
+ -movflags +faststart -shortest "$OUT/portrait.mp4"
+
+echo "no-audio.mp4: 1280x720 H.264, 6 s, no audio track"
+ff -f lavfi -i "$(video 1280x720 6)" \
+ -c:v libx264 -pix_fmt yuv420p -preset veryfast -an \
+ -movflags +faststart "$OUT/no-audio.mp4"
+
+if [[ "${1:-}" != "--small" ]]; then
+ # Noise defeats compression, so a constant 40 Mb/s for 60 s lands near 300 MB.
+ echo "large.mp4: 1920x1080 H.264/AAC, 60 s, ~300 MB (memory testing)"
+ ff -f lavfi -i "$(video 1920x1080 60),noise=alls=60:allf=t" -f lavfi -i "$(tone 60)" \
+ -c:v libx264 -pix_fmt yuv420p -preset ultrafast \
+ -b:v 40M -minrate 40M -maxrate 40M -bufsize 80M \
+ -c:a aac -b:a 128k -movflags +faststart -shortest "$OUT/large.mp4"
+fi
+
+echo
+for f in "$OUT"/*.{mp4,webm}; do
+ [[ -e "$f" ]] || continue
+ printf '%-28s %8s duration=%s\n' "$f" "$(du -h "$f" | cut -f1)" \
+ "$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$f")"
+done
diff --git a/scripts/serve-isolated.mjs b/scripts/serve-isolated.mjs
new file mode 100644
index 000000000..0c2a62cae
--- /dev/null
+++ b/scripts/serve-isolated.mjs
@@ -0,0 +1,64 @@
+#!/usr/bin/env node
+// Serves the static export (out/) with the same cross-origin isolation headers
+// as production (netlify.toml), so the multi-threaded FFmpeg core loads exactly
+// as it does on the live site. Used by the Playwright tests.
+//
+// node scripts/serve-isolated.mjs [port] (default 4173)
+// ISOLATED=0 node scripts/serve-isolated.mjs (no COOP/COEP: single-threaded)
+import { createServer } from "node:http";
+import { readFile, stat } from "node:fs/promises";
+import { extname, join, normalize, resolve } from "node:path";
+
+const root = resolve(process.cwd(), "out");
+const port = Number(process.argv[2] ?? process.env.PORT ?? 4173);
+const isolated = process.env.ISOLATED !== "0";
+
+const TYPES = {
+ ".html": "text/html; charset=utf-8",
+ ".js": "text/javascript; charset=utf-8",
+ ".mjs": "text/javascript; charset=utf-8",
+ ".css": "text/css; charset=utf-8",
+ ".json": "application/json",
+ ".svg": "image/svg+xml",
+ ".png": "image/png",
+ ".jpg": "image/jpeg",
+ ".ico": "image/x-icon",
+ ".webp": "image/webp",
+ ".woff2": "font/woff2",
+ ".wasm": "application/wasm",
+ ".txt": "text/plain; charset=utf-8",
+ ".webmanifest": "application/manifest+json",
+};
+
+async function resolveFile(urlPath) {
+ const clean = normalize(decodeURIComponent(urlPath.split("?")[0])).replace(/^(\.\.[/\\])+/, "");
+ const base = join(root, clean);
+ if (!base.startsWith(root)) return null;
+ for (const candidate of [base, `${base}.html`, join(base, "index.html")]) {
+ try {
+ if ((await stat(candidate)).isFile()) return candidate;
+ } catch {
+ // try the next candidate
+ }
+ }
+ return null;
+}
+
+createServer(async (req, res) => {
+ const headers = { "X-Content-Type-Options": "nosniff" };
+ if (isolated) {
+ headers["Cross-Origin-Opener-Policy"] = "same-origin";
+ headers["Cross-Origin-Embedder-Policy"] = "require-corp";
+ }
+ const file = await resolveFile(req.url ?? "/");
+ if (!file) {
+ const notFound = await resolveFile("/404");
+ res.writeHead(404, { ...headers, "Content-Type": TYPES[".html"] });
+ res.end(notFound ? await readFile(notFound) : "Not found");
+ return;
+ }
+ res.writeHead(200, { ...headers, "Content-Type": TYPES[extname(file)] ?? "application/octet-stream" });
+ res.end(await readFile(file));
+}).listen(port, "127.0.0.1", () => {
+ console.log(`Serving out/ on http://127.0.0.1:${port} (cross-origin isolated: ${isolated})`);
+});
diff --git a/src/hooks/tests/useVideoEditor.test.ts b/src/hooks/tests/useVideoEditor.test.ts
index 8e164d314..e8465d046 100644
--- a/src/hooks/tests/useVideoEditor.test.ts
+++ b/src/hooks/tests/useVideoEditor.test.ts
@@ -10,6 +10,7 @@ vi.mock("@/lib/ffmpeg", () => ({
terminateFFmpeg: vi.fn(),
getEngineMode: vi.fn(() => null),
FFmpegLoadError: class extends Error {},
+ ExportStalledError: class extends Error {},
}));
vi.mock("@/lib/sessionDB", () => ({
diff --git a/src/hooks/tests/useVideoEditorProject.test.ts b/src/hooks/tests/useVideoEditorProject.test.ts
index c0b2c0e9a..c3edea682 100644
--- a/src/hooks/tests/useVideoEditorProject.test.ts
+++ b/src/hooks/tests/useVideoEditorProject.test.ts
@@ -20,6 +20,7 @@ vi.mock("@/lib/ffmpeg", () => ({
getEngineMode: vi.fn(() => "multi"),
formatBytes: (n: number) => `${n} B`,
FFmpegLoadError: class extends Error {},
+ ExportStalledError: class extends Error {},
}));
vi.mock("@/lib/sessionDB", () => ({
diff --git a/src/hooks/useVideoEditor.ts b/src/hooks/useVideoEditor.ts
index 020d334a8..f78dd43a3 100644
--- a/src/hooks/useVideoEditor.ts
+++ b/src/hooks/useVideoEditor.ts
@@ -30,7 +30,14 @@ import {
} from "@/lib/project";
import { DEFAULT_RECIPE, SPEED_STEPS } from "@/lib/constants";
import { getPresetById } from "@/lib/presets";
-import { loadFFmpeg, exportVideo, terminateFFmpeg, getEngineMode, FFmpegLoadError } from "@/lib/ffmpeg";
+import {
+ loadFFmpeg,
+ exportVideo,
+ terminateFFmpeg,
+ getEngineMode,
+ FFmpegLoadError,
+ ExportStalledError,
+} from "@/lib/ffmpeg";
import {
addTelemetryRedaction,
durationBucket,
@@ -62,6 +69,7 @@ import {
EDITOR_STATE_KEY,
} from "@/lib/editorPersistence";
import { saveSessionFile, loadSessionFile, clearSessionFile } from "@/lib/sessionDB";
+import { resolveDuration } from "@/lib/mediaDuration";
const DEFAULT_TITLE = "Reframe — Resize, trim, and export videos in your browser";
const PROJECT_SAVE_DELAY_MS = 500;
@@ -130,12 +138,14 @@ export function extractMetadata(
}, 5000);
video.preload = "metadata";
- video.onloadedmetadata = () => {
+ video.onloadedmetadata = async () => {
clearTimeout(timeout);
+ // Screen recordings report an Infinity duration until read to the end.
+ const duration = await resolveDuration(video);
resolve({
width: video.videoWidth,
height: video.videoHeight,
- duration: isFinite(video.duration) ? video.duration : 0,
+ duration,
});
URL.revokeObjectURL(url);
};
@@ -775,7 +785,8 @@ export function useVideoEditor() {
// Reported above as export_failed; the prefix keeps RUM's automatic
// console-error collection from reporting it a second time.
console.error(`${HANDLED_ERROR_PREFIX} export failed:`, err);
- if (err instanceof FFmpegLoadError) {
+ if (err instanceof FFmpegLoadError || err instanceof ExportStalledError) {
+ // Both messages say what happened and what to do next.
setError(err.message);
} else if (err instanceof Error && err.message.includes("network")) {
setError("Network error. Check your internet connection and try again.");
diff --git a/src/lib/exportTelemetry.ts b/src/lib/exportTelemetry.ts
index 497712492..56ea5b3c0 100644
--- a/src/lib/exportTelemetry.ts
+++ b/src/lib/exportTelemetry.ts
@@ -20,7 +20,14 @@ export type ExportFailureCategory =
| "out_of_memory"
| "encoder_crash"
| "network"
- | "encode_error";
+ | "encode_error"
+ // The browser couldn't tell how long the clip is, and the export then
+ // stalled or failed. Usually a live recording with no duration header.
+ | "unknown_duration";
+
+/** Which watchdog rule stopped a stalled export (see ExportStalledError). */
+export type StallReason = "no_activity" | "no_progress" | "overrun";
+const STALL_REASONS: readonly StallReason[] = ["no_activity", "no_progress", "overrun"];
export type EngineLoadFailureCategory = "network" | "integrity" | "wasm" | "worker_script" | "unknown";
@@ -144,8 +151,22 @@ export class ExportAttempt {
return null;
}
this.outcome = "failed";
- const category = exportFailureCategory(err, this.stage);
- trackAction("export_failed", { ...this.common(), stage: this.stage, category });
+ let category = exportFailureCategory(err, this.stage);
+ if (
+ this.context.clip_length === "unknown" &&
+ this.stage === "encode" &&
+ (category === "timeout" || category === "encode_error")
+ ) {
+ category = "unknown_duration";
+ }
+ const reason = (err as { reason?: unknown } | null)?.reason;
+ const stallReason = STALL_REASONS.find((r) => r === reason);
+ trackAction("export_failed", {
+ ...this.common(),
+ stage: this.stage,
+ category,
+ ...(stallReason ? { stall_reason: stallReason } : {}),
+ });
return category;
}
diff --git a/src/lib/ffmpeg.ts b/src/lib/ffmpeg.ts
index 7eb543615..350872a71 100644
--- a/src/lib/ffmpeg.ts
+++ b/src/lib/ffmpeg.ts
@@ -1,7 +1,8 @@
import { EditRecipe, ExportResult, BackgroundMusicOptions, ImageOverlayOptions, MAX_FILE_SIZE } from "./types";
import { trackTiming } from "./telemetry";
-import { startEngineLoad } from "./exportTelemetry";
-import type { EngineMode } from "./ffmpegArgs";
+import { startEngineLoad, type StallReason } from "./exportTelemetry";
+import { expectedOutputSeconds, isUsableDuration, type EngineMode } from "./ffmpegArgs";
+import { resolveDuration } from "./mediaDuration";
// The command line itself is built in ffmpegArgs.ts and run by the worker.
export { buildVideoFilter, buildAudioFilter } from "./ffmpegArgs";
@@ -14,11 +15,31 @@ export class FFmpegLoadError extends Error {
}
}
-/** The engine stopped reporting activity mid-export (see STALL_TIMEOUT_MS). */
+/**
+ * Why the watchdog gave up on an export:
+ * - no_activity: the engine went silent (the multi-threaded deadlock);
+ * - no_progress: FFmpeg kept logging but encoded nothing new;
+ * - overrun: it encoded well past the length the export should have.
+ */
+export type { StallReason };
+
+const STALL_MESSAGES: Record = {
+ no_activity: "The export stopped responding.",
+ no_progress: "The export stopped making progress.",
+ overrun: "The export kept running past the end of the video.",
+};
+
+/** The watchdog stopped an export that wasn't going to finish. */
export class ExportStalledError extends Error {
name = "ExportStalledError";
- constructor(readonly mode: EngineMode) {
- super("Export stopped responding");
+ constructor(
+ readonly mode: EngineMode,
+ readonly reason: StallReason = "no_activity"
+ ) {
+ super(
+ `${STALL_MESSAGES[reason]} Press Retry to try again` +
+ (mode === "multi" ? " with the single-threaded engine, which is slower but more reliable." : ".")
+ );
}
}
@@ -30,6 +51,19 @@ export class ExportStalledError extends Error {
*/
export const STALL_TIMEOUT_MS = 60_000;
+/**
+ * FFmpeg can keep printing log lines while encoding nothing (for example a
+ * filter that never gets input). Two minutes without a new encoded second
+ * means it won't finish. Long enough for the slowest legitimate gap: the GIF
+ * palette pass and muxing a large file at the end.
+ */
+export const NO_PROGRESS_TIMEOUT_MS = 120_000;
+
+/** How far past the expected length encoding may run before it's an overrun. */
+export function overrunLimitSeconds(expectedSeconds: number): number {
+ return expectedSeconds * 1.25 + 15;
+}
+
type SerializedFile = {
name: string;
@@ -51,7 +85,7 @@ type WorkerExportRequest = {
type WorkerLoadResponse = { type: "ready"; mode: EngineMode };
type WorkerHeartbeatResponse = { type: "heartbeat" };
-type WorkerProgressResponse = { type: "progress"; percent: number };
+type WorkerProgressResponse = { type: "progress"; percent: number; outputSeconds?: number };
type WorkerResultResponse = {
type: "result";
id: string;
@@ -83,8 +117,11 @@ 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;
+/**
+ * Called on any sign of life from the worker during an export, with the
+ * encoded output time when the worker reports it.
+ */
+let pendingActivity: ((outputSeconds?: number) => void) | null = null;
/** The core the current worker loaded, once it is ready. */
let engineMode: EngineMode | null = null;
/**
@@ -173,7 +210,7 @@ function handleWorkerMessage(event: MessageEvent) {
}
if (data.type === "progress") {
- pendingActivity?.();
+ pendingActivity?.(data.outputSeconds);
pendingProgress?.(data.percent);
return;
}
@@ -377,18 +414,39 @@ export async function exportVideo(
};
signal?.addEventListener("abort", onAbort, { once: true });
+ const videoDuration = isUsableDuration(knownDuration) ? knownDuration : await getVideoDuration(file);
+ const expectedSeconds = expectedOutputSeconds(recipe, videoDuration);
+
const mode = engineMode ?? "single";
let lastActivity = Date.now();
- pendingActivity = () => {
- lastActivity = Date.now();
+ let lastAdvance = Date.now();
+ let encodedSeconds = -1;
+ let overrun = false;
+ pendingActivity = (outputSeconds) => {
+ const now = Date.now();
+ lastActivity = now;
+ if (outputSeconds === undefined || outputSeconds === encodedSeconds) return;
+ // Any change counts: the GIF export restarts its clock for the second pass.
+ encodedSeconds = outputSeconds;
+ lastAdvance = now;
+ if (expectedSeconds !== null && outputSeconds > overrunLimitSeconds(expectedSeconds)) overrun = true;
};
const watchdog = setInterval(() => {
- if (Date.now() - lastActivity < STALL_TIMEOUT_MS) return;
+ const now = Date.now();
+ const reason: StallReason | null = overrun
+ ? "overrun"
+ : now - lastActivity >= STALL_TIMEOUT_MS
+ ? "no_activity"
+ : // Only once the worker reports encoded time; without it, activity is all we know.
+ encodedSeconds >= 0 && now - lastAdvance >= NO_PROGRESS_TIMEOUT_MS
+ ? "no_progress"
+ : null;
+ if (!reason) 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.
+ cancelPendingExport(new ExportStalledError(mode, reason));
+ // A wedged worker can't be told to stop; discard it (this also frees its
+ // memory, including the files in FFmpeg's virtual filesystem).
resetWorker();
}, 5_000);
@@ -402,10 +460,7 @@ export async function exportVideo(
id: sessionId,
file: filePayload,
recipe,
- videoDuration:
- knownDuration && Number.isFinite(knownDuration) && knownDuration > 0
- ? knownDuration
- : await getVideoDuration(file),
+ videoDuration,
musicFile: musicFilePayload,
musicOptions: sanitizedMusicOptions,
overlayFile: overlayFilePayload,
@@ -436,7 +491,11 @@ async function getVideoDuration(file: File): Promise {
// 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.onloadedmetadata = () => {
+ clearTimeout(timer);
+ // Live-recorded files need a seek before the browser knows their length.
+ void resolveDuration(video).then(finish);
+ };
video.onerror = () => finish(0);
video.src = url;
});
diff --git a/src/lib/ffmpeg.worker.ts b/src/lib/ffmpeg.worker.ts
index 72ed5ee5e..cd32fe559 100644
--- a/src/lib/ffmpeg.worker.ts
+++ b/src/lib/ffmpeg.worker.ts
@@ -2,7 +2,13 @@ import { FFmpeg } from "@ffmpeg/ffmpeg";
import { toBlobURL } from "@ffmpeg/util";
import { EditRecipe, BackgroundMusicOptions, ImageOverlayOptions } from "./types";
import { getPresetById } from "./presets";
-import { buildArguments, buildGifArguments, resolveThreadCount, type EngineMode } from "./ffmpegArgs";
+import {
+ buildArguments,
+ buildGifArguments,
+ expectedOutputSeconds,
+ 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.
@@ -58,7 +64,11 @@ type TerminateRequest = { type: "terminate" };
type WorkerCommand = LoadRequest | ExportRequest | CancelRequest | TerminateRequest;
-type ProgressPayload = { type: "progress"; percent: number };
+/**
+ * `outputSeconds`: how much output has been encoded, so the client can tell an
+ * export that is advancing from one that is stuck or running past its end.
+ */
+type ProgressPayload = { type: "progress"; percent: number; outputSeconds?: number };
type ReadyPayload = { type: "ready"; mode: EngineMode };
@@ -238,16 +248,30 @@ async function runExport(request: ExportRequest): Promise {
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.
+ // FFmpeg's own `progress` divides by the duration in the file header, which
+ // live-recorded files (MediaRecorder) don't have. Prefer encoded time over
+ // the length we expect, which also accounts for trim and speed.
+ const expectedSeconds = expectedOutputSeconds(recipe, videoDuration);
+ // FFmpeg emits progress per frame; post only when the whole percentage or
+ // the encoded second changes so the main thread isn't flooded.
let lastPercent = -1;
- const handleProgress = ({ progress }: { progress: number }) => {
+ let lastSecond = -1;
+ const handleProgress = ({ progress, time }: { progress: number; time?: number }) => {
if (activeExportId !== sessionId) return;
heartbeat();
- const percent = Math.max(0, Math.min(99, Math.round(progress * 100)));
- if (percent === lastPercent) return;
+ const outputSeconds = typeof time === "number" && Number.isFinite(time) && time > 0 ? time / 1e6 : undefined;
+ const fraction =
+ expectedSeconds !== null && outputSeconds !== undefined
+ ? outputSeconds / expectedSeconds
+ : Number.isFinite(progress)
+ ? progress
+ : 0;
+ const percent = Math.max(0, Math.min(99, Math.round(fraction * 100)));
+ const second = outputSeconds === undefined ? -1 : Math.floor(outputSeconds);
+ if (percent === lastPercent && second === lastSecond) return;
lastPercent = percent;
- postMessage({ type: "progress", percent });
+ lastSecond = second;
+ postMessage({ type: "progress", percent, outputSeconds });
};
// FFmpeg prints a stats line about twice a second while it works, so log
diff --git a/src/lib/ffmpegArgs.ts b/src/lib/ffmpegArgs.ts
index 09f623a51..1b699669d 100644
--- a/src/lib/ffmpegArgs.ts
+++ b/src/lib/ffmpegArgs.ts
@@ -49,16 +49,62 @@ function outputThreadArgs(threads: number, hasFilterComplex: boolean): string[]
/**
* 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.
+ * never ends. Omitted when the duration is unknown (metadata read failed, or
+ * a live-recorded file whose header has none): `-t 0` would produce an empty
+ * file, and `-t Infinity` / `-t NaN` are invalid.
*/
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)];
+ const seconds = expectedOutputSeconds(recipe, videoDuration);
+ return seconds === null ? [] : ["-t", seconds.toFixed(6)];
}
-export function buildVideoFilter(recipe: EditRecipe, targetW: number, targetH: number): string {
+/** A usable duration in seconds: finite and positive. */
+export function isUsableDuration(seconds: unknown): seconds is number {
+ return typeof seconds === "number" && Number.isFinite(seconds) && seconds > 0;
+}
+
+/**
+ * Length of the exported video in seconds, or null when it can't be known
+ * (no usable source duration and no trim end, or an invalid speed).
+ */
+export function expectedOutputSeconds(recipe: EditRecipe, videoDuration: number): number | null {
+ if (!isUsableDuration(recipe.speed)) return null;
+ const end = recipe.trimEnd ?? videoDuration;
+ if (!isUsableDuration(end)) return null;
+ const start = Number.isFinite(recipe.trimStart) && recipe.trimStart > 0 ? recipe.trimStart : 0;
+ const seconds = (end - start) / recipe.speed;
+ return isUsableDuration(seconds) ? seconds : null;
+}
+
+/**
+ * The recipe with every time value FFmpeg will see made valid: a non-finite
+ * or negative trim start becomes 0, a trim end that isn't finite or isn't
+ * after the start becomes "to the end of the stream", and an unusable speed
+ * becomes 1. The editor shouldn't produce these, but a bad value would
+ * otherwise reach FFmpeg as "Infinity" or "NaN".
+ */
+export function withSafeTimes(recipe: EditRecipe): EditRecipe {
+ const trimStart = Number.isFinite(recipe.trimStart) && recipe.trimStart > 0 ? recipe.trimStart : 0;
+ const trimEnd =
+ recipe.trimEnd !== null && Number.isFinite(recipe.trimEnd) && recipe.trimEnd > trimStart
+ ? recipe.trimEnd
+ : null;
+ const speed = isUsableDuration(recipe.speed) ? recipe.speed : 1;
+ if (trimStart === recipe.trimStart && trimEnd === recipe.trimEnd && speed === recipe.speed) return recipe;
+ return { ...recipe, trimStart, trimEnd, speed };
+}
+
+/** Throws if any argument carries a non-finite number. Last line of defence. */
+export function assertFiniteArgs(args: string[]): string[] {
+ const bad = args.find((arg) => /(^|[^a-z])(-?Infinity|NaN)([^a-z]|$)/.test(arg));
+ if (bad !== undefined) {
+ throw new Error(`Invalid export settings: a time value is not a number (${bad.slice(0, 80)})`);
+ }
+ return args;
+}
+
+export function buildVideoFilter(input: EditRecipe, targetW: number, targetH: number): string {
+ const recipe = withSafeTimes(input);
const filters: string[] = [];
if (recipe.trimStart > 0 || recipe.trimEnd !== null) {
@@ -132,8 +178,8 @@ export function buildVideoFilter(recipe: EditRecipe, targetW: number, targetH: n
return filters.join(",");
}
-export function buildAudioFilter(recipe: EditRecipe): string {
- if (recipe.speed <= 0) return "";
+export function buildAudioFilter(input: EditRecipe): string {
+ const recipe = withSafeTimes(input);
const filters: string[] = [];
let remaining = recipe.speed;
@@ -160,7 +206,8 @@ export function buildAudioFilter(recipe: EditRecipe): string {
return filters.join(",");
}
-export function buildAudioTrimFilter(recipe: EditRecipe): string {
+export function buildAudioTrimFilter(input: EditRecipe): string {
+ const recipe = withSafeTimes(input);
if (recipe.trimStart === 0 && recipe.trimEnd === null) return "";
let trimFilter = `atrim=start=${recipe.trimStart}`;
if (recipe.trimEnd !== null) {
@@ -192,7 +239,7 @@ export interface ExportArgsOptions {
}
export function buildArguments({
- recipe,
+ recipe: input,
format,
inputName,
outputName,
@@ -204,6 +251,7 @@ export function buildArguments({
overlay,
threads,
}: ExportArgsOptions): string[] {
+ const recipe = withSafeTimes(input);
const vf = buildVideoFilter(recipe, targetW, targetH);
const audioTrim = hasOriginalAudio ? buildAudioTrimFilter(recipe) : "";
const audioSpeed = hasOriginalAudio ? buildAudioFilter(recipe) : "";
@@ -313,9 +361,13 @@ export function buildArguments({
}
args.push(...outputThreadArgs(threads, usesFilterComplex));
- args.push(...outputDurationArgs(recipe, videoDuration));
+ const durationArgs = outputDurationArgs(recipe, videoDuration);
+ args.push(...durationArgs);
+ // Without -t, looped music (-stream_loop -1) never ends: stop at the end
+ // of the shortest stream, which is the video.
+ if (durationArgs.length === 0 && music?.options.loopMusic) args.push("-shortest");
args.push(outputName);
- return args;
+ return assertFiniteArgs(args);
}
export interface GifArgsOptions {
@@ -331,7 +383,7 @@ export interface GifArgsOptions {
/** Two-pass GIF: palettegen, then paletteuse. */
export function buildGifArguments({
- recipe,
+ recipe: input,
inputName,
paletteName,
outputName,
@@ -340,6 +392,7 @@ export function buildGifArguments({
videoDuration,
threads,
}: GifArgsOptions): { palette: string[]; render: string[] } {
+ const recipe = withSafeTimes(input);
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";
@@ -348,17 +401,17 @@ export function buildGifArguments({
const durationArgs = recipe.speed !== 1 ? outputDurationArgs(recipe, videoDuration) : [];
return {
- palette: [
+ palette: assertFiniteArgs([
...inputThreadArgs(threads), "-i", inputName,
"-vf", vfWithPalette,
...durationArgs, ...outputThreadArgs(threads, false),
"-y", paletteName,
- ],
- render: [
+ ]),
+ render: assertFiniteArgs([
...inputThreadArgs(threads), "-i", inputName, "-i", paletteName,
"-lavfi", vfWithPaletteUse,
...durationArgs, ...outputThreadArgs(threads, true),
"-y", outputName,
- ],
+ ]),
};
}
diff --git a/src/lib/mediaDuration.ts b/src/lib/mediaDuration.ts
new file mode 100644
index 000000000..557f6d000
--- /dev/null
+++ b/src/lib/mediaDuration.ts
@@ -0,0 +1,45 @@
+import { isUsableDuration } from "./ffmpegArgs";
+
+/**
+ * The duration of a video element whose metadata has loaded, in seconds, or 0
+ * when it can't be found.
+ *
+ * Files recorded live (MediaRecorder, so most browser screen recordings) have
+ * no duration in their header, and the browser reports Infinity until it has
+ * read to the end. Seeking far past the end makes it find the real duration.
+ * The element is left at time 0.
+ */
+export function resolveDuration(video: HTMLVideoElement, timeoutMs = 4000): Promise {
+ if (isUsableDuration(video.duration)) return Promise.resolve(video.duration);
+ if (video.duration !== Infinity) return Promise.resolve(0);
+
+ return new Promise((resolve) => {
+ let settled = false;
+ const finish = (value: number) => {
+ if (settled) return;
+ settled = true;
+ clearTimeout(timer);
+ video.removeEventListener("durationchange", check);
+ video.removeEventListener("timeupdate", check);
+ video.removeEventListener("seeked", check);
+ try {
+ video.currentTime = 0;
+ } catch {
+ // the element may already be detached
+ }
+ resolve(value);
+ };
+ const check = () => {
+ if (isUsableDuration(video.duration)) finish(video.duration);
+ };
+ const timer = setTimeout(() => finish(0), timeoutMs);
+ video.addEventListener("durationchange", check);
+ video.addEventListener("timeupdate", check);
+ video.addEventListener("seeked", check);
+ try {
+ video.currentTime = Number.MAX_SAFE_INTEGER;
+ } catch {
+ finish(0);
+ }
+ });
+}
diff --git a/src/lib/tests/exportTelemetry.test.ts b/src/lib/tests/exportTelemetry.test.ts
index 52916ac3c..824dd5a7e 100644
--- a/src/lib/tests/exportTelemetry.test.ts
+++ b/src/lib/tests/exportTelemetry.test.ts
@@ -8,15 +8,7 @@ import {
type ExportContext,
} from "../exportTelemetry";
import { HANDLED_ERROR_PREFIX, registerRum, resetTelemetryForTests, sanitizeRumEvent } from "../telemetry";
-import { FFmpegLoadError } from "../ffmpeg";
-
-// main has no stall watchdog yet; an error with its name must still map to "timeout".
-class ExportStalledError extends Error {
- name = "ExportStalledError";
- constructor(readonly mode: "multi" | "single") {
- super("Export stopped responding");
- }
-}
+import { ExportStalledError, FFmpegLoadError } from "../ffmpeg";
import type { DatadogRum } from "@datadog/browser-rum";
const context: ExportContext = {
@@ -43,6 +35,7 @@ const ALLOWED_KEYS = new Set([
"category",
"load_number",
"single_reason",
+ "stall_reason",
]);
let rum: { addAction: ReturnType; addDurationVital: ReturnType };
@@ -106,6 +99,31 @@ describe("export attempt outcomes", () => {
expect(events()[1]!.ctx).toMatchObject({ stage: "encode", category: "encode_error" });
});
+ it("reports which watchdog rule stopped a stalled export", () => {
+ const attempt = new ExportAttempt(context);
+ attempt.encoding("multi");
+ expect(attempt.failed(new ExportStalledError("multi", "no_progress"))).toBe("timeout");
+ expect(events()[1]!.ctx).toMatchObject({ category: "timeout", stall_reason: "no_progress" });
+ });
+
+ it("a clip of unknown length that stalls or fails is reported as unknown_duration", () => {
+ const unknown = { ...context, clip_length: "unknown" };
+ const stalled = new ExportAttempt(unknown);
+ stalled.encoding("multi");
+ expect(stalled.failed(new ExportStalledError("multi", "overrun"))).toBe("unknown_duration");
+ expect(events()[1]!.ctx).toMatchObject({ category: "unknown_duration", stall_reason: "overrun" });
+
+ const failed = new ExportAttempt(unknown);
+ failed.encoding("single");
+ expect(failed.failed(new Error("ffmpeg exited with code 1"))).toBe("unknown_duration");
+
+ // Failures with a clearer cause keep it, and the engine stage is never this.
+ const oom = new ExportAttempt(unknown);
+ oom.encoding("single");
+ expect(oom.failed(new RangeError("Array buffer allocation failed"))).toBe("out_of_memory");
+ expect(new ExportAttempt(unknown).failed(new FFmpegLoadError("Failed to fetch"))).toBe("engine_load");
+ });
+
it("marks retries, and whether the retry recovered", () => {
new ExportAttempt(context, "timeout").succeeded("<25MB");
expect(events()[0]!.ctx).toMatchObject({ is_retry: true, retry_after: "timeout" });
diff --git a/src/lib/tests/ffmpegArgs.test.ts b/src/lib/tests/ffmpegArgs.test.ts
index d354ddc59..11a231b64 100644
--- a/src/lib/tests/ffmpegArgs.test.ts
+++ b/src/lib/tests/ffmpegArgs.test.ts
@@ -3,6 +3,10 @@ import {
buildArguments,
buildGifArguments,
outputDurationArgs,
+ expectedOutputSeconds,
+ withSafeTimes,
+ assertFiniteArgs,
+ buildAudioFilter,
resolveThreadCount,
MT_THREAD_CAP,
type ExportArgsOptions,
@@ -167,3 +171,87 @@ describe("outputDurationArgs", () => {
expect(outputDurationArgs(recipe({ speed: 0 }), 6)).toEqual([]);
});
});
+
+describe("time values never reach FFmpeg as Infinity or NaN", () => {
+ const BAD = [Infinity, -Infinity, NaN];
+ const music = {
+ inputName: "music.mp3",
+ options: { file: null, musicVolume: 50, originalAudioVolume: 100, loopMusic: true },
+ } as unknown as ExportArgsOptions["music"];
+
+ it("expectedOutputSeconds only returns finite, positive lengths", () => {
+ expect(expectedOutputSeconds(recipe(), 10)).toBe(10);
+ expect(expectedOutputSeconds(recipe({ trimStart: 2, trimEnd: 8, speed: 2 }), 10)).toBe(3);
+ for (const bad of [...BAD, 0, -5]) {
+ expect(expectedOutputSeconds(recipe(), bad)).toBeNull();
+ expect(expectedOutputSeconds(recipe({ speed: bad }), 10)).toBeNull();
+ }
+ // A trim end still bounds the clip when the file's own duration is unknown.
+ expect(expectedOutputSeconds(recipe({ trimEnd: 4 }), Infinity)).toBe(4);
+ });
+
+ it("omits -t when the duration is unknown, at any speed", () => {
+ for (const bad of [...BAD, 0]) {
+ for (const speed of [0.5, 1, 2]) {
+ expect(outputDurationArgs(recipe({ speed }), bad)).toEqual([]);
+ expect(buildArguments(opts({ recipe: recipe({ speed }), videoDuration: bad }))).not.toContain("-t");
+ }
+ }
+ });
+
+ it("stops looped music at the end of the video when there's no -t", () => {
+ const args = buildArguments(opts({ videoDuration: Infinity, music }));
+ expect(args).toContain("-stream_loop");
+ expect(args).toContain("-shortest");
+ expect(args).not.toContain("-t");
+ // With a known length, -t bounds it instead.
+ expect(buildArguments(opts({ videoDuration: 6, music }))).not.toContain("-shortest");
+ });
+
+ it("repairs invalid trim and speed values instead of passing them on", () => {
+ const fixed = withSafeTimes(recipe({ trimStart: NaN, trimEnd: Infinity, speed: Infinity }));
+ expect(fixed).toMatchObject({ trimStart: 0, trimEnd: null, speed: 1 });
+ expect(withSafeTimes(recipe({ trimStart: 5, trimEnd: 3 })).trimEnd).toBeNull();
+ const ok = recipe({ trimStart: 1, trimEnd: 4, speed: 1.5 });
+ expect(withSafeTimes(ok)).toBe(ok);
+ });
+
+ it("an infinite speed doesn't hang the audio filter builder", () => {
+ expect(buildAudioFilter(recipe({ speed: Infinity }))).not.toMatch(/Infinity|NaN/);
+ expect(buildAudioFilter(recipe({ speed: NaN }))).not.toMatch(/Infinity|NaN/);
+ });
+
+ it("no combination of bad values produces a non-finite argument", () => {
+ const values = [...BAD, 0, -1, 2.5];
+ for (const trimStart of values)
+ for (const trimEnd of [...values, null])
+ for (const speed of values)
+ for (const videoDuration of values) {
+ const r = recipe({ trimStart, trimEnd, speed });
+ const all = [
+ ...buildArguments(opts({ recipe: r, videoDuration })),
+ ...buildArguments(opts({ recipe: r, videoDuration, format: "webm", music })),
+ ...Object.values(
+ buildGifArguments({
+ recipe: r,
+ inputName: "in.mp4",
+ paletteName: "p.png",
+ outputName: "out.gif",
+ targetW: 480,
+ targetH: 270,
+ videoDuration,
+ threads: 4,
+ })
+ ).flat(),
+ ];
+ for (const arg of all) expect(arg, JSON.stringify({ trimStart, trimEnd, speed, videoDuration })).not.toMatch(/Infinity|NaN/);
+ }
+ });
+
+ it("assertFiniteArgs rejects a non-finite argument with a clear error", () => {
+ expect(() => assertFiniteArgs(["-t", "Infinity"])).toThrow(/time value is not a number/);
+ expect(() => assertFiniteArgs(["-vf", "trim=start=NaN"])).toThrow();
+ // Words that merely contain the letters are fine.
+ expect(assertFiniteArgs(["-i", "Nanaimo.mp4", "-metadata", "title=Infinityband"])).toHaveLength(4);
+ });
+});
diff --git a/src/lib/tests/ffmpegClient.test.ts b/src/lib/tests/ffmpegClient.test.ts
index 5cc3c2cda..45434b9b9 100644
--- a/src/lib/tests/ffmpegClient.test.ts
+++ b/src/lib/tests/ffmpegClient.test.ts
@@ -10,6 +10,8 @@ import {
FFmpegLoadError,
ExportStalledError,
STALL_TIMEOUT_MS,
+ NO_PROGRESS_TIMEOUT_MS,
+ overrunLimitSeconds,
} from "../ffmpeg";
import { DEFAULT_RECIPE } from "../constants";
@@ -233,5 +235,103 @@ describe("FFmpeg client lifecycle", () => {
await expect(run()).resolves.toMatchObject({ format: "mp4" });
expect(workers[1]!.posted.find((m) => m.type === "load")).toMatchObject({ singleThread: true });
});
+
+ it("a stall's message says what happened and to press Retry", async () => {
+ vi.useFakeTimers();
+ script.onLoad = (w) => w.emit({ type: "ready", mode: "multi" });
+ script.onExport = () => {};
+ const pending = run();
+ const assertion = expect(pending).rejects.toMatchObject({
+ reason: "no_activity",
+ message: expect.stringMatching(/stopped responding.*Retry.*single-threaded/),
+ });
+ await vi.advanceTimersByTimeAsync(STALL_TIMEOUT_MS + 5_000);
+ await assertion;
+ });
+
+ it("fails an export that keeps logging but encodes nothing new (stuck at 100%)", async () => {
+ vi.useFakeTimers();
+ let worker!: FakeWorker;
+ script.onExport = (w) => {
+ worker = w;
+ w.emit({ type: "progress", percent: 99, outputSeconds: 5.9 });
+ };
+ const pending = run();
+ const assertion = expect(pending).rejects.toMatchObject({ name: "ExportStalledError", reason: "no_progress" });
+ for (let t = 0; t <= NO_PROGRESS_TIMEOUT_MS; t += 10_000) {
+ await vi.advanceTimersByTimeAsync(10_000);
+ worker?.emit({ type: "heartbeat" });
+ worker?.emit({ type: "progress", percent: 99, outputSeconds: 5.9 });
+ }
+ await assertion;
+ expect(workers[0]!.terminated).toBe(true);
+ });
+
+ it("keeps a long export alive while encoded time keeps advancing", async () => {
+ vi.useFakeTimers();
+ let worker!: FakeWorker;
+ let jobId = "";
+ script.onExport = (w, id) => {
+ worker = w;
+ jobId = id;
+ };
+ const pending = exportVideo(file(), { ...DEFAULT_RECIPE }, vi.fn(), undefined, undefined, undefined, 600);
+ for (let t = 1; t <= 30; t++) {
+ await vi.advanceTimersByTimeAsync(10_000);
+ worker?.emit({ type: "progress", percent: Math.round((t * 10 * 100) / 600), outputSeconds: t * 10 });
+ }
+ worker.emit(result(jobId));
+ await expect(pending).resolves.toMatchObject({ size: 8 });
+ });
+
+ it("without encoded-time reports, only silence counts as a stall", async () => {
+ vi.useFakeTimers();
+ let worker!: FakeWorker;
+ let jobId = "";
+ script.onExport = (w, id) => {
+ worker = w;
+ jobId = id;
+ };
+ const pending = run();
+ for (let t = 0; t < NO_PROGRESS_TIMEOUT_MS * 2; t += 10_000) {
+ await vi.advanceTimersByTimeAsync(10_000);
+ worker?.emit({ type: "progress", percent: 0 });
+ }
+ worker.emit(result(jobId));
+ await expect(pending).resolves.toMatchObject({ size: 8 });
+ });
+
+ it("fails an export that runs far past the clip's length", async () => {
+ vi.useFakeTimers();
+ script.onExport = (w) => {
+ // A 6 s clip; looped music without -t would keep going like this.
+ [3, 6, 12, 30].forEach((outputSeconds) => w.emit({ type: "progress", percent: 99, outputSeconds }));
+ };
+ const pending = run();
+ const assertion = expect(pending).rejects.toMatchObject({ reason: "overrun" });
+ await vi.advanceTimersByTimeAsync(5_000);
+ await assertion;
+ });
+
+ it("never posts a non-finite duration to the worker", async () => {
+ // jsdom never loads media, so the fallback metadata read times out (5 s).
+ vi.useFakeTimers();
+ script.onExport = (w, id) => w.emit(result(id));
+ for (const knownDuration of [Infinity, NaN, -1, 0]) {
+ const pending = exportVideo(file(), { ...DEFAULT_RECIPE }, vi.fn(), undefined, undefined, undefined, knownDuration);
+ await vi.advanceTimersByTimeAsync(6_000);
+ await pending;
+ }
+ const durations = workers.flatMap((w) => w.posted).filter((m) => m.type === "export").map((m) => (m as { videoDuration?: number }).videoDuration);
+ expect(durations).toHaveLength(4);
+ for (const d of durations) expect(Number.isFinite(d)).toBe(true);
+ });
+});
+
+describe("overrunLimitSeconds", () => {
+ it("allows a margin over the expected length", () => {
+ expect(overrunLimitSeconds(6)).toBeGreaterThan(6);
+ expect(overrunLimitSeconds(600)).toBeGreaterThan(700);
+ });
});
diff --git a/src/lib/tests/mediaDuration.test.ts b/src/lib/tests/mediaDuration.test.ts
new file mode 100644
index 000000000..8caeb8a3e
--- /dev/null
+++ b/src/lib/tests/mediaDuration.test.ts
@@ -0,0 +1,53 @@
+import { describe, it, expect, vi, afterEach } from "vitest";
+import { resolveDuration } from "../mediaDuration";
+
+/** A video element whose duration becomes known once it is seeked to `revealAt`. */
+function fakeVideo(initial: number, reveal?: { duration: number; on: "durationchange" | "seeked" }) {
+ const target = new EventTarget() as EventTarget & { duration: number; currentTime: number };
+ let duration = initial;
+ let time = 0;
+ Object.defineProperty(target, "duration", { get: () => duration });
+ Object.defineProperty(target, "currentTime", {
+ get: () => time,
+ set(value: number) {
+ time = value;
+ if (reveal && value > 1e6) {
+ queueMicrotask(() => {
+ duration = reveal.duration;
+ target.dispatchEvent(new Event(reveal.on));
+ });
+ }
+ },
+ });
+ return target as unknown as HTMLVideoElement;
+}
+
+afterEach(() => vi.useRealTimers());
+
+describe("resolveDuration", () => {
+ it("returns a known duration straight away", async () => {
+ await expect(resolveDuration(fakeVideo(12.5))).resolves.toBe(12.5);
+ });
+
+ it("finds the real length of a live recording (Infinity) by seeking past the end", async () => {
+ const video = fakeVideo(Infinity, { duration: 6, on: "durationchange" });
+ await expect(resolveDuration(video)).resolves.toBe(6);
+ expect(video.currentTime).toBe(0);
+ });
+
+ it("also accepts the length reported on seeked", async () => {
+ await expect(resolveDuration(fakeVideo(Infinity, { duration: 9, on: "seeked" }))).resolves.toBe(9);
+ });
+
+ it("gives up with 0 when the browser never finds it", async () => {
+ vi.useFakeTimers();
+ const pending = resolveDuration(fakeVideo(Infinity), 4000);
+ await vi.advanceTimersByTimeAsync(4000);
+ await expect(pending).resolves.toBe(0);
+ });
+
+ it("treats NaN and non-positive durations as unknown", async () => {
+ await expect(resolveDuration(fakeVideo(NaN))).resolves.toBe(0);
+ await expect(resolveDuration(fakeVideo(0))).resolves.toBe(0);
+ });
+});
diff --git a/test/e2e/export.spec.ts b/test/e2e/export.spec.ts
new file mode 100644
index 000000000..d29de6c96
--- /dev/null
+++ b/test/e2e/export.spec.ts
@@ -0,0 +1,135 @@
+import { test, expect, type Page } from "@playwright/test";
+import { execFileSync } from "node:child_process";
+import { existsSync } from "node:fs";
+import { join } from "node:path";
+
+// Fixtures come from scripts/make-fixtures.sh (test/fixtures/, gitignored).
+const FIXTURES = join(__dirname, "..", "fixtures");
+
+interface Fixture {
+ file: string;
+ hasAudio: boolean;
+ /** Only with E2E_LARGE=1: slow, and needs a lot of memory. */
+ large?: boolean;
+}
+
+const fixtures: Fixture[] = [
+ { file: "normal.mp4", hasAudio: true },
+ // No duration in the container, like a browser screen recording.
+ { file: "no-duration.webm", hasAudio: true },
+ { file: "portrait.mp4", hasAudio: true },
+ { file: "no-audio.mp4", hasAudio: false },
+ { file: "large.mp4", hasAudio: true, large: true },
+];
+
+interface Probe {
+ width: number;
+ height: number;
+ duration: number;
+ audioStreams: number;
+}
+
+function probe(path: string): Probe {
+ const out = execFileSync(
+ "ffprobe",
+ ["-v", "error", "-show_entries", "stream=codec_type,width,height:format=duration", "-of", "json", path],
+ { encoding: "utf8" }
+ );
+ const json = JSON.parse(out) as {
+ streams: { codec_type: string; width?: number; height?: number }[];
+ format: { duration?: string };
+ };
+ const video = json.streams.find((s) => s.codec_type === "video");
+ return {
+ width: video?.width ?? 0,
+ height: video?.height ?? 0,
+ duration: Number(json.format.duration ?? NaN),
+ audioStreams: json.streams.filter((s) => s.codec_type === "audio").length,
+ };
+}
+
+async function openSection(page: Page, name: RegExp) {
+ const toggle = page.getByRole("button", { name }).first();
+ if ((await toggle.getAttribute("aria-expanded")) === "false") await toggle.click();
+}
+
+async function loadEditor(page: Page, fixture: string) {
+ // Skip the onboarding tour, which covers the controls.
+ await page.addInitScript(() => localStorage.setItem("reframe_onboarding_complete", "1"));
+ await page.goto("/");
+ await page.locator('input[type=file][accept="video/*"]').setInputFiles(join(FIXTURES, fixture));
+ await expect(page.getByRole("button", { name: "Export video" }).first()).toBeVisible();
+}
+
+async function exportAndDownload(page: Page, savePath: string) {
+ await page.getByRole("button", { name: "Export video" }).first().click();
+ const download = page.getByRole("link", { name: /^Download (MP4|WEBM|MKV|GIF)$/ });
+ const failure = page.getByText(/^Export failed|stopped (responding|making progress)/).first();
+ await expect(download.or(failure)).toBeVisible({ timeout: 180_000 });
+ await expect(failure, "the export reported an error").toHaveCount(0);
+ const [file] = await Promise.all([page.waitForEvent("download"), download.click()]);
+ await file.saveAs(savePath);
+}
+
+test.describe("export", () => {
+ test.beforeAll(() => {
+ const missing = fixtures.filter((f) => !f.large && !existsSync(join(FIXTURES, f.file)));
+ if (missing.length) throw new Error(`Missing fixtures (${missing.map((f) => f.file).join(", ")}): run scripts/make-fixtures.sh`);
+ });
+
+ for (const fixture of fixtures) {
+ test(`${fixture.file}: trim, rotate and speed up, then export a valid file`, async ({ page }, testInfo) => {
+ test.skip(!!fixture.large && !process.env.E2E_LARGE, "set E2E_LARGE=1 to run the ~300 MB fixture");
+
+ await loadEditor(page, fixture.file);
+ const isolated = await page.evaluate(() => crossOriginIsolated);
+ expect(isolated).toBe(testInfo.project.name === "multi-threaded");
+
+ // 1:1 output, so the expected size doesn't depend on the input's shape.
+ await page.getByRole("button", { name: "1 is to 1 output ratio" }).click();
+
+ await openSection(page, /^Trim$/i);
+ await page.getByLabel("Trim start time in seconds").fill("1");
+ await page.getByLabel("Trim end time in seconds").fill("5");
+
+ await openSection(page, /^Rotation$/i);
+ await page.getByRole("button", { name: "Rotate video to 90 degrees" }).click();
+
+ await openSection(page, /Audio & Speed/i);
+ await page.getByRole("button", { name: "Set speed to 2x" }).click();
+
+ const out = testInfo.outputPath(`${fixture.file}.mp4`);
+ await exportAndDownload(page, out);
+
+ const result = probe(out);
+ expect(result.width).toBe(1080);
+ expect(result.height).toBe(1080);
+ // 4 s of source (1 s → 5 s) at 2× speed.
+ expect(result.duration).toBeGreaterThan(1.7);
+ expect(result.duration).toBeLessThan(2.4);
+ expect(result.audioStreams).toBe(fixture.hasAudio ? 1 : 0);
+ });
+ }
+
+ test("no-duration.webm: the editor finds the real length", async ({ page }) => {
+ await loadEditor(page, "no-duration.webm");
+ await openSection(page, /^Trim$/i);
+ // The end field's placeholder and limit are the clip's length (6 s), not
+ // empty, 0 or Infinity, so trimming works on screen recordings.
+ const end = page.getByLabel("Trim end time in seconds");
+ await expect(end).toHaveAttribute("placeholder", "6.0");
+ expect(Number(await end.getAttribute("max"))).toBeCloseTo(6, 1);
+ await expect(page.getByText("Duration: 0:06")).toBeVisible();
+ });
+
+ test("no-duration.webm at 2× speed without a trim exports the whole clip", async ({ page }, testInfo) => {
+ await loadEditor(page, "no-duration.webm");
+ await openSection(page, /Audio & Speed/i);
+ await page.getByRole("button", { name: "Set speed to 2x" }).click();
+ const out = testInfo.outputPath("no-duration-2x.mp4");
+ await exportAndDownload(page, out);
+ const result = probe(out);
+ expect(result.duration).toBeGreaterThan(2.6);
+ expect(result.duration).toBeLessThan(3.4);
+ });
+});
From 3fe03351ac78673113936aaa1ad5172689ce5413 Mon Sep 17 00:00:00 2001
From: Akanksha Trehun
Date: Tue, 3 Nov 2026 13:45:03 +0530
Subject: [PATCH 3/6] fix(ffmpeg): retry a stalled export on the
single-threaded engine; thread default 2
- Threads per stage are min(4, navigator.hardwareConcurrency || 2); the
fallback for an unreported core count was 4.
- When the multi-threaded engine stalls, exportVideo retries the same export
(same file and edits) once on the single-threaded engine instead of failing.
The overlay says so, and RUM gets engine_fallback with the stall reason;
the attempt's single outcome then carries engine "single" and fell_back.
- A single-threaded stall, or a stall after cancelling, is not retried; the
error tells the user to press Retry.
- The editor passes the duration it already read to the export.
Co-Authored-By: Claude Opus 5.5
---
docs/DATADOG.md | 3 +-
docs/PROJECTS 2.md | 136 +++++++
.../AudioSpeedControl.stories 2.tsx | 38 ++
src/components/BrandLogo.stories 2.tsx | 16 +
src/components/ColorPicker 2.tsx | 318 +++++++++++++++
src/components/DatadogInit 2.tsx | 36 ++
src/components/DownloadResult.stories 2.tsx | 74 ++++
src/components/ExportOverlay.stories 2.tsx | 83 ++++
src/components/ExportOverlay.tsx | 9 +-
src/components/ExportSettings.stories 2.tsx | 45 +++
src/components/FileUpload.stories 2.tsx | 42 ++
src/components/FormatSelector.stories 2.tsx | 31 ++
src/components/FramingControl.stories 2.tsx | 28 ++
src/components/HistoryControls 2.tsx | 51 +++
src/components/PresetSelector.stories 2.tsx | 51 +++
src/components/PrivacyBanner 2.tsx | 81 ++++
src/components/PrivacyBanner.stories 2.tsx | 21 +
src/components/ProjectPanel 2.tsx | 216 ++++++++++
src/components/RotateControl.stories 2.tsx | 35 ++
src/components/TrimControl.stories 2.tsx | 52 +++
src/components/VideoEditor.stories 2.tsx | 41 ++
src/components/VideoEditor.tsx | 2 +
.../__stories__/RecipeHarness 2.tsx | 45 +++
src/components/components.stories 2.tsx | 88 ++++
src/components/ui/BaseButton.stories 2.tsx | 65 +++
src/components/ui/Skeleton.stories 2.tsx | 31 ++
src/hooks/tests/exportTelemetry.hook.test.ts | 17 +
src/hooks/useVideoEditor.ts | 10 +
src/lib/editorPersistence 2.ts | 131 ++++++
src/lib/exportTelemetry.ts | 17 +
src/lib/ffmpeg.ts | 42 +-
src/lib/ffmpegArgs.ts | 10 +-
src/lib/lottie/trail-loading 2.json | 1 +
src/lib/overlayGraph 2.ts | 309 ++++++++++++++
src/lib/sessionDB 2.ts | 82 ++++
src/lib/tests/editorPersistence.test 2.ts | 90 +++++
src/lib/tests/exportTelemetry.test.ts | 27 ++
src/lib/tests/ffmpegArgs.test.ts | 11 +-
src/lib/tests/ffmpegClient.test.ts | 56 ++-
src/lib/tests/overlayGraph.test 2.ts | 334 ++++++++++++++++
src/lib/tests/timeline.test 2.ts | 377 ++++++++++++++++++
src/lib/timeline 2.ts | 211 ++++++++++
42 files changed, 3339 insertions(+), 24 deletions(-)
create mode 100644 docs/PROJECTS 2.md
create mode 100644 src/components/AudioSpeedControl.stories 2.tsx
create mode 100644 src/components/BrandLogo.stories 2.tsx
create mode 100644 src/components/ColorPicker 2.tsx
create mode 100644 src/components/DatadogInit 2.tsx
create mode 100644 src/components/DownloadResult.stories 2.tsx
create mode 100644 src/components/ExportOverlay.stories 2.tsx
create mode 100644 src/components/ExportSettings.stories 2.tsx
create mode 100644 src/components/FileUpload.stories 2.tsx
create mode 100644 src/components/FormatSelector.stories 2.tsx
create mode 100644 src/components/FramingControl.stories 2.tsx
create mode 100644 src/components/HistoryControls 2.tsx
create mode 100644 src/components/PresetSelector.stories 2.tsx
create mode 100644 src/components/PrivacyBanner 2.tsx
create mode 100644 src/components/PrivacyBanner.stories 2.tsx
create mode 100644 src/components/ProjectPanel 2.tsx
create mode 100644 src/components/RotateControl.stories 2.tsx
create mode 100644 src/components/TrimControl.stories 2.tsx
create mode 100644 src/components/VideoEditor.stories 2.tsx
create mode 100644 src/components/__stories__/RecipeHarness 2.tsx
create mode 100644 src/components/components.stories 2.tsx
create mode 100644 src/components/ui/BaseButton.stories 2.tsx
create mode 100644 src/components/ui/Skeleton.stories 2.tsx
create mode 100644 src/lib/editorPersistence 2.ts
create mode 100644 src/lib/lottie/trail-loading 2.json
create mode 100644 src/lib/overlayGraph 2.ts
create mode 100644 src/lib/sessionDB 2.ts
create mode 100644 src/lib/tests/editorPersistence.test 2.ts
create mode 100644 src/lib/tests/overlayGraph.test 2.ts
create mode 100644 src/lib/tests/timeline.test 2.ts
create mode 100644 src/lib/timeline 2.ts
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/docs/PROJECTS 2.md b/docs/PROJECTS 2.md
new file mode 100644
index 000000000..cda3b066c
--- /dev/null
+++ b/docs/PROJECTS 2.md
@@ -0,0 +1,136 @@
+# Undo, recovery and project files
+
+This covers how Reframe keeps edit history, what it saves in the browser, and the `.reframe.json` project format. Nothing here sends data anywhere. Everything stays in the user's browser or in files the user saves themselves.
+
+## Edit state
+
+`EditState` (`src/lib/editHistory.ts`) is the single source of truth for every edit that changes the exported video:
+
+| Included | Where it lives |
+|---|---|
+| Preset, custom size, framing, trim, rotation | `recipe` |
+| Keep audio, normalise, volume, speed | `recipe` |
+| Quality, format, brightness, contrast, saturation, sharpness, stabilisation, denoise | `recipe` |
+| Text overlays (text, position, size, colour, weight, font name) | `recipe.textOverlays` |
+| Image overlay position, size and opacity | `imageOverlay` |
+
+Not included, on purpose:
+
+- **The completion-sound preference.** It's a setting, not an edit, so undo never flips it.
+- **Files** (the source video, the image overlay file, background music). They are binary assets, not edit values, so adding or removing the image file is not an undo step.
+- **Transient UI state.** Hover, open panels, the selected text overlay, export progress, object URLs and worker state.
+- **The multi-track timeline state.** It isn't connected to the UI or the export yet (see `PHASE_1_MVP.md`).
+
+`useVideoEditor` exposes `recipe` derived from `EditState`. `handleExport` passes that same object, plus the image overlay settings, to `exportVideo`. So after an undo the preview and the export can't disagree.
+
+## Undo and redo
+
+- **Snapshots.** Each history entry is an immutable `EditState`, about 1 KB, never media. Undo and redo just move between entries.
+- **Bounded.** `HISTORY_LIMIT` is 100 entries. The oldest are dropped first.
+- **No duplicates.** A change that leaves the state equal to the current one is not recorded.
+- **Coalescing.** Repeated changes to the same continuous control within `COALESCE_WINDOW_MS` (600 ms) merge into one step:
+ - Continuous controls are sliders, trim handles, custom size, and text-overlay text, position, size and colour.
+ - The window slides, so a long drag stays one step.
+ - Dragging back to the starting value removes the step.
+ - Discrete choices (preset, format, rotation, toggles, image overlay position) always get their own step, however fast they're clicked.
+ - Adding or removing a text overlay is always its own step.
+- **When history resets.** Choosing a new video, restoring a session and deleting saved data each start a fresh history.
+- **During export.** Undo and redo are disabled while an export runs. The running export has already taken its settings.
+- **Reset settings.** "Reset settings" and the `R` shortcut are now a single undoable step. They no longer delete saved data.
+
+**Shortcuts.** macOS uses ⌘Z and ⇧⌘Z. Other platforms use Ctrl+Z, and Ctrl+Y or Ctrl+Shift+Z. They work while a slider, checkbox or button has focus. In text fields and `contenteditable` they are left to the browser, so they undo the typing. See `src/lib/historyShortcuts.ts`.
+
+## What is saved in the browser
+
+| Key | Store | Contents |
+|---|---|---|
+| `reframe:project` | localStorage | The current project document (below). Written 500 ms after the last edit while a video is open, and on `pagehide`. |
+| `reframe-session` / `files` / `currentVideo` | IndexedDB | One copy of the open source video, overwritten when a new video is chosen. Unchanged from before. |
+| `reframe:recipe`, `editorState`, `soundOnCompletion` | localStorage | Existing settings records, kept for compatibility. |
+
+The project record is stored in localStorage rather than IndexedDB because it is only a few KB and a synchronous write can be flushed reliably when the page is hidden.
+
+**Recovery on load**, in this order:
+
+1. A `?settings=` share link wins.
+2. Otherwise the saved project's edits are restored.
+3. Otherwise the older URL and recipe records are used, as before.
+
+Then the stored video copy is read from IndexedDB:
+
+- **The copy is present.** The editor opens with the video and the edits.
+- **The copy is missing, or IndexedDB fails.** This can happen after eviction, private mode, or a quota error when saving. A notice names the file, its size and length, and asks the user to choose it again.
+ - Choosing a file with the same name and byte size resumes the project, trims included.
+ - A different file keeps the other settings but resets the trim points, like any new video.
+ - "Discard saved edits" forgets the project.
+
+**Failures are reported in the "Saved on this device" panel:**
+
+- If the video copy can't be stored, the user is told they'll need to choose the file again after a refresh.
+- If the edit record can't be written, they're told to use "Save project file".
+
+**Delete saved data** asks for confirmation. It then removes the project record, the recipe and overlay records and the stored video copy, closes the video and resets the editor. Project files the user saved are not affected.
+
+## Project file format
+
+"Save project file" downloads `.reframe.json`:
+
+```json
+{
+ "format": "reframe-project",
+ "schemaVersion": 1,
+ "savedAt": "2026-10-22T12:00:00.000Z",
+ "source": { "name": "clip.mp4", "size": 24000000, "type": "video/mp4",
+ "lastModified": 1700000000000, "duration": 42.5, "width": 1920, "height": 1080 },
+ "edit": {
+ "recipe": { "preset": "square-1-1", "trimStart": 3, "trimEnd": 20, "...": "every EditRecipe field except soundOnCompletion" },
+ "imageOverlay": { "position": "top-left", "size": 250, "opacity": 60 }
+ }
+}
+```
+
+The file never contains the video, the image overlay or any other media.
+
+**Opening a project file:**
+
+- **A video is open.** The edits are applied as one undoable step.
+ - Same video (name and size match): everything applies, and trim points that don't fit are dropped.
+ - Different video: everything applies **except the trim points**, which stay as they were for the open video, and the message says so. Trims belong to the video they were made for, so they are never applied silently to another one.
+- **No video is open.** The edits are held and the user is asked to choose the project's video.
+
+### Validation
+
+Every document, whether imported or read back from storage, goes through `parseProject` in `src/lib/project.ts`. It rejects the whole document if anything is wrong. It never clamps values or merges them with defaults.
+
+**Size and shape:**
+
+- Input over 512 KB is rejected before parsing.
+- The document must be a JSON object with `format: "reframe-project"` and an integer `schemaVersion`.
+
+**Field checks:**
+
+- Every recipe field must be present, with the same types and ranges the editor enforces.
+- Presets must be known ids, and speed one of the speed steps.
+- Trim end must be after trim start.
+- At most 50 text overlays, each with text of at most 500 characters.
+- Overlay ids must match `[A-Za-z0-9_-]{1,64}` and must be unique. Colours must be `#rrggbb`.
+- Font names must match `[A-Za-z0-9 _-]{1,64}`, because they reach FFmpeg's `drawtext` filter.
+
+**Safe construction:**
+
+- `fontPath` is never read from a project file, because it is passed to FFmpeg as a file path. It is also never written, since a `blob:` URL is meaningless outside the tab.
+- Output objects are built field by field, so unknown keys, including `__proto__`, are dropped rather than copied.
+
+### Changing the schema
+
+1. Bump `PROJECT_SCHEMA_VERSION`.
+2. Add `MIGRATIONS[oldVersion] = (doc) => upgradedDoc`. It runs on raw JSON before validation, and migrations chain one version at a time.
+3. Update the validators for the new shape and add a test that parses a document from the old version.
+
+Documents from a newer schema than the page knows are refused with "saved by a newer version of Reframe… reload the page". A version with no migration path is refused too.
+
+A change to `EditRecipe` fields needs a project schema bump, because the validator requires every field.
+
+## Export profiles
+
+Implemented separately; see [EXPORT_PROFILES.md](EXPORT_PROFILES.md). Applying a profile is one undo step and changes only the output fields of `EditState`. Profiles are stored apart from projects, and a project file doesn't include them.
diff --git a/src/components/AudioSpeedControl.stories 2.tsx b/src/components/AudioSpeedControl.stories 2.tsx
new file mode 100644
index 000000000..1e0a24f98
--- /dev/null
+++ b/src/components/AudioSpeedControl.stories 2.tsx
@@ -0,0 +1,38 @@
+import type { Meta, StoryObj } from "@storybook/nextjs";
+import AudioSpeedControl from "./AudioSpeedControl";
+import { RecipeHarness, makeRecipe } from "./__stories__/RecipeHarness";
+
+/** Audio toggle, volume, normalisation and playback-speed steps. */
+const meta = {
+ title: "Editor/Controls/AudioSpeedControl",
+ component: AudioSpeedControl,
+ parameters: { layout: "padded" },
+ args: {
+ recipe: makeRecipe(),
+ onChange: () => {},
+ },
+ // `recipe` seeds the harness, which then owns the state so the control is
+ // actually interactive in Storybook rather than frozen on first render.
+ render: (args) => (
+
+ {(recipe, onChange) => }
+
+ ),
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {};
+
+/** Audio stripped — the dependent volume controls should read as disabled. */
+export const AudioMuted: Story = {
+ args: { recipe: makeRecipe({ keepAudio: false }) },
+};
+
+/** Non-default speed and boosted volume, with normalisation on. */
+export const FastWithNormalisedAudio: Story = {
+ args: {
+ recipe: makeRecipe({ speed: 2, volume: 150, normalizeAudio: true }),
+ },
+};
diff --git a/src/components/BrandLogo.stories 2.tsx b/src/components/BrandLogo.stories 2.tsx
new file mode 100644
index 000000000..746a02719
--- /dev/null
+++ b/src/components/BrandLogo.stories 2.tsx
@@ -0,0 +1,16 @@
+import type { Meta, StoryObj } from "@storybook/nextjs";
+import BrandLogo from "./BrandLogo";
+
+/** Wordmark/logo used in the header and footer. */
+const meta = {
+ title: "Design System/BrandLogo",
+ component: BrandLogo,
+ parameters: { layout: "padded" },
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {};
+export const Large: Story = { args: { size: 64 } };
+export const Small: Story = { args: { size: 20 } };
diff --git a/src/components/ColorPicker 2.tsx b/src/components/ColorPicker 2.tsx
new file mode 100644
index 000000000..c7f7db633
--- /dev/null
+++ b/src/components/ColorPicker 2.tsx
@@ -0,0 +1,318 @@
+import React, { useRef, useEffect, useState, useCallback } from 'react';
+import { createPortal } from 'react-dom';
+import { Pipette } from 'lucide-react';
+
+interface ColorPickerProps {
+ value: string;
+ onChange: (hex: string) => void;
+ label?: string;
+}
+
+const getGlassyClasses = (opacity = 10) =>
+ `backdrop-filter backdrop-blur-lg bg-[var(--surface)] bg-opacity-${opacity} border border-[var(--border)] rounded-lg shadow-lg transition-all duration-300`;
+
+function hexToHsv(hex: string): [number, number, number] {
+ const r = parseInt(hex.slice(1, 3), 16) / 255;
+ const g = parseInt(hex.slice(3, 5), 16) / 255;
+ const b = parseInt(hex.slice(5, 7), 16) / 255;
+ const max = Math.max(r, g, b),
+ min = Math.min(r, g, b),
+ d = max - min;
+ let h = 0;
+ if (d !== 0) {
+ if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
+ else if (max === g) h = ((b - r) / d + 2) / 6;
+ else h = ((r - g) / d + 4) / 6;
+ }
+ return [h * 360, max === 0 ? 0 : d / max, max];
+}
+
+function hsvToRgb(h: number, s: number, v: number): [number, number, number] {
+ const i = Math.floor(h / 60) % 6;
+ const f = h / 60 - Math.floor(h / 60);
+ const p = v * (1 - s),
+ q = v * (1 - f * s),
+ t = v * (1 - (1 - f) * s);
+ const sets: [number, number, number][] = [
+ [v, t, p],
+ [q, v, p],
+ [p, v, t],
+ [p, q, v],
+ [t, p, v],
+ [v, p, q],
+ ];
+ return sets[i]?.map(x => Math.round(x * 255)) as [number, number, number];
+}
+
+function rgbToHex(r: number, g: number, b: number) {
+ return (
+ '#' +
+ [r, g, b]
+ .map(x => x.toString(16).padStart(2, '0'))
+ .join('')
+ .toUpperCase()
+ );
+}
+
+const ColorPicker: React.FC = ({
+ value,
+ onChange,
+ label,
+}) => {
+ const canvasRef = useRef(null);
+ const [open, setOpen] = useState(false);
+
+ const initialHex = /^#[0-9a-fA-F]{6}$/.test(value) ? value : '#FFFFFF';
+ const [initH, initS, initV] = hexToHsv(initialHex);
+
+ const [hue, setHue] = useState(initH);
+ const [sat, setSat] = useState(initS);
+ const [val, setVal] = useState(initV);
+ const CANVAS_W = 232,
+ CANVAS_H = 140;
+ const [cursorX, setCursorX] = useState(initS * (CANVAS_W - 1));
+ const [cursorY, setCursorY] = useState((1 - initV) * (CANVAS_H - 1));
+ const [hexInput, setHexInput] = useState(initialHex.slice(1).toUpperCase());
+ const dragging = useRef(false);
+ const panelRef = useRef(null);
+ const triggerRef = useRef(null);
+ const [panelPos, setPanelPos] = useState({ top: 0, left: 0 });
+
+ const updatePosition = useCallback(() => {
+ if (triggerRef.current) {
+ const rect = triggerRef.current.getBoundingClientRect();
+ setPanelPos({
+ top: rect.bottom + window.scrollY + 6,
+ left: rect.left + window.scrollX,
+ });
+ }
+ }, []);
+
+ useEffect(() => {
+ if (open) {
+ updatePosition();
+ window.addEventListener('scroll', updatePosition, true);
+ window.addEventListener('resize', updatePosition);
+ }
+ return () => {
+ window.removeEventListener('scroll', updatePosition, true);
+ window.removeEventListener('resize', updatePosition);
+ };
+ }, [open, updatePosition]);
+
+ const drawCanvas = useCallback(() => {
+ const canvas = canvasRef.current;
+ if (!canvas) return;
+ const ctx = canvas.getContext('2d')!;
+ const w = canvas.width,
+ h = canvas.height;
+ ctx.clearRect(0, 0, w, h);
+ const gH = ctx.createLinearGradient(0, 0, w, 0);
+ gH.addColorStop(0, '#FFFFFF');
+ gH.addColorStop(1, `hsl(${hue},100%,50%)`);
+ ctx.fillStyle = gH;
+ ctx.fillRect(0, 0, w, h);
+ const gV = ctx.createLinearGradient(0, 0, 0, h);
+ gV.addColorStop(0, 'rgba(0,0,0,0)');
+ gV.addColorStop(1, '#000000');
+ ctx.fillStyle = gV;
+ ctx.fillRect(0, 0, w, h);
+ ctx.beginPath();
+ ctx.arc(cursorX, cursorY, 6, 0, 2 * Math.PI);
+ ctx.strokeStyle = 'rgba(255,255,255,0.95)';
+ ctx.lineWidth = 2;
+ ctx.stroke();
+ }, [hue, cursorX, cursorY]);
+
+ useEffect(() => {
+ if (open) drawCanvas();
+ }, [open, drawCanvas]);
+
+ const pickColor = useCallback(
+ (clientX: number, clientY: number) => {
+ const canvas = canvasRef.current;
+ if (!canvas) return;
+ const rect = canvas.getBoundingClientRect();
+ const scaleX = canvas.width / rect.width;
+ const scaleY = canvas.height / rect.height;
+ const cx = Math.max(0, Math.min(canvas.width - 1, (clientX - rect.left) * scaleX));
+ const cy = Math.max(0, Math.min(canvas.height - 1, (clientY - rect.top) * scaleY));
+ setCursorX(cx);
+ setCursorY(cy);
+ const s = cx / (canvas.width - 1);
+ const v = 1 - cy / (canvas.height - 1);
+ setSat(s);
+ setVal(v);
+ const [r_val, g_val, b_val] = hsvToRgb(hue, s, v);
+ const hex = rgbToHex(r_val, g_val, b_val);
+ setHexInput(hex.slice(1));
+ onChange(hex);
+ },
+ [hue, onChange],
+ );
+
+ const handleHueChange = (e: React.ChangeEvent) => {
+ const h = Number(e.target.value);
+ setHue(h);
+ const [r_v, g_v, b_v] = hsvToRgb(h, sat, val);
+ const hex = rgbToHex(r_v, g_v, b_v);
+ setHexInput(hex.slice(1));
+ onChange(hex);
+ };
+
+ const handleHexInput = (e: React.ChangeEvent) => {
+ const v = e.target.value.replace(/[^0-9a-fA-F]/g, '').slice(0, 6);
+ setHexInput(v.toUpperCase());
+ if (v.length === 6) {
+ const full = '#' + v;
+ const [h, s, v2] = hexToHsv(full);
+ setHue(h);
+ setSat(s);
+ setVal(v2);
+ setCursorX(s * (CANVAS_W - 1));
+ setCursorY((1 - v2) * (CANVAS_H - 1));
+ onChange(full.toUpperCase());
+ }
+ };
+
+ useEffect(() => {
+ if (!open) return;
+ const handler = (e: MouseEvent) => {
+ const isTriggerClick = triggerRef.current?.contains(e.target as Node);
+ const isPanelClick = panelRef.current?.contains(e.target as Node);
+ if (!isTriggerClick && !isPanelClick) {
+ setOpen(false);
+ }
+ };
+ document.addEventListener('mousedown', handler);
+ return () => document.removeEventListener('mousedown', handler);
+ }, [open]);
+
+ const handleEyeDropper = async () => {
+ if (!('EyeDropper' in window)) return;
+ try {
+ const eyeDropper = new (window as any).EyeDropper();
+ const result = await eyeDropper.open();
+ const hex = result.sRGBHex.toUpperCase();
+ const [h, s, v2] = hexToHsv(hex);
+ setHue(h);
+ setSat(s);
+ setVal(v2);
+ setHexInput(hex.slice(1));
+ setCursorX(s * (CANVAS_W - 1));
+ setCursorY((1 - v2) * (CANVAS_H - 1));
+ onChange(hex);
+ } catch (_) { }
+ };
+
+ const [r, g, b] = hsvToRgb(hue, sat, val);
+
+ return (
+
+ {label && (
+
+ {label}
+
+ )}
+
+
+ setOpen(!open)}
+ className={`flex items-center gap-3 ${getGlassyClasses(20)} p-1.5 border border-[var(--border)] hover:bg-[var(--border)] active:scale-95 transition-transform`}
+ aria-label="Open custom palette"
+ >
+
+
+
+
+
+
+ {open &&
+ createPortal(
+
+
{
+ dragging.current = true;
+ pickColor(e.clientX, e.clientY);
+ }}
+ onMouseMove={e => {
+ if (dragging.current) pickColor(e.clientX, e.clientY);
+ }}
+ onMouseUp={() => { dragging.current = false; }}
+ onMouseLeave={() => { dragging.current = false; }}
+ />
+
+
+
+
+
+ {/* 📍 THIS IS THE MISSING PIPETTE TRIGGER */}
+ {'EyeDropper' in window && (
+
+
+
+ )}
+
+
+ {[
+ ['R', r],
+ ['G', g],
+ ['B', b],
+ ].map(([ch, v_rgb]) => (
+
+ ))}
+
+ ,
+ document.body,
+ )}
+
+ );
+};
+
+export default ColorPicker;
\ No newline at end of file
diff --git a/src/components/DatadogInit 2.tsx b/src/components/DatadogInit 2.tsx
new file mode 100644
index 000000000..4d8ed0574
--- /dev/null
+++ b/src/components/DatadogInit 2.tsx
@@ -0,0 +1,36 @@
+"use client";
+
+import { useEffect } from "react";
+import { datadogRum } from "@datadog/browser-rum";
+import { reactPlugin } from "@datadog/browser-rum-react";
+
+export default function DatadogInit() {
+ useEffect(() => {
+ const applicationId = process.env.NEXT_PUBLIC_DATADOG_APPLICATION_ID;
+ const clientToken = process.env.NEXT_PUBLIC_DATADOG_CLIENT_TOKEN;
+
+ if (!applicationId || !clientToken) {
+ return;
+ }
+
+ datadogRum.init({
+ applicationId,
+ clientToken,
+ site: "datadoghq.com",
+ service: "reframe",
+ env: "production",
+ version: "0.1.0",
+ sessionSampleRate: 100,
+ // Reframe advertises itself as 100% private client-side video editing —
+ // session replay would visually record the editor UI, which cuts
+ // against that claim. Metrics/errors only.
+ sessionReplaySampleRate: 0,
+ plugins: [reactPlugin()],
+ trackUserInteractions: true,
+ trackResources: true,
+ trackLongTasks: true,
+ });
+ }, []);
+
+ return null;
+}
diff --git a/src/components/DownloadResult.stories 2.tsx b/src/components/DownloadResult.stories 2.tsx
new file mode 100644
index 000000000..0282f0b7a
--- /dev/null
+++ b/src/components/DownloadResult.stories 2.tsx
@@ -0,0 +1,74 @@
+import type { Meta, StoryObj } from "@storybook/nextjs";
+import DownloadResult from "./DownloadResult";
+import type { ExportResult } from "@/lib/types";
+
+/**
+ * Success panel shown after an export finishes: dimensions, file size, a
+ * filename field and the download button.
+ *
+ * The ExportResult is synthesised rather than produced by a real export — a
+ * genuine FFmpeg run would take ~30s and produce a different blob each time.
+ * A small in-memory Blob exercises exactly the same render path.
+ */
+function makeResult(overrides: Partial = {}): ExportResult {
+ const blob = new Blob(["reframe-storybook-fixture"], { type: "video/mp4" });
+ return {
+ blobUrl: "about:blank",
+ blob,
+ size: 8_432_119,
+ width: 1080,
+ height: 1920,
+ format: "mp4",
+ dispose: () => {},
+ ...overrides,
+ };
+}
+
+const meta = {
+ title: "Editor/Modals/DownloadResult",
+ component: DownloadResult,
+ parameters: { layout: "padded" },
+ args: {
+ onReset: () => {},
+ onToggleSound: () => {},
+ soundOnCompletion: false,
+ },
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+/** A vertical 9:16 export, the most common output for this tool. */
+export const Default: Story = {
+ args: { result: makeResult() },
+};
+
+/** Landscape WebM, with the export-duration readout present. */
+export const WithExportDuration: Story = {
+ args: {
+ result: makeResult({
+ width: 1920,
+ height: 1080,
+ format: "webm",
+ size: 24_908_311,
+ exportDurationMs: 42_500,
+ }),
+ },
+};
+
+/** Completion sound enabled — toggles the speaker control's active state. */
+export const SoundEnabled: Story = {
+ args: { result: makeResult(), soundOnCompletion: true },
+};
+
+/** A GIF export: small, square, and audio-less. */
+export const GifExport: Story = {
+ args: {
+ result: makeResult({
+ format: "gif",
+ width: 480,
+ height: 480,
+ size: 1_204_880,
+ }),
+ },
+};
diff --git a/src/components/ExportOverlay.stories 2.tsx b/src/components/ExportOverlay.stories 2.tsx
new file mode 100644
index 000000000..599871a68
--- /dev/null
+++ b/src/components/ExportOverlay.stories 2.tsx
@@ -0,0 +1,83 @@
+import * as React from "react";
+import type { Meta, StoryObj, Decorator } from "@storybook/nextjs";
+import ExportOverlay from "./ExportOverlay";
+
+/**
+ * Full-screen modal shown while an export runs.
+ *
+ * ── Why this file works the way it does ──────────────────────────────────────
+ * The overlay contains a that rotates to the next tip every
+ * 6000ms. A visual regression service screenshots at an arbitrary moment, so
+ * that rotation reports a change on every build that reflects nothing but
+ * timing. It is marked data-chromatic="ignore": Chromatic still renders it,
+ * but excludes it from the diff.
+ *
+ * The Lottie spinner used to need the same treatment. It no longer does —
+ * LottiePlayer checks isChromatic() and renders frame 0 statically, so the
+ * spinner is deterministic and stays fully covered by the diff.
+ *
+ * A word of warning from how this file got here: an earlier version froze both
+ * by stubbing window.setInterval and window.requestAnimationFrame. Chromatic's
+ * capture instrumentation relies on requestAnimationFrame to decide a story
+ * has settled, so starving it meant all six snapshots (3 stories x light/dark)
+ * failed with "took too long to load". Never stub browser globals to stabilise
+ * a snapshot.
+ */
+const withCarouselIgnored: Decorator = (Story) => (
+
+
+
+);
+
+function IgnoreCarousel({ children }: { children: React.ReactNode }) {
+ React.useEffect(() => {
+ // The TipCarousel root, identified by its min-h-[142px] utility.
+ const marked: Element[] = [];
+
+ document.querySelectorAll('[class*="min-h-[142px]"]').forEach((el) => {
+ el.setAttribute("data-chromatic", "ignore");
+ marked.push(el);
+ });
+
+ return () => marked.forEach((el) => el.removeAttribute("data-chromatic"));
+ }, []);
+
+ return <>{children}>;
+}
+
+const meta = {
+ title: "Editor/Modals/ExportOverlay",
+ component: ExportOverlay,
+ parameters: { layout: "fullscreen" },
+ decorators: [withCarouselIgnored],
+ args: {
+ // With a real timestamp the component starts a 1s interval and renders
+ // Date.now() - exportStartedAt as elapsed time, which would differ between
+ // builds. null takes its own early-return branch and pins it at zero.
+ exportStartedAt: null,
+ onCancel: () => {},
+ },
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+/**
+ * FFmpeg.wasm (~30 MB) is being fetched on first export.
+ *
+ * The overlay only renders for "loading-engine" and "exporting"; the other
+ * ExportStatus values return null, so they aren't worth stories.
+ */
+export const LoadingEngine: Story = {
+ args: { status: "loading-engine", progress: 0 },
+};
+
+/** Mid-export, just past a third done. */
+export const Exporting: Story = {
+ args: { status: "exporting", progress: 37 },
+};
+
+/** Near completion — checks the progress bar at the far end of its track. */
+export const AlmostDone: Story = {
+ args: { status: "exporting", progress: 98 },
+};
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/ExportSettings.stories 2.tsx b/src/components/ExportSettings.stories 2.tsx
new file mode 100644
index 000000000..62cb7a63d
--- /dev/null
+++ b/src/components/ExportSettings.stories 2.tsx
@@ -0,0 +1,45 @@
+import type { Meta, StoryObj } from "@storybook/nextjs";
+import ExportSettings from "./ExportSettings";
+import { RecipeHarness, makeRecipe } from "./__stories__/RecipeHarness";
+
+/**
+ * Quality (CRF) slider plus the estimated output size, which is derived from
+ * the recipe and the clip duration.
+ */
+const meta = {
+ title: "Editor/Controls/ExportSettings",
+ component: ExportSettings,
+ parameters: { layout: "padded" },
+ args: {
+ recipe: makeRecipe(),
+ onChange: () => {},
+ duration: 90,
+ },
+ render: (args) => (
+
+ {(recipe, onChange) => (
+
+ )}
+
+ ),
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+/** CRF 23 — the default balance of size and quality. */
+export const Default: Story = {};
+
+/** Near-lossless: low CRF, large estimate. */
+export const HighQuality: Story = {
+ args: { recipe: makeRecipe({ quality: 16 }) },
+};
+
+/** Heavily compressed: high CRF, small estimate. */
+export const SmallFile: Story = {
+ args: { recipe: makeRecipe({ quality: 34 }) },
+};
diff --git a/src/components/FileUpload.stories 2.tsx b/src/components/FileUpload.stories 2.tsx
new file mode 100644
index 000000000..07d48ab81
--- /dev/null
+++ b/src/components/FileUpload.stories 2.tsx
@@ -0,0 +1,42 @@
+import type { Meta, StoryObj } from "@storybook/nextjs";
+import FileUpload from "./FileUpload";
+
+/**
+ * The drag-and-drop landing state — the first thing every user sees, and the
+ * only screen shown until a file is chosen. Worth snapshotting in both themes.
+ *
+ * `currentFile` stays null across these stories: the "file selected" branch
+ * reads File.name/size and renders a duration that arrives asynchronously from
+ * media metadata, which would not be reproducible in CI.
+ */
+const meta = {
+ title: "Editor/FileUpload",
+ component: FileUpload,
+ parameters: { layout: "padded" },
+ args: {
+ onFileSelect: () => {},
+ currentFile: null,
+ fileError: "",
+ duration: 0,
+ },
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+/** Empty drop zone awaiting a file. */
+export const Empty: Story = {};
+
+/** Rejected file — checks the error styling and that layout doesn't jump. */
+export const WithError: Story = {
+ args: {
+ fileError: "That file is 3.2 GB. The maximum supported size is 2 GB.",
+ },
+};
+
+/** An unsupported-format rejection, which is the more common failure. */
+export const UnsupportedFormat: Story = {
+ args: {
+ fileError: "Unsupported file type. Please choose an MP4, WebM or MOV file.",
+ },
+};
diff --git a/src/components/FormatSelector.stories 2.tsx b/src/components/FormatSelector.stories 2.tsx
new file mode 100644
index 000000000..a465cac82
--- /dev/null
+++ b/src/components/FormatSelector.stories 2.tsx
@@ -0,0 +1,31 @@
+import type { Meta, StoryObj } from "@storybook/nextjs";
+import FormatSelector from "./FormatSelector";
+import { RecipeHarness, makeRecipe } from "./__stories__/RecipeHarness";
+
+/** Container format choice: mp4 / webm / mkv / gif. */
+const meta = {
+ title: "Editor/Controls/FormatSelector",
+ component: FormatSelector,
+ parameters: { layout: "padded" },
+ args: {
+ recipe: makeRecipe(),
+ onChange: () => {},
+ },
+ // `recipe` seeds the harness, which then owns the state so the control is
+ // actually interactive in Storybook rather than frozen on first render.
+ render: (args) => (
+
+ {(recipe, onChange) => }
+
+ ),
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {};
+
+export const WebM: Story = { args: { recipe: makeRecipe({ format: "webm" }) } };
+
+/** GIF is the odd one out — it drops audio, so its selected state matters. */
+export const Gif: Story = { args: { recipe: makeRecipe({ format: "gif" }) } };
diff --git a/src/components/FramingControl.stories 2.tsx b/src/components/FramingControl.stories 2.tsx
new file mode 100644
index 000000000..f02f87126
--- /dev/null
+++ b/src/components/FramingControl.stories 2.tsx
@@ -0,0 +1,28 @@
+import type { Meta, StoryObj } from "@storybook/nextjs";
+import FramingControl from "./FramingControl";
+import { RecipeHarness, makeRecipe } from "./__stories__/RecipeHarness";
+
+/** Fit (letterbox) vs Fill (crop) toggle. */
+const meta = {
+ title: "Editor/Controls/FramingControl",
+ component: FramingControl,
+ parameters: { layout: "padded" },
+ args: {
+ recipe: makeRecipe(),
+ onChange: () => {},
+ },
+ // `recipe` seeds the harness, which then owns the state so the control is
+ // actually interactive in Storybook rather than frozen on first render.
+ render: (args) => (
+
+ {(recipe, onChange) => }
+
+ ),
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const Fit: Story = { args: { recipe: makeRecipe({ framing: "fit" }) } };
+
+export const Fill: Story = { args: { recipe: makeRecipe({ framing: "fill" }) } };
diff --git a/src/components/HistoryControls 2.tsx b/src/components/HistoryControls 2.tsx
new file mode 100644
index 000000000..5091afc9a
--- /dev/null
+++ b/src/components/HistoryControls 2.tsx
@@ -0,0 +1,51 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { Undo2, Redo2 } from "lucide-react";
+import { isMacPlatform } from "@/lib/historyShortcuts";
+
+interface Props {
+ canUndo: boolean;
+ canRedo: boolean;
+ onUndo: () => void;
+ onRedo: () => void;
+}
+
+export default function HistoryControls({ canUndo, canRedo, onUndo, onRedo }: Props) {
+ const [isMac, setIsMac] = useState(false);
+ useEffect(() => {
+ // navigator is browser-only, so the platform can't be read during render.
+ // eslint-disable-next-line react-hooks/set-state-in-effect
+ setIsMac(isMacPlatform());
+ }, []);
+
+ const undoKeys = isMac ? "⌘Z" : "Ctrl+Z";
+ const redoKeys = isMac ? "⇧⌘Z" : "Ctrl+Y";
+
+ return (
+
+
+
+ Undo
+
+
+
+ Redo
+
+
+ );
+}
diff --git a/src/components/PresetSelector.stories 2.tsx b/src/components/PresetSelector.stories 2.tsx
new file mode 100644
index 000000000..9609119fe
--- /dev/null
+++ b/src/components/PresetSelector.stories 2.tsx
@@ -0,0 +1,51 @@
+import type { Meta, StoryObj } from "@storybook/nextjs";
+import PresetSelector from "./PresetSelector";
+import { RecipeHarness, makeRecipe } from "./__stories__/RecipeHarness";
+
+/**
+ * The preset grid is the first real choice a user makes, and the most
+ * layout-sensitive part of the editor: presets are grouped into categories and
+ * each tile draws a scaled aspect-ratio box, so a change to spacing or to the
+ * ratio maths surfaces here before anywhere else.
+ */
+const meta = {
+ title: "Editor/Controls/PresetSelector",
+ component: PresetSelector,
+ parameters: { layout: "padded" },
+ args: {
+ recipe: makeRecipe(),
+ onChange: () => {},
+ },
+ // `recipe` seeds the harness, which then owns the state so the control is
+ // actually interactive in Storybook rather than frozen on first render.
+ render: (args) => (
+
+ {(recipe, onChange) => }
+
+ ),
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+/** Default state — the 9:16 vertical preset selected. */
+export const Default: Story = {};
+
+/** A landscape preset selected, to catch category/highlight regressions. */
+export const LandscapeSelected: Story = {
+ args: { recipe: makeRecipe({ preset: "landscape-16-9" }) },
+};
+
+/**
+ * Custom dimensions reveal the width/height inputs, historically the source of
+ * overflow bugs in this panel.
+ */
+export const CustomDimensions: Story = {
+ args: {
+ recipe: makeRecipe({
+ preset: "custom",
+ customWidth: 1440,
+ customHeight: 1080,
+ }),
+ },
+};
diff --git a/src/components/PrivacyBanner 2.tsx b/src/components/PrivacyBanner 2.tsx
new file mode 100644
index 000000000..5b600a63c
--- /dev/null
+++ b/src/components/PrivacyBanner 2.tsx
@@ -0,0 +1,81 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { ShieldCheck, X } from "lucide-react";
+
+/** localStorage key holding the timestamp (ms) until which the banner stays hidden. */
+const DISMISS_KEY = "reframe_privacy_banner_dismissed_until";
+/** Keep the banner hidden for 7 days after it is dismissed. */
+const DISMISS_DURATION_MS = 7 * 24 * 60 * 60 * 1000;
+
+/**
+ * Dismissible notice shown above the upload area highlighting that all video
+ * processing happens locally. Dismissal is remembered in localStorage and the
+ * banner does not reappear for 7 days.
+ */
+export default function PrivacyBanner() {
+ // Start hidden so the server-rendered markup matches the first client paint
+ // (static export → no hydration mismatch); reveal after reading localStorage.
+ const [visible, setVisible] = useState(false);
+
+ useEffect(() => {
+ // Genuinely needs an effect: localStorage is unavailable during SSR, so
+ // this can't be computed as derived state without a hydration mismatch.
+ try {
+ const raw = localStorage.getItem(DISMISS_KEY);
+ const dismissedUntil = raw ? Number(raw) : 0;
+ if (!Number.isFinite(dismissedUntil) || Date.now() >= dismissedUntil) {
+ // eslint-disable-next-line react-hooks/set-state-in-effect
+ setVisible(true);
+ }
+ } catch {
+ // localStorage unavailable (private mode, blocked storage) — show anyway.
+ setVisible(true);
+ }
+ }, []);
+
+ const dismiss = () => {
+ try {
+ localStorage.setItem(
+ DISMISS_KEY,
+ String(Date.now() + DISMISS_DURATION_MS)
+ );
+ } catch {
+ // Ignore write failures — dismissal simply won't persist across reloads.
+ }
+ setVisible(false);
+ };
+
+ if (!visible) return null;
+
+ return (
+
+
+
+
+ Your videos never leave your device.
+
+
+ Processing is done entirely in your browser using FFmpeg.wasm. No
+ server, no upload, no account required.
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/PrivacyBanner.stories 2.tsx b/src/components/PrivacyBanner.stories 2.tsx
new file mode 100644
index 000000000..52ea8144c
--- /dev/null
+++ b/src/components/PrivacyBanner.stories 2.tsx
@@ -0,0 +1,21 @@
+import type { Meta, StoryObj } from "@storybook/nextjs";
+import PrivacyBanner from "./PrivacyBanner";
+
+/**
+ * "Your video never leaves your device" banner above the upload area — a key
+ * piece of the product's positioning, so its styling is worth protecting.
+ *
+ * It is dismissible and persists that choice, so it can render empty if
+ * localStorage already holds a dismissal. Storybook gives each story a fresh
+ * iframe, so the default (visible) state is what gets captured.
+ */
+const meta = {
+ title: "Editor/PrivacyBanner",
+ component: PrivacyBanner,
+ parameters: { layout: "padded" },
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {};
diff --git a/src/components/ProjectPanel 2.tsx b/src/components/ProjectPanel 2.tsx
new file mode 100644
index 000000000..e973f3f89
--- /dev/null
+++ b/src/components/ProjectPanel 2.tsx
@@ -0,0 +1,216 @@
+"use client";
+
+import { useRef, useState } from "react";
+import { FolderOpen, Save, Trash2, HardDrive } from "lucide-react";
+import { formatBytes } from "@/lib/ffmpeg";
+import { MAX_PROJECT_FILE_BYTES } from "@/lib/project";
+import type {
+ PendingRecovery,
+ ProjectImportResult,
+ ProjectStorageStatus,
+} from "@/hooks/useVideoEditor";
+
+function formatDuration(seconds: number): string {
+ const total = Math.max(0, Math.round(seconds));
+ return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, "0")}`;
+}
+
+function formatSavedAt(savedAt: number): string {
+ return new Date(savedAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
+}
+
+const actionClass = "btn btn-secondary btn-sm";
+
+interface PanelProps {
+ hasFile: boolean;
+ storageStatus: ProjectStorageStatus;
+ disabled?: boolean;
+ onBuildProjectFile: () => { fileName: string; contents: string };
+ onImportProject: (contents: string) => ProjectImportResult;
+ onDeleteSavedData: () => void;
+}
+
+/** Explains what is saved in this browser, and saves, opens or deletes it. */
+export default function ProjectPanel({
+ hasFile,
+ storageStatus,
+ disabled = false,
+ onBuildProjectFile,
+ onImportProject,
+ onDeleteSavedData,
+}: PanelProps) {
+ const inputRef = useRef(null);
+ const [message, setMessage] = useState<{ tone: "ok" | "error"; text: string } | null>(null);
+ const [confirmingDelete, setConfirmingDelete] = useState(false);
+
+ const handleSave = () => {
+ const { fileName, contents } = onBuildProjectFile();
+ const url = URL.createObjectURL(new Blob([contents], { type: "application/json" }));
+ const link = document.createElement("a");
+ link.href = url;
+ link.download = fileName;
+ link.click();
+ setTimeout(() => URL.revokeObjectURL(url), 0);
+ setMessage({ tone: "ok", text: `Saved ${fileName}. It holds your edits, not the video.` });
+ };
+
+ const handleOpen = async (event: React.ChangeEvent) => {
+ const chosen = event.target.files?.[0];
+ event.target.value = "";
+ if (!chosen) return;
+ if (chosen.size > MAX_PROJECT_FILE_BYTES) {
+ setMessage({ tone: "error", text: "That file is too large to be a Reframe project." });
+ return;
+ }
+ let contents: string;
+ try {
+ contents = await chosen.text();
+ } catch {
+ setMessage({ tone: "error", text: "That file couldn't be read." });
+ return;
+ }
+ const outcome = onImportProject(contents);
+ setMessage(
+ outcome.ok
+ ? { tone: "ok", text: outcome.message }
+ : { tone: "error", text: `Couldn't open the project. ${outcome.error}` }
+ );
+ };
+
+ const sourceLine =
+ storageStatus.sourceCopy === "saved"
+ ? "A copy of the video is kept in this browser's storage so a refresh doesn't lose it."
+ : storageStatus.sourceCopy === "failed"
+ ? "This browser didn't have room to keep a copy of the video. After a refresh you'll be asked to choose it again."
+ : storageStatus.sourceCopy === "saving"
+ ? "Keeping a copy of the video in this browser's storage…"
+ : null;
+
+ return (
+
+
+
+ Saved on this device
+
+
+
+
+ Your edit settings are saved in this browser as you work, so you can pick up after a
+ refresh. Nothing is uploaded.
+
+ {hasFile && sourceLine &&
{sourceLine}
}
+ {hasFile && storageStatus.savedAt && !storageStatus.error && (
+
Edits saved at {formatSavedAt(storageStatus.savedAt)}.
+ )}
+ {storageStatus.error &&
{storageStatus.error}
}
+
+
+
+
+
+ Save project file
+
+ inputRef.current?.click()}
+ disabled={disabled}
+ className={actionClass}
+ >
+
+ Open project file
+
+
+
+
+
+ {message && (
+
+ {message.text}
+
+ )}
+
+
+ {confirmingDelete ? (
+
+
+ Delete the saved edits and the stored copy of the video from this browser, and close the
+ current video? Project files you saved are not affected.
+
+
+ {
+ onDeleteSavedData();
+ setConfirmingDelete(false);
+ setMessage({ tone: "ok", text: "Saved data deleted. The editor has been reset." });
+ }}
+ className="btn btn-danger btn-sm"
+ >
+ Delete
+
+ setConfirmingDelete(false)}
+ className="btn btn-secondary btn-sm"
+ >
+ Cancel
+
+
+
+ ) : (
+ setConfirmingDelete(true)}
+ disabled={disabled}
+ className="btn btn-ghost btn-sm text-[var(--muted)] hover:text-[var(--error)]"
+ >
+
+ Delete saved data
+
+ )}
+
+ );
+}
+
+interface RecoveryProps {
+ recovery: PendingRecovery;
+ onDiscard: () => void;
+}
+
+/** Shown when a saved project's video has to be chosen again. */
+export function RecoveryNotice({ recovery, onDiscard }: RecoveryProps) {
+ const { source } = recovery;
+ return (
+
+
+ {recovery.origin === "session" ? "Pick up where you left off" : "Choose the project's video"}
+
+
+ {recovery.origin === "session"
+ ? "Your edits were saved, but this browser no longer has the video. "
+ : "Project files don't include the video. "}
+ Choose {source.name} ({formatBytes(source.size)}
+ {source.duration > 0 ? `, ${formatDuration(source.duration)}` : ""}) below to continue with
+ your edits. A different video starts with fresh trim points.
+
+
+ Discard saved edits
+
+
+ );
+}
diff --git a/src/components/RotateControl.stories 2.tsx b/src/components/RotateControl.stories 2.tsx
new file mode 100644
index 000000000..57b9e7c1e
--- /dev/null
+++ b/src/components/RotateControl.stories 2.tsx
@@ -0,0 +1,35 @@
+import type { Meta, StoryObj } from "@storybook/nextjs";
+import RotateControl from "./RotateControl";
+import { RecipeHarness, makeRecipe } from "./__stories__/RecipeHarness";
+
+/**
+ * Rotation buttons (0/90/180/270), implemented as a radiogroup with roving
+ * tabindex — so the selected-state styling is worth pinning down visually.
+ */
+const meta = {
+ title: "Editor/Controls/RotateControl",
+ component: RotateControl,
+ parameters: { layout: "padded" },
+ args: {
+ recipe: makeRecipe(),
+ onChange: () => {},
+ },
+ // `recipe` seeds the harness, which then owns the state so the control is
+ // actually interactive in Storybook rather than frozen on first render.
+ render: (args) => (
+
+ {(recipe, onChange) => }
+
+ ),
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const NoRotation: Story = { args: { recipe: makeRecipe({ rotate: 0 }) } };
+
+export const Rotated90: Story = { args: { recipe: makeRecipe({ rotate: 90 }) } };
+
+export const Rotated270: Story = {
+ args: { recipe: makeRecipe({ rotate: 270 }) },
+};
diff --git a/src/components/TrimControl.stories 2.tsx b/src/components/TrimControl.stories 2.tsx
new file mode 100644
index 000000000..9269aa864
--- /dev/null
+++ b/src/components/TrimControl.stories 2.tsx
@@ -0,0 +1,52 @@
+import type { Meta, StoryObj } from "@storybook/nextjs";
+import TrimControl from "./TrimControl";
+import { RecipeHarness, makeRecipe } from "./__stories__/RecipeHarness";
+
+/**
+ * Trim in/out controls.
+ *
+ * Determinism note: `file` is null in every story. The component feeds it to
+ * useAudioWaveform, which decodes real audio to draw the waveform — an async
+ * result that depends on the media, so a real file would make these snapshots
+ * unstable. The null path still renders the trim inputs and duration readout,
+ * which is the part worth regression-testing.
+ */
+const meta = {
+ title: "Editor/Controls/TrimControl",
+ component: TrimControl,
+ parameters: { layout: "padded" },
+ args: {
+ recipe: makeRecipe(),
+ onChange: () => {},
+ duration: 90,
+ file: null,
+ },
+ render: (args) => (
+
+ {(recipe, onChange) => (
+
+ )}
+
+ ),
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+/** Full 90-second clip, nothing trimmed. */
+export const Default: Story = {};
+
+/** A trimmed range, so the computed output duration is exercised. */
+export const Trimmed: Story = {
+ args: { recipe: makeRecipe({ trimStart: 12.5, trimEnd: 47.25 }) },
+};
+
+/** Before media loads the duration is 0 — check it degrades cleanly. */
+export const NoMediaLoaded: Story = {
+ args: { duration: 0 },
+};
diff --git a/src/components/VideoEditor.stories 2.tsx b/src/components/VideoEditor.stories 2.tsx
new file mode 100644
index 000000000..bcdb0f7de
--- /dev/null
+++ b/src/components/VideoEditor.stories 2.tsx
@@ -0,0 +1,41 @@
+import type { Meta, StoryObj } from "@storybook/nextjs";
+import VideoEditor from "./VideoEditor";
+
+/**
+ * The whole editor shell — the single most valuable snapshot in the project,
+ * because it catches layout regressions that individual control stories miss
+ * (section spacing, sidebar width, responsive stacking).
+ *
+ * It renders its pre-upload state here. VideoEditor owns real state via
+ * useVideoEditor, but FFmpeg.wasm is lazy-loaded and only touched on export,
+ * so mounting it is cheap and side-effect free.
+ *
+ * The onboarding tour is marked complete before the story renders. Two
+ * reasons: it measures its target element and animates a spotlight over a
+ * chain of timeouts, so a screenshot taken at an arbitrary moment catches it
+ * mid-move and reports a change that is purely timing; and the tour's dimming
+ * overlay covers most of the editor, which is precisely what this story exists
+ * to show.
+ *
+ * Interacting further (choosing a file, exporting) needs real media and a
+ * ~30 MB WASM download, so those paths are covered by the individual control
+ * stories instead of here.
+ */
+const meta = {
+ title: "Editor/VideoEditor",
+ component: VideoEditor,
+ parameters: {
+ layout: "fullscreen",
+ chromatic: { delay: 300 },
+ },
+ beforeEach: async () => {
+ // Matches TOUR_KEY in OnboardingTour.tsx.
+ window.localStorage.setItem("reframe_onboarding_complete", "1");
+ },
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+/** Initial load: upload prompt plus editor chrome. */
+export const Initial: Story = {};
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/components/__stories__/RecipeHarness 2.tsx b/src/components/__stories__/RecipeHarness 2.tsx
new file mode 100644
index 000000000..da0382744
--- /dev/null
+++ b/src/components/__stories__/RecipeHarness 2.tsx
@@ -0,0 +1,45 @@
+"use client";
+
+import * as React from "react";
+import { DEFAULT_RECIPE } from "@/lib/constants";
+import type { EditRecipe } from "@/lib/types";
+
+/**
+ * Shared story harness for the editor's control components.
+ *
+ * Every control in src/components takes the same pair of props — the whole
+ * `EditRecipe` plus an `onChange(patch)` callback — and renders purely from
+ * that state (the `updateRecipe(patch)` pattern described in CLAUDE.md).
+ *
+ * A story that passed a frozen object and a no-op `onChange` would render, but
+ * clicking anything would do nothing, which makes the Storybook docs useless
+ * for reviewing behaviour. This holds the recipe in real state so controls are
+ * interactive, while still starting from a deterministic snapshot for
+ * Chromatic.
+ */
+export function RecipeHarness({
+ initial,
+ children,
+}: {
+ initial?: Partial;
+ children: (
+ recipe: EditRecipe,
+ onChange: (patch: Partial) => void
+ ) => React.ReactNode;
+}) {
+ const [recipe, setRecipe] = React.useState({
+ ...DEFAULT_RECIPE,
+ ...initial,
+ });
+
+ const onChange = React.useCallback((patch: Partial) => {
+ setRecipe((prev) => ({ ...prev, ...patch }));
+ }, []);
+
+ return <>{children(recipe, onChange)}>;
+}
+
+/** A deterministic recipe for stories that only need a static value. */
+export function makeRecipe(overrides: Partial = {}): EditRecipe {
+ return { ...DEFAULT_RECIPE, ...overrides };
+}
diff --git a/src/components/components.stories 2.tsx b/src/components/components.stories 2.tsx
new file mode 100644
index 000000000..b69ac981f
--- /dev/null
+++ b/src/components/components.stories 2.tsx
@@ -0,0 +1,88 @@
+import type { Meta, StoryObj } from "@storybook/nextjs";
+import { Card, Button, Input, Badge } from "./components";
+
+/**
+ * The shared primitives every other screen is built from. These are the
+ * highest-value stories in the project for visual regression: a change to a
+ * design token (--bg, --surface, --border, --text, --muted, --accent) shows up
+ * here first, and CLAUDE.md calls out renaming those variables as a breaking
+ * change. One diff on this page localises the blast radius immediately.
+ */
+const meta = {
+ title: "Design System/Primitives",
+ parameters: { layout: "padded" },
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+/** Every button variant side by side, plus the disabled state. */
+export const Buttons: Story = {
+ render: () => (
+
+ Export video
+ Reset all
+ Cancel
+
+ Exporting…
+
+
+ ),
+};
+
+/** All badge variants — these carry status colour, so contrast matters. */
+export const Badges: Story = {
+ render: () => (
+
+
+
+
+
+
+ ),
+};
+
+/** Text input, including the focus-visible outline globals.css defines. */
+export const Inputs: Story = {
+ render: () => (
+
+
+
+
+
+ ),
+};
+
+/** Card container with and without a description and body content. */
+export const Cards: Story = {
+ render: () => (
+
+
+ 9 : 16 — 1080 × 1920
+
+
+
+ ),
+};
+
+/** Everything together, which is how these actually appear in the editor. */
+export const Kitchensink: Story = {
+ render: () => (
+
+
+ Export
+ Reset
+ Cancel
+
+
+
+
+
+
+
+
+
+
+
+ ),
+};
diff --git a/src/components/ui/BaseButton.stories 2.tsx b/src/components/ui/BaseButton.stories 2.tsx
new file mode 100644
index 000000000..33b84e995
--- /dev/null
+++ b/src/components/ui/BaseButton.stories 2.tsx
@@ -0,0 +1,65 @@
+import type { Meta, StoryObj } from "@storybook/nextjs";
+import BaseButton from "./BaseButton";
+
+/**
+ * The shared button used across the editor. Four variants times four sizes is
+ * exactly the kind of combinatorial surface that regresses silently, so the
+ * matrix story below captures all of it in a single snapshot.
+ */
+const meta = {
+ title: "Design System/BaseButton",
+ component: BaseButton,
+ parameters: { layout: "padded" },
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const Primary: Story = {
+ args: { variant: "primary", children: "Export video" },
+};
+
+export const Secondary: Story = {
+ args: { variant: "secondary", children: "Reset all" },
+};
+
+export const Ghost: Story = {
+ args: { variant: "ghost", children: "Cancel" },
+};
+
+export const Outline: Story = {
+ args: { variant: "outline", children: "Learn more" },
+};
+
+/** Marked active — used for selected presets and toggles. */
+export const Active: Story = {
+ args: { variant: "secondary", active: true, children: "9 : 16" },
+};
+
+export const Disabled: Story = {
+ args: { variant: "primary", disabled: true, children: "Exporting…" },
+};
+
+/** Every variant/size combination in one snapshot. */
+export const Matrix: Story = {
+ render: () => {
+ const variants = ["primary", "secondary", "ghost", "outline"] as const;
+ const sizes = ["sm", "md", "lg", "xl"] as const;
+ return (
+
+ {variants.map((variant) => (
+
+
+ {variant}
+
+ {sizes.map((size) => (
+
+ {size}
+
+ ))}
+
+ ))}
+
+ );
+ },
+};
diff --git a/src/components/ui/Skeleton.stories 2.tsx b/src/components/ui/Skeleton.stories 2.tsx
new file mode 100644
index 000000000..06239301b
--- /dev/null
+++ b/src/components/ui/Skeleton.stories 2.tsx
@@ -0,0 +1,31 @@
+import type { Meta, StoryObj } from "@storybook/nextjs";
+import Skeleton from "./Skeleton";
+
+/**
+ * Loading placeholder. Its shimmer is a CSS animation, which .storybook/preview.css
+ * freezes so Chromatic captures a settled frame instead of a random one.
+ */
+const meta = {
+ title: "Design System/Skeleton",
+ component: Skeleton,
+ parameters: { layout: "padded" },
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const Line: Story = { args: { className: "h-4 w-64" } };
+
+export const Block: Story = { args: { className: "h-32 w-full max-w-md" } };
+
+/** A realistic cluster, as used while thumbnails load. */
+export const Group: Story = {
+ render: () => (
+
+
+
+
+
+
+ ),
+};
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/editorPersistence 2.ts b/src/lib/editorPersistence 2.ts
new file mode 100644
index 000000000..1abc8ba67
--- /dev/null
+++ b/src/lib/editorPersistence 2.ts
@@ -0,0 +1,131 @@
+import { DEFAULT_RECIPE } from "@/lib/constants";
+import { EditRecipe, OverlayPosition, isValidRecipe, TextOverlay } from "@/lib/types";
+import { generateTextOverlayId } from "@/lib/text-overlay";
+
+export const RECIPE_STORAGE_KEY = "reframe:recipe";
+export const LEGACY_SETTINGS_KEY = "reframe-settings";
+export const EDITOR_STATE_KEY = "editorState";
+export const SOUND_PREF_KEY = "soundOnCompletion";
+
+export interface OverlayEditorState {
+ overlayPosition?: OverlayPosition;
+ overlaySize?: number;
+ overlayOpacity?: number;
+}
+
+function clamp(value: number, min: number, max: number): number {
+ return Math.min(Math.max(value, min), max);
+}
+
+const VALID_FONT_WEIGHTS = ["normal", "bold", "900"];
+
+function sanitizeTextOverlay(overlay: any): TextOverlay {
+ return {
+ id: typeof overlay.id === "string" ? overlay.id : generateTextOverlayId(),
+ text: typeof overlay.text === "string" ? overlay.text.slice(0, 500) : "",
+ x: typeof overlay.x === "number" && isFinite(overlay.x) ? clamp(overlay.x, 0, 100) : 50,
+ y: typeof overlay.y === "number" && isFinite(overlay.y) ? clamp(overlay.y, 0, 100) : 20,
+ fontSize: typeof overlay.fontSize === "number" && isFinite(overlay.fontSize) ? clamp(overlay.fontSize, 12, 120) : 48,
+ color: typeof overlay.color === "string" && /^#[0-9A-Fa-f]{6}$/.test(overlay.color) ? overlay.color : "#ffffff",
+ fontWeight: VALID_FONT_WEIGHTS.includes(overlay.fontWeight) ? (overlay.fontWeight as "normal" | "bold" | "900") : "normal",
+ fontFamily: typeof overlay.fontFamily === "string" ? overlay.fontFamily : "Arial",
+ fontPath: typeof overlay.fontPath === "string" ? overlay.fontPath : undefined,
+ };
+}
+
+export function migrateRecipe(recipe: Partial): EditRecipe {
+ const rotateValue = [0, 90, 180, 270].includes(recipe.rotate as any) ? (recipe.rotate as 0 | 90 | 180 | 270) : DEFAULT_RECIPE.rotate;
+
+ return {
+ ...DEFAULT_RECIPE,
+ ...recipe,
+ quality: typeof recipe.quality === "number" ? clamp(recipe.quality, 18, 30) : DEFAULT_RECIPE.quality,
+ speed: typeof recipe.speed === "number" ? clamp(recipe.speed, 0.25, 4) : DEFAULT_RECIPE.speed,
+ brightness: typeof recipe.brightness === "number" ? clamp(recipe.brightness, -1, 1) : DEFAULT_RECIPE.brightness,
+ contrast: typeof recipe.contrast === "number" ? clamp(recipe.contrast, 0, 2) : DEFAULT_RECIPE.contrast,
+ saturation: typeof recipe.saturation === "number" ? clamp(recipe.saturation, 0, 3) : DEFAULT_RECIPE.saturation,
+ customWidth: typeof recipe.customWidth === "number" ? clamp(recipe.customWidth, 16, 7680) : DEFAULT_RECIPE.customWidth,
+ customHeight: typeof recipe.customHeight === "number" ? clamp(recipe.customHeight, 16, 7680) : DEFAULT_RECIPE.customHeight,
+ trimStart: typeof recipe.trimStart === "number" ? Math.max(0, recipe.trimStart) : DEFAULT_RECIPE.trimStart,
+ rotate: rotateValue,
+ textOverlays: Array.isArray(recipe.textOverlays) ? recipe.textOverlays.map(sanitizeTextOverlay) : [],
+ };
+}
+
+export function getStoredSoundPreference(storage: Pick): boolean {
+ return storage.getItem(SOUND_PREF_KEY) === "true";
+}
+
+export function loadPersistedRecipe(
+ storage: Pick,
+ fallback: EditRecipe
+): EditRecipe {
+ const raw = storage.getItem(RECIPE_STORAGE_KEY);
+ if (raw) {
+ try {
+ const parsed = JSON.parse(raw);
+ if (isValidRecipe(parsed)) {
+ return parsed;
+ }
+ } catch {
+ // fall through to legacy state
+ }
+ }
+
+ const legacy = storage.getItem(LEGACY_SETTINGS_KEY);
+ if (legacy) {
+ try {
+ const parsed = JSON.parse(legacy);
+ return migrateRecipe({
+ preset: parsed.preset ?? fallback.preset,
+ quality: parsed.quality ?? fallback.quality,
+ speed: parsed.speed ?? fallback.speed,
+ customWidth: Number.isFinite(Number(parsed.customWidth)) ? Number(parsed.customWidth) : fallback.customWidth,
+ customHeight: Number.isFinite(Number(parsed.customHeight)) ? Number(parsed.customHeight) : fallback.customHeight,
+ });
+ } catch {
+ // ignore malformed legacy data
+ }
+ }
+
+ return fallback;
+}
+
+export function loadOverlayState(
+ storage: Pick,
+ fallback: OverlayEditorState
+): OverlayEditorState {
+ const raw = storage.getItem(EDITOR_STATE_KEY);
+ if (!raw) return fallback;
+
+ try {
+ const parsed = JSON.parse(raw) as OverlayEditorState & { recipe?: unknown };
+ return {
+ overlayPosition: typeof parsed.overlayPosition === "string" ? parsed.overlayPosition : fallback.overlayPosition,
+ overlaySize: typeof parsed.overlaySize === "number" ? parsed.overlaySize : fallback.overlaySize,
+ overlayOpacity: typeof parsed.overlayOpacity === "number" ? parsed.overlayOpacity : fallback.overlayOpacity,
+ };
+ } catch {
+ return fallback;
+ }
+}
+
+export function persistRecipe(storage: Pick, recipe: EditRecipe) {
+ storage.setItem(RECIPE_STORAGE_KEY, JSON.stringify(recipe));
+ storage.removeItem(LEGACY_SETTINGS_KEY);
+}
+
+export function persistSoundPreference(storage: Pick, soundOnCompletion: boolean) {
+ storage.setItem(SOUND_PREF_KEY, String(soundOnCompletion));
+}
+
+export function persistOverlayState(storage: Pick, overlay: OverlayEditorState) {
+ storage.setItem(
+ EDITOR_STATE_KEY,
+ JSON.stringify({
+ overlayPosition: overlay.overlayPosition,
+ overlaySize: overlay.overlaySize,
+ overlayOpacity: overlay.overlayOpacity,
+ })
+ );
+}
diff --git a/src/lib/exportTelemetry.ts b/src/lib/exportTelemetry.ts
index 497712492..2076e8fff 100644
--- a/src/lib/exportTelemetry.ts
+++ b/src/lib/exportTelemetry.ts
@@ -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 7eb543615..c347cba01 100644
--- a/src/lib/ffmpeg.ts
+++ b/src/lib/ffmpeg.ts
@@ -1,6 +1,6 @@
import { EditRecipe, ExportResult, BackgroundMusicOptions, ImageOverlayOptions, MAX_FILE_SIZE } from "./types";
import { trackTiming } from "./telemetry";
-import { startEngineLoad } 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.
@@ -14,11 +14,18 @@ export class FFmpegLoadError extends Error {
}
}
-/** The engine stopped reporting activity mid-export (see STALL_TIMEOUT_MS). */
+/** The watchdog stopped an export that wasn't going to finish. */
export class ExportStalledError extends Error {
name = "ExportStalledError";
- constructor(readonly mode: EngineMode) {
- super("Export stopped responding");
+ 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."
+ );
}
}
@@ -300,7 +307,34 @@ function cancelPendingExport(reason?: unknown) {
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,
+ knownDuration?: number,
+ onEngineFallback?: (reason: StallReason) => void
+): Promise {
+ 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,
diff --git a/src/lib/ffmpegArgs.ts b/src/lib/ffmpegArgs.ts
index 09f623a51..2a0499c14 100644
--- a/src/lib/ffmpegArgs.ts
+++ b/src/lib/ffmpegArgs.ts
@@ -23,13 +23,19 @@ export type EngineMode = "multi" | "single";
*/
export const MT_THREAD_CAP = 4;
-/** Threads to request per stage; 0 means "don't pass any thread flags". */
+/** 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)
- : MT_THREAD_CAP;
+ : UNKNOWN_CORES_THREADS;
return Math.max(1, Math.min(MT_THREAD_CAP, cores));
}
diff --git a/src/lib/lottie/trail-loading 2.json b/src/lib/lottie/trail-loading 2.json
new file mode 100644
index 000000000..84f09c286
--- /dev/null
+++ b/src/lib/lottie/trail-loading 2.json
@@ -0,0 +1 @@
+{"v":"4.6.8","fr":60,"ip":0,"op":106,"w":500,"h":500,"nm":"Comp 1","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":2,"ty":4,"nm":"Shape Layer 5","ks":{"o":{"a":0,"k":100},"r":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"n":["0p667_1_0p333_0"],"t":20,"s":[0],"e":[360]},{"t":110}]},"p":{"a":0,"k":[251,250,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":0,"k":[100,100,100]}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[10,10]},"p":{"a":0,"k":[0,-100]},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse"},{"ty":"st","c":{"a":0,"k":[0.8706,0.3255,0.298,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":0},"lc":1,"lj":1,"ml":4,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke"},{"ty":"fl","c":{"a":0,"k":[1,0.05,0,1]},"o":{"a":0,"k":100},"r":1,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill"},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"ix":1,"mn":"ADBE Vector Group"}],"ip":20,"op":620,"st":20,"bm":0,"sr":1},{"ddd":0,"ind":3,"ty":4,"nm":"Shape Layer 4","ks":{"o":{"a":0,"k":100},"r":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"n":["0p667_1_0p333_0"],"t":15,"s":[0],"e":[360]},{"t":105}]},"p":{"a":0,"k":[251,250,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":0,"k":[100,100,100]}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[20,20]},"p":{"a":0,"k":[0,-100]},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse"},{"ty":"st","c":{"a":0,"k":[0.8706,0.3255,0.298,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":0},"lc":1,"lj":1,"ml":4,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke"},{"ty":"fl","c":{"a":0,"k":[1,0.05,0,1]},"o":{"a":0,"k":100},"r":1,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill"},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"ix":1,"mn":"ADBE Vector Group"}],"ip":15,"op":615,"st":15,"bm":0,"sr":1},{"ddd":0,"ind":4,"ty":4,"nm":"Shape Layer 3","ks":{"o":{"a":0,"k":100},"r":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"n":["0p667_1_0p333_0"],"t":10,"s":[0],"e":[360]},{"t":100}]},"p":{"a":0,"k":[251,250,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":0,"k":[100,100,100]}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[30,30]},"p":{"a":0,"k":[0,-100]},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse"},{"ty":"st","c":{"a":0,"k":[0.8706,0.3255,0.298,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":0},"lc":1,"lj":1,"ml":4,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke"},{"ty":"fl","c":{"a":0,"k":[1,0.05,0,1]},"o":{"a":0,"k":100},"r":1,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill"},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"ix":1,"mn":"ADBE Vector Group"}],"ip":10,"op":610,"st":10,"bm":0,"sr":1},{"ddd":0,"ind":5,"ty":4,"nm":"Shape Layer 2","ks":{"o":{"a":0,"k":100},"r":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"n":["0p667_1_0p333_0"],"t":5,"s":[0],"e":[360]},{"t":95}]},"p":{"a":0,"k":[251,250,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":0,"k":[100,100,100]}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[40,40]},"p":{"a":0,"k":[0,-100]},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse"},{"ty":"st","c":{"a":0,"k":[0.8706,0.3255,0.298,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":0},"lc":1,"lj":1,"ml":4,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke"},{"ty":"fl","c":{"a":0,"k":[1,0.05,0,1]},"o":{"a":0,"k":100},"r":1,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill"},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"ix":1,"mn":"ADBE Vector Group"}],"ip":5,"op":605,"st":5,"bm":0,"sr":1},{"ddd":0,"ind":6,"ty":4,"nm":"Shape Layer 1","ks":{"o":{"a":0,"k":100},"r":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"n":["0p667_1_0p333_0"],"t":0,"s":[0],"e":[360]},{"t":90}]},"p":{"a":0,"k":[250,250,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":0,"k":[100,100,100]}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":1,"k":[{"i":{"x":[0.667,0.667],"y":[1,1]},"o":{"x":[0.333,0.333],"y":[0,0]},"n":["0p667_1_0p333_0","0p667_1_0p333_0"],"t":0,"s":[50,50],"e":[40,40]},{"i":{"x":[0.667,0.667],"y":[1,1]},"o":{"x":[0.333,0.333],"y":[0,0]},"n":["0p667_1_0p333_0","0p667_1_0p333_0"],"t":84,"s":[40,40],"e":[50,50]},{"t":100}]},"p":{"a":0,"k":[0,-100]},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse"},{"ty":"st","c":{"a":0,"k":[0.8706,0.3255,0.298,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":0},"lc":1,"lj":1,"ml":4,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke"},{"ty":"fl","c":{"a":0,"k":[1,0.05,0,1]},"o":{"a":0,"k":100},"r":1,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill"},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"ix":1,"mn":"ADBE Vector Group"}],"ip":0,"op":600,"st":0,"bm":0,"sr":1}]}
\ No newline at end of file
diff --git a/src/lib/overlayGraph 2.ts b/src/lib/overlayGraph 2.ts
new file mode 100644
index 000000000..6e06d2bf5
--- /dev/null
+++ b/src/lib/overlayGraph 2.ts
@@ -0,0 +1,309 @@
+/**
+ * FFmpeg Overlay Filter Graph Generator
+ *
+ * Phase 1 MVP: Dynamic filter_complex generation for multi-track compositing
+ * Supports up to 2 simultaneous video tracks with scaling, positioning, and opacity.
+ *
+ * Example output for 2-track PiP:
+ * [0:v]scale=1920:1080[bg];
+ * [1:v]scale=720:-1[pip];
+ * [bg][pip]overlay=600:400:alpha=linear[outv]
+ */
+
+import { TimelineTrack } from "./types";
+import { getBackgroundTrack, getOverlayTracks } from "./timeline";
+
+interface OverlayPosition {
+ x: number;
+ y: number;
+}
+
+interface ScaledTrackInfo {
+ inputIndex: number;
+ track: TimelineTrack;
+ width: number;
+ height: number;
+ position: OverlayPosition;
+ opacity: number;
+}
+
+/**
+ * Determine output position for an overlay
+ * If track.position is auto (-1), center the overlay
+ */
+function resolveOverlayPosition(
+ track: TimelineTrack,
+ overlayWidth: number,
+ overlayHeight: number,
+ canvasWidth: number,
+ canvasHeight: number
+): OverlayPosition {
+ let x = track.position.x;
+ let y = track.position.y;
+
+ // Auto-center logic
+ if (x === -1) {
+ x = Math.max(0, (canvasWidth - overlayWidth) / 2);
+ }
+ if (y === -1) {
+ y = Math.max(0, (canvasHeight - overlayHeight) / 2);
+ }
+
+ return { x: Math.round(x), y: Math.round(y) };
+}
+
+/**
+ * Calculate scaled dimensions maintaining aspect ratio
+ * If height is -1, scale based on width proportionally
+ */
+function calculateScaledDimensions(
+ sourceWidth: number,
+ sourceHeight: number,
+ targetWidth: number,
+ targetHeight: number = -1
+): { width: number; height: number } {
+ if (targetHeight === -1) {
+ // Scale width and derive height
+ const scale = targetWidth / sourceWidth;
+ return {
+ width: Math.round(targetWidth),
+ height: Math.round(sourceHeight * scale),
+ };
+ }
+
+ return {
+ width: Math.round(targetWidth),
+ height: Math.round(targetHeight),
+ };
+}
+
+/**
+ * Build a scale filter spec for a single track
+ * Format: [Nin]scale=W:H[Nout]
+ */
+function buildScaleFilter(
+ inputIndex: number,
+ width: number,
+ height: number,
+ labelSuffix: string
+): string {
+ // Use -2 for height to preserve aspect ratio
+ const heightSpec = height === -1 ? "-1" : String(height);
+ return `[${inputIndex}:v]scale=${width}:${heightSpec}[${labelSuffix}]`;
+}
+
+/**
+ * Build an overlay filter spec combining two video streams
+ * Format: [base][overlay]overlay=x:y:alpha=linear[out]
+ */
+function buildOverlayFilter(
+ baseLabel: string,
+ overlayLabel: string,
+ x: number,
+ y: number,
+ opacityPercent: number,
+ outputLabel: string
+): string {
+ const alpha = opacityPercent < 100 ? `:alpha=linear` : "";
+ const alphaStr = opacityPercent < 100
+ ? `[${overlayLabel}]format=rgba,colorchannelmixer=aa=${(opacityPercent / 100).toFixed(2)}[${overlayLabel}_a]`
+ : "";
+
+ const overlayLabelFinal = opacityPercent < 100 ? `${overlayLabel}_a` : overlayLabel;
+
+ if (alphaStr) {
+ return [alphaStr, `[${baseLabel}][${overlayLabelFinal}]overlay=${x}:${y}${alpha}[${outputLabel}]`].join(
+ "; "
+ );
+ }
+
+ return `[${baseLabel}][${overlayLabelFinal}]overlay=${x}:${y}${alpha}[${outputLabel}]`;
+}
+
+/**
+ * Main entry point: Generate FFmpeg filter_complex for multi-track compositing
+ *
+ * @param tracks - Timeline tracks sorted by z-index (background first)
+ * @param canvasWidth - Output canvas width
+ * @param canvasHeight - Output canvas height
+ * @returns Filter complex string ready for FFmpeg -filter_complex argument
+ */
+export function buildOverlayFilterGraph(
+ tracks: TimelineTrack[],
+ canvasWidth: number,
+ canvasHeight: number
+): { filterComplex: string; videoOutput: string } {
+ // Get background and overlay tracks in proper order
+ const bgTrack = getBackgroundTrack(tracks);
+ const overlayTracks = getOverlayTracks(tracks);
+
+ // Phase 1 MVP: Only support up to 2 tracks
+ if (!bgTrack) {
+ // No video tracks; return pass-through
+ return { filterComplex: "", videoOutput: "[0:v]" };
+ }
+
+ if (overlayTracks.length === 0) {
+ // Only background track; scale if needed
+ const scaledDims = calculateScaledDimensions(
+ 1920, // Assume 1920 for now; this should come from metadata
+ 1080,
+ canvasWidth,
+ canvasHeight
+ );
+
+ const scaleFilter = buildScaleFilter(0, scaledDims.width, scaledDims.height, "bg");
+ return { filterComplex: scaleFilter, videoOutput: "[bg]" };
+ }
+
+ // 2-track compositing (Background + 1 Overlay)
+ const parts: string[] = [];
+ let currentOutput = "bg";
+ let inputIdx = 0;
+
+ // Scale background to canvas size
+ const bgScaled = calculateScaledDimensions(1920, 1080, canvasWidth, canvasHeight);
+ parts.push(buildScaleFilter(inputIdx, bgScaled.width, bgScaled.height, currentOutput));
+ inputIdx++;
+
+ // Process each overlay track
+ overlayTracks.forEach((track, overlayIdx) => {
+ const overlayLabel = `overlay${overlayIdx}`;
+
+ // Scale overlay (typically smaller for PiP)
+ const overlayWidth = Math.round(canvasWidth * (track.scale || 1.0));
+ const overlayHeight = -1; // Preserve aspect ratio
+ const overlayScaled = calculateScaledDimensions(
+ 1920,
+ 1080,
+ overlayWidth,
+ overlayHeight
+ );
+
+ parts.push(
+ buildScaleFilter(inputIdx, overlayScaled.width, overlayScaled.height, overlayLabel)
+ );
+
+ // Calculate overlay position (with auto-center if needed)
+ const position = resolveOverlayPosition(
+ track,
+ overlayScaled.width,
+ overlayScaled.height,
+ canvasWidth,
+ canvasHeight
+ );
+
+ // Compose overlay onto background/previous output
+ const outLabel = overlayIdx === overlayTracks.length - 1 ? "out" : `layer${overlayIdx}`;
+ parts.push(
+ buildOverlayFilter(
+ currentOutput,
+ overlayLabel,
+ position.x,
+ position.y,
+ track.opacity ?? 100,
+ outLabel
+ )
+ );
+
+ currentOutput = outLabel;
+ inputIdx++;
+ });
+
+ const filterComplex = parts.join(";");
+ const videoOutput = `[${currentOutput}]`;
+
+ return { filterComplex, videoOutput };
+}
+
+/**
+ * Validate overlay graph configuration
+ * Checks for common issues before passing to FFmpeg
+ */
+export function validateOverlayGraph(
+ tracks: TimelineTrack[],
+ canvasWidth: number,
+ canvasHeight: number
+): { valid: boolean; errors: string[] } {
+ const errors: string[] = [];
+
+ if (canvasWidth < 16 || canvasWidth > 7680) {
+ errors.push(`Canvas width out of range: ${canvasWidth}`);
+ }
+ if (canvasHeight < 16 || canvasHeight > 7680) {
+ errors.push(`Canvas height out of range: ${canvasHeight}`);
+ }
+
+ // Check for more than 2 visible video tracks
+ const visibleVideoCount = tracks.filter(
+ t => t.visible && t.type === "video" && t.source
+ ).length;
+ if (visibleVideoCount > 2) {
+ errors.push(
+ `Phase 1 MVP: Max 2 visible video tracks, found ${visibleVideoCount}`
+ );
+ }
+
+ // Check track validity
+ tracks.forEach((track, idx) => {
+ if (!track.id) {
+ errors.push(`Track ${idx} missing ID`);
+ }
+ if (track.opacity < 0 || track.opacity > 100) {
+ errors.push(
+ `Track ${idx} opacity out of range: ${track.opacity} (expected 0-100)`
+ );
+ }
+ if (track.scale <= 0) {
+ errors.push(`Track ${idx} invalid scale: ${track.scale}`);
+ }
+ });
+
+ return { valid: errors.length === 0, errors };
+}
+
+/**
+ * Generate example overlay graph for documentation/testing
+ */
+export function generateExampleOverlayGraphs(): Record {
+ // Mock a 2-track PiP scenario
+ const mockBgTrack: TimelineTrack = {
+ id: "bg",
+ type: "video",
+ source: null,
+ startTime: 0,
+ duration: 10,
+ zIndex: 0,
+ visible: true,
+ opacity: 100,
+ position: { x: 0, y: 0 },
+ scale: 1.0,
+ rotation: 0,
+ };
+
+ const mockPipTrack: TimelineTrack = {
+ id: "pip",
+ type: "video",
+ source: null,
+ startTime: 0,
+ duration: 5,
+ zIndex: 1,
+ visible: true,
+ opacity: 100,
+ position: { x: -1, y: -1 }, // auto-center
+ scale: 0.4,
+ rotation: 0,
+ };
+
+ const { filterComplex } = buildOverlayFilterGraph(
+ [mockBgTrack, mockPipTrack],
+ 1920,
+ 1080
+ );
+
+ return {
+ "2-track-pip": filterComplex,
+ "example-background-track": mockBgTrack.id,
+ "example-overlay-track": mockPipTrack.id,
+ };
+}
diff --git a/src/lib/sessionDB 2.ts b/src/lib/sessionDB 2.ts
new file mode 100644
index 000000000..57f0a1d7c
--- /dev/null
+++ b/src/lib/sessionDB 2.ts
@@ -0,0 +1,82 @@
+const DB_NAME = "reframe-session";
+const STORE_NAME = "files";
+
+export async function saveSessionFile(file: File): Promise {
+ return new Promise((resolve, reject) => {
+ try {
+ const request = indexedDB.open(DB_NAME, 1);
+ request.onupgradeneeded = () => {
+ if (!request.result.objectStoreNames.contains(STORE_NAME)) {
+ request.result.createObjectStore(STORE_NAME);
+ }
+ };
+ request.onsuccess = () => {
+ const db = request.result;
+ const tx = db.transaction(STORE_NAME, "readwrite");
+ tx.objectStore(STORE_NAME).put(file, "currentVideo");
+ tx.oncomplete = () => { db.close(); resolve(); };
+ tx.onerror = () => reject(tx.error);
+ };
+ request.onerror = () => reject(request.error);
+ } catch (e) {
+ reject(e);
+ }
+ });
+}
+
+export async function loadSessionFile(): Promise {
+ return new Promise((resolve, reject) => {
+ try {
+ const request = indexedDB.open(DB_NAME, 1);
+ request.onupgradeneeded = () => {
+ if (!request.result.objectStoreNames.contains(STORE_NAME)) {
+ request.result.createObjectStore(STORE_NAME);
+ }
+ };
+ request.onsuccess = () => {
+ const db = request.result;
+ if (!db.objectStoreNames.contains(STORE_NAME)) {
+ db.close();
+ return resolve(null);
+ }
+ const tx = db.transaction(STORE_NAME, "readonly");
+ const getReq = tx.objectStore(STORE_NAME).get("currentVideo");
+ getReq.onsuccess = () => {
+ db.close();
+ resolve(getReq.result || null);
+ };
+ getReq.onerror = () => reject(getReq.error);
+ };
+ request.onerror = () => reject(request.error);
+ } catch (e) {
+ reject(e);
+ }
+ });
+}
+
+export async function clearSessionFile(): Promise {
+ return new Promise((resolve, reject) => {
+ try {
+ const request = indexedDB.open(DB_NAME, 1);
+ request.onupgradeneeded = () => {
+ if (!request.result.objectStoreNames.contains(STORE_NAME)) {
+ request.result.createObjectStore(STORE_NAME);
+ }
+ };
+ request.onsuccess = () => {
+ const db = request.result;
+ if (!db.objectStoreNames.contains(STORE_NAME)) {
+ db.close();
+ return resolve();
+ }
+ const tx = db.transaction(STORE_NAME, "readwrite");
+ tx.objectStore(STORE_NAME).clear();
+ tx.oncomplete = () => { db.close(); resolve(); };
+ tx.onerror = () => reject(tx.error);
+ };
+ request.onerror = () => reject(request.error);
+ } catch (e) {
+ reject(e);
+ }
+ });
+}
diff --git a/src/lib/tests/editorPersistence.test 2.ts b/src/lib/tests/editorPersistence.test 2.ts
new file mode 100644
index 000000000..e93ec704f
--- /dev/null
+++ b/src/lib/tests/editorPersistence.test 2.ts
@@ -0,0 +1,90 @@
+import { describe, expect, it, beforeEach } from "vitest";
+import { DEFAULT_RECIPE } from "@/lib/constants";
+import {
+ EDITOR_STATE_KEY,
+ LEGACY_SETTINGS_KEY,
+ RECIPE_STORAGE_KEY,
+ loadOverlayState,
+ loadPersistedRecipe,
+ persistOverlayState,
+ persistRecipe,
+ persistSoundPreference,
+} from "@/lib/editorPersistence";
+import { EditRecipe } from "@/lib/types";
+
+describe("editorPersistence", () => {
+ beforeEach(() => {
+ localStorage.clear();
+ });
+
+ it("loads the canonical recipe key first", () => {
+ const recipe: EditRecipe = { ...DEFAULT_RECIPE, quality: 28, version: DEFAULT_RECIPE.version };
+ localStorage.setItem(RECIPE_STORAGE_KEY, JSON.stringify(recipe));
+ localStorage.setItem(LEGACY_SETTINGS_KEY, JSON.stringify({ quality: 18 }));
+
+ expect(loadPersistedRecipe(localStorage, DEFAULT_RECIPE)).toEqual(recipe);
+ });
+
+ it("migrates legacy settings when the canonical key is missing", () => {
+ localStorage.setItem(
+ LEGACY_SETTINGS_KEY,
+ JSON.stringify({ preset: "custom", quality: 19, speed: 1.5, customWidth: 1280, customHeight: 720 })
+ );
+
+ const loaded = loadPersistedRecipe(localStorage, DEFAULT_RECIPE);
+
+ expect(loaded.preset).toBe("custom");
+ expect(loaded.quality).toBe(19);
+ expect(loaded.speed).toBe(1.5);
+ expect(loaded.customWidth).toBe(1280);
+ expect(loaded.customHeight).toBe(720);
+ });
+
+ it("persists only the canonical recipe key and clears legacy recipe settings", () => {
+ persistRecipe(localStorage, DEFAULT_RECIPE);
+
+ expect(localStorage.getItem(RECIPE_STORAGE_KEY)).toBe(JSON.stringify(DEFAULT_RECIPE));
+ expect(localStorage.getItem(LEGACY_SETTINGS_KEY)).toBeNull();
+ });
+
+ it("persists overlay state without recipe data", () => {
+ persistOverlayState(localStorage, {
+ overlayPosition: "top-left",
+ overlaySize: 120,
+ overlayOpacity: 85,
+ });
+
+ expect(JSON.parse(localStorage.getItem(EDITOR_STATE_KEY) ?? "{}")).toEqual({
+ overlayPosition: "top-left",
+ overlaySize: 120,
+ overlayOpacity: 85,
+ });
+ });
+
+ it("reads overlay state while ignoring any legacy embedded recipe", () => {
+ localStorage.setItem(
+ EDITOR_STATE_KEY,
+ JSON.stringify({
+ recipe: { quality: 12 },
+ overlayPosition: "bottom-left",
+ overlaySize: 155,
+ overlayOpacity: 72,
+ })
+ );
+
+ expect(loadOverlayState(localStorage, {
+ overlayPosition: "bottom-right",
+ overlaySize: 150,
+ overlayOpacity: 100,
+ })).toEqual({
+ overlayPosition: "bottom-left",
+ overlaySize: 155,
+ overlayOpacity: 72,
+ });
+ });
+
+ it("persists sound preference separately", () => {
+ persistSoundPreference(localStorage, true);
+ expect(localStorage.getItem("soundOnCompletion")).toBe("true");
+ });
+});
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
index d354ddc59..2e4665057 100644
--- a/src/lib/tests/ffmpegArgs.test.ts
+++ b/src/lib/tests/ffmpegArgs.test.ts
@@ -5,6 +5,7 @@ import {
outputDurationArgs,
resolveThreadCount,
MT_THREAD_CAP,
+ UNKNOWN_CORES_THREADS,
type ExportArgsOptions,
} from "../ffmpegArgs";
import { DEFAULT_RECIPE } from "../constants";
@@ -41,12 +42,14 @@ describe("resolveThreadCount", () => {
expect(resolveThreadCount("multi", 64)).toBe(4);
});
- it("uses fewer threads on small devices and falls back to the cap when unknown", () => {
+ it("is min(4, hardwareConcurrency || 2)", () => {
expect(resolveThreadCount("multi", 2)).toBe(2);
expect(resolveThreadCount("multi", 1)).toBe(1);
- expect(resolveThreadCount("multi", 0)).toBe(4);
- expect(resolveThreadCount("multi", undefined)).toBe(4);
- expect(resolveThreadCount("multi", NaN)).toBe(4);
+ 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);
});
});
diff --git a/src/lib/tests/ffmpegClient.test.ts b/src/lib/tests/ffmpegClient.test.ts
index 5cc3c2cda..7054b38d7 100644
--- a/src/lib/tests/ffmpegClient.test.ts
+++ b/src/lib/tests/ffmpegClient.test.ts
@@ -216,22 +216,54 @@ describe("FFmpeg client lifecycle", () => {
await expect(pending).resolves.toMatchObject({ size: 8 });
});
- it("a stalled multi-threaded export fails, and the retry loads the single-threaded core", async () => {
+ it("a stalled multi-threaded export is retried automatically on the single-threaded core", async () => {
vi.useFakeTimers();
- script.onLoad = (w) => w.emit({ type: "ready", mode: "multi" });
- script.onExport = () => {}; // wedged: no progress, no heartbeat
- const pending = run();
- const assertion = expect(pending).rejects.toBeInstanceOf(ExportStalledError);
+ // 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 assertion;
- await expect(pending).rejects.toMatchObject({ mode: "multi" });
- expect(workers[0]!.terminated).toBe(true);
- expect(getFFmpegClientState().hasWorker).toBe(false);
+ await expect(pending).resolves.toMatchObject({ format: "mp4" });
- script.onLoad = undefined;
- script.onExport = (w, id) => w.emit(result(id));
- await expect(run()).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/overlayGraph.test 2.ts b/src/lib/tests/overlayGraph.test 2.ts
new file mode 100644
index 000000000..08ebd240f
--- /dev/null
+++ b/src/lib/tests/overlayGraph.test 2.ts
@@ -0,0 +1,334 @@
+/**
+ * Tests for FFmpeg Overlay Graph Generation
+ * Phase 1 MVP: Multi-track filter_complex generation
+ */
+
+import { describe, it, expect } from "vitest";
+import {
+ buildOverlayFilterGraph,
+ validateOverlayGraph,
+ generateExampleOverlayGraphs,
+} from "@/lib/overlayGraph";
+import { TimelineTrack } from "@/lib/types";
+
+describe("buildOverlayFilterGraph", () => {
+ it("should return pass-through for empty track list", () => {
+ const { filterComplex, videoOutput } = buildOverlayFilterGraph([], 1920, 1080);
+ expect(videoOutput).toBe("[0:v]");
+ });
+
+ it("should return pass-through for single track with no source", () => {
+ const track: TimelineTrack = {
+ id: "test",
+ type: "video",
+ source: null,
+ startTime: 0,
+ duration: 10,
+ zIndex: 0,
+ visible: true,
+ opacity: 100,
+ position: { x: 0, y: 0 },
+ scale: 1.0,
+ rotation: 0,
+ };
+
+ const { filterComplex, videoOutput } = buildOverlayFilterGraph([track], 1920, 1080);
+ expect(videoOutput).toBe("[0:v]");
+ });
+
+ it("should scale background track to canvas dimensions", () => {
+ const bgTrack: TimelineTrack = {
+ id: "bg",
+ type: "video",
+ source: {} as File,
+ startTime: 0,
+ duration: 10,
+ zIndex: 0,
+ visible: true,
+ opacity: 100,
+ position: { x: 0, y: 0 },
+ scale: 1.0,
+ rotation: 0,
+ };
+
+ const { filterComplex } = buildOverlayFilterGraph([bgTrack], 1920, 1080);
+ expect(filterComplex).toContain("[0:v]scale=1920:1080[bg]");
+ });
+
+ it("should compose two video tracks with PiP positioning", () => {
+ const bgTrack: TimelineTrack = {
+ id: "bg",
+ type: "video",
+ source: {} as File,
+ startTime: 0,
+ duration: 10,
+ zIndex: 0,
+ visible: true,
+ opacity: 100,
+ position: { x: 0, y: 0 },
+ scale: 1.0,
+ rotation: 0,
+ };
+
+ const pipTrack: TimelineTrack = {
+ id: "pip",
+ type: "video",
+ source: {} as File,
+ startTime: 0,
+ duration: 5,
+ zIndex: 1,
+ visible: true,
+ opacity: 100,
+ position: { x: 50, y: 50 }, // Manual positioning
+ scale: 0.4,
+ rotation: 0,
+ };
+
+ const { filterComplex, videoOutput } = buildOverlayFilterGraph(
+ [bgTrack, pipTrack],
+ 1920,
+ 1080
+ );
+
+ expect(filterComplex).toContain("[0:v]scale=1920:1080[bg]");
+ // Scale: 0.4 * 1920 = 768, height scales proportionally: 768 * (1080/1920) = 432
+ expect(filterComplex).toContain("[1:v]scale=768:432[overlay0]");
+ expect(filterComplex).toContain("[bg][overlay0]overlay=50:50");
+ expect(videoOutput).toBe("[out]");
+ });
+
+ it("should apply opacity to overlay tracks", () => {
+ const bgTrack: TimelineTrack = {
+ id: "bg",
+ type: "video",
+ source: {} as File,
+ startTime: 0,
+ duration: 10,
+ zIndex: 0,
+ visible: true,
+ opacity: 100,
+ position: { x: 0, y: 0 },
+ scale: 1.0,
+ rotation: 0,
+ };
+
+ const pipTrack: TimelineTrack = {
+ id: "pip",
+ type: "video",
+ source: {} as File,
+ startTime: 0,
+ duration: 5,
+ zIndex: 1,
+ visible: true,
+ opacity: 50, // 50% opacity
+ position: { x: -1, y: -1 }, // auto-center
+ scale: 0.4,
+ rotation: 0,
+ };
+
+ const { filterComplex } = buildOverlayFilterGraph(
+ [bgTrack, pipTrack],
+ 1920,
+ 1080
+ );
+
+ // Should include opacity/alpha handling
+ expect(filterComplex).toContain("colorchannelmixer");
+ expect(filterComplex).toContain("0.50"); // 50% = 0.50
+ });
+
+ it("should auto-center overlay when position is -1", () => {
+ const bgTrack: TimelineTrack = {
+ id: "bg",
+ type: "video",
+ source: {} as File,
+ startTime: 0,
+ duration: 10,
+ zIndex: 0,
+ visible: true,
+ opacity: 100,
+ position: { x: 0, y: 0 },
+ scale: 1.0,
+ rotation: 0,
+ };
+
+ const pipTrack: TimelineTrack = {
+ id: "pip",
+ type: "video",
+ source: {} as File,
+ startTime: 0,
+ duration: 5,
+ zIndex: 1,
+ visible: true,
+ opacity: 100,
+ position: { x: -1, y: -1 }, // auto-center
+ scale: 0.3,
+ rotation: 0,
+ };
+
+ const { filterComplex } = buildOverlayFilterGraph(
+ [bgTrack, pipTrack],
+ 1920,
+ 1080
+ );
+
+ // Scale: 0.3 * 1920 = 576
+ // Center X: (1920 - 576) / 2 = 672
+ // Center Y: (1080 - scaled_height) / 2
+ expect(filterComplex).toContain("overlay=672:");
+ });
+
+ it("should hide invisible tracks", () => {
+ const bgTrack: TimelineTrack = {
+ id: "bg",
+ type: "video",
+ source: {} as File,
+ startTime: 0,
+ duration: 10,
+ zIndex: 0,
+ visible: true,
+ opacity: 100,
+ position: { x: 0, y: 0 },
+ scale: 1.0,
+ rotation: 0,
+ };
+
+ const hiddenTrack: TimelineTrack = {
+ id: "hidden",
+ type: "video",
+ source: {} as File,
+ startTime: 0,
+ duration: 5,
+ zIndex: 1,
+ visible: false, // Hidden
+ opacity: 100,
+ position: { x: 50, y: 50 },
+ scale: 0.4,
+ rotation: 0,
+ };
+
+ const { filterComplex, videoOutput } = buildOverlayFilterGraph(
+ [bgTrack, hiddenTrack],
+ 1920,
+ 1080
+ );
+
+ // Hidden track should not be in filter
+ expect(filterComplex).not.toContain("hidden");
+ expect(videoOutput).toBe("[bg]");
+ });
+});
+
+describe("validateOverlayGraph", () => {
+ it("should validate correct graph configuration", () => {
+ const tracks: TimelineTrack[] = [
+ {
+ id: "bg",
+ type: "video",
+ source: {} as File,
+ startTime: 0,
+ duration: 10,
+ zIndex: 0,
+ visible: true,
+ opacity: 100,
+ position: { x: 0, y: 0 },
+ scale: 1.0,
+ rotation: 0,
+ },
+ ];
+
+ const result = validateOverlayGraph(tracks, 1920, 1080);
+ expect(result.valid).toBe(true);
+ expect(result.errors).toHaveLength(0);
+ });
+
+ it("should reject invalid canvas dimensions", () => {
+ const tracks: TimelineTrack[] = [];
+
+ const result = validateOverlayGraph(tracks, 10, 1080); // Width too small
+ expect(result.valid).toBe(false);
+ expect(result.errors.some(e => e.includes("Canvas width"))).toBe(true);
+ });
+
+ it("should enforce max 2 video tracks limit", () => {
+ const tracks: TimelineTrack[] = [
+ {
+ id: "1",
+ type: "video",
+ source: {} as File,
+ startTime: 0,
+ duration: 10,
+ zIndex: 0,
+ visible: true,
+ opacity: 100,
+ position: { x: 0, y: 0 },
+ scale: 1.0,
+ rotation: 0,
+ },
+ {
+ id: "2",
+ type: "video",
+ source: {} as File,
+ startTime: 0,
+ duration: 10,
+ zIndex: 1,
+ visible: true,
+ opacity: 100,
+ position: { x: 0, y: 0 },
+ scale: 1.0,
+ rotation: 0,
+ },
+ {
+ id: "3",
+ type: "video",
+ source: {} as File,
+ startTime: 0,
+ duration: 10,
+ zIndex: 2,
+ visible: true,
+ opacity: 100,
+ position: { x: 0, y: 0 },
+ scale: 1.0,
+ rotation: 0,
+ },
+ ];
+
+ const result = validateOverlayGraph(tracks, 1920, 1080);
+ expect(result.valid).toBe(false);
+ expect(result.errors.some(e => e.includes("Max 2 visible"))).toBe(true);
+ });
+
+ it("should check for invalid opacity", () => {
+ const tracks: TimelineTrack[] = [
+ {
+ id: "bg",
+ type: "video",
+ source: {} as File,
+ startTime: 0,
+ duration: 10,
+ zIndex: 0,
+ visible: true,
+ opacity: 150, // Invalid
+ position: { x: 0, y: 0 },
+ scale: 1.0,
+ rotation: 0,
+ },
+ ];
+
+ const result = validateOverlayGraph(tracks, 1920, 1080);
+ expect(result.valid).toBe(false);
+ expect(result.errors.some(e => e.includes("opacity"))).toBe(true);
+ });
+});
+
+describe("generateExampleOverlayGraphs", () => {
+ it("should generate example graphs", () => {
+ const examples = generateExampleOverlayGraphs();
+ expect(examples).toHaveProperty("2-track-pip");
+ // The filter complex will be generated (even if empty for pass-through)
+ expect(typeof examples["2-track-pip"]).toBe("string");
+ // Example tracks should be listed
+ expect(examples).toHaveProperty("example-background-track");
+ expect(examples).toHaveProperty("example-overlay-track");
+ });
+});
diff --git a/src/lib/tests/timeline.test 2.ts b/src/lib/tests/timeline.test 2.ts
new file mode 100644
index 000000000..9df92167e
--- /dev/null
+++ b/src/lib/tests/timeline.test 2.ts
@@ -0,0 +1,377 @@
+/**
+ * Tests for Timeline Track Management
+ * Phase 1 MVP: Multi-track state operations
+ */
+
+import { describe, it, expect } from "vitest";
+import {
+ generateTrackId,
+ createTimelineTrack,
+ createMultiTrackState,
+ addTrackToTimeline,
+ removeTrackFromTimeline,
+ updateTrackInTimeline,
+ sortTracksByZIndex,
+ getVisibleVideoTracks,
+ getBackgroundTrack,
+ getOverlayTracks,
+ validateMultiTrackState,
+ serializeMultiTrackState,
+} from "@/lib/timeline";
+import { TimelineTrack } from "@/lib/types";
+
+describe("Timeline Track Management", () => {
+ describe("generateTrackId", () => {
+ it("should generate unique IDs", () => {
+ const id1 = generateTrackId();
+ const id2 = generateTrackId();
+ expect(id1).not.toBe(id2);
+ expect(id1).toMatch(/^track-\d+-[a-z0-9]+$/);
+ });
+ });
+
+ describe("createTimelineTrack", () => {
+ it("should create track with video type and defaults", () => {
+ const track = createTimelineTrack("video", null);
+
+ expect(track.type).toBe("video");
+ expect(track.source).toBeNull();
+ expect(track.startTime).toBe(0);
+ expect(track.duration).toBe(0);
+ expect(track.zIndex).toBe(0);
+ expect(track.visible).toBe(true);
+ expect(track.opacity).toBe(100);
+ expect(track.scale).toBe(1.0);
+ expect(track.rotation).toBe(0);
+ expect(track.position).toEqual({ x: -1, y: -1 }); // auto-center
+ });
+
+ it("should create track with custom start time", () => {
+ const track = createTimelineTrack("video", null, 5);
+ expect(track.startTime).toBe(5);
+ });
+
+ it("should generate unique ID for each track", () => {
+ const track1 = createTimelineTrack("video", null);
+ const track2 = createTimelineTrack("video", null);
+ expect(track1.id).not.toBe(track2.id);
+ });
+ });
+
+ describe("createMultiTrackState", () => {
+ it("should create initial state with empty tracks", () => {
+ const state = createMultiTrackState();
+
+ expect(state.timelineTracks).toEqual([]);
+ expect(state.activeTrackId).toBeNull();
+ expect(state.maxActiveTracks).toBe(2); // Phase 1 limit
+ });
+ });
+
+ describe("addTrackToTimeline", () => {
+ it("should add track to timeline", () => {
+ const state = createMultiTrackState();
+ const track = createTimelineTrack("video", null);
+
+ const updated = addTrackToTimeline(state, track);
+
+ expect(updated.timelineTracks).toHaveLength(1);
+ expect(updated.timelineTracks[0]).toBe(track);
+ expect(updated.activeTrackId).toBe(track.id);
+ });
+
+ it("should enforce max 2 video tracks by hiding excess", () => {
+ const state = createMultiTrackState();
+ const track1 = createTimelineTrack("video", null);
+ const track2 = createTimelineTrack("video", null);
+ const track3 = createTimelineTrack("video", null);
+
+ const s1 = addTrackToTimeline(state, track1);
+ const s2 = addTrackToTimeline(s1, track2);
+ const s3 = addTrackToTimeline(s2, track3);
+
+ expect(s3.timelineTracks).toHaveLength(3);
+ expect(s3.timelineTracks[0]!.visible).toBe(true);
+ expect(s3.timelineTracks[1]!.visible).toBe(true);
+ expect(s3.timelineTracks[2]!.visible).toBe(false); // Excess hidden
+ });
+
+ it("should allow unlimited image tracks", () => {
+ const state = createMultiTrackState();
+ const imgTrack1 = createTimelineTrack("image", null);
+ const imgTrack2 = createTimelineTrack("image", null);
+ const imgTrack3 = createTimelineTrack("image", null);
+
+ const s1 = addTrackToTimeline(state, imgTrack1);
+ const s2 = addTrackToTimeline(s1, imgTrack2);
+ const s3 = addTrackToTimeline(s2, imgTrack3);
+
+ // All image tracks should remain visible
+ expect(s3.timelineTracks.filter(t => t.visible)).toHaveLength(3);
+ });
+ });
+
+ describe("removeTrackFromTimeline", () => {
+ it("should remove track by ID", () => {
+ const state = createMultiTrackState();
+ const track1 = createTimelineTrack("video", null);
+ const track2 = createTimelineTrack("video", null);
+
+ const s1 = addTrackToTimeline(state, track1);
+ const s2 = addTrackToTimeline(s1, track2);
+ const s3 = removeTrackFromTimeline(s2, track1.id);
+
+ expect(s3.timelineTracks).toHaveLength(1);
+ expect(s3.timelineTracks[0]!.id).toBe(track2.id);
+ });
+
+ it("should clear activeTrackId if active track is removed", () => {
+ const state = createMultiTrackState();
+ const track = createTimelineTrack("video", null);
+
+ const s1 = addTrackToTimeline(state, track);
+ expect(s1.activeTrackId).toBe(track.id);
+
+ const s2 = removeTrackFromTimeline(s1, track.id);
+ expect(s2.activeTrackId).toBeNull();
+ });
+
+ it("should reassign activeTrackId to remaining track", () => {
+ const state = createMultiTrackState();
+ const track1 = createTimelineTrack("video", null);
+ const track2 = createTimelineTrack("video", null);
+
+ const s1 = addTrackToTimeline(state, track1);
+ const s2 = addTrackToTimeline(s1, track2);
+ const s3 = removeTrackFromTimeline(s2, track1.id);
+
+ expect(s3.activeTrackId).toBe(track2.id);
+ });
+ });
+
+ describe("updateTrackInTimeline", () => {
+ it("should update track properties", () => {
+ const state = createMultiTrackState();
+ const track = createTimelineTrack("video", null);
+ const s1 = addTrackToTimeline(state, track);
+
+ const s2 = updateTrackInTimeline(s1, track.id, {
+ opacity: 50,
+ position: { x: 100, y: 200 },
+ });
+
+ expect(s2.timelineTracks[0]!.opacity).toBe(50);
+ expect(s2.timelineTracks[0]!.position).toEqual({ x: 100, y: 200 });
+ });
+
+ it("should not affect other tracks", () => {
+ const state = createMultiTrackState();
+ const track1 = createTimelineTrack("video", null);
+ const track2 = createTimelineTrack("video", null);
+
+ const s1 = addTrackToTimeline(state, track1);
+ const s2 = addTrackToTimeline(s1, track2);
+ const s3 = updateTrackInTimeline(s2, track1.id, { opacity: 75 });
+
+ expect(s3.timelineTracks[0]!.opacity).toBe(75);
+ expect(s3.timelineTracks[1]!.opacity).toBe(100); // unchanged
+ });
+ });
+
+ describe("sortTracksByZIndex", () => {
+ it("should sort tracks by z-index ascending", () => {
+ const track1: TimelineTrack = {
+ ...createTimelineTrack("video", null),
+ zIndex: 2,
+ };
+ const track2: TimelineTrack = {
+ ...createTimelineTrack("video", null),
+ zIndex: 0,
+ };
+ const track3: TimelineTrack = {
+ ...createTimelineTrack("video", null),
+ zIndex: 1,
+ };
+
+ const sorted = sortTracksByZIndex([track1, track2, track3]);
+
+ expect(sorted[0]!.zIndex).toBe(0);
+ expect(sorted[1]!.zIndex).toBe(1);
+ expect(sorted[2]!.zIndex).toBe(2);
+ });
+
+ it("should not modify original array", () => {
+ const tracks = [
+ { ...createTimelineTrack("video", null), zIndex: 2 },
+ { ...createTimelineTrack("video", null), zIndex: 1 },
+ ];
+
+ sortTracksByZIndex(tracks);
+
+ expect(tracks[0]!.zIndex).toBe(2); // original order preserved
+ });
+ });
+
+ describe("getVisibleVideoTracks", () => {
+ it("should return only visible video tracks with source", () => {
+ const visible: TimelineTrack = {
+ ...createTimelineTrack("video", {} as File),
+ visible: true,
+ };
+ const hidden: TimelineTrack = {
+ ...createTimelineTrack("video", {} as File),
+ visible: false,
+ };
+ const noSource: TimelineTrack = {
+ ...createTimelineTrack("video", null),
+ visible: true,
+ };
+
+ const filtered = getVisibleVideoTracks([visible, hidden, noSource]);
+
+ expect(filtered).toHaveLength(1);
+ expect(filtered[0]!.id).toBe(visible.id);
+ });
+
+ it("should return sorted by z-index", () => {
+ const track1: TimelineTrack = {
+ ...createTimelineTrack("video", {} as File),
+ zIndex: 1,
+ visible: true,
+ };
+ const track2: TimelineTrack = {
+ ...createTimelineTrack("video", {} as File),
+ zIndex: 0,
+ visible: true,
+ };
+
+ const filtered = getVisibleVideoTracks([track1, track2]);
+
+ expect(filtered[0]!.zIndex).toBe(0);
+ expect(filtered[1]!.zIndex).toBe(1);
+ });
+ });
+
+ describe("getBackgroundTrack", () => {
+ it("should return lowest z-index visible video", () => {
+ const bg: TimelineTrack = {
+ ...createTimelineTrack("video", {} as File),
+ zIndex: 0,
+ visible: true,
+ };
+ const fg: TimelineTrack = {
+ ...createTimelineTrack("video", {} as File),
+ zIndex: 1,
+ visible: true,
+ };
+
+ const track = getBackgroundTrack([bg, fg]);
+ expect(track?.id).toBe(bg.id);
+ });
+
+ it("should return null if no visible videos", () => {
+ const track = getBackgroundTrack([]);
+ expect(track).toBeNull();
+ });
+ });
+
+ describe("getOverlayTracks", () => {
+ it("should return all visible videos except background", () => {
+ const bg: TimelineTrack = {
+ ...createTimelineTrack("video", {} as File),
+ zIndex: 0,
+ visible: true,
+ };
+ const overlay1: TimelineTrack = {
+ ...createTimelineTrack("video", {} as File),
+ zIndex: 1,
+ visible: true,
+ };
+ const overlay2: TimelineTrack = {
+ ...createTimelineTrack("video", {} as File),
+ zIndex: 2,
+ visible: true,
+ };
+
+ const overlays = getOverlayTracks([bg, overlay1, overlay2]);
+ expect(overlays).toHaveLength(2);
+ expect(overlays.every(t => t.id !== bg.id)).toBe(true);
+ });
+ });
+
+ describe("validateMultiTrackState", () => {
+ it("should validate correct state", () => {
+ const state = createMultiTrackState();
+ const result = validateMultiTrackState(state);
+
+ expect(result.valid).toBe(true);
+ expect(result.errors).toHaveLength(0);
+ });
+
+ it("should detect too many active video tracks", () => {
+ let state = createMultiTrackState();
+ state = addTrackToTimeline(state, createTimelineTrack("video", {} as File));
+ state = addTrackToTimeline(state, createTimelineTrack("video", {} as File));
+ const track3 = createTimelineTrack("video", {} as File);
+ track3.visible = true; // Force visible to trigger violation
+ state.timelineTracks.push(track3);
+ state.maxActiveTracks = 2;
+
+ const result = validateMultiTrackState(state);
+
+ expect(result.valid).toBe(false);
+ expect(result.errors.some(e => e.includes("Too many"))).toBe(true);
+ });
+
+ it("should check for duplicate IDs", () => {
+ const state = createMultiTrackState();
+ const track1 = createTimelineTrack("video", null);
+ const track2 = { ...track1 }; // Duplicate ID
+
+ state.timelineTracks = [track1, track2];
+
+ const result = validateMultiTrackState(state);
+
+ expect(result.valid).toBe(false);
+ expect(result.errors.some(e => e.includes("Duplicate"))).toBe(true);
+ });
+
+ it("should validate opacity ranges", () => {
+ let state = createMultiTrackState();
+ const track = createTimelineTrack("video", null);
+ track.opacity = 150; // Out of range
+
+ state.timelineTracks = [track];
+
+ const result = validateMultiTrackState(state);
+
+ expect(result.valid).toBe(false);
+ expect(result.errors.some(e => e.includes("opacity"))).toBe(true);
+ });
+ });
+
+ describe("serializeMultiTrackState", () => {
+ it("should remove File objects for serialization", () => {
+ let state = createMultiTrackState();
+ const track = createTimelineTrack("video", {} as File);
+ state = addTrackToTimeline(state, track);
+
+ const serialized = serializeMultiTrackState(state);
+
+ expect(serialized.timelineTracks[0]!.source).toBeNull();
+ });
+
+ it("should preserve all other properties", () => {
+ let state = createMultiTrackState();
+ const track = createTimelineTrack("video", null);
+ track.opacity = 75;
+ track.position = { x: 100, y: 200 };
+ state = addTrackToTimeline(state, track);
+
+ const serialized = serializeMultiTrackState(state);
+
+ expect(serialized.timelineTracks[0]!.opacity).toBe(75);
+ expect(serialized.timelineTracks[0]!.position).toEqual({ x: 100, y: 200 });
+ });
+ });
+});
diff --git a/src/lib/timeline 2.ts b/src/lib/timeline 2.ts
new file mode 100644
index 000000000..8a1089f79
--- /dev/null
+++ b/src/lib/timeline 2.ts
@@ -0,0 +1,211 @@
+/**
+ * Timeline Track Management Utilities
+ *
+ * Phase 1 MVP: Multi-track timeline architecture
+ * Provides foundational track operations for the non-linear timeline.
+ * Limited to 2 simultaneous video tracks per FFmpeg.wasm memory constraints.
+ */
+
+import { TimelineTrack, MultiTrackEditorState } from "./types";
+
+const MAX_TRACKS_PHASE_1 = 2;
+
+/**
+ * Generate a unique track ID
+ */
+export function generateTrackId(): string {
+ return `track-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
+}
+
+/**
+ * Create a new timeline track with sensible defaults
+ */
+export function createTimelineTrack(
+ type: "video" | "image",
+ source: File | null,
+ startTime: number = 0
+): TimelineTrack {
+ return {
+ id: generateTrackId(),
+ type,
+ source,
+ startTime,
+ duration: 0, // Will be derived from video metadata
+ zIndex: 0,
+ visible: true,
+ opacity: 100,
+ position: {
+ x: -1, // auto-center
+ y: -1, // auto-center
+ },
+ scale: 1.0,
+ rotation: 0,
+ };
+}
+
+/**
+ * Create initial multi-track editor state
+ */
+export function createMultiTrackState(): MultiTrackEditorState {
+ return {
+ timelineTracks: [],
+ activeTrackId: null,
+ maxActiveTracks: MAX_TRACKS_PHASE_1,
+ };
+}
+
+/**
+ * Add a track to the timeline
+ * Returns updated state; enforces max track limit
+ */
+export function addTrackToTimeline(
+ state: MultiTrackEditorState,
+ track: TimelineTrack
+): MultiTrackEditorState {
+ // Phase 1: Only allow 2 active video tracks
+ const videoTrackCount = state.timelineTracks.filter(t => t.visible && t.type === "video").length;
+
+ if (track.type === "video" && videoTrackCount >= MAX_TRACKS_PHASE_1) {
+ track.visible = false;
+ }
+
+ return {
+ ...state,
+ timelineTracks: [...state.timelineTracks, track],
+ activeTrackId: track.id,
+ };
+}
+
+/**
+ * Remove a track by ID
+ */
+export function removeTrackFromTimeline(
+ state: MultiTrackEditorState,
+ trackId: string
+): MultiTrackEditorState {
+ const filtered = state.timelineTracks.filter(t => t.id !== trackId);
+ return {
+ ...state,
+ timelineTracks: filtered,
+ activeTrackId: state.activeTrackId === trackId ? filtered[0]?.id ?? null : state.activeTrackId,
+ };
+}
+
+/**
+ * Update a track's properties
+ */
+export function updateTrackInTimeline(
+ state: MultiTrackEditorState,
+ trackId: string,
+ updates: Partial
+): MultiTrackEditorState {
+ return {
+ ...state,
+ timelineTracks: state.timelineTracks.map(t =>
+ t.id === trackId ? { ...t, ...updates } : t
+ ),
+ };
+}
+
+/**
+ * Reorder tracks by z-index (for compositing order)
+ * Returns tracks sorted by zIndex ascending (lower index = background)
+ */
+export function sortTracksByZIndex(tracks: TimelineTrack[]): TimelineTrack[] {
+ return [...tracks].sort((a, b) => a.zIndex - b.zIndex);
+}
+
+/**
+ * Get visible video tracks in compositing order
+ */
+export function getVisibleVideoTracks(tracks: TimelineTrack[]): TimelineTrack[] {
+ return sortTracksByZIndex(
+ tracks.filter(t => t.visible && t.type === "video" && t.source)
+ );
+}
+
+/**
+ * Get the background track (lowest z-index visible video)
+ */
+export function getBackgroundTrack(tracks: TimelineTrack[]): TimelineTrack | null {
+ const visible = getVisibleVideoTracks(tracks);
+ return visible.length > 0 ? visible[0]! : null;
+}
+
+/**
+ * Get overlay tracks (all visible videos except background)
+ */
+export function getOverlayTracks(tracks: TimelineTrack[]): TimelineTrack[] {
+ const visible = getVisibleVideoTracks(tracks);
+ return visible.slice(1); // All except first (background)
+}
+
+/**
+ * Validate multi-track state
+ * Ensures consistency and enforces Phase 1 constraints
+ */
+export function validateMultiTrackState(state: MultiTrackEditorState): {
+ valid: boolean;
+ errors: string[];
+} {
+ const errors: string[] = [];
+
+ if (!Array.isArray(state.timelineTracks)) {
+ errors.push("timelineTracks must be an array");
+ }
+
+ const videoTracks = state.timelineTracks.filter(t => t.type === "video");
+ const visibleVideoTracks = videoTracks.filter(t => t.visible);
+
+ if (visibleVideoTracks.length > state.maxActiveTracks) {
+ errors.push(
+ `Too many active video tracks: ${visibleVideoTracks.length} (max: ${state.maxActiveTracks})`
+ );
+ }
+
+ // Check for duplicate IDs
+ const ids = state.timelineTracks.map(t => t.id);
+ const uniqueIds = new Set(ids);
+ if (ids.length !== uniqueIds.size) {
+ errors.push("Duplicate track IDs detected");
+ }
+
+ // Check z-index validity
+ state.timelineTracks.forEach((track, idx) => {
+ if (typeof track.zIndex !== "number") {
+ errors.push(`Track ${idx} has invalid zIndex`);
+ }
+ if (track.opacity < 0 || track.opacity > 100) {
+ errors.push(`Track ${idx} opacity out of range [0, 100]`);
+ }
+ if (track.scale <= 0) {
+ errors.push(`Track ${idx} scale must be positive`);
+ }
+ });
+
+ return {
+ valid: errors.length === 0,
+ errors,
+ };
+}
+
+/**
+ * Compact track representation for serialization
+ * Omits File objects which are not serializable
+ */
+export function serializeMultiTrackState(
+ state: MultiTrackEditorState
+): Omit & {
+ timelineTracks: (Omit & { source: null })[];
+} {
+ return {
+ ...state,
+ timelineTracks: state.timelineTracks.map(t => {
+ const { source, ...rest } = t;
+ return {
+ ...rest,
+ source: null,
+ };
+ }),
+ };
+}
From 29db120501c362635c4d3d149c9d4f5ce0d9dc2b Mon Sep 17 00:00:00 2001
From: Akanksha Trehun
Date: Tue, 3 Nov 2026 13:45:32 +0530
Subject: [PATCH 4/6] chore: remove Finder duplicate files committed by mistake
in 3fe0335
They are untracked local copies (names ending in " 2"), not project files.
Co-Authored-By: Claude Opus 5.5
---
docs/PROJECTS 2.md | 136 -------
.../AudioSpeedControl.stories 2.tsx | 38 --
src/components/BrandLogo.stories 2.tsx | 16 -
src/components/ColorPicker 2.tsx | 318 ---------------
src/components/DatadogInit 2.tsx | 36 --
src/components/DownloadResult.stories 2.tsx | 74 ----
src/components/ExportOverlay.stories 2.tsx | 83 ----
src/components/ExportSettings.stories 2.tsx | 45 ---
src/components/FileUpload.stories 2.tsx | 42 --
src/components/FormatSelector.stories 2.tsx | 31 --
src/components/FramingControl.stories 2.tsx | 28 --
src/components/HistoryControls 2.tsx | 51 ---
src/components/PresetSelector.stories 2.tsx | 51 ---
src/components/PrivacyBanner 2.tsx | 81 ----
src/components/PrivacyBanner.stories 2.tsx | 21 -
src/components/ProjectPanel 2.tsx | 216 ----------
src/components/RotateControl.stories 2.tsx | 35 --
src/components/TrimControl.stories 2.tsx | 52 ---
src/components/VideoEditor.stories 2.tsx | 41 --
.../__stories__/RecipeHarness 2.tsx | 45 ---
src/components/components.stories 2.tsx | 88 ----
src/components/ui/BaseButton.stories 2.tsx | 65 ---
src/components/ui/Skeleton.stories 2.tsx | 31 --
src/lib/editorPersistence 2.ts | 131 ------
src/lib/lottie/trail-loading 2.json | 1 -
src/lib/overlayGraph 2.ts | 309 --------------
src/lib/sessionDB 2.ts | 82 ----
src/lib/tests/editorPersistence.test 2.ts | 90 -----
src/lib/tests/overlayGraph.test 2.ts | 334 ----------------
src/lib/tests/timeline.test 2.ts | 377 ------------------
src/lib/timeline 2.ts | 211 ----------
31 files changed, 3159 deletions(-)
delete mode 100644 docs/PROJECTS 2.md
delete mode 100644 src/components/AudioSpeedControl.stories 2.tsx
delete mode 100644 src/components/BrandLogo.stories 2.tsx
delete mode 100644 src/components/ColorPicker 2.tsx
delete mode 100644 src/components/DatadogInit 2.tsx
delete mode 100644 src/components/DownloadResult.stories 2.tsx
delete mode 100644 src/components/ExportOverlay.stories 2.tsx
delete mode 100644 src/components/ExportSettings.stories 2.tsx
delete mode 100644 src/components/FileUpload.stories 2.tsx
delete mode 100644 src/components/FormatSelector.stories 2.tsx
delete mode 100644 src/components/FramingControl.stories 2.tsx
delete mode 100644 src/components/HistoryControls 2.tsx
delete mode 100644 src/components/PresetSelector.stories 2.tsx
delete mode 100644 src/components/PrivacyBanner 2.tsx
delete mode 100644 src/components/PrivacyBanner.stories 2.tsx
delete mode 100644 src/components/ProjectPanel 2.tsx
delete mode 100644 src/components/RotateControl.stories 2.tsx
delete mode 100644 src/components/TrimControl.stories 2.tsx
delete mode 100644 src/components/VideoEditor.stories 2.tsx
delete mode 100644 src/components/__stories__/RecipeHarness 2.tsx
delete mode 100644 src/components/components.stories 2.tsx
delete mode 100644 src/components/ui/BaseButton.stories 2.tsx
delete mode 100644 src/components/ui/Skeleton.stories 2.tsx
delete mode 100644 src/lib/editorPersistence 2.ts
delete mode 100644 src/lib/lottie/trail-loading 2.json
delete mode 100644 src/lib/overlayGraph 2.ts
delete mode 100644 src/lib/sessionDB 2.ts
delete mode 100644 src/lib/tests/editorPersistence.test 2.ts
delete mode 100644 src/lib/tests/overlayGraph.test 2.ts
delete mode 100644 src/lib/tests/timeline.test 2.ts
delete mode 100644 src/lib/timeline 2.ts
diff --git a/docs/PROJECTS 2.md b/docs/PROJECTS 2.md
deleted file mode 100644
index cda3b066c..000000000
--- a/docs/PROJECTS 2.md
+++ /dev/null
@@ -1,136 +0,0 @@
-# Undo, recovery and project files
-
-This covers how Reframe keeps edit history, what it saves in the browser, and the `.reframe.json` project format. Nothing here sends data anywhere. Everything stays in the user's browser or in files the user saves themselves.
-
-## Edit state
-
-`EditState` (`src/lib/editHistory.ts`) is the single source of truth for every edit that changes the exported video:
-
-| Included | Where it lives |
-|---|---|
-| Preset, custom size, framing, trim, rotation | `recipe` |
-| Keep audio, normalise, volume, speed | `recipe` |
-| Quality, format, brightness, contrast, saturation, sharpness, stabilisation, denoise | `recipe` |
-| Text overlays (text, position, size, colour, weight, font name) | `recipe.textOverlays` |
-| Image overlay position, size and opacity | `imageOverlay` |
-
-Not included, on purpose:
-
-- **The completion-sound preference.** It's a setting, not an edit, so undo never flips it.
-- **Files** (the source video, the image overlay file, background music). They are binary assets, not edit values, so adding or removing the image file is not an undo step.
-- **Transient UI state.** Hover, open panels, the selected text overlay, export progress, object URLs and worker state.
-- **The multi-track timeline state.** It isn't connected to the UI or the export yet (see `PHASE_1_MVP.md`).
-
-`useVideoEditor` exposes `recipe` derived from `EditState`. `handleExport` passes that same object, plus the image overlay settings, to `exportVideo`. So after an undo the preview and the export can't disagree.
-
-## Undo and redo
-
-- **Snapshots.** Each history entry is an immutable `EditState`, about 1 KB, never media. Undo and redo just move between entries.
-- **Bounded.** `HISTORY_LIMIT` is 100 entries. The oldest are dropped first.
-- **No duplicates.** A change that leaves the state equal to the current one is not recorded.
-- **Coalescing.** Repeated changes to the same continuous control within `COALESCE_WINDOW_MS` (600 ms) merge into one step:
- - Continuous controls are sliders, trim handles, custom size, and text-overlay text, position, size and colour.
- - The window slides, so a long drag stays one step.
- - Dragging back to the starting value removes the step.
- - Discrete choices (preset, format, rotation, toggles, image overlay position) always get their own step, however fast they're clicked.
- - Adding or removing a text overlay is always its own step.
-- **When history resets.** Choosing a new video, restoring a session and deleting saved data each start a fresh history.
-- **During export.** Undo and redo are disabled while an export runs. The running export has already taken its settings.
-- **Reset settings.** "Reset settings" and the `R` shortcut are now a single undoable step. They no longer delete saved data.
-
-**Shortcuts.** macOS uses ⌘Z and ⇧⌘Z. Other platforms use Ctrl+Z, and Ctrl+Y or Ctrl+Shift+Z. They work while a slider, checkbox or button has focus. In text fields and `contenteditable` they are left to the browser, so they undo the typing. See `src/lib/historyShortcuts.ts`.
-
-## What is saved in the browser
-
-| Key | Store | Contents |
-|---|---|---|
-| `reframe:project` | localStorage | The current project document (below). Written 500 ms after the last edit while a video is open, and on `pagehide`. |
-| `reframe-session` / `files` / `currentVideo` | IndexedDB | One copy of the open source video, overwritten when a new video is chosen. Unchanged from before. |
-| `reframe:recipe`, `editorState`, `soundOnCompletion` | localStorage | Existing settings records, kept for compatibility. |
-
-The project record is stored in localStorage rather than IndexedDB because it is only a few KB and a synchronous write can be flushed reliably when the page is hidden.
-
-**Recovery on load**, in this order:
-
-1. A `?settings=` share link wins.
-2. Otherwise the saved project's edits are restored.
-3. Otherwise the older URL and recipe records are used, as before.
-
-Then the stored video copy is read from IndexedDB:
-
-- **The copy is present.** The editor opens with the video and the edits.
-- **The copy is missing, or IndexedDB fails.** This can happen after eviction, private mode, or a quota error when saving. A notice names the file, its size and length, and asks the user to choose it again.
- - Choosing a file with the same name and byte size resumes the project, trims included.
- - A different file keeps the other settings but resets the trim points, like any new video.
- - "Discard saved edits" forgets the project.
-
-**Failures are reported in the "Saved on this device" panel:**
-
-- If the video copy can't be stored, the user is told they'll need to choose the file again after a refresh.
-- If the edit record can't be written, they're told to use "Save project file".
-
-**Delete saved data** asks for confirmation. It then removes the project record, the recipe and overlay records and the stored video copy, closes the video and resets the editor. Project files the user saved are not affected.
-
-## Project file format
-
-"Save project file" downloads `.reframe.json`:
-
-```json
-{
- "format": "reframe-project",
- "schemaVersion": 1,
- "savedAt": "2026-10-22T12:00:00.000Z",
- "source": { "name": "clip.mp4", "size": 24000000, "type": "video/mp4",
- "lastModified": 1700000000000, "duration": 42.5, "width": 1920, "height": 1080 },
- "edit": {
- "recipe": { "preset": "square-1-1", "trimStart": 3, "trimEnd": 20, "...": "every EditRecipe field except soundOnCompletion" },
- "imageOverlay": { "position": "top-left", "size": 250, "opacity": 60 }
- }
-}
-```
-
-The file never contains the video, the image overlay or any other media.
-
-**Opening a project file:**
-
-- **A video is open.** The edits are applied as one undoable step.
- - Same video (name and size match): everything applies, and trim points that don't fit are dropped.
- - Different video: everything applies **except the trim points**, which stay as they were for the open video, and the message says so. Trims belong to the video they were made for, so they are never applied silently to another one.
-- **No video is open.** The edits are held and the user is asked to choose the project's video.
-
-### Validation
-
-Every document, whether imported or read back from storage, goes through `parseProject` in `src/lib/project.ts`. It rejects the whole document if anything is wrong. It never clamps values or merges them with defaults.
-
-**Size and shape:**
-
-- Input over 512 KB is rejected before parsing.
-- The document must be a JSON object with `format: "reframe-project"` and an integer `schemaVersion`.
-
-**Field checks:**
-
-- Every recipe field must be present, with the same types and ranges the editor enforces.
-- Presets must be known ids, and speed one of the speed steps.
-- Trim end must be after trim start.
-- At most 50 text overlays, each with text of at most 500 characters.
-- Overlay ids must match `[A-Za-z0-9_-]{1,64}` and must be unique. Colours must be `#rrggbb`.
-- Font names must match `[A-Za-z0-9 _-]{1,64}`, because they reach FFmpeg's `drawtext` filter.
-
-**Safe construction:**
-
-- `fontPath` is never read from a project file, because it is passed to FFmpeg as a file path. It is also never written, since a `blob:` URL is meaningless outside the tab.
-- Output objects are built field by field, so unknown keys, including `__proto__`, are dropped rather than copied.
-
-### Changing the schema
-
-1. Bump `PROJECT_SCHEMA_VERSION`.
-2. Add `MIGRATIONS[oldVersion] = (doc) => upgradedDoc`. It runs on raw JSON before validation, and migrations chain one version at a time.
-3. Update the validators for the new shape and add a test that parses a document from the old version.
-
-Documents from a newer schema than the page knows are refused with "saved by a newer version of Reframe… reload the page". A version with no migration path is refused too.
-
-A change to `EditRecipe` fields needs a project schema bump, because the validator requires every field.
-
-## Export profiles
-
-Implemented separately; see [EXPORT_PROFILES.md](EXPORT_PROFILES.md). Applying a profile is one undo step and changes only the output fields of `EditState`. Profiles are stored apart from projects, and a project file doesn't include them.
diff --git a/src/components/AudioSpeedControl.stories 2.tsx b/src/components/AudioSpeedControl.stories 2.tsx
deleted file mode 100644
index 1e0a24f98..000000000
--- a/src/components/AudioSpeedControl.stories 2.tsx
+++ /dev/null
@@ -1,38 +0,0 @@
-import type { Meta, StoryObj } from "@storybook/nextjs";
-import AudioSpeedControl from "./AudioSpeedControl";
-import { RecipeHarness, makeRecipe } from "./__stories__/RecipeHarness";
-
-/** Audio toggle, volume, normalisation and playback-speed steps. */
-const meta = {
- title: "Editor/Controls/AudioSpeedControl",
- component: AudioSpeedControl,
- parameters: { layout: "padded" },
- args: {
- recipe: makeRecipe(),
- onChange: () => {},
- },
- // `recipe` seeds the harness, which then owns the state so the control is
- // actually interactive in Storybook rather than frozen on first render.
- render: (args) => (
-
- {(recipe, onChange) => }
-
- ),
-} satisfies Meta;
-
-export default meta;
-type Story = StoryObj;
-
-export const Default: Story = {};
-
-/** Audio stripped — the dependent volume controls should read as disabled. */
-export const AudioMuted: Story = {
- args: { recipe: makeRecipe({ keepAudio: false }) },
-};
-
-/** Non-default speed and boosted volume, with normalisation on. */
-export const FastWithNormalisedAudio: Story = {
- args: {
- recipe: makeRecipe({ speed: 2, volume: 150, normalizeAudio: true }),
- },
-};
diff --git a/src/components/BrandLogo.stories 2.tsx b/src/components/BrandLogo.stories 2.tsx
deleted file mode 100644
index 746a02719..000000000
--- a/src/components/BrandLogo.stories 2.tsx
+++ /dev/null
@@ -1,16 +0,0 @@
-import type { Meta, StoryObj } from "@storybook/nextjs";
-import BrandLogo from "./BrandLogo";
-
-/** Wordmark/logo used in the header and footer. */
-const meta = {
- title: "Design System/BrandLogo",
- component: BrandLogo,
- parameters: { layout: "padded" },
-} satisfies Meta;
-
-export default meta;
-type Story = StoryObj;
-
-export const Default: Story = {};
-export const Large: Story = { args: { size: 64 } };
-export const Small: Story = { args: { size: 20 } };
diff --git a/src/components/ColorPicker 2.tsx b/src/components/ColorPicker 2.tsx
deleted file mode 100644
index c7f7db633..000000000
--- a/src/components/ColorPicker 2.tsx
+++ /dev/null
@@ -1,318 +0,0 @@
-import React, { useRef, useEffect, useState, useCallback } from 'react';
-import { createPortal } from 'react-dom';
-import { Pipette } from 'lucide-react';
-
-interface ColorPickerProps {
- value: string;
- onChange: (hex: string) => void;
- label?: string;
-}
-
-const getGlassyClasses = (opacity = 10) =>
- `backdrop-filter backdrop-blur-lg bg-[var(--surface)] bg-opacity-${opacity} border border-[var(--border)] rounded-lg shadow-lg transition-all duration-300`;
-
-function hexToHsv(hex: string): [number, number, number] {
- const r = parseInt(hex.slice(1, 3), 16) / 255;
- const g = parseInt(hex.slice(3, 5), 16) / 255;
- const b = parseInt(hex.slice(5, 7), 16) / 255;
- const max = Math.max(r, g, b),
- min = Math.min(r, g, b),
- d = max - min;
- let h = 0;
- if (d !== 0) {
- if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
- else if (max === g) h = ((b - r) / d + 2) / 6;
- else h = ((r - g) / d + 4) / 6;
- }
- return [h * 360, max === 0 ? 0 : d / max, max];
-}
-
-function hsvToRgb(h: number, s: number, v: number): [number, number, number] {
- const i = Math.floor(h / 60) % 6;
- const f = h / 60 - Math.floor(h / 60);
- const p = v * (1 - s),
- q = v * (1 - f * s),
- t = v * (1 - (1 - f) * s);
- const sets: [number, number, number][] = [
- [v, t, p],
- [q, v, p],
- [p, v, t],
- [p, q, v],
- [t, p, v],
- [v, p, q],
- ];
- return sets[i]?.map(x => Math.round(x * 255)) as [number, number, number];
-}
-
-function rgbToHex(r: number, g: number, b: number) {
- return (
- '#' +
- [r, g, b]
- .map(x => x.toString(16).padStart(2, '0'))
- .join('')
- .toUpperCase()
- );
-}
-
-const ColorPicker: React.FC = ({
- value,
- onChange,
- label,
-}) => {
- const canvasRef = useRef(null);
- const [open, setOpen] = useState(false);
-
- const initialHex = /^#[0-9a-fA-F]{6}$/.test(value) ? value : '#FFFFFF';
- const [initH, initS, initV] = hexToHsv(initialHex);
-
- const [hue, setHue] = useState(initH);
- const [sat, setSat] = useState(initS);
- const [val, setVal] = useState(initV);
- const CANVAS_W = 232,
- CANVAS_H = 140;
- const [cursorX, setCursorX] = useState(initS * (CANVAS_W - 1));
- const [cursorY, setCursorY] = useState((1 - initV) * (CANVAS_H - 1));
- const [hexInput, setHexInput] = useState(initialHex.slice(1).toUpperCase());
- const dragging = useRef(false);
- const panelRef = useRef(null);
- const triggerRef = useRef(null);
- const [panelPos, setPanelPos] = useState({ top: 0, left: 0 });
-
- const updatePosition = useCallback(() => {
- if (triggerRef.current) {
- const rect = triggerRef.current.getBoundingClientRect();
- setPanelPos({
- top: rect.bottom + window.scrollY + 6,
- left: rect.left + window.scrollX,
- });
- }
- }, []);
-
- useEffect(() => {
- if (open) {
- updatePosition();
- window.addEventListener('scroll', updatePosition, true);
- window.addEventListener('resize', updatePosition);
- }
- return () => {
- window.removeEventListener('scroll', updatePosition, true);
- window.removeEventListener('resize', updatePosition);
- };
- }, [open, updatePosition]);
-
- const drawCanvas = useCallback(() => {
- const canvas = canvasRef.current;
- if (!canvas) return;
- const ctx = canvas.getContext('2d')!;
- const w = canvas.width,
- h = canvas.height;
- ctx.clearRect(0, 0, w, h);
- const gH = ctx.createLinearGradient(0, 0, w, 0);
- gH.addColorStop(0, '#FFFFFF');
- gH.addColorStop(1, `hsl(${hue},100%,50%)`);
- ctx.fillStyle = gH;
- ctx.fillRect(0, 0, w, h);
- const gV = ctx.createLinearGradient(0, 0, 0, h);
- gV.addColorStop(0, 'rgba(0,0,0,0)');
- gV.addColorStop(1, '#000000');
- ctx.fillStyle = gV;
- ctx.fillRect(0, 0, w, h);
- ctx.beginPath();
- ctx.arc(cursorX, cursorY, 6, 0, 2 * Math.PI);
- ctx.strokeStyle = 'rgba(255,255,255,0.95)';
- ctx.lineWidth = 2;
- ctx.stroke();
- }, [hue, cursorX, cursorY]);
-
- useEffect(() => {
- if (open) drawCanvas();
- }, [open, drawCanvas]);
-
- const pickColor = useCallback(
- (clientX: number, clientY: number) => {
- const canvas = canvasRef.current;
- if (!canvas) return;
- const rect = canvas.getBoundingClientRect();
- const scaleX = canvas.width / rect.width;
- const scaleY = canvas.height / rect.height;
- const cx = Math.max(0, Math.min(canvas.width - 1, (clientX - rect.left) * scaleX));
- const cy = Math.max(0, Math.min(canvas.height - 1, (clientY - rect.top) * scaleY));
- setCursorX(cx);
- setCursorY(cy);
- const s = cx / (canvas.width - 1);
- const v = 1 - cy / (canvas.height - 1);
- setSat(s);
- setVal(v);
- const [r_val, g_val, b_val] = hsvToRgb(hue, s, v);
- const hex = rgbToHex(r_val, g_val, b_val);
- setHexInput(hex.slice(1));
- onChange(hex);
- },
- [hue, onChange],
- );
-
- const handleHueChange = (e: React.ChangeEvent) => {
- const h = Number(e.target.value);
- setHue(h);
- const [r_v, g_v, b_v] = hsvToRgb(h, sat, val);
- const hex = rgbToHex(r_v, g_v, b_v);
- setHexInput(hex.slice(1));
- onChange(hex);
- };
-
- const handleHexInput = (e: React.ChangeEvent) => {
- const v = e.target.value.replace(/[^0-9a-fA-F]/g, '').slice(0, 6);
- setHexInput(v.toUpperCase());
- if (v.length === 6) {
- const full = '#' + v;
- const [h, s, v2] = hexToHsv(full);
- setHue(h);
- setSat(s);
- setVal(v2);
- setCursorX(s * (CANVAS_W - 1));
- setCursorY((1 - v2) * (CANVAS_H - 1));
- onChange(full.toUpperCase());
- }
- };
-
- useEffect(() => {
- if (!open) return;
- const handler = (e: MouseEvent) => {
- const isTriggerClick = triggerRef.current?.contains(e.target as Node);
- const isPanelClick = panelRef.current?.contains(e.target as Node);
- if (!isTriggerClick && !isPanelClick) {
- setOpen(false);
- }
- };
- document.addEventListener('mousedown', handler);
- return () => document.removeEventListener('mousedown', handler);
- }, [open]);
-
- const handleEyeDropper = async () => {
- if (!('EyeDropper' in window)) return;
- try {
- const eyeDropper = new (window as any).EyeDropper();
- const result = await eyeDropper.open();
- const hex = result.sRGBHex.toUpperCase();
- const [h, s, v2] = hexToHsv(hex);
- setHue(h);
- setSat(s);
- setVal(v2);
- setHexInput(hex.slice(1));
- setCursorX(s * (CANVAS_W - 1));
- setCursorY((1 - v2) * (CANVAS_H - 1));
- onChange(hex);
- } catch (_) { }
- };
-
- const [r, g, b] = hsvToRgb(hue, sat, val);
-
- return (
-
- {label && (
-
- {label}
-
- )}
-
-
- setOpen(!open)}
- className={`flex items-center gap-3 ${getGlassyClasses(20)} p-1.5 border border-[var(--border)] hover:bg-[var(--border)] active:scale-95 transition-transform`}
- aria-label="Open custom palette"
- >
-
-
-
-
-
-
- {open &&
- createPortal(
-
-
{
- dragging.current = true;
- pickColor(e.clientX, e.clientY);
- }}
- onMouseMove={e => {
- if (dragging.current) pickColor(e.clientX, e.clientY);
- }}
- onMouseUp={() => { dragging.current = false; }}
- onMouseLeave={() => { dragging.current = false; }}
- />
-
-
-
-
-
- {/* 📍 THIS IS THE MISSING PIPETTE TRIGGER */}
- {'EyeDropper' in window && (
-
-
-
- )}
-
-
- {[
- ['R', r],
- ['G', g],
- ['B', b],
- ].map(([ch, v_rgb]) => (
-
- ))}
-
- ,
- document.body,
- )}
-
- );
-};
-
-export default ColorPicker;
\ No newline at end of file
diff --git a/src/components/DatadogInit 2.tsx b/src/components/DatadogInit 2.tsx
deleted file mode 100644
index 4d8ed0574..000000000
--- a/src/components/DatadogInit 2.tsx
+++ /dev/null
@@ -1,36 +0,0 @@
-"use client";
-
-import { useEffect } from "react";
-import { datadogRum } from "@datadog/browser-rum";
-import { reactPlugin } from "@datadog/browser-rum-react";
-
-export default function DatadogInit() {
- useEffect(() => {
- const applicationId = process.env.NEXT_PUBLIC_DATADOG_APPLICATION_ID;
- const clientToken = process.env.NEXT_PUBLIC_DATADOG_CLIENT_TOKEN;
-
- if (!applicationId || !clientToken) {
- return;
- }
-
- datadogRum.init({
- applicationId,
- clientToken,
- site: "datadoghq.com",
- service: "reframe",
- env: "production",
- version: "0.1.0",
- sessionSampleRate: 100,
- // Reframe advertises itself as 100% private client-side video editing —
- // session replay would visually record the editor UI, which cuts
- // against that claim. Metrics/errors only.
- sessionReplaySampleRate: 0,
- plugins: [reactPlugin()],
- trackUserInteractions: true,
- trackResources: true,
- trackLongTasks: true,
- });
- }, []);
-
- return null;
-}
diff --git a/src/components/DownloadResult.stories 2.tsx b/src/components/DownloadResult.stories 2.tsx
deleted file mode 100644
index 0282f0b7a..000000000
--- a/src/components/DownloadResult.stories 2.tsx
+++ /dev/null
@@ -1,74 +0,0 @@
-import type { Meta, StoryObj } from "@storybook/nextjs";
-import DownloadResult from "./DownloadResult";
-import type { ExportResult } from "@/lib/types";
-
-/**
- * Success panel shown after an export finishes: dimensions, file size, a
- * filename field and the download button.
- *
- * The ExportResult is synthesised rather than produced by a real export — a
- * genuine FFmpeg run would take ~30s and produce a different blob each time.
- * A small in-memory Blob exercises exactly the same render path.
- */
-function makeResult(overrides: Partial = {}): ExportResult {
- const blob = new Blob(["reframe-storybook-fixture"], { type: "video/mp4" });
- return {
- blobUrl: "about:blank",
- blob,
- size: 8_432_119,
- width: 1080,
- height: 1920,
- format: "mp4",
- dispose: () => {},
- ...overrides,
- };
-}
-
-const meta = {
- title: "Editor/Modals/DownloadResult",
- component: DownloadResult,
- parameters: { layout: "padded" },
- args: {
- onReset: () => {},
- onToggleSound: () => {},
- soundOnCompletion: false,
- },
-} satisfies Meta;
-
-export default meta;
-type Story = StoryObj;
-
-/** A vertical 9:16 export, the most common output for this tool. */
-export const Default: Story = {
- args: { result: makeResult() },
-};
-
-/** Landscape WebM, with the export-duration readout present. */
-export const WithExportDuration: Story = {
- args: {
- result: makeResult({
- width: 1920,
- height: 1080,
- format: "webm",
- size: 24_908_311,
- exportDurationMs: 42_500,
- }),
- },
-};
-
-/** Completion sound enabled — toggles the speaker control's active state. */
-export const SoundEnabled: Story = {
- args: { result: makeResult(), soundOnCompletion: true },
-};
-
-/** A GIF export: small, square, and audio-less. */
-export const GifExport: Story = {
- args: {
- result: makeResult({
- format: "gif",
- width: 480,
- height: 480,
- size: 1_204_880,
- }),
- },
-};
diff --git a/src/components/ExportOverlay.stories 2.tsx b/src/components/ExportOverlay.stories 2.tsx
deleted file mode 100644
index 599871a68..000000000
--- a/src/components/ExportOverlay.stories 2.tsx
+++ /dev/null
@@ -1,83 +0,0 @@
-import * as React from "react";
-import type { Meta, StoryObj, Decorator } from "@storybook/nextjs";
-import ExportOverlay from "./ExportOverlay";
-
-/**
- * Full-screen modal shown while an export runs.
- *
- * ── Why this file works the way it does ──────────────────────────────────────
- * The overlay contains a that rotates to the next tip every
- * 6000ms. A visual regression service screenshots at an arbitrary moment, so
- * that rotation reports a change on every build that reflects nothing but
- * timing. It is marked data-chromatic="ignore": Chromatic still renders it,
- * but excludes it from the diff.
- *
- * The Lottie spinner used to need the same treatment. It no longer does —
- * LottiePlayer checks isChromatic() and renders frame 0 statically, so the
- * spinner is deterministic and stays fully covered by the diff.
- *
- * A word of warning from how this file got here: an earlier version froze both
- * by stubbing window.setInterval and window.requestAnimationFrame. Chromatic's
- * capture instrumentation relies on requestAnimationFrame to decide a story
- * has settled, so starving it meant all six snapshots (3 stories x light/dark)
- * failed with "took too long to load". Never stub browser globals to stabilise
- * a snapshot.
- */
-const withCarouselIgnored: Decorator = (Story) => (
-
-
-
-);
-
-function IgnoreCarousel({ children }: { children: React.ReactNode }) {
- React.useEffect(() => {
- // The TipCarousel root, identified by its min-h-[142px] utility.
- const marked: Element[] = [];
-
- document.querySelectorAll('[class*="min-h-[142px]"]').forEach((el) => {
- el.setAttribute("data-chromatic", "ignore");
- marked.push(el);
- });
-
- return () => marked.forEach((el) => el.removeAttribute("data-chromatic"));
- }, []);
-
- return <>{children}>;
-}
-
-const meta = {
- title: "Editor/Modals/ExportOverlay",
- component: ExportOverlay,
- parameters: { layout: "fullscreen" },
- decorators: [withCarouselIgnored],
- args: {
- // With a real timestamp the component starts a 1s interval and renders
- // Date.now() - exportStartedAt as elapsed time, which would differ between
- // builds. null takes its own early-return branch and pins it at zero.
- exportStartedAt: null,
- onCancel: () => {},
- },
-} satisfies Meta;
-
-export default meta;
-type Story = StoryObj;
-
-/**
- * FFmpeg.wasm (~30 MB) is being fetched on first export.
- *
- * The overlay only renders for "loading-engine" and "exporting"; the other
- * ExportStatus values return null, so they aren't worth stories.
- */
-export const LoadingEngine: Story = {
- args: { status: "loading-engine", progress: 0 },
-};
-
-/** Mid-export, just past a third done. */
-export const Exporting: Story = {
- args: { status: "exporting", progress: 37 },
-};
-
-/** Near completion — checks the progress bar at the far end of its track. */
-export const AlmostDone: Story = {
- args: { status: "exporting", progress: 98 },
-};
diff --git a/src/components/ExportSettings.stories 2.tsx b/src/components/ExportSettings.stories 2.tsx
deleted file mode 100644
index 62cb7a63d..000000000
--- a/src/components/ExportSettings.stories 2.tsx
+++ /dev/null
@@ -1,45 +0,0 @@
-import type { Meta, StoryObj } from "@storybook/nextjs";
-import ExportSettings from "./ExportSettings";
-import { RecipeHarness, makeRecipe } from "./__stories__/RecipeHarness";
-
-/**
- * Quality (CRF) slider plus the estimated output size, which is derived from
- * the recipe and the clip duration.
- */
-const meta = {
- title: "Editor/Controls/ExportSettings",
- component: ExportSettings,
- parameters: { layout: "padded" },
- args: {
- recipe: makeRecipe(),
- onChange: () => {},
- duration: 90,
- },
- render: (args) => (
-
- {(recipe, onChange) => (
-
- )}
-
- ),
-} satisfies Meta;
-
-export default meta;
-type Story = StoryObj;
-
-/** CRF 23 — the default balance of size and quality. */
-export const Default: Story = {};
-
-/** Near-lossless: low CRF, large estimate. */
-export const HighQuality: Story = {
- args: { recipe: makeRecipe({ quality: 16 }) },
-};
-
-/** Heavily compressed: high CRF, small estimate. */
-export const SmallFile: Story = {
- args: { recipe: makeRecipe({ quality: 34 }) },
-};
diff --git a/src/components/FileUpload.stories 2.tsx b/src/components/FileUpload.stories 2.tsx
deleted file mode 100644
index 07d48ab81..000000000
--- a/src/components/FileUpload.stories 2.tsx
+++ /dev/null
@@ -1,42 +0,0 @@
-import type { Meta, StoryObj } from "@storybook/nextjs";
-import FileUpload from "./FileUpload";
-
-/**
- * The drag-and-drop landing state — the first thing every user sees, and the
- * only screen shown until a file is chosen. Worth snapshotting in both themes.
- *
- * `currentFile` stays null across these stories: the "file selected" branch
- * reads File.name/size and renders a duration that arrives asynchronously from
- * media metadata, which would not be reproducible in CI.
- */
-const meta = {
- title: "Editor/FileUpload",
- component: FileUpload,
- parameters: { layout: "padded" },
- args: {
- onFileSelect: () => {},
- currentFile: null,
- fileError: "",
- duration: 0,
- },
-} satisfies Meta;
-
-export default meta;
-type Story = StoryObj;
-
-/** Empty drop zone awaiting a file. */
-export const Empty: Story = {};
-
-/** Rejected file — checks the error styling and that layout doesn't jump. */
-export const WithError: Story = {
- args: {
- fileError: "That file is 3.2 GB. The maximum supported size is 2 GB.",
- },
-};
-
-/** An unsupported-format rejection, which is the more common failure. */
-export const UnsupportedFormat: Story = {
- args: {
- fileError: "Unsupported file type. Please choose an MP4, WebM or MOV file.",
- },
-};
diff --git a/src/components/FormatSelector.stories 2.tsx b/src/components/FormatSelector.stories 2.tsx
deleted file mode 100644
index a465cac82..000000000
--- a/src/components/FormatSelector.stories 2.tsx
+++ /dev/null
@@ -1,31 +0,0 @@
-import type { Meta, StoryObj } from "@storybook/nextjs";
-import FormatSelector from "./FormatSelector";
-import { RecipeHarness, makeRecipe } from "./__stories__/RecipeHarness";
-
-/** Container format choice: mp4 / webm / mkv / gif. */
-const meta = {
- title: "Editor/Controls/FormatSelector",
- component: FormatSelector,
- parameters: { layout: "padded" },
- args: {
- recipe: makeRecipe(),
- onChange: () => {},
- },
- // `recipe` seeds the harness, which then owns the state so the control is
- // actually interactive in Storybook rather than frozen on first render.
- render: (args) => (
-
- {(recipe, onChange) => }
-
- ),
-} satisfies Meta;
-
-export default meta;
-type Story = StoryObj;
-
-export const Default: Story = {};
-
-export const WebM: Story = { args: { recipe: makeRecipe({ format: "webm" }) } };
-
-/** GIF is the odd one out — it drops audio, so its selected state matters. */
-export const Gif: Story = { args: { recipe: makeRecipe({ format: "gif" }) } };
diff --git a/src/components/FramingControl.stories 2.tsx b/src/components/FramingControl.stories 2.tsx
deleted file mode 100644
index f02f87126..000000000
--- a/src/components/FramingControl.stories 2.tsx
+++ /dev/null
@@ -1,28 +0,0 @@
-import type { Meta, StoryObj } from "@storybook/nextjs";
-import FramingControl from "./FramingControl";
-import { RecipeHarness, makeRecipe } from "./__stories__/RecipeHarness";
-
-/** Fit (letterbox) vs Fill (crop) toggle. */
-const meta = {
- title: "Editor/Controls/FramingControl",
- component: FramingControl,
- parameters: { layout: "padded" },
- args: {
- recipe: makeRecipe(),
- onChange: () => {},
- },
- // `recipe` seeds the harness, which then owns the state so the control is
- // actually interactive in Storybook rather than frozen on first render.
- render: (args) => (
-
- {(recipe, onChange) => }
-
- ),
-} satisfies Meta;
-
-export default meta;
-type Story = StoryObj;
-
-export const Fit: Story = { args: { recipe: makeRecipe({ framing: "fit" }) } };
-
-export const Fill: Story = { args: { recipe: makeRecipe({ framing: "fill" }) } };
diff --git a/src/components/HistoryControls 2.tsx b/src/components/HistoryControls 2.tsx
deleted file mode 100644
index 5091afc9a..000000000
--- a/src/components/HistoryControls 2.tsx
+++ /dev/null
@@ -1,51 +0,0 @@
-"use client";
-
-import { useEffect, useState } from "react";
-import { Undo2, Redo2 } from "lucide-react";
-import { isMacPlatform } from "@/lib/historyShortcuts";
-
-interface Props {
- canUndo: boolean;
- canRedo: boolean;
- onUndo: () => void;
- onRedo: () => void;
-}
-
-export default function HistoryControls({ canUndo, canRedo, onUndo, onRedo }: Props) {
- const [isMac, setIsMac] = useState(false);
- useEffect(() => {
- // navigator is browser-only, so the platform can't be read during render.
- // eslint-disable-next-line react-hooks/set-state-in-effect
- setIsMac(isMacPlatform());
- }, []);
-
- const undoKeys = isMac ? "⌘Z" : "Ctrl+Z";
- const redoKeys = isMac ? "⇧⌘Z" : "Ctrl+Y";
-
- return (
-
-
-
- Undo
-
-
-
- Redo
-
-
- );
-}
diff --git a/src/components/PresetSelector.stories 2.tsx b/src/components/PresetSelector.stories 2.tsx
deleted file mode 100644
index 9609119fe..000000000
--- a/src/components/PresetSelector.stories 2.tsx
+++ /dev/null
@@ -1,51 +0,0 @@
-import type { Meta, StoryObj } from "@storybook/nextjs";
-import PresetSelector from "./PresetSelector";
-import { RecipeHarness, makeRecipe } from "./__stories__/RecipeHarness";
-
-/**
- * The preset grid is the first real choice a user makes, and the most
- * layout-sensitive part of the editor: presets are grouped into categories and
- * each tile draws a scaled aspect-ratio box, so a change to spacing or to the
- * ratio maths surfaces here before anywhere else.
- */
-const meta = {
- title: "Editor/Controls/PresetSelector",
- component: PresetSelector,
- parameters: { layout: "padded" },
- args: {
- recipe: makeRecipe(),
- onChange: () => {},
- },
- // `recipe` seeds the harness, which then owns the state so the control is
- // actually interactive in Storybook rather than frozen on first render.
- render: (args) => (
-
- {(recipe, onChange) => }
-
- ),
-} satisfies Meta;
-
-export default meta;
-type Story = StoryObj;
-
-/** Default state — the 9:16 vertical preset selected. */
-export const Default: Story = {};
-
-/** A landscape preset selected, to catch category/highlight regressions. */
-export const LandscapeSelected: Story = {
- args: { recipe: makeRecipe({ preset: "landscape-16-9" }) },
-};
-
-/**
- * Custom dimensions reveal the width/height inputs, historically the source of
- * overflow bugs in this panel.
- */
-export const CustomDimensions: Story = {
- args: {
- recipe: makeRecipe({
- preset: "custom",
- customWidth: 1440,
- customHeight: 1080,
- }),
- },
-};
diff --git a/src/components/PrivacyBanner 2.tsx b/src/components/PrivacyBanner 2.tsx
deleted file mode 100644
index 5b600a63c..000000000
--- a/src/components/PrivacyBanner 2.tsx
+++ /dev/null
@@ -1,81 +0,0 @@
-"use client";
-
-import { useEffect, useState } from "react";
-import { ShieldCheck, X } from "lucide-react";
-
-/** localStorage key holding the timestamp (ms) until which the banner stays hidden. */
-const DISMISS_KEY = "reframe_privacy_banner_dismissed_until";
-/** Keep the banner hidden for 7 days after it is dismissed. */
-const DISMISS_DURATION_MS = 7 * 24 * 60 * 60 * 1000;
-
-/**
- * Dismissible notice shown above the upload area highlighting that all video
- * processing happens locally. Dismissal is remembered in localStorage and the
- * banner does not reappear for 7 days.
- */
-export default function PrivacyBanner() {
- // Start hidden so the server-rendered markup matches the first client paint
- // (static export → no hydration mismatch); reveal after reading localStorage.
- const [visible, setVisible] = useState(false);
-
- useEffect(() => {
- // Genuinely needs an effect: localStorage is unavailable during SSR, so
- // this can't be computed as derived state without a hydration mismatch.
- try {
- const raw = localStorage.getItem(DISMISS_KEY);
- const dismissedUntil = raw ? Number(raw) : 0;
- if (!Number.isFinite(dismissedUntil) || Date.now() >= dismissedUntil) {
- // eslint-disable-next-line react-hooks/set-state-in-effect
- setVisible(true);
- }
- } catch {
- // localStorage unavailable (private mode, blocked storage) — show anyway.
- setVisible(true);
- }
- }, []);
-
- const dismiss = () => {
- try {
- localStorage.setItem(
- DISMISS_KEY,
- String(Date.now() + DISMISS_DURATION_MS)
- );
- } catch {
- // Ignore write failures — dismissal simply won't persist across reloads.
- }
- setVisible(false);
- };
-
- if (!visible) return null;
-
- return (
-
-
-
-
- Your videos never leave your device.
-
-
- Processing is done entirely in your browser using FFmpeg.wasm. No
- server, no upload, no account required.
-
-
-
-
-
-
- );
-}
diff --git a/src/components/PrivacyBanner.stories 2.tsx b/src/components/PrivacyBanner.stories 2.tsx
deleted file mode 100644
index 52ea8144c..000000000
--- a/src/components/PrivacyBanner.stories 2.tsx
+++ /dev/null
@@ -1,21 +0,0 @@
-import type { Meta, StoryObj } from "@storybook/nextjs";
-import PrivacyBanner from "./PrivacyBanner";
-
-/**
- * "Your video never leaves your device" banner above the upload area — a key
- * piece of the product's positioning, so its styling is worth protecting.
- *
- * It is dismissible and persists that choice, so it can render empty if
- * localStorage already holds a dismissal. Storybook gives each story a fresh
- * iframe, so the default (visible) state is what gets captured.
- */
-const meta = {
- title: "Editor/PrivacyBanner",
- component: PrivacyBanner,
- parameters: { layout: "padded" },
-} satisfies Meta;
-
-export default meta;
-type Story = StoryObj;
-
-export const Default: Story = {};
diff --git a/src/components/ProjectPanel 2.tsx b/src/components/ProjectPanel 2.tsx
deleted file mode 100644
index e973f3f89..000000000
--- a/src/components/ProjectPanel 2.tsx
+++ /dev/null
@@ -1,216 +0,0 @@
-"use client";
-
-import { useRef, useState } from "react";
-import { FolderOpen, Save, Trash2, HardDrive } from "lucide-react";
-import { formatBytes } from "@/lib/ffmpeg";
-import { MAX_PROJECT_FILE_BYTES } from "@/lib/project";
-import type {
- PendingRecovery,
- ProjectImportResult,
- ProjectStorageStatus,
-} from "@/hooks/useVideoEditor";
-
-function formatDuration(seconds: number): string {
- const total = Math.max(0, Math.round(seconds));
- return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, "0")}`;
-}
-
-function formatSavedAt(savedAt: number): string {
- return new Date(savedAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
-}
-
-const actionClass = "btn btn-secondary btn-sm";
-
-interface PanelProps {
- hasFile: boolean;
- storageStatus: ProjectStorageStatus;
- disabled?: boolean;
- onBuildProjectFile: () => { fileName: string; contents: string };
- onImportProject: (contents: string) => ProjectImportResult;
- onDeleteSavedData: () => void;
-}
-
-/** Explains what is saved in this browser, and saves, opens or deletes it. */
-export default function ProjectPanel({
- hasFile,
- storageStatus,
- disabled = false,
- onBuildProjectFile,
- onImportProject,
- onDeleteSavedData,
-}: PanelProps) {
- const inputRef = useRef(null);
- const [message, setMessage] = useState<{ tone: "ok" | "error"; text: string } | null>(null);
- const [confirmingDelete, setConfirmingDelete] = useState(false);
-
- const handleSave = () => {
- const { fileName, contents } = onBuildProjectFile();
- const url = URL.createObjectURL(new Blob([contents], { type: "application/json" }));
- const link = document.createElement("a");
- link.href = url;
- link.download = fileName;
- link.click();
- setTimeout(() => URL.revokeObjectURL(url), 0);
- setMessage({ tone: "ok", text: `Saved ${fileName}. It holds your edits, not the video.` });
- };
-
- const handleOpen = async (event: React.ChangeEvent) => {
- const chosen = event.target.files?.[0];
- event.target.value = "";
- if (!chosen) return;
- if (chosen.size > MAX_PROJECT_FILE_BYTES) {
- setMessage({ tone: "error", text: "That file is too large to be a Reframe project." });
- return;
- }
- let contents: string;
- try {
- contents = await chosen.text();
- } catch {
- setMessage({ tone: "error", text: "That file couldn't be read." });
- return;
- }
- const outcome = onImportProject(contents);
- setMessage(
- outcome.ok
- ? { tone: "ok", text: outcome.message }
- : { tone: "error", text: `Couldn't open the project. ${outcome.error}` }
- );
- };
-
- const sourceLine =
- storageStatus.sourceCopy === "saved"
- ? "A copy of the video is kept in this browser's storage so a refresh doesn't lose it."
- : storageStatus.sourceCopy === "failed"
- ? "This browser didn't have room to keep a copy of the video. After a refresh you'll be asked to choose it again."
- : storageStatus.sourceCopy === "saving"
- ? "Keeping a copy of the video in this browser's storage…"
- : null;
-
- return (
-
-
-
- Saved on this device
-
-
-
-
- Your edit settings are saved in this browser as you work, so you can pick up after a
- refresh. Nothing is uploaded.
-
- {hasFile && sourceLine &&
{sourceLine}
}
- {hasFile && storageStatus.savedAt && !storageStatus.error && (
-
Edits saved at {formatSavedAt(storageStatus.savedAt)}.
- )}
- {storageStatus.error &&
{storageStatus.error}
}
-
-
-
-
-
- Save project file
-
- inputRef.current?.click()}
- disabled={disabled}
- className={actionClass}
- >
-
- Open project file
-
-
-
-
-
- {message && (
-
- {message.text}
-
- )}
-
-
- {confirmingDelete ? (
-
-
- Delete the saved edits and the stored copy of the video from this browser, and close the
- current video? Project files you saved are not affected.
-
-
- {
- onDeleteSavedData();
- setConfirmingDelete(false);
- setMessage({ tone: "ok", text: "Saved data deleted. The editor has been reset." });
- }}
- className="btn btn-danger btn-sm"
- >
- Delete
-
- setConfirmingDelete(false)}
- className="btn btn-secondary btn-sm"
- >
- Cancel
-
-
-
- ) : (
- setConfirmingDelete(true)}
- disabled={disabled}
- className="btn btn-ghost btn-sm text-[var(--muted)] hover:text-[var(--error)]"
- >
-
- Delete saved data
-
- )}
-
- );
-}
-
-interface RecoveryProps {
- recovery: PendingRecovery;
- onDiscard: () => void;
-}
-
-/** Shown when a saved project's video has to be chosen again. */
-export function RecoveryNotice({ recovery, onDiscard }: RecoveryProps) {
- const { source } = recovery;
- return (
-
-
- {recovery.origin === "session" ? "Pick up where you left off" : "Choose the project's video"}
-
-
- {recovery.origin === "session"
- ? "Your edits were saved, but this browser no longer has the video. "
- : "Project files don't include the video. "}
- Choose {source.name} ({formatBytes(source.size)}
- {source.duration > 0 ? `, ${formatDuration(source.duration)}` : ""}) below to continue with
- your edits. A different video starts with fresh trim points.
-
-
- Discard saved edits
-
-
- );
-}
diff --git a/src/components/RotateControl.stories 2.tsx b/src/components/RotateControl.stories 2.tsx
deleted file mode 100644
index 57b9e7c1e..000000000
--- a/src/components/RotateControl.stories 2.tsx
+++ /dev/null
@@ -1,35 +0,0 @@
-import type { Meta, StoryObj } from "@storybook/nextjs";
-import RotateControl from "./RotateControl";
-import { RecipeHarness, makeRecipe } from "./__stories__/RecipeHarness";
-
-/**
- * Rotation buttons (0/90/180/270), implemented as a radiogroup with roving
- * tabindex — so the selected-state styling is worth pinning down visually.
- */
-const meta = {
- title: "Editor/Controls/RotateControl",
- component: RotateControl,
- parameters: { layout: "padded" },
- args: {
- recipe: makeRecipe(),
- onChange: () => {},
- },
- // `recipe` seeds the harness, which then owns the state so the control is
- // actually interactive in Storybook rather than frozen on first render.
- render: (args) => (
-
- {(recipe, onChange) => }
-
- ),
-} satisfies Meta;
-
-export default meta;
-type Story = StoryObj;
-
-export const NoRotation: Story = { args: { recipe: makeRecipe({ rotate: 0 }) } };
-
-export const Rotated90: Story = { args: { recipe: makeRecipe({ rotate: 90 }) } };
-
-export const Rotated270: Story = {
- args: { recipe: makeRecipe({ rotate: 270 }) },
-};
diff --git a/src/components/TrimControl.stories 2.tsx b/src/components/TrimControl.stories 2.tsx
deleted file mode 100644
index 9269aa864..000000000
--- a/src/components/TrimControl.stories 2.tsx
+++ /dev/null
@@ -1,52 +0,0 @@
-import type { Meta, StoryObj } from "@storybook/nextjs";
-import TrimControl from "./TrimControl";
-import { RecipeHarness, makeRecipe } from "./__stories__/RecipeHarness";
-
-/**
- * Trim in/out controls.
- *
- * Determinism note: `file` is null in every story. The component feeds it to
- * useAudioWaveform, which decodes real audio to draw the waveform — an async
- * result that depends on the media, so a real file would make these snapshots
- * unstable. The null path still renders the trim inputs and duration readout,
- * which is the part worth regression-testing.
- */
-const meta = {
- title: "Editor/Controls/TrimControl",
- component: TrimControl,
- parameters: { layout: "padded" },
- args: {
- recipe: makeRecipe(),
- onChange: () => {},
- duration: 90,
- file: null,
- },
- render: (args) => (
-
- {(recipe, onChange) => (
-
- )}
-
- ),
-} satisfies Meta;
-
-export default meta;
-type Story = StoryObj;
-
-/** Full 90-second clip, nothing trimmed. */
-export const Default: Story = {};
-
-/** A trimmed range, so the computed output duration is exercised. */
-export const Trimmed: Story = {
- args: { recipe: makeRecipe({ trimStart: 12.5, trimEnd: 47.25 }) },
-};
-
-/** Before media loads the duration is 0 — check it degrades cleanly. */
-export const NoMediaLoaded: Story = {
- args: { duration: 0 },
-};
diff --git a/src/components/VideoEditor.stories 2.tsx b/src/components/VideoEditor.stories 2.tsx
deleted file mode 100644
index bcdb0f7de..000000000
--- a/src/components/VideoEditor.stories 2.tsx
+++ /dev/null
@@ -1,41 +0,0 @@
-import type { Meta, StoryObj } from "@storybook/nextjs";
-import VideoEditor from "./VideoEditor";
-
-/**
- * The whole editor shell — the single most valuable snapshot in the project,
- * because it catches layout regressions that individual control stories miss
- * (section spacing, sidebar width, responsive stacking).
- *
- * It renders its pre-upload state here. VideoEditor owns real state via
- * useVideoEditor, but FFmpeg.wasm is lazy-loaded and only touched on export,
- * so mounting it is cheap and side-effect free.
- *
- * The onboarding tour is marked complete before the story renders. Two
- * reasons: it measures its target element and animates a spotlight over a
- * chain of timeouts, so a screenshot taken at an arbitrary moment catches it
- * mid-move and reports a change that is purely timing; and the tour's dimming
- * overlay covers most of the editor, which is precisely what this story exists
- * to show.
- *
- * Interacting further (choosing a file, exporting) needs real media and a
- * ~30 MB WASM download, so those paths are covered by the individual control
- * stories instead of here.
- */
-const meta = {
- title: "Editor/VideoEditor",
- component: VideoEditor,
- parameters: {
- layout: "fullscreen",
- chromatic: { delay: 300 },
- },
- beforeEach: async () => {
- // Matches TOUR_KEY in OnboardingTour.tsx.
- window.localStorage.setItem("reframe_onboarding_complete", "1");
- },
-} satisfies Meta;
-
-export default meta;
-type Story = StoryObj;
-
-/** Initial load: upload prompt plus editor chrome. */
-export const Initial: Story = {};
diff --git a/src/components/__stories__/RecipeHarness 2.tsx b/src/components/__stories__/RecipeHarness 2.tsx
deleted file mode 100644
index da0382744..000000000
--- a/src/components/__stories__/RecipeHarness 2.tsx
+++ /dev/null
@@ -1,45 +0,0 @@
-"use client";
-
-import * as React from "react";
-import { DEFAULT_RECIPE } from "@/lib/constants";
-import type { EditRecipe } from "@/lib/types";
-
-/**
- * Shared story harness for the editor's control components.
- *
- * Every control in src/components takes the same pair of props — the whole
- * `EditRecipe` plus an `onChange(patch)` callback — and renders purely from
- * that state (the `updateRecipe(patch)` pattern described in CLAUDE.md).
- *
- * A story that passed a frozen object and a no-op `onChange` would render, but
- * clicking anything would do nothing, which makes the Storybook docs useless
- * for reviewing behaviour. This holds the recipe in real state so controls are
- * interactive, while still starting from a deterministic snapshot for
- * Chromatic.
- */
-export function RecipeHarness({
- initial,
- children,
-}: {
- initial?: Partial;
- children: (
- recipe: EditRecipe,
- onChange: (patch: Partial) => void
- ) => React.ReactNode;
-}) {
- const [recipe, setRecipe] = React.useState({
- ...DEFAULT_RECIPE,
- ...initial,
- });
-
- const onChange = React.useCallback((patch: Partial) => {
- setRecipe((prev) => ({ ...prev, ...patch }));
- }, []);
-
- return <>{children(recipe, onChange)}>;
-}
-
-/** A deterministic recipe for stories that only need a static value. */
-export function makeRecipe(overrides: Partial = {}): EditRecipe {
- return { ...DEFAULT_RECIPE, ...overrides };
-}
diff --git a/src/components/components.stories 2.tsx b/src/components/components.stories 2.tsx
deleted file mode 100644
index b69ac981f..000000000
--- a/src/components/components.stories 2.tsx
+++ /dev/null
@@ -1,88 +0,0 @@
-import type { Meta, StoryObj } from "@storybook/nextjs";
-import { Card, Button, Input, Badge } from "./components";
-
-/**
- * The shared primitives every other screen is built from. These are the
- * highest-value stories in the project for visual regression: a change to a
- * design token (--bg, --surface, --border, --text, --muted, --accent) shows up
- * here first, and CLAUDE.md calls out renaming those variables as a breaking
- * change. One diff on this page localises the blast radius immediately.
- */
-const meta = {
- title: "Design System/Primitives",
- parameters: { layout: "padded" },
-} satisfies Meta;
-
-export default meta;
-type Story = StoryObj;
-
-/** Every button variant side by side, plus the disabled state. */
-export const Buttons: Story = {
- render: () => (
-
- Export video
- Reset all
- Cancel
-
- Exporting…
-
-
- ),
-};
-
-/** All badge variants — these carry status colour, so contrast matters. */
-export const Badges: Story = {
- render: () => (
-
-
-
-
-
-
- ),
-};
-
-/** Text input, including the focus-visible outline globals.css defines. */
-export const Inputs: Story = {
- render: () => (
-
-
-
-
-
- ),
-};
-
-/** Card container with and without a description and body content. */
-export const Cards: Story = {
- render: () => (
-
-
- 9 : 16 — 1080 × 1920
-
-
-
- ),
-};
-
-/** Everything together, which is how these actually appear in the editor. */
-export const Kitchensink: Story = {
- render: () => (
-
-
- Export
- Reset
- Cancel
-
-
-
-
-
-
-
-
-
-
-
- ),
-};
diff --git a/src/components/ui/BaseButton.stories 2.tsx b/src/components/ui/BaseButton.stories 2.tsx
deleted file mode 100644
index 33b84e995..000000000
--- a/src/components/ui/BaseButton.stories 2.tsx
+++ /dev/null
@@ -1,65 +0,0 @@
-import type { Meta, StoryObj } from "@storybook/nextjs";
-import BaseButton from "./BaseButton";
-
-/**
- * The shared button used across the editor. Four variants times four sizes is
- * exactly the kind of combinatorial surface that regresses silently, so the
- * matrix story below captures all of it in a single snapshot.
- */
-const meta = {
- title: "Design System/BaseButton",
- component: BaseButton,
- parameters: { layout: "padded" },
-} satisfies Meta;
-
-export default meta;
-type Story = StoryObj;
-
-export const Primary: Story = {
- args: { variant: "primary", children: "Export video" },
-};
-
-export const Secondary: Story = {
- args: { variant: "secondary", children: "Reset all" },
-};
-
-export const Ghost: Story = {
- args: { variant: "ghost", children: "Cancel" },
-};
-
-export const Outline: Story = {
- args: { variant: "outline", children: "Learn more" },
-};
-
-/** Marked active — used for selected presets and toggles. */
-export const Active: Story = {
- args: { variant: "secondary", active: true, children: "9 : 16" },
-};
-
-export const Disabled: Story = {
- args: { variant: "primary", disabled: true, children: "Exporting…" },
-};
-
-/** Every variant/size combination in one snapshot. */
-export const Matrix: Story = {
- render: () => {
- const variants = ["primary", "secondary", "ghost", "outline"] as const;
- const sizes = ["sm", "md", "lg", "xl"] as const;
- return (
-
- {variants.map((variant) => (
-
-
- {variant}
-
- {sizes.map((size) => (
-
- {size}
-
- ))}
-
- ))}
-
- );
- },
-};
diff --git a/src/components/ui/Skeleton.stories 2.tsx b/src/components/ui/Skeleton.stories 2.tsx
deleted file mode 100644
index 06239301b..000000000
--- a/src/components/ui/Skeleton.stories 2.tsx
+++ /dev/null
@@ -1,31 +0,0 @@
-import type { Meta, StoryObj } from "@storybook/nextjs";
-import Skeleton from "./Skeleton";
-
-/**
- * Loading placeholder. Its shimmer is a CSS animation, which .storybook/preview.css
- * freezes so Chromatic captures a settled frame instead of a random one.
- */
-const meta = {
- title: "Design System/Skeleton",
- component: Skeleton,
- parameters: { layout: "padded" },
-} satisfies Meta;
-
-export default meta;
-type Story = StoryObj;
-
-export const Line: Story = { args: { className: "h-4 w-64" } };
-
-export const Block: Story = { args: { className: "h-32 w-full max-w-md" } };
-
-/** A realistic cluster, as used while thumbnails load. */
-export const Group: Story = {
- render: () => (
-
-
-
-
-
-
- ),
-};
diff --git a/src/lib/editorPersistence 2.ts b/src/lib/editorPersistence 2.ts
deleted file mode 100644
index 1abc8ba67..000000000
--- a/src/lib/editorPersistence 2.ts
+++ /dev/null
@@ -1,131 +0,0 @@
-import { DEFAULT_RECIPE } from "@/lib/constants";
-import { EditRecipe, OverlayPosition, isValidRecipe, TextOverlay } from "@/lib/types";
-import { generateTextOverlayId } from "@/lib/text-overlay";
-
-export const RECIPE_STORAGE_KEY = "reframe:recipe";
-export const LEGACY_SETTINGS_KEY = "reframe-settings";
-export const EDITOR_STATE_KEY = "editorState";
-export const SOUND_PREF_KEY = "soundOnCompletion";
-
-export interface OverlayEditorState {
- overlayPosition?: OverlayPosition;
- overlaySize?: number;
- overlayOpacity?: number;
-}
-
-function clamp(value: number, min: number, max: number): number {
- return Math.min(Math.max(value, min), max);
-}
-
-const VALID_FONT_WEIGHTS = ["normal", "bold", "900"];
-
-function sanitizeTextOverlay(overlay: any): TextOverlay {
- return {
- id: typeof overlay.id === "string" ? overlay.id : generateTextOverlayId(),
- text: typeof overlay.text === "string" ? overlay.text.slice(0, 500) : "",
- x: typeof overlay.x === "number" && isFinite(overlay.x) ? clamp(overlay.x, 0, 100) : 50,
- y: typeof overlay.y === "number" && isFinite(overlay.y) ? clamp(overlay.y, 0, 100) : 20,
- fontSize: typeof overlay.fontSize === "number" && isFinite(overlay.fontSize) ? clamp(overlay.fontSize, 12, 120) : 48,
- color: typeof overlay.color === "string" && /^#[0-9A-Fa-f]{6}$/.test(overlay.color) ? overlay.color : "#ffffff",
- fontWeight: VALID_FONT_WEIGHTS.includes(overlay.fontWeight) ? (overlay.fontWeight as "normal" | "bold" | "900") : "normal",
- fontFamily: typeof overlay.fontFamily === "string" ? overlay.fontFamily : "Arial",
- fontPath: typeof overlay.fontPath === "string" ? overlay.fontPath : undefined,
- };
-}
-
-export function migrateRecipe(recipe: Partial): EditRecipe {
- const rotateValue = [0, 90, 180, 270].includes(recipe.rotate as any) ? (recipe.rotate as 0 | 90 | 180 | 270) : DEFAULT_RECIPE.rotate;
-
- return {
- ...DEFAULT_RECIPE,
- ...recipe,
- quality: typeof recipe.quality === "number" ? clamp(recipe.quality, 18, 30) : DEFAULT_RECIPE.quality,
- speed: typeof recipe.speed === "number" ? clamp(recipe.speed, 0.25, 4) : DEFAULT_RECIPE.speed,
- brightness: typeof recipe.brightness === "number" ? clamp(recipe.brightness, -1, 1) : DEFAULT_RECIPE.brightness,
- contrast: typeof recipe.contrast === "number" ? clamp(recipe.contrast, 0, 2) : DEFAULT_RECIPE.contrast,
- saturation: typeof recipe.saturation === "number" ? clamp(recipe.saturation, 0, 3) : DEFAULT_RECIPE.saturation,
- customWidth: typeof recipe.customWidth === "number" ? clamp(recipe.customWidth, 16, 7680) : DEFAULT_RECIPE.customWidth,
- customHeight: typeof recipe.customHeight === "number" ? clamp(recipe.customHeight, 16, 7680) : DEFAULT_RECIPE.customHeight,
- trimStart: typeof recipe.trimStart === "number" ? Math.max(0, recipe.trimStart) : DEFAULT_RECIPE.trimStart,
- rotate: rotateValue,
- textOverlays: Array.isArray(recipe.textOverlays) ? recipe.textOverlays.map(sanitizeTextOverlay) : [],
- };
-}
-
-export function getStoredSoundPreference(storage: Pick): boolean {
- return storage.getItem(SOUND_PREF_KEY) === "true";
-}
-
-export function loadPersistedRecipe(
- storage: Pick,
- fallback: EditRecipe
-): EditRecipe {
- const raw = storage.getItem(RECIPE_STORAGE_KEY);
- if (raw) {
- try {
- const parsed = JSON.parse(raw);
- if (isValidRecipe(parsed)) {
- return parsed;
- }
- } catch {
- // fall through to legacy state
- }
- }
-
- const legacy = storage.getItem(LEGACY_SETTINGS_KEY);
- if (legacy) {
- try {
- const parsed = JSON.parse(legacy);
- return migrateRecipe({
- preset: parsed.preset ?? fallback.preset,
- quality: parsed.quality ?? fallback.quality,
- speed: parsed.speed ?? fallback.speed,
- customWidth: Number.isFinite(Number(parsed.customWidth)) ? Number(parsed.customWidth) : fallback.customWidth,
- customHeight: Number.isFinite(Number(parsed.customHeight)) ? Number(parsed.customHeight) : fallback.customHeight,
- });
- } catch {
- // ignore malformed legacy data
- }
- }
-
- return fallback;
-}
-
-export function loadOverlayState(
- storage: Pick,
- fallback: OverlayEditorState
-): OverlayEditorState {
- const raw = storage.getItem(EDITOR_STATE_KEY);
- if (!raw) return fallback;
-
- try {
- const parsed = JSON.parse(raw) as OverlayEditorState & { recipe?: unknown };
- return {
- overlayPosition: typeof parsed.overlayPosition === "string" ? parsed.overlayPosition : fallback.overlayPosition,
- overlaySize: typeof parsed.overlaySize === "number" ? parsed.overlaySize : fallback.overlaySize,
- overlayOpacity: typeof parsed.overlayOpacity === "number" ? parsed.overlayOpacity : fallback.overlayOpacity,
- };
- } catch {
- return fallback;
- }
-}
-
-export function persistRecipe(storage: Pick, recipe: EditRecipe) {
- storage.setItem(RECIPE_STORAGE_KEY, JSON.stringify(recipe));
- storage.removeItem(LEGACY_SETTINGS_KEY);
-}
-
-export function persistSoundPreference(storage: Pick, soundOnCompletion: boolean) {
- storage.setItem(SOUND_PREF_KEY, String(soundOnCompletion));
-}
-
-export function persistOverlayState(storage: Pick, overlay: OverlayEditorState) {
- storage.setItem(
- EDITOR_STATE_KEY,
- JSON.stringify({
- overlayPosition: overlay.overlayPosition,
- overlaySize: overlay.overlaySize,
- overlayOpacity: overlay.overlayOpacity,
- })
- );
-}
diff --git a/src/lib/lottie/trail-loading 2.json b/src/lib/lottie/trail-loading 2.json
deleted file mode 100644
index 84f09c286..000000000
--- a/src/lib/lottie/trail-loading 2.json
+++ /dev/null
@@ -1 +0,0 @@
-{"v":"4.6.8","fr":60,"ip":0,"op":106,"w":500,"h":500,"nm":"Comp 1","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":2,"ty":4,"nm":"Shape Layer 5","ks":{"o":{"a":0,"k":100},"r":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"n":["0p667_1_0p333_0"],"t":20,"s":[0],"e":[360]},{"t":110}]},"p":{"a":0,"k":[251,250,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":0,"k":[100,100,100]}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[10,10]},"p":{"a":0,"k":[0,-100]},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse"},{"ty":"st","c":{"a":0,"k":[0.8706,0.3255,0.298,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":0},"lc":1,"lj":1,"ml":4,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke"},{"ty":"fl","c":{"a":0,"k":[1,0.05,0,1]},"o":{"a":0,"k":100},"r":1,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill"},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"ix":1,"mn":"ADBE Vector Group"}],"ip":20,"op":620,"st":20,"bm":0,"sr":1},{"ddd":0,"ind":3,"ty":4,"nm":"Shape Layer 4","ks":{"o":{"a":0,"k":100},"r":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"n":["0p667_1_0p333_0"],"t":15,"s":[0],"e":[360]},{"t":105}]},"p":{"a":0,"k":[251,250,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":0,"k":[100,100,100]}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[20,20]},"p":{"a":0,"k":[0,-100]},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse"},{"ty":"st","c":{"a":0,"k":[0.8706,0.3255,0.298,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":0},"lc":1,"lj":1,"ml":4,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke"},{"ty":"fl","c":{"a":0,"k":[1,0.05,0,1]},"o":{"a":0,"k":100},"r":1,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill"},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"ix":1,"mn":"ADBE Vector Group"}],"ip":15,"op":615,"st":15,"bm":0,"sr":1},{"ddd":0,"ind":4,"ty":4,"nm":"Shape Layer 3","ks":{"o":{"a":0,"k":100},"r":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"n":["0p667_1_0p333_0"],"t":10,"s":[0],"e":[360]},{"t":100}]},"p":{"a":0,"k":[251,250,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":0,"k":[100,100,100]}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[30,30]},"p":{"a":0,"k":[0,-100]},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse"},{"ty":"st","c":{"a":0,"k":[0.8706,0.3255,0.298,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":0},"lc":1,"lj":1,"ml":4,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke"},{"ty":"fl","c":{"a":0,"k":[1,0.05,0,1]},"o":{"a":0,"k":100},"r":1,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill"},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"ix":1,"mn":"ADBE Vector Group"}],"ip":10,"op":610,"st":10,"bm":0,"sr":1},{"ddd":0,"ind":5,"ty":4,"nm":"Shape Layer 2","ks":{"o":{"a":0,"k":100},"r":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"n":["0p667_1_0p333_0"],"t":5,"s":[0],"e":[360]},{"t":95}]},"p":{"a":0,"k":[251,250,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":0,"k":[100,100,100]}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[40,40]},"p":{"a":0,"k":[0,-100]},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse"},{"ty":"st","c":{"a":0,"k":[0.8706,0.3255,0.298,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":0},"lc":1,"lj":1,"ml":4,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke"},{"ty":"fl","c":{"a":0,"k":[1,0.05,0,1]},"o":{"a":0,"k":100},"r":1,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill"},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"ix":1,"mn":"ADBE Vector Group"}],"ip":5,"op":605,"st":5,"bm":0,"sr":1},{"ddd":0,"ind":6,"ty":4,"nm":"Shape Layer 1","ks":{"o":{"a":0,"k":100},"r":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"n":["0p667_1_0p333_0"],"t":0,"s":[0],"e":[360]},{"t":90}]},"p":{"a":0,"k":[250,250,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":0,"k":[100,100,100]}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":1,"k":[{"i":{"x":[0.667,0.667],"y":[1,1]},"o":{"x":[0.333,0.333],"y":[0,0]},"n":["0p667_1_0p333_0","0p667_1_0p333_0"],"t":0,"s":[50,50],"e":[40,40]},{"i":{"x":[0.667,0.667],"y":[1,1]},"o":{"x":[0.333,0.333],"y":[0,0]},"n":["0p667_1_0p333_0","0p667_1_0p333_0"],"t":84,"s":[40,40],"e":[50,50]},{"t":100}]},"p":{"a":0,"k":[0,-100]},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse"},{"ty":"st","c":{"a":0,"k":[0.8706,0.3255,0.298,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":0},"lc":1,"lj":1,"ml":4,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke"},{"ty":"fl","c":{"a":0,"k":[1,0.05,0,1]},"o":{"a":0,"k":100},"r":1,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill"},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"ix":1,"mn":"ADBE Vector Group"}],"ip":0,"op":600,"st":0,"bm":0,"sr":1}]}
\ No newline at end of file
diff --git a/src/lib/overlayGraph 2.ts b/src/lib/overlayGraph 2.ts
deleted file mode 100644
index 6e06d2bf5..000000000
--- a/src/lib/overlayGraph 2.ts
+++ /dev/null
@@ -1,309 +0,0 @@
-/**
- * FFmpeg Overlay Filter Graph Generator
- *
- * Phase 1 MVP: Dynamic filter_complex generation for multi-track compositing
- * Supports up to 2 simultaneous video tracks with scaling, positioning, and opacity.
- *
- * Example output for 2-track PiP:
- * [0:v]scale=1920:1080[bg];
- * [1:v]scale=720:-1[pip];
- * [bg][pip]overlay=600:400:alpha=linear[outv]
- */
-
-import { TimelineTrack } from "./types";
-import { getBackgroundTrack, getOverlayTracks } from "./timeline";
-
-interface OverlayPosition {
- x: number;
- y: number;
-}
-
-interface ScaledTrackInfo {
- inputIndex: number;
- track: TimelineTrack;
- width: number;
- height: number;
- position: OverlayPosition;
- opacity: number;
-}
-
-/**
- * Determine output position for an overlay
- * If track.position is auto (-1), center the overlay
- */
-function resolveOverlayPosition(
- track: TimelineTrack,
- overlayWidth: number,
- overlayHeight: number,
- canvasWidth: number,
- canvasHeight: number
-): OverlayPosition {
- let x = track.position.x;
- let y = track.position.y;
-
- // Auto-center logic
- if (x === -1) {
- x = Math.max(0, (canvasWidth - overlayWidth) / 2);
- }
- if (y === -1) {
- y = Math.max(0, (canvasHeight - overlayHeight) / 2);
- }
-
- return { x: Math.round(x), y: Math.round(y) };
-}
-
-/**
- * Calculate scaled dimensions maintaining aspect ratio
- * If height is -1, scale based on width proportionally
- */
-function calculateScaledDimensions(
- sourceWidth: number,
- sourceHeight: number,
- targetWidth: number,
- targetHeight: number = -1
-): { width: number; height: number } {
- if (targetHeight === -1) {
- // Scale width and derive height
- const scale = targetWidth / sourceWidth;
- return {
- width: Math.round(targetWidth),
- height: Math.round(sourceHeight * scale),
- };
- }
-
- return {
- width: Math.round(targetWidth),
- height: Math.round(targetHeight),
- };
-}
-
-/**
- * Build a scale filter spec for a single track
- * Format: [Nin]scale=W:H[Nout]
- */
-function buildScaleFilter(
- inputIndex: number,
- width: number,
- height: number,
- labelSuffix: string
-): string {
- // Use -2 for height to preserve aspect ratio
- const heightSpec = height === -1 ? "-1" : String(height);
- return `[${inputIndex}:v]scale=${width}:${heightSpec}[${labelSuffix}]`;
-}
-
-/**
- * Build an overlay filter spec combining two video streams
- * Format: [base][overlay]overlay=x:y:alpha=linear[out]
- */
-function buildOverlayFilter(
- baseLabel: string,
- overlayLabel: string,
- x: number,
- y: number,
- opacityPercent: number,
- outputLabel: string
-): string {
- const alpha = opacityPercent < 100 ? `:alpha=linear` : "";
- const alphaStr = opacityPercent < 100
- ? `[${overlayLabel}]format=rgba,colorchannelmixer=aa=${(opacityPercent / 100).toFixed(2)}[${overlayLabel}_a]`
- : "";
-
- const overlayLabelFinal = opacityPercent < 100 ? `${overlayLabel}_a` : overlayLabel;
-
- if (alphaStr) {
- return [alphaStr, `[${baseLabel}][${overlayLabelFinal}]overlay=${x}:${y}${alpha}[${outputLabel}]`].join(
- "; "
- );
- }
-
- return `[${baseLabel}][${overlayLabelFinal}]overlay=${x}:${y}${alpha}[${outputLabel}]`;
-}
-
-/**
- * Main entry point: Generate FFmpeg filter_complex for multi-track compositing
- *
- * @param tracks - Timeline tracks sorted by z-index (background first)
- * @param canvasWidth - Output canvas width
- * @param canvasHeight - Output canvas height
- * @returns Filter complex string ready for FFmpeg -filter_complex argument
- */
-export function buildOverlayFilterGraph(
- tracks: TimelineTrack[],
- canvasWidth: number,
- canvasHeight: number
-): { filterComplex: string; videoOutput: string } {
- // Get background and overlay tracks in proper order
- const bgTrack = getBackgroundTrack(tracks);
- const overlayTracks = getOverlayTracks(tracks);
-
- // Phase 1 MVP: Only support up to 2 tracks
- if (!bgTrack) {
- // No video tracks; return pass-through
- return { filterComplex: "", videoOutput: "[0:v]" };
- }
-
- if (overlayTracks.length === 0) {
- // Only background track; scale if needed
- const scaledDims = calculateScaledDimensions(
- 1920, // Assume 1920 for now; this should come from metadata
- 1080,
- canvasWidth,
- canvasHeight
- );
-
- const scaleFilter = buildScaleFilter(0, scaledDims.width, scaledDims.height, "bg");
- return { filterComplex: scaleFilter, videoOutput: "[bg]" };
- }
-
- // 2-track compositing (Background + 1 Overlay)
- const parts: string[] = [];
- let currentOutput = "bg";
- let inputIdx = 0;
-
- // Scale background to canvas size
- const bgScaled = calculateScaledDimensions(1920, 1080, canvasWidth, canvasHeight);
- parts.push(buildScaleFilter(inputIdx, bgScaled.width, bgScaled.height, currentOutput));
- inputIdx++;
-
- // Process each overlay track
- overlayTracks.forEach((track, overlayIdx) => {
- const overlayLabel = `overlay${overlayIdx}`;
-
- // Scale overlay (typically smaller for PiP)
- const overlayWidth = Math.round(canvasWidth * (track.scale || 1.0));
- const overlayHeight = -1; // Preserve aspect ratio
- const overlayScaled = calculateScaledDimensions(
- 1920,
- 1080,
- overlayWidth,
- overlayHeight
- );
-
- parts.push(
- buildScaleFilter(inputIdx, overlayScaled.width, overlayScaled.height, overlayLabel)
- );
-
- // Calculate overlay position (with auto-center if needed)
- const position = resolveOverlayPosition(
- track,
- overlayScaled.width,
- overlayScaled.height,
- canvasWidth,
- canvasHeight
- );
-
- // Compose overlay onto background/previous output
- const outLabel = overlayIdx === overlayTracks.length - 1 ? "out" : `layer${overlayIdx}`;
- parts.push(
- buildOverlayFilter(
- currentOutput,
- overlayLabel,
- position.x,
- position.y,
- track.opacity ?? 100,
- outLabel
- )
- );
-
- currentOutput = outLabel;
- inputIdx++;
- });
-
- const filterComplex = parts.join(";");
- const videoOutput = `[${currentOutput}]`;
-
- return { filterComplex, videoOutput };
-}
-
-/**
- * Validate overlay graph configuration
- * Checks for common issues before passing to FFmpeg
- */
-export function validateOverlayGraph(
- tracks: TimelineTrack[],
- canvasWidth: number,
- canvasHeight: number
-): { valid: boolean; errors: string[] } {
- const errors: string[] = [];
-
- if (canvasWidth < 16 || canvasWidth > 7680) {
- errors.push(`Canvas width out of range: ${canvasWidth}`);
- }
- if (canvasHeight < 16 || canvasHeight > 7680) {
- errors.push(`Canvas height out of range: ${canvasHeight}`);
- }
-
- // Check for more than 2 visible video tracks
- const visibleVideoCount = tracks.filter(
- t => t.visible && t.type === "video" && t.source
- ).length;
- if (visibleVideoCount > 2) {
- errors.push(
- `Phase 1 MVP: Max 2 visible video tracks, found ${visibleVideoCount}`
- );
- }
-
- // Check track validity
- tracks.forEach((track, idx) => {
- if (!track.id) {
- errors.push(`Track ${idx} missing ID`);
- }
- if (track.opacity < 0 || track.opacity > 100) {
- errors.push(
- `Track ${idx} opacity out of range: ${track.opacity} (expected 0-100)`
- );
- }
- if (track.scale <= 0) {
- errors.push(`Track ${idx} invalid scale: ${track.scale}`);
- }
- });
-
- return { valid: errors.length === 0, errors };
-}
-
-/**
- * Generate example overlay graph for documentation/testing
- */
-export function generateExampleOverlayGraphs(): Record {
- // Mock a 2-track PiP scenario
- const mockBgTrack: TimelineTrack = {
- id: "bg",
- type: "video",
- source: null,
- startTime: 0,
- duration: 10,
- zIndex: 0,
- visible: true,
- opacity: 100,
- position: { x: 0, y: 0 },
- scale: 1.0,
- rotation: 0,
- };
-
- const mockPipTrack: TimelineTrack = {
- id: "pip",
- type: "video",
- source: null,
- startTime: 0,
- duration: 5,
- zIndex: 1,
- visible: true,
- opacity: 100,
- position: { x: -1, y: -1 }, // auto-center
- scale: 0.4,
- rotation: 0,
- };
-
- const { filterComplex } = buildOverlayFilterGraph(
- [mockBgTrack, mockPipTrack],
- 1920,
- 1080
- );
-
- return {
- "2-track-pip": filterComplex,
- "example-background-track": mockBgTrack.id,
- "example-overlay-track": mockPipTrack.id,
- };
-}
diff --git a/src/lib/sessionDB 2.ts b/src/lib/sessionDB 2.ts
deleted file mode 100644
index 57f0a1d7c..000000000
--- a/src/lib/sessionDB 2.ts
+++ /dev/null
@@ -1,82 +0,0 @@
-const DB_NAME = "reframe-session";
-const STORE_NAME = "files";
-
-export async function saveSessionFile(file: File): Promise {
- return new Promise((resolve, reject) => {
- try {
- const request = indexedDB.open(DB_NAME, 1);
- request.onupgradeneeded = () => {
- if (!request.result.objectStoreNames.contains(STORE_NAME)) {
- request.result.createObjectStore(STORE_NAME);
- }
- };
- request.onsuccess = () => {
- const db = request.result;
- const tx = db.transaction(STORE_NAME, "readwrite");
- tx.objectStore(STORE_NAME).put(file, "currentVideo");
- tx.oncomplete = () => { db.close(); resolve(); };
- tx.onerror = () => reject(tx.error);
- };
- request.onerror = () => reject(request.error);
- } catch (e) {
- reject(e);
- }
- });
-}
-
-export async function loadSessionFile(): Promise {
- return new Promise((resolve, reject) => {
- try {
- const request = indexedDB.open(DB_NAME, 1);
- request.onupgradeneeded = () => {
- if (!request.result.objectStoreNames.contains(STORE_NAME)) {
- request.result.createObjectStore(STORE_NAME);
- }
- };
- request.onsuccess = () => {
- const db = request.result;
- if (!db.objectStoreNames.contains(STORE_NAME)) {
- db.close();
- return resolve(null);
- }
- const tx = db.transaction(STORE_NAME, "readonly");
- const getReq = tx.objectStore(STORE_NAME).get("currentVideo");
- getReq.onsuccess = () => {
- db.close();
- resolve(getReq.result || null);
- };
- getReq.onerror = () => reject(getReq.error);
- };
- request.onerror = () => reject(request.error);
- } catch (e) {
- reject(e);
- }
- });
-}
-
-export async function clearSessionFile(): Promise {
- return new Promise((resolve, reject) => {
- try {
- const request = indexedDB.open(DB_NAME, 1);
- request.onupgradeneeded = () => {
- if (!request.result.objectStoreNames.contains(STORE_NAME)) {
- request.result.createObjectStore(STORE_NAME);
- }
- };
- request.onsuccess = () => {
- const db = request.result;
- if (!db.objectStoreNames.contains(STORE_NAME)) {
- db.close();
- return resolve();
- }
- const tx = db.transaction(STORE_NAME, "readwrite");
- tx.objectStore(STORE_NAME).clear();
- tx.oncomplete = () => { db.close(); resolve(); };
- tx.onerror = () => reject(tx.error);
- };
- request.onerror = () => reject(request.error);
- } catch (e) {
- reject(e);
- }
- });
-}
diff --git a/src/lib/tests/editorPersistence.test 2.ts b/src/lib/tests/editorPersistence.test 2.ts
deleted file mode 100644
index e93ec704f..000000000
--- a/src/lib/tests/editorPersistence.test 2.ts
+++ /dev/null
@@ -1,90 +0,0 @@
-import { describe, expect, it, beforeEach } from "vitest";
-import { DEFAULT_RECIPE } from "@/lib/constants";
-import {
- EDITOR_STATE_KEY,
- LEGACY_SETTINGS_KEY,
- RECIPE_STORAGE_KEY,
- loadOverlayState,
- loadPersistedRecipe,
- persistOverlayState,
- persistRecipe,
- persistSoundPreference,
-} from "@/lib/editorPersistence";
-import { EditRecipe } from "@/lib/types";
-
-describe("editorPersistence", () => {
- beforeEach(() => {
- localStorage.clear();
- });
-
- it("loads the canonical recipe key first", () => {
- const recipe: EditRecipe = { ...DEFAULT_RECIPE, quality: 28, version: DEFAULT_RECIPE.version };
- localStorage.setItem(RECIPE_STORAGE_KEY, JSON.stringify(recipe));
- localStorage.setItem(LEGACY_SETTINGS_KEY, JSON.stringify({ quality: 18 }));
-
- expect(loadPersistedRecipe(localStorage, DEFAULT_RECIPE)).toEqual(recipe);
- });
-
- it("migrates legacy settings when the canonical key is missing", () => {
- localStorage.setItem(
- LEGACY_SETTINGS_KEY,
- JSON.stringify({ preset: "custom", quality: 19, speed: 1.5, customWidth: 1280, customHeight: 720 })
- );
-
- const loaded = loadPersistedRecipe(localStorage, DEFAULT_RECIPE);
-
- expect(loaded.preset).toBe("custom");
- expect(loaded.quality).toBe(19);
- expect(loaded.speed).toBe(1.5);
- expect(loaded.customWidth).toBe(1280);
- expect(loaded.customHeight).toBe(720);
- });
-
- it("persists only the canonical recipe key and clears legacy recipe settings", () => {
- persistRecipe(localStorage, DEFAULT_RECIPE);
-
- expect(localStorage.getItem(RECIPE_STORAGE_KEY)).toBe(JSON.stringify(DEFAULT_RECIPE));
- expect(localStorage.getItem(LEGACY_SETTINGS_KEY)).toBeNull();
- });
-
- it("persists overlay state without recipe data", () => {
- persistOverlayState(localStorage, {
- overlayPosition: "top-left",
- overlaySize: 120,
- overlayOpacity: 85,
- });
-
- expect(JSON.parse(localStorage.getItem(EDITOR_STATE_KEY) ?? "{}")).toEqual({
- overlayPosition: "top-left",
- overlaySize: 120,
- overlayOpacity: 85,
- });
- });
-
- it("reads overlay state while ignoring any legacy embedded recipe", () => {
- localStorage.setItem(
- EDITOR_STATE_KEY,
- JSON.stringify({
- recipe: { quality: 12 },
- overlayPosition: "bottom-left",
- overlaySize: 155,
- overlayOpacity: 72,
- })
- );
-
- expect(loadOverlayState(localStorage, {
- overlayPosition: "bottom-right",
- overlaySize: 150,
- overlayOpacity: 100,
- })).toEqual({
- overlayPosition: "bottom-left",
- overlaySize: 155,
- overlayOpacity: 72,
- });
- });
-
- it("persists sound preference separately", () => {
- persistSoundPreference(localStorage, true);
- expect(localStorage.getItem("soundOnCompletion")).toBe("true");
- });
-});
diff --git a/src/lib/tests/overlayGraph.test 2.ts b/src/lib/tests/overlayGraph.test 2.ts
deleted file mode 100644
index 08ebd240f..000000000
--- a/src/lib/tests/overlayGraph.test 2.ts
+++ /dev/null
@@ -1,334 +0,0 @@
-/**
- * Tests for FFmpeg Overlay Graph Generation
- * Phase 1 MVP: Multi-track filter_complex generation
- */
-
-import { describe, it, expect } from "vitest";
-import {
- buildOverlayFilterGraph,
- validateOverlayGraph,
- generateExampleOverlayGraphs,
-} from "@/lib/overlayGraph";
-import { TimelineTrack } from "@/lib/types";
-
-describe("buildOverlayFilterGraph", () => {
- it("should return pass-through for empty track list", () => {
- const { filterComplex, videoOutput } = buildOverlayFilterGraph([], 1920, 1080);
- expect(videoOutput).toBe("[0:v]");
- });
-
- it("should return pass-through for single track with no source", () => {
- const track: TimelineTrack = {
- id: "test",
- type: "video",
- source: null,
- startTime: 0,
- duration: 10,
- zIndex: 0,
- visible: true,
- opacity: 100,
- position: { x: 0, y: 0 },
- scale: 1.0,
- rotation: 0,
- };
-
- const { filterComplex, videoOutput } = buildOverlayFilterGraph([track], 1920, 1080);
- expect(videoOutput).toBe("[0:v]");
- });
-
- it("should scale background track to canvas dimensions", () => {
- const bgTrack: TimelineTrack = {
- id: "bg",
- type: "video",
- source: {} as File,
- startTime: 0,
- duration: 10,
- zIndex: 0,
- visible: true,
- opacity: 100,
- position: { x: 0, y: 0 },
- scale: 1.0,
- rotation: 0,
- };
-
- const { filterComplex } = buildOverlayFilterGraph([bgTrack], 1920, 1080);
- expect(filterComplex).toContain("[0:v]scale=1920:1080[bg]");
- });
-
- it("should compose two video tracks with PiP positioning", () => {
- const bgTrack: TimelineTrack = {
- id: "bg",
- type: "video",
- source: {} as File,
- startTime: 0,
- duration: 10,
- zIndex: 0,
- visible: true,
- opacity: 100,
- position: { x: 0, y: 0 },
- scale: 1.0,
- rotation: 0,
- };
-
- const pipTrack: TimelineTrack = {
- id: "pip",
- type: "video",
- source: {} as File,
- startTime: 0,
- duration: 5,
- zIndex: 1,
- visible: true,
- opacity: 100,
- position: { x: 50, y: 50 }, // Manual positioning
- scale: 0.4,
- rotation: 0,
- };
-
- const { filterComplex, videoOutput } = buildOverlayFilterGraph(
- [bgTrack, pipTrack],
- 1920,
- 1080
- );
-
- expect(filterComplex).toContain("[0:v]scale=1920:1080[bg]");
- // Scale: 0.4 * 1920 = 768, height scales proportionally: 768 * (1080/1920) = 432
- expect(filterComplex).toContain("[1:v]scale=768:432[overlay0]");
- expect(filterComplex).toContain("[bg][overlay0]overlay=50:50");
- expect(videoOutput).toBe("[out]");
- });
-
- it("should apply opacity to overlay tracks", () => {
- const bgTrack: TimelineTrack = {
- id: "bg",
- type: "video",
- source: {} as File,
- startTime: 0,
- duration: 10,
- zIndex: 0,
- visible: true,
- opacity: 100,
- position: { x: 0, y: 0 },
- scale: 1.0,
- rotation: 0,
- };
-
- const pipTrack: TimelineTrack = {
- id: "pip",
- type: "video",
- source: {} as File,
- startTime: 0,
- duration: 5,
- zIndex: 1,
- visible: true,
- opacity: 50, // 50% opacity
- position: { x: -1, y: -1 }, // auto-center
- scale: 0.4,
- rotation: 0,
- };
-
- const { filterComplex } = buildOverlayFilterGraph(
- [bgTrack, pipTrack],
- 1920,
- 1080
- );
-
- // Should include opacity/alpha handling
- expect(filterComplex).toContain("colorchannelmixer");
- expect(filterComplex).toContain("0.50"); // 50% = 0.50
- });
-
- it("should auto-center overlay when position is -1", () => {
- const bgTrack: TimelineTrack = {
- id: "bg",
- type: "video",
- source: {} as File,
- startTime: 0,
- duration: 10,
- zIndex: 0,
- visible: true,
- opacity: 100,
- position: { x: 0, y: 0 },
- scale: 1.0,
- rotation: 0,
- };
-
- const pipTrack: TimelineTrack = {
- id: "pip",
- type: "video",
- source: {} as File,
- startTime: 0,
- duration: 5,
- zIndex: 1,
- visible: true,
- opacity: 100,
- position: { x: -1, y: -1 }, // auto-center
- scale: 0.3,
- rotation: 0,
- };
-
- const { filterComplex } = buildOverlayFilterGraph(
- [bgTrack, pipTrack],
- 1920,
- 1080
- );
-
- // Scale: 0.3 * 1920 = 576
- // Center X: (1920 - 576) / 2 = 672
- // Center Y: (1080 - scaled_height) / 2
- expect(filterComplex).toContain("overlay=672:");
- });
-
- it("should hide invisible tracks", () => {
- const bgTrack: TimelineTrack = {
- id: "bg",
- type: "video",
- source: {} as File,
- startTime: 0,
- duration: 10,
- zIndex: 0,
- visible: true,
- opacity: 100,
- position: { x: 0, y: 0 },
- scale: 1.0,
- rotation: 0,
- };
-
- const hiddenTrack: TimelineTrack = {
- id: "hidden",
- type: "video",
- source: {} as File,
- startTime: 0,
- duration: 5,
- zIndex: 1,
- visible: false, // Hidden
- opacity: 100,
- position: { x: 50, y: 50 },
- scale: 0.4,
- rotation: 0,
- };
-
- const { filterComplex, videoOutput } = buildOverlayFilterGraph(
- [bgTrack, hiddenTrack],
- 1920,
- 1080
- );
-
- // Hidden track should not be in filter
- expect(filterComplex).not.toContain("hidden");
- expect(videoOutput).toBe("[bg]");
- });
-});
-
-describe("validateOverlayGraph", () => {
- it("should validate correct graph configuration", () => {
- const tracks: TimelineTrack[] = [
- {
- id: "bg",
- type: "video",
- source: {} as File,
- startTime: 0,
- duration: 10,
- zIndex: 0,
- visible: true,
- opacity: 100,
- position: { x: 0, y: 0 },
- scale: 1.0,
- rotation: 0,
- },
- ];
-
- const result = validateOverlayGraph(tracks, 1920, 1080);
- expect(result.valid).toBe(true);
- expect(result.errors).toHaveLength(0);
- });
-
- it("should reject invalid canvas dimensions", () => {
- const tracks: TimelineTrack[] = [];
-
- const result = validateOverlayGraph(tracks, 10, 1080); // Width too small
- expect(result.valid).toBe(false);
- expect(result.errors.some(e => e.includes("Canvas width"))).toBe(true);
- });
-
- it("should enforce max 2 video tracks limit", () => {
- const tracks: TimelineTrack[] = [
- {
- id: "1",
- type: "video",
- source: {} as File,
- startTime: 0,
- duration: 10,
- zIndex: 0,
- visible: true,
- opacity: 100,
- position: { x: 0, y: 0 },
- scale: 1.0,
- rotation: 0,
- },
- {
- id: "2",
- type: "video",
- source: {} as File,
- startTime: 0,
- duration: 10,
- zIndex: 1,
- visible: true,
- opacity: 100,
- position: { x: 0, y: 0 },
- scale: 1.0,
- rotation: 0,
- },
- {
- id: "3",
- type: "video",
- source: {} as File,
- startTime: 0,
- duration: 10,
- zIndex: 2,
- visible: true,
- opacity: 100,
- position: { x: 0, y: 0 },
- scale: 1.0,
- rotation: 0,
- },
- ];
-
- const result = validateOverlayGraph(tracks, 1920, 1080);
- expect(result.valid).toBe(false);
- expect(result.errors.some(e => e.includes("Max 2 visible"))).toBe(true);
- });
-
- it("should check for invalid opacity", () => {
- const tracks: TimelineTrack[] = [
- {
- id: "bg",
- type: "video",
- source: {} as File,
- startTime: 0,
- duration: 10,
- zIndex: 0,
- visible: true,
- opacity: 150, // Invalid
- position: { x: 0, y: 0 },
- scale: 1.0,
- rotation: 0,
- },
- ];
-
- const result = validateOverlayGraph(tracks, 1920, 1080);
- expect(result.valid).toBe(false);
- expect(result.errors.some(e => e.includes("opacity"))).toBe(true);
- });
-});
-
-describe("generateExampleOverlayGraphs", () => {
- it("should generate example graphs", () => {
- const examples = generateExampleOverlayGraphs();
- expect(examples).toHaveProperty("2-track-pip");
- // The filter complex will be generated (even if empty for pass-through)
- expect(typeof examples["2-track-pip"]).toBe("string");
- // Example tracks should be listed
- expect(examples).toHaveProperty("example-background-track");
- expect(examples).toHaveProperty("example-overlay-track");
- });
-});
diff --git a/src/lib/tests/timeline.test 2.ts b/src/lib/tests/timeline.test 2.ts
deleted file mode 100644
index 9df92167e..000000000
--- a/src/lib/tests/timeline.test 2.ts
+++ /dev/null
@@ -1,377 +0,0 @@
-/**
- * Tests for Timeline Track Management
- * Phase 1 MVP: Multi-track state operations
- */
-
-import { describe, it, expect } from "vitest";
-import {
- generateTrackId,
- createTimelineTrack,
- createMultiTrackState,
- addTrackToTimeline,
- removeTrackFromTimeline,
- updateTrackInTimeline,
- sortTracksByZIndex,
- getVisibleVideoTracks,
- getBackgroundTrack,
- getOverlayTracks,
- validateMultiTrackState,
- serializeMultiTrackState,
-} from "@/lib/timeline";
-import { TimelineTrack } from "@/lib/types";
-
-describe("Timeline Track Management", () => {
- describe("generateTrackId", () => {
- it("should generate unique IDs", () => {
- const id1 = generateTrackId();
- const id2 = generateTrackId();
- expect(id1).not.toBe(id2);
- expect(id1).toMatch(/^track-\d+-[a-z0-9]+$/);
- });
- });
-
- describe("createTimelineTrack", () => {
- it("should create track with video type and defaults", () => {
- const track = createTimelineTrack("video", null);
-
- expect(track.type).toBe("video");
- expect(track.source).toBeNull();
- expect(track.startTime).toBe(0);
- expect(track.duration).toBe(0);
- expect(track.zIndex).toBe(0);
- expect(track.visible).toBe(true);
- expect(track.opacity).toBe(100);
- expect(track.scale).toBe(1.0);
- expect(track.rotation).toBe(0);
- expect(track.position).toEqual({ x: -1, y: -1 }); // auto-center
- });
-
- it("should create track with custom start time", () => {
- const track = createTimelineTrack("video", null, 5);
- expect(track.startTime).toBe(5);
- });
-
- it("should generate unique ID for each track", () => {
- const track1 = createTimelineTrack("video", null);
- const track2 = createTimelineTrack("video", null);
- expect(track1.id).not.toBe(track2.id);
- });
- });
-
- describe("createMultiTrackState", () => {
- it("should create initial state with empty tracks", () => {
- const state = createMultiTrackState();
-
- expect(state.timelineTracks).toEqual([]);
- expect(state.activeTrackId).toBeNull();
- expect(state.maxActiveTracks).toBe(2); // Phase 1 limit
- });
- });
-
- describe("addTrackToTimeline", () => {
- it("should add track to timeline", () => {
- const state = createMultiTrackState();
- const track = createTimelineTrack("video", null);
-
- const updated = addTrackToTimeline(state, track);
-
- expect(updated.timelineTracks).toHaveLength(1);
- expect(updated.timelineTracks[0]).toBe(track);
- expect(updated.activeTrackId).toBe(track.id);
- });
-
- it("should enforce max 2 video tracks by hiding excess", () => {
- const state = createMultiTrackState();
- const track1 = createTimelineTrack("video", null);
- const track2 = createTimelineTrack("video", null);
- const track3 = createTimelineTrack("video", null);
-
- const s1 = addTrackToTimeline(state, track1);
- const s2 = addTrackToTimeline(s1, track2);
- const s3 = addTrackToTimeline(s2, track3);
-
- expect(s3.timelineTracks).toHaveLength(3);
- expect(s3.timelineTracks[0]!.visible).toBe(true);
- expect(s3.timelineTracks[1]!.visible).toBe(true);
- expect(s3.timelineTracks[2]!.visible).toBe(false); // Excess hidden
- });
-
- it("should allow unlimited image tracks", () => {
- const state = createMultiTrackState();
- const imgTrack1 = createTimelineTrack("image", null);
- const imgTrack2 = createTimelineTrack("image", null);
- const imgTrack3 = createTimelineTrack("image", null);
-
- const s1 = addTrackToTimeline(state, imgTrack1);
- const s2 = addTrackToTimeline(s1, imgTrack2);
- const s3 = addTrackToTimeline(s2, imgTrack3);
-
- // All image tracks should remain visible
- expect(s3.timelineTracks.filter(t => t.visible)).toHaveLength(3);
- });
- });
-
- describe("removeTrackFromTimeline", () => {
- it("should remove track by ID", () => {
- const state = createMultiTrackState();
- const track1 = createTimelineTrack("video", null);
- const track2 = createTimelineTrack("video", null);
-
- const s1 = addTrackToTimeline(state, track1);
- const s2 = addTrackToTimeline(s1, track2);
- const s3 = removeTrackFromTimeline(s2, track1.id);
-
- expect(s3.timelineTracks).toHaveLength(1);
- expect(s3.timelineTracks[0]!.id).toBe(track2.id);
- });
-
- it("should clear activeTrackId if active track is removed", () => {
- const state = createMultiTrackState();
- const track = createTimelineTrack("video", null);
-
- const s1 = addTrackToTimeline(state, track);
- expect(s1.activeTrackId).toBe(track.id);
-
- const s2 = removeTrackFromTimeline(s1, track.id);
- expect(s2.activeTrackId).toBeNull();
- });
-
- it("should reassign activeTrackId to remaining track", () => {
- const state = createMultiTrackState();
- const track1 = createTimelineTrack("video", null);
- const track2 = createTimelineTrack("video", null);
-
- const s1 = addTrackToTimeline(state, track1);
- const s2 = addTrackToTimeline(s1, track2);
- const s3 = removeTrackFromTimeline(s2, track1.id);
-
- expect(s3.activeTrackId).toBe(track2.id);
- });
- });
-
- describe("updateTrackInTimeline", () => {
- it("should update track properties", () => {
- const state = createMultiTrackState();
- const track = createTimelineTrack("video", null);
- const s1 = addTrackToTimeline(state, track);
-
- const s2 = updateTrackInTimeline(s1, track.id, {
- opacity: 50,
- position: { x: 100, y: 200 },
- });
-
- expect(s2.timelineTracks[0]!.opacity).toBe(50);
- expect(s2.timelineTracks[0]!.position).toEqual({ x: 100, y: 200 });
- });
-
- it("should not affect other tracks", () => {
- const state = createMultiTrackState();
- const track1 = createTimelineTrack("video", null);
- const track2 = createTimelineTrack("video", null);
-
- const s1 = addTrackToTimeline(state, track1);
- const s2 = addTrackToTimeline(s1, track2);
- const s3 = updateTrackInTimeline(s2, track1.id, { opacity: 75 });
-
- expect(s3.timelineTracks[0]!.opacity).toBe(75);
- expect(s3.timelineTracks[1]!.opacity).toBe(100); // unchanged
- });
- });
-
- describe("sortTracksByZIndex", () => {
- it("should sort tracks by z-index ascending", () => {
- const track1: TimelineTrack = {
- ...createTimelineTrack("video", null),
- zIndex: 2,
- };
- const track2: TimelineTrack = {
- ...createTimelineTrack("video", null),
- zIndex: 0,
- };
- const track3: TimelineTrack = {
- ...createTimelineTrack("video", null),
- zIndex: 1,
- };
-
- const sorted = sortTracksByZIndex([track1, track2, track3]);
-
- expect(sorted[0]!.zIndex).toBe(0);
- expect(sorted[1]!.zIndex).toBe(1);
- expect(sorted[2]!.zIndex).toBe(2);
- });
-
- it("should not modify original array", () => {
- const tracks = [
- { ...createTimelineTrack("video", null), zIndex: 2 },
- { ...createTimelineTrack("video", null), zIndex: 1 },
- ];
-
- sortTracksByZIndex(tracks);
-
- expect(tracks[0]!.zIndex).toBe(2); // original order preserved
- });
- });
-
- describe("getVisibleVideoTracks", () => {
- it("should return only visible video tracks with source", () => {
- const visible: TimelineTrack = {
- ...createTimelineTrack("video", {} as File),
- visible: true,
- };
- const hidden: TimelineTrack = {
- ...createTimelineTrack("video", {} as File),
- visible: false,
- };
- const noSource: TimelineTrack = {
- ...createTimelineTrack("video", null),
- visible: true,
- };
-
- const filtered = getVisibleVideoTracks([visible, hidden, noSource]);
-
- expect(filtered).toHaveLength(1);
- expect(filtered[0]!.id).toBe(visible.id);
- });
-
- it("should return sorted by z-index", () => {
- const track1: TimelineTrack = {
- ...createTimelineTrack("video", {} as File),
- zIndex: 1,
- visible: true,
- };
- const track2: TimelineTrack = {
- ...createTimelineTrack("video", {} as File),
- zIndex: 0,
- visible: true,
- };
-
- const filtered = getVisibleVideoTracks([track1, track2]);
-
- expect(filtered[0]!.zIndex).toBe(0);
- expect(filtered[1]!.zIndex).toBe(1);
- });
- });
-
- describe("getBackgroundTrack", () => {
- it("should return lowest z-index visible video", () => {
- const bg: TimelineTrack = {
- ...createTimelineTrack("video", {} as File),
- zIndex: 0,
- visible: true,
- };
- const fg: TimelineTrack = {
- ...createTimelineTrack("video", {} as File),
- zIndex: 1,
- visible: true,
- };
-
- const track = getBackgroundTrack([bg, fg]);
- expect(track?.id).toBe(bg.id);
- });
-
- it("should return null if no visible videos", () => {
- const track = getBackgroundTrack([]);
- expect(track).toBeNull();
- });
- });
-
- describe("getOverlayTracks", () => {
- it("should return all visible videos except background", () => {
- const bg: TimelineTrack = {
- ...createTimelineTrack("video", {} as File),
- zIndex: 0,
- visible: true,
- };
- const overlay1: TimelineTrack = {
- ...createTimelineTrack("video", {} as File),
- zIndex: 1,
- visible: true,
- };
- const overlay2: TimelineTrack = {
- ...createTimelineTrack("video", {} as File),
- zIndex: 2,
- visible: true,
- };
-
- const overlays = getOverlayTracks([bg, overlay1, overlay2]);
- expect(overlays).toHaveLength(2);
- expect(overlays.every(t => t.id !== bg.id)).toBe(true);
- });
- });
-
- describe("validateMultiTrackState", () => {
- it("should validate correct state", () => {
- const state = createMultiTrackState();
- const result = validateMultiTrackState(state);
-
- expect(result.valid).toBe(true);
- expect(result.errors).toHaveLength(0);
- });
-
- it("should detect too many active video tracks", () => {
- let state = createMultiTrackState();
- state = addTrackToTimeline(state, createTimelineTrack("video", {} as File));
- state = addTrackToTimeline(state, createTimelineTrack("video", {} as File));
- const track3 = createTimelineTrack("video", {} as File);
- track3.visible = true; // Force visible to trigger violation
- state.timelineTracks.push(track3);
- state.maxActiveTracks = 2;
-
- const result = validateMultiTrackState(state);
-
- expect(result.valid).toBe(false);
- expect(result.errors.some(e => e.includes("Too many"))).toBe(true);
- });
-
- it("should check for duplicate IDs", () => {
- const state = createMultiTrackState();
- const track1 = createTimelineTrack("video", null);
- const track2 = { ...track1 }; // Duplicate ID
-
- state.timelineTracks = [track1, track2];
-
- const result = validateMultiTrackState(state);
-
- expect(result.valid).toBe(false);
- expect(result.errors.some(e => e.includes("Duplicate"))).toBe(true);
- });
-
- it("should validate opacity ranges", () => {
- let state = createMultiTrackState();
- const track = createTimelineTrack("video", null);
- track.opacity = 150; // Out of range
-
- state.timelineTracks = [track];
-
- const result = validateMultiTrackState(state);
-
- expect(result.valid).toBe(false);
- expect(result.errors.some(e => e.includes("opacity"))).toBe(true);
- });
- });
-
- describe("serializeMultiTrackState", () => {
- it("should remove File objects for serialization", () => {
- let state = createMultiTrackState();
- const track = createTimelineTrack("video", {} as File);
- state = addTrackToTimeline(state, track);
-
- const serialized = serializeMultiTrackState(state);
-
- expect(serialized.timelineTracks[0]!.source).toBeNull();
- });
-
- it("should preserve all other properties", () => {
- let state = createMultiTrackState();
- const track = createTimelineTrack("video", null);
- track.opacity = 75;
- track.position = { x: 100, y: 200 };
- state = addTrackToTimeline(state, track);
-
- const serialized = serializeMultiTrackState(state);
-
- expect(serialized.timelineTracks[0]!.opacity).toBe(75);
- expect(serialized.timelineTracks[0]!.position).toEqual({ x: 100, y: 200 });
- });
- });
-});
diff --git a/src/lib/timeline 2.ts b/src/lib/timeline 2.ts
deleted file mode 100644
index 8a1089f79..000000000
--- a/src/lib/timeline 2.ts
+++ /dev/null
@@ -1,211 +0,0 @@
-/**
- * Timeline Track Management Utilities
- *
- * Phase 1 MVP: Multi-track timeline architecture
- * Provides foundational track operations for the non-linear timeline.
- * Limited to 2 simultaneous video tracks per FFmpeg.wasm memory constraints.
- */
-
-import { TimelineTrack, MultiTrackEditorState } from "./types";
-
-const MAX_TRACKS_PHASE_1 = 2;
-
-/**
- * Generate a unique track ID
- */
-export function generateTrackId(): string {
- return `track-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
-}
-
-/**
- * Create a new timeline track with sensible defaults
- */
-export function createTimelineTrack(
- type: "video" | "image",
- source: File | null,
- startTime: number = 0
-): TimelineTrack {
- return {
- id: generateTrackId(),
- type,
- source,
- startTime,
- duration: 0, // Will be derived from video metadata
- zIndex: 0,
- visible: true,
- opacity: 100,
- position: {
- x: -1, // auto-center
- y: -1, // auto-center
- },
- scale: 1.0,
- rotation: 0,
- };
-}
-
-/**
- * Create initial multi-track editor state
- */
-export function createMultiTrackState(): MultiTrackEditorState {
- return {
- timelineTracks: [],
- activeTrackId: null,
- maxActiveTracks: MAX_TRACKS_PHASE_1,
- };
-}
-
-/**
- * Add a track to the timeline
- * Returns updated state; enforces max track limit
- */
-export function addTrackToTimeline(
- state: MultiTrackEditorState,
- track: TimelineTrack
-): MultiTrackEditorState {
- // Phase 1: Only allow 2 active video tracks
- const videoTrackCount = state.timelineTracks.filter(t => t.visible && t.type === "video").length;
-
- if (track.type === "video" && videoTrackCount >= MAX_TRACKS_PHASE_1) {
- track.visible = false;
- }
-
- return {
- ...state,
- timelineTracks: [...state.timelineTracks, track],
- activeTrackId: track.id,
- };
-}
-
-/**
- * Remove a track by ID
- */
-export function removeTrackFromTimeline(
- state: MultiTrackEditorState,
- trackId: string
-): MultiTrackEditorState {
- const filtered = state.timelineTracks.filter(t => t.id !== trackId);
- return {
- ...state,
- timelineTracks: filtered,
- activeTrackId: state.activeTrackId === trackId ? filtered[0]?.id ?? null : state.activeTrackId,
- };
-}
-
-/**
- * Update a track's properties
- */
-export function updateTrackInTimeline(
- state: MultiTrackEditorState,
- trackId: string,
- updates: Partial
-): MultiTrackEditorState {
- return {
- ...state,
- timelineTracks: state.timelineTracks.map(t =>
- t.id === trackId ? { ...t, ...updates } : t
- ),
- };
-}
-
-/**
- * Reorder tracks by z-index (for compositing order)
- * Returns tracks sorted by zIndex ascending (lower index = background)
- */
-export function sortTracksByZIndex(tracks: TimelineTrack[]): TimelineTrack[] {
- return [...tracks].sort((a, b) => a.zIndex - b.zIndex);
-}
-
-/**
- * Get visible video tracks in compositing order
- */
-export function getVisibleVideoTracks(tracks: TimelineTrack[]): TimelineTrack[] {
- return sortTracksByZIndex(
- tracks.filter(t => t.visible && t.type === "video" && t.source)
- );
-}
-
-/**
- * Get the background track (lowest z-index visible video)
- */
-export function getBackgroundTrack(tracks: TimelineTrack[]): TimelineTrack | null {
- const visible = getVisibleVideoTracks(tracks);
- return visible.length > 0 ? visible[0]! : null;
-}
-
-/**
- * Get overlay tracks (all visible videos except background)
- */
-export function getOverlayTracks(tracks: TimelineTrack[]): TimelineTrack[] {
- const visible = getVisibleVideoTracks(tracks);
- return visible.slice(1); // All except first (background)
-}
-
-/**
- * Validate multi-track state
- * Ensures consistency and enforces Phase 1 constraints
- */
-export function validateMultiTrackState(state: MultiTrackEditorState): {
- valid: boolean;
- errors: string[];
-} {
- const errors: string[] = [];
-
- if (!Array.isArray(state.timelineTracks)) {
- errors.push("timelineTracks must be an array");
- }
-
- const videoTracks = state.timelineTracks.filter(t => t.type === "video");
- const visibleVideoTracks = videoTracks.filter(t => t.visible);
-
- if (visibleVideoTracks.length > state.maxActiveTracks) {
- errors.push(
- `Too many active video tracks: ${visibleVideoTracks.length} (max: ${state.maxActiveTracks})`
- );
- }
-
- // Check for duplicate IDs
- const ids = state.timelineTracks.map(t => t.id);
- const uniqueIds = new Set(ids);
- if (ids.length !== uniqueIds.size) {
- errors.push("Duplicate track IDs detected");
- }
-
- // Check z-index validity
- state.timelineTracks.forEach((track, idx) => {
- if (typeof track.zIndex !== "number") {
- errors.push(`Track ${idx} has invalid zIndex`);
- }
- if (track.opacity < 0 || track.opacity > 100) {
- errors.push(`Track ${idx} opacity out of range [0, 100]`);
- }
- if (track.scale <= 0) {
- errors.push(`Track ${idx} scale must be positive`);
- }
- });
-
- return {
- valid: errors.length === 0,
- errors,
- };
-}
-
-/**
- * Compact track representation for serialization
- * Omits File objects which are not serializable
- */
-export function serializeMultiTrackState(
- state: MultiTrackEditorState
-): Omit & {
- timelineTracks: (Omit & { source: null })[];
-} {
- return {
- ...state,
- timelineTracks: state.timelineTracks.map(t => {
- const { source, ...rest } = t;
- return {
- ...rest,
- source: null,
- };
- }),
- };
-}
From 8ada016bed6a7b1bc0a7846bb50570862faeed60 Mon Sep 17 00:00:00 2001
From: Akanksha Trehun
Date: Tue, 3 Nov 2026 13:56:44 +0530
Subject: [PATCH 5/6] feat(telemetry): flag cancellations of stuck exports
export_cancelled carries stuck: true when the progress percentage hadn't
changed for 60 s, so monitors can count a user giving up on a hang as a
failure instead of an ordinary cancellation.
Co-Authored-By: Claude Opus 5.5
---
docs/DATADOG.md | 2 +-
src/hooks/tests/exportTelemetry.hook.test.ts | 36 ++++++++++++++++++++
src/hooks/useVideoEditor.ts | 25 +++++++++++---
src/lib/exportTelemetry.ts | 11 ++++--
src/lib/tests/exportTelemetry.test.ts | 1 +
src/lib/tests/ffmpegArgs.test.ts | 5 ++-
6 files changed, 71 insertions(+), 9 deletions(-)
diff --git a/docs/DATADOG.md b/docs/DATADOG.md
index 68549d485..230405b54 100644
--- a/docs/DATADOG.md
+++ b/docs/DATADOG.md
@@ -45,7 +45,7 @@ its own, so we can see which browser and device combinations fail.
| `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`, `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`, and `stall_reason` for stalls |
-| `export_cancelled` | Terminal: the user cancelled | the context, plus `engine`, `durationMs`, `stage` |
+| `export_cancelled` | Terminal: the user cancelled | the context, plus `engine`, `durationMs`, `stage`, `stuck` (true when progress hadn't changed for 60 s: the user gave up on a hang, and monitors count it as a failure) |
| `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` |
diff --git a/src/hooks/tests/exportTelemetry.hook.test.ts b/src/hooks/tests/exportTelemetry.hook.test.ts
index 891b96a0d..7a9c0257a 100644
--- a/src/hooks/tests/exportTelemetry.hook.test.ts
+++ b/src/hooks/tests/exportTelemetry.hook.test.ts
@@ -125,6 +125,42 @@ describe("export reliability telemetry in the editor", () => {
expect(lifecycle()[1]!.ctx).toMatchObject({ stage: "encode" });
});
+ it("a cancel after a minute without progress is reported as stuck", async () => {
+ const { result } = await editorWithFile();
+ let onProgress!: (p: number) => void;
+ vi.mocked(exportVideo).mockImplementationOnce((_f, _r, progress) => {
+ onProgress = progress;
+ return new Promise(() => {});
+ });
+ act(() => {
+ void result.current.handleExport();
+ });
+ await waitFor(() => expect(result.current.status).toBe("exporting"));
+ act(() => onProgress(100));
+ // Only the cancel happens "a minute later"; waitFor above uses real time.
+ const later = Date.now() + 61_000;
+ const now = vi.spyOn(Date, "now").mockReturnValue(later);
+ act(() => result.current.cancelExport());
+ now.mockRestore();
+ expect(lifecycle().at(-1)).toMatchObject({ name: "export_cancelled", ctx: { stuck: true } });
+ });
+
+ it("a cancel while progress is moving is not stuck", async () => {
+ const { result } = await editorWithFile();
+ let onProgress!: (p: number) => void;
+ vi.mocked(exportVideo).mockImplementationOnce((_f, _r, progress) => {
+ onProgress = progress;
+ return new Promise(() => {});
+ });
+ act(() => {
+ void result.current.handleExport();
+ });
+ await waitFor(() => expect(result.current.status).toBe("exporting"));
+ act(() => onProgress(40));
+ act(() => result.current.cancelExport());
+ expect(lifecycle().at(-1)).toMatchObject({ name: "export_cancelled", ctx: { stuck: false } });
+ });
+
it("cancelling while the engine loads is reported at the engine stage", async () => {
const { result } = await editorWithFile();
vi.mocked(loadFFmpeg).mockImplementationOnce(() => new Promise(() => {}));
diff --git a/src/hooks/useVideoEditor.ts b/src/hooks/useVideoEditor.ts
index 8b9a57869..a423431a6 100644
--- a/src/hooks/useVideoEditor.ts
+++ b/src/hooks/useVideoEditor.ts
@@ -73,6 +73,11 @@ import { resolveDuration } from "@/lib/mediaDuration";
const DEFAULT_TITLE = "Reframe — Resize, trim, and export videos in your browser";
const PROJECT_SAVE_DELAY_MS = 500;
+/**
+ * A cancel after this long without the progress percentage changing is
+ * reported as stuck (export_cancelled.stuck): the user gave up on a hang.
+ */
+const STUCK_CANCEL_MS = 60_000;
/** A saved project whose source video has to be chosen again. */
export interface PendingRecovery {
@@ -328,6 +333,16 @@ export function useVideoEditor() {
const [exportNotice, setExportNotice] = useState(null);
const exportAbortControllerRef = useRef(null);
const exportCancelledRef = useRef(false);
+ /** When the export progress percentage last changed (see STUCK_CANCEL_MS). */
+ const lastProgressAtRef = useRef(0);
+ const lastProgressRef = useRef(-1);
+ const trackProgress = useCallback((percent: number) => {
+ if (percent !== lastProgressRef.current) {
+ lastProgressRef.current = percent;
+ lastProgressAtRef.current = Date.now();
+ }
+ setProgress(percent);
+ }, []);
/** The export attempt in progress, which reports its single outcome. */
const exportAttemptRef = useRef(null);
// Category of the last failed export for this source, so the next attempt
@@ -736,14 +751,14 @@ export function useVideoEditor() {
try {
setStatus("loading-engine");
- setProgress(0);
+ trackProgress(0);
setExportNotice(null);
setError(null);
setExportStartedAt(null);
if (result?.blobUrl) URL.revokeObjectURL(result.blobUrl);
setResult(null);
- await loadFFmpeg(abortController.signal, setProgress);
+ await loadFFmpeg(abortController.signal, trackProgress);
if (exportCancelledRef.current) return;
const startedAt = Date.now();
@@ -754,7 +769,7 @@ export function useVideoEditor() {
const exportResult = await exportVideo(
file,
recipe,
- setProgress,
+ trackProgress,
abortController.signal,
{
file: musicFile,
@@ -815,6 +830,7 @@ export function useVideoEditor() {
}
}
}, [
+ trackProgress,
duration,
file,
loopMusic,
@@ -937,7 +953,8 @@ export function useVideoEditor() {
}, [file, musicFile, overlayFile, recovery, recipe.textOverlays]);
const cancelExport = useCallback(() => {
- exportAttemptRef.current?.cancelled();
+ const stuck = Date.now() - lastProgressAtRef.current >= STUCK_CANCEL_MS;
+ exportAttemptRef.current?.cancelled(stuck);
exportAttemptRef.current = null;
exportCancelledRef.current = true;
exportAbortControllerRef.current?.abort();
diff --git a/src/lib/exportTelemetry.ts b/src/lib/exportTelemetry.ts
index e5ba7d80b..332006003 100644
--- a/src/lib/exportTelemetry.ts
+++ b/src/lib/exportTelemetry.ts
@@ -184,11 +184,16 @@ export class ExportAttempt {
return category;
}
- /** Returns false if this attempt already had an outcome. */
- cancelled(): boolean {
+ /**
+ * Returns false if this attempt already had an outcome. `stuck`: progress
+ * hadn't moved for a while when the user cancelled, so this cancellation is
+ * probably a hang the watchdog hadn't caught yet, and monitors count it as
+ * a failure.
+ */
+ cancelled(stuck = false): boolean {
if (this.settled) return false;
this.outcome = "cancelled";
- trackAction("export_cancelled", { ...this.common(), stage: this.stage });
+ trackAction("export_cancelled", { ...this.common(), stage: this.stage, stuck });
return true;
}
}
diff --git a/src/lib/tests/exportTelemetry.test.ts b/src/lib/tests/exportTelemetry.test.ts
index 8293d0443..99e6db16b 100644
--- a/src/lib/tests/exportTelemetry.test.ts
+++ b/src/lib/tests/exportTelemetry.test.ts
@@ -39,6 +39,7 @@ const ALLOWED_KEYS = new Set([
"from",
"to",
"stall_reason",
+ "stuck",
]);
let rum: { addAction: ReturnType; addDurationVital: ReturnType };
diff --git a/src/lib/tests/ffmpegArgs.test.ts b/src/lib/tests/ffmpegArgs.test.ts
index 945e178f4..4b367abf1 100644
--- a/src/lib/tests/ffmpegArgs.test.ts
+++ b/src/lib/tests/ffmpegArgs.test.ts
@@ -226,6 +226,7 @@ describe("time values never reach FFmpeg as Infinity or NaN", () => {
it("no combination of bad values produces a non-finite argument", () => {
const values = [...BAD, 0, -1, 2.5];
+ const failures: string[] = [];
for (const trimStart of values)
for (const trimEnd of [...values, null])
for (const speed of values)
@@ -247,8 +248,10 @@ describe("time values never reach FFmpeg as Infinity or NaN", () => {
})
).flat(),
];
- for (const arg of all) expect(arg, JSON.stringify({ trimStart, trimEnd, speed, videoDuration })).not.toMatch(/Infinity|NaN/);
+ const bad = all.find((arg) => /Infinity|NaN/.test(arg));
+ if (bad) failures.push(`${JSON.stringify({ trimStart, trimEnd, speed, videoDuration })}: ${bad}`);
}
+ expect(failures).toEqual([]);
});
it("assertFiniteArgs rejects a non-finite argument with a clear error", () => {
From 8e24b37f939f46c624bbfeb56e261ff75d5836bd Mon Sep 17 00:00:00 2001
From: Akanksha Trehun
Date: Tue, 3 Nov 2026 15:07:42 +0530
Subject: [PATCH 6/6] docs(datadog): record the new monitors, RUM metrics and
export SLO
Co-Authored-By: Claude Opus 5.5
---
docs/DATADOG.md | 17 ++++++++++++++++-
1 file changed, 16 insertions(+), 1 deletion(-)
diff --git a/docs/DATADOG.md b/docs/DATADOG.md
index 230405b54..99b7662a5 100644
--- a/docs/DATADOG.md
+++ b/docs/DATADOG.md
@@ -134,10 +134,25 @@ Created through the API on 2026-11-03 (site `datadoghq.com`):
| Item | ID |
|---|---|
| Dashboard "Reframe — export reliability" | [`2tw-pge-qkk`](https://app.datadoghq.com/dashboard/2tw-pge-qkk) |
-| Monitor: exports failing in production (> 10 `export_failed` in 1 h; warning at 5) | `340137974` |
+| Monitor: exports not completing (started − succeeded − ordinary cancellations > 10 in 1 h; warning at 5). Counts failures, stuck cancellations (`stuck: true`) and abandoned exports | `340137974` |
| Monitor: FFmpeg engine failing to load (> 5 `ffmpeg_load_failure` in 1 h) | `340138002` |
| Monitor: cross-origin isolation missing (> 3 loads with `single_reason:not_isolated` in 1 h) | `340138004` |
| Monitor: frontend errors spike (> 25 uncaught errors in 1 h) | `340138012` |
+| Monitor: exports start but none succeed (> 3 `export_started` and 0 `export_succeeded` in 6 h) | `340168191` |
+| RUM-based metrics `reframe.export.started` and `reframe.export.succeeded` (production action counts, from 2026-11-03) | — |
+| SLO: 95% of started exports succeed, 30 days (and 7 days), warning at 97% | `0ac26b24a9a156729eaa2bdab83d07ff` |
+| Monitor: SLO error budget burning > 6× over 6 h and 30 min | `340187598` |
+
+Why the "none succeed" and "not completing" monitors exist: during the
+multi-threaded engine outage (2026-10-22 to 2026-11-03) every export hung,
+and hung exports produced no `export_failed` at all, only cancellations or
+nothing. The original failure monitor counted only `export_failed`, so it
+stayed OK the whole time.
+
+The SLO counts every start that didn't end in success, so user
+cancellations count against it as well; that is deliberate (a cancel is
+usually an export that was too slow). With today's traffic a single failure
+moves it a lot; treat it as meaningful after a few hundred exports.
The monitors only watch `env:production`, never page anyone, and email the
maintainer. Their thresholds are absolute counts, chosen for today's low