From 8f1ecccb4d15b2ebca7fd1ebf477b619670a7a34 Mon Sep 17 00:00:00 2001
From: Akanksha Trehun
Date: Tue, 3 Nov 2026 13:00:22 +0530
Subject: [PATCH 1/3] 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 3fe03351ac78673113936aaa1ad5172689ce5413 Mon Sep 17 00:00:00 2001
From: Akanksha Trehun
Date: Tue, 3 Nov 2026 13:45:03 +0530
Subject: [PATCH 2/3] 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 3/3] 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,
- };
- }),
- };
-}