From c82209acaf04b7181edfcd476df6fe8cea2b93fe Mon Sep 17 00:00:00 2001
From: Viraj Jadav <12402130601074@adit.ac.in>
Date: Mon, 25 May 2026 19:53:14 +0530
Subject: [PATCH 1/3] fix(ffmpeg): add structured load errors, retries, and
troubleshooting docs
---
README.md | 2 +
docs/ffmpeg-troubleshooting.md | 44 ++++++
src/components/VideoEditor.tsx | 34 +++--
src/hooks/useVideoEditor.ts | 32 ++++-
src/lib/ffmpeg.ts | 230 ++++++++++++++++++++++++++++--
src/lib/tests/ffmpeg-load.test.ts | 106 ++++++++++++++
src/lib/tests/ffmpeg.test.ts | 16 ++-
src/types/vendor.d.ts | 31 ++++
8 files changed, 472 insertions(+), 23 deletions(-)
create mode 100644 docs/ffmpeg-troubleshooting.md
create mode 100644 src/lib/tests/ffmpeg-load.test.ts
create mode 100644 src/types/vendor.d.ts
diff --git a/README.md b/README.md
index 590c0563..da345082 100644
--- a/README.md
+++ b/README.md
@@ -207,6 +207,8 @@ Learn more: https://nextjs.org/docs/architecture/fast-refresh
### 2. FFmpeg Module Changes
Changes to `ffmpeg.ts` may not hot-reload correctly because FFmpeg initialization and WebAssembly modules can persist in memory.
+If exports fail during download or initialization, see [docs/ffmpeg-troubleshooting.md](docs/ffmpeg-troubleshooting.md).
+
If updates are not reflected:
- Perform a full browser page reload
diff --git a/docs/ffmpeg-troubleshooting.md b/docs/ffmpeg-troubleshooting.md
new file mode 100644
index 00000000..1ab34074
--- /dev/null
+++ b/docs/ffmpeg-troubleshooting.md
@@ -0,0 +1,44 @@
+# FFmpeg Troubleshooting
+
+This guide covers the most common reasons FFmpeg.wasm fails to load in Reframe and what to try next.
+
+## Common causes
+
+- Unstable or blocked network access to the FFmpeg CDN
+- Browser restrictions around WebAssembly or SharedArrayBuffer support
+- Privacy tools, ad blockers, or firewall rules preventing `.js` or `.wasm` downloads
+- Corporate or offline environments that cannot reach external package CDNs
+
+## What to try
+
+1. Retry the export after a short delay. Temporary CDN or network issues often recover on their own.
+2. Check whether your browser can reach external HTTPS resources.
+3. Disable aggressive content blockers for the site, then try again.
+4. Use a current version of Chrome, Edge, or Firefox.
+5. If you are on a restricted network, try from an unrestricted connection or mirror the app behind a CDN you control.
+
+## Retry behavior
+
+Reframe automatically retries FFmpeg loading up to three times with exponential backoff.
+
+- First retry happens quickly
+- Each later retry waits longer
+- Permanent failures surface a readable error and a retry action in the UI
+
+If the retry button keeps failing, the issue is usually environmental rather than a problem with the video itself.
+
+## Offline and restricted networks
+
+FFmpeg.wasm assets are large and are fetched at runtime. If the browser cannot reach the CDN, the export flow cannot start.
+
+In offline or locked-down environments, you may need to:
+
+- Allow access to the FFmpeg CDN hosts
+- Relax firewall or proxy rules for the app
+- Host the FFmpeg core assets on an accessible origin
+
+## Browser compatibility
+
+Most modern browsers can run Reframe, but older builds or hardened privacy settings may block the WebAssembly setup step.
+
+If loading fails immediately even on a healthy network, try another modern browser first.
\ No newline at end of file
diff --git a/src/components/VideoEditor.tsx b/src/components/VideoEditor.tsx
index 5b4b4c55..1ebc276f 100644
--- a/src/components/VideoEditor.tsx
+++ b/src/components/VideoEditor.tsx
@@ -46,7 +46,7 @@ function Section({ icon, title, children, delay = 0 }: SectionProps) {
export default function VideoEditor() {
const {
file, duration, recipe, status, progress,
- result, error, updateRecipe,
+ result, error, errorInfo, updateRecipe,
handleFileSelect, handleExport, cancelExport, reset, resetSettings,
} = useVideoEditor();
const [copied, setCopied] = useState(false);
@@ -240,27 +240,41 @@ export default function VideoEditor() {
)}
{status === "error" && error && (
-
+
{
- navigator.clipboard.writeText(error).then(() => {
+ navigator.clipboard.writeText(
+ errorInfo?.debugMessage ? `${error}\n\n${errorInfo.debugMessage}` : error
+ ).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
}}
className="px-3 py-1.5 bg-[var(--border)] border border-[var(--border)] rounded-lg text-xs font-semibold hover:opacity-80 transition-colors shrink-0 whitespace-nowrap"
- aria-label="Copy error message to clipboard"
+ aria-label={errorInfo?.debugMessage ? "Copy error details to clipboard" : "Copy error message to clipboard"}
>
- {copied ? "Copied!" : "Copy error"}
+ {copied ? "Copied!" : errorInfo?.debugMessage ? "Copy details" : "Copy error"}
{!error.includes("Validation Failed") && (
- Retry Export
+ {errorInfo?.code ? "Retry load" : "Retry export"}
)}
diff --git a/src/hooks/useVideoEditor.ts b/src/hooks/useVideoEditor.ts
index 21ff88c6..f80fdc2d 100644
--- a/src/hooks/useVideoEditor.ts
+++ b/src/hooks/useVideoEditor.ts
@@ -53,6 +53,13 @@ function verifyMagicBytes(file: File): Promise
{
});
}
+interface ExportErrorInfo {
+ code?: string;
+ userMessage: string;
+ debugMessage?: string;
+ troubleshootingUrl?: string;
+}
+
export function useVideoEditor() {
const [file, setFile] = useState(null);
const [duration, setDuration] = useState(0);
@@ -61,6 +68,7 @@ export function useVideoEditor() {
const [progress, setProgress] = useState(0);
const [result, setResult] = useState(null);
const [error, setError] = useState(null);
+ const [errorInfo, setErrorInfo] = useState(null);
const exportAbortControllerRef = useRef(null);
const exportCancelledRef = useRef(false);
@@ -72,6 +80,7 @@ export function useVideoEditor() {
setResult(null);
setStatus("idle");
setError(null);
+ setErrorInfo(null);
setFile(null);
// LAYER 0: Size check
@@ -131,6 +140,7 @@ export function useVideoEditor() {
setStatus("loading-engine");
setProgress(0);
setError(null);
+ setErrorInfo(null);
if (result?.blobUrl) URL.revokeObjectURL(result.blobUrl);
setResult(null);
@@ -155,13 +165,30 @@ export function useVideoEditor() {
console.error("export failed:", err);
if (err instanceof FFmpegLoadError) {
- setError(err.message);
+ setError(err.userMessage);
+ setErrorInfo({
+ code: err.code,
+ userMessage: err.userMessage,
+ debugMessage: err.context
+ ? `Attempt ${err.context.attempt} of ${err.context.maxAttempts}. Core: ${err.context.coreName}. Retryable: ${err.context.retryable}. ${err.context.originalError ?? ""}`
+ : undefined,
+ troubleshootingUrl: "https://github.com/magic-peach/reframe/blob/main/docs/ffmpeg-troubleshooting.md",
+ });
} else if (err instanceof Error && err.message.includes('network')) {
setError('Network error. Check your internet connection and try again.');
+ setErrorInfo({
+ userMessage: 'Network error. Check your internet connection and try again.',
+ });
} else if (err instanceof Error && err.message.includes('codec')) {
setError('This video format is not supported. Try converting to MP4 first.');
+ setErrorInfo({
+ userMessage: 'This video format is not supported. Try converting to MP4 first.',
+ });
} else {
setError('Export failed. Please try again or use a different video.');
+ setErrorInfo({
+ userMessage: 'Export failed. Please try again or use a different video.',
+ });
}
setStatus("error");
}
@@ -211,6 +238,7 @@ export function useVideoEditor() {
setStatus("idle");
setProgress(0);
setError(null);
+ setErrorInfo(null);
}, []);
const resetSettings = useCallback(() => {
@@ -226,6 +254,7 @@ export function useVideoEditor() {
setProgress(0);
setResult(null);
setError(null);
+ setErrorInfo(null);
}, [result]);
// Development-only memory monitoring during export
@@ -251,6 +280,7 @@ export function useVideoEditor() {
progress,
result,
error,
+ errorInfo,
updateRecipe,
handleFileSelect,
handleExport,
diff --git a/src/lib/ffmpeg.ts b/src/lib/ffmpeg.ts
index f0331571..be3d6d6f 100644
--- a/src/lib/ffmpeg.ts
+++ b/src/lib/ffmpeg.ts
@@ -5,46 +5,254 @@ import { getPresetById } from "./presets";
import { simd } from "wasm-feature-detect";
const CORE_BASE_URL = "https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.10/dist/umd";
+const DEFAULT_LOAD_RETRIES = 3;
+const DEFAULT_RETRY_DELAY_MS = 250;
+const DEFAULT_RETRY_BACKOFF = 2;
+const DEFAULT_LOAD_TIMEOUT_MS = 30000;
let ffmpegInstance: FFmpeg | null = null;
+export type FFmpegLoadErrorCode =
+ | "NETWORK_LOAD_FAILED"
+ | "WASM_INSTANTIATION_FAILED"
+ | "FFMPEG_TIMEOUT"
+ | "CDN_UNREACHABLE";
+
+export interface FFmpegLoadErrorContext {
+ attempt: number;
+ maxAttempts: number;
+ coreName: string;
+ retryable: boolean;
+ originalError?: string;
+}
+
+export interface LoadFFmpegOptions {
+ signal?: AbortSignal;
+ retries?: number;
+ retryDelayMs?: number;
+ retryBackoffFactor?: number;
+ timeoutMs?: number;
+}
+
/**
* Error thrown when the FFmpeg WebAssembly core fails to load.
* This typically happens when the user is offline, the CDN is unreachable (or if the url is wrong),
* or there are network interruptions during the initialization phase.
*/
export class FFmpegLoadError extends Error {
- constructor(message: string) {
- super(message);
+ code: FFmpegLoadErrorCode;
+
+ userMessage: string;
+
+ context?: FFmpegLoadErrorContext;
+
+ constructor(code: FFmpegLoadErrorCode, userMessage: string, context?: FFmpegLoadErrorContext) {
+ super(userMessage);
this.name = "FFmpegLoadError";
+ this.code = code;
+ this.userMessage = userMessage;
+ this.context = context;
+ }
+}
+
+function sleep(ms: number, signal?: AbortSignal): Promise {
+ if (ms <= 0) return Promise.resolve();
+
+ return new Promise((resolve, reject) => {
+ const timer = globalThis.setTimeout(() => {
+ signal?.removeEventListener("abort", onAbort);
+ resolve();
+ }, ms);
+
+ const onAbort = () => {
+ globalThis.clearTimeout(timer);
+ signal?.removeEventListener("abort", onAbort);
+ reject(new DOMException("Aborted", "AbortError"));
+ };
+
+ if (signal?.aborted) {
+ onAbort();
+ return;
+ }
+
+ signal?.addEventListener("abort", onAbort, { once: true });
+ });
+}
+
+function withTimeout(promise: Promise, timeoutMs: number, signal?: AbortSignal): Promise {
+ if (timeoutMs <= 0 || !Number.isFinite(timeoutMs)) {
+ return promise;
+ }
+
+ return new Promise((resolve, reject) => {
+ const timeoutId = globalThis.setTimeout(() => {
+ signal?.removeEventListener("abort", onAbort);
+ reject(new FFmpegLoadError("FFMPEG_TIMEOUT", "Failed to load the video engine in time."));
+ }, timeoutMs);
+
+ const onAbort = () => {
+ globalThis.clearTimeout(timeoutId);
+ signal?.removeEventListener("abort", onAbort);
+ reject(new DOMException("Aborted", "AbortError"));
+ };
+
+ if (signal?.aborted) {
+ onAbort();
+ return;
+ }
+
+ signal?.addEventListener("abort", onAbort, { once: true });
+
+ promise.then(
+ (value) => {
+ globalThis.clearTimeout(timeoutId);
+ signal?.removeEventListener("abort", onAbort);
+ resolve(value);
+ },
+ (error) => {
+ globalThis.clearTimeout(timeoutId);
+ signal?.removeEventListener("abort", onAbort);
+ reject(error);
+ }
+ );
+ });
+}
+
+function describeError(err: unknown): string {
+ if (err instanceof Error) {
+ return `${err.name}: ${err.message}`;
+ }
+ return typeof err === "string" ? err : "Unknown error";
+}
+
+function classifyLoadFailure(err: unknown): { code: FFmpegLoadErrorCode; userMessage: string; retryable: boolean } {
+ const text = describeError(err).toLowerCase();
+
+ if (text.includes("timeout")) {
+ return {
+ code: "FFMPEG_TIMEOUT",
+ userMessage: "Loading the video engine took too long. Please try again on a more stable connection.",
+ retryable: true,
+ };
}
+
+ if (text.includes("network") || text.includes("failed to fetch") || text.includes("fetch")) {
+ return {
+ code: "NETWORK_LOAD_FAILED",
+ userMessage: "Failed to load video processing components. Please check your internet connection and try again.",
+ retryable: true,
+ };
+ }
+
+ if (text.includes("cdn") || text.includes("bloburl") || text.includes("404") || text.includes("503")) {
+ return {
+ code: "CDN_UNREACHABLE",
+ userMessage: "The video engine could not be downloaded right now. Please try again in a moment.",
+ retryable: true,
+ };
+ }
+
+ return {
+ code: "WASM_INSTANTIATION_FAILED",
+ userMessage: "Your browser could not initialize the video engine. Please try a recent version of Chrome, Edge, or Firefox.",
+ retryable: false,
+ };
}
-export async function loadFFmpeg(signal?: AbortSignal): Promise {
+async function loadFFmpegAttempt(
+ ffmpeg: FFmpeg,
+ signal: AbortSignal | undefined,
+ coreName: string,
+ timeoutMs: number
+): Promise {
+ const loadPromise = ffmpeg.load(
+ {
+ coreURL: await toBlobURL(`${CORE_BASE_URL}/${coreName}.js`, "text/javascript"),
+ wasmURL: await toBlobURL(`${CORE_BASE_URL}/${coreName}.wasm`, "application/wasm"),
+ },
+ { signal }
+ );
+
+ await withTimeout(loadPromise, timeoutMs, signal);
+}
+
+export async function loadFFmpeg(signalOrOptions?: AbortSignal | LoadFFmpegOptions): Promise {
+ const options: LoadFFmpegOptions =
+ signalOrOptions instanceof AbortSignal || !signalOrOptions
+ ? { signal: signalOrOptions ?? undefined }
+ : signalOrOptions;
+ const signal = options.signal;
+ const retries = options.retries ?? DEFAULT_LOAD_RETRIES;
+ const retryDelayMs = options.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS;
+ const retryBackoffFactor = options.retryBackoffFactor ?? DEFAULT_RETRY_BACKOFF;
+ const timeoutMs = options.timeoutMs ?? DEFAULT_LOAD_TIMEOUT_MS;
+
if (ffmpegInstance?.loaded) return ffmpegInstance;
const ffmpeg = ffmpegInstance ?? new FFmpeg();
ffmpegInstance = ffmpeg;
+ let coreName = "ffmpeg-core";
+
try {
// Check if the user's browser supports WebAssembly SIMD
const isSimdSupported = await simd();
// Dynamically set the core filename
- const coreName = isSimdSupported ? "ffmpeg-core-simd" : "ffmpeg-core";
+ coreName = isSimdSupported ? "ffmpeg-core-simd" : "ffmpeg-core";
- // Load FFmpeg using the dynamic URLs + the new signal parameter
- await ffmpeg.load({
- coreURL: await toBlobURL(`${CORE_BASE_URL}/${coreName}.js`, "text/javascript"),
- wasmURL: await toBlobURL(`${CORE_BASE_URL}/${coreName}.wasm`, "application/wasm"),
- }, { signal });
+ const maxAttempts = Math.max(1, retries);
+
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
+ try {
+ await loadFFmpegAttempt(ffmpeg, signal, coreName, timeoutMs);
+ return ffmpeg;
+ } catch (err) {
+ if (err instanceof FFmpegLoadError) {
+ throw err;
+ }
+
+ if (err instanceof DOMException && err.name === "AbortError") {
+ throw err;
+ }
+
+ const failure = classifyLoadFailure(err);
+ const context: FFmpegLoadErrorContext = {
+ attempt,
+ maxAttempts,
+ coreName,
+ retryable: failure.retryable,
+ originalError: describeError(err),
+ };
+
+ if (attempt < maxAttempts && failure.retryable) {
+ console.warn("[FFmpeg] load attempt failed; retrying", {
+ attempt,
+ maxAttempts,
+ code: failure.code,
+ originalError: context.originalError,
+ });
+ await sleep(retryDelayMs * (retryBackoffFactor ** (attempt - 1)), signal);
+ continue;
+ }
+
+ throw new FFmpegLoadError(failure.code, failure.userMessage, context);
+ }
+ }
- return ffmpeg;
+ const failure = classifyLoadFailure(new Error("Unknown FFmpeg load failure"));
+ throw new FFmpegLoadError(failure.code, failure.userMessage, {
+ attempt: maxAttempts,
+ maxAttempts,
+ coreName,
+ retryable: failure.retryable,
+ originalError: "Unknown FFmpeg load failure",
+ });
} catch (err) {
if (ffmpegInstance === ffmpeg) {
ffmpegInstance = null;
}
- throw new FFmpegLoadError("Failed to load the FFmpeg engine. Check your internet connection.");
+ throw err;
}
}
diff --git a/src/lib/tests/ffmpeg-load.test.ts b/src/lib/tests/ffmpeg-load.test.ts
new file mode 100644
index 00000000..b47e209e
--- /dev/null
+++ b/src/lib/tests/ffmpeg-load.test.ts
@@ -0,0 +1,106 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+const testState = vi.hoisted(() => ({
+ loadImpl: vi.fn(),
+ simdMock: vi.fn(),
+ toBlobURLMock: vi.fn(async (url: string) => url),
+ createdInstances: [] as Array<{ loaded: boolean }>,
+}));
+
+vi.mock("@ffmpeg/ffmpeg", () => {
+ class MockFFmpeg {
+ loaded = false;
+
+ load = vi.fn(async (...args: unknown[]) => {
+ await testState.loadImpl(...args);
+ this.loaded = true;
+ });
+
+ terminate = vi.fn(() => {
+ this.loaded = false;
+ });
+
+ writeFile = vi.fn();
+ readFile = vi.fn();
+ exec = vi.fn();
+ deleteFile = vi.fn();
+ on = vi.fn();
+ off = vi.fn();
+
+ constructor() {
+ testState.createdInstances.push(this);
+ }
+ }
+
+ return { FFmpeg: MockFFmpeg };
+});
+
+vi.mock("@ffmpeg/util", () => ({
+ fetchFile: vi.fn(),
+ toBlobURL: testState.toBlobURLMock,
+}));
+
+vi.mock("wasm-feature-detect", () => ({
+ simd: testState.simdMock,
+}));
+
+import { FFmpegLoadError, loadFFmpeg, terminateFFmpeg } from "../ffmpeg";
+
+describe("loadFFmpeg", () => {
+ beforeEach(() => {
+ testState.loadImpl.mockReset();
+ testState.simdMock.mockReset();
+ testState.toBlobURLMock.mockClear();
+ testState.createdInstances.length = 0;
+ testState.simdMock.mockResolvedValue(true);
+ });
+
+ afterEach(() => {
+ terminateFFmpeg();
+ });
+
+ it("retries transient load failures and succeeds on the third attempt", async () => {
+ testState.loadImpl
+ .mockRejectedValueOnce(new TypeError("Failed to fetch"))
+ .mockRejectedValueOnce(new TypeError("Failed to fetch"))
+ .mockResolvedValueOnce(undefined);
+
+ const ffmpeg = await loadFFmpeg({ retryDelayMs: 0, timeoutMs: 1000 });
+
+ expect(ffmpeg).toBe(testState.createdInstances[0]);
+ expect(testState.loadImpl).toHaveBeenCalledTimes(3);
+ expect((testState.createdInstances[0] as { loaded: boolean }).loaded).toBe(true);
+ });
+
+ it("throws a structured retryable error after exhausting retries", async () => {
+ testState.loadImpl.mockRejectedValue(new TypeError("Failed to fetch"));
+
+ await expect(loadFFmpeg({ retryDelayMs: 0, timeoutMs: 1000 })).rejects.toMatchObject({
+ name: "FFmpegLoadError",
+ code: "NETWORK_LOAD_FAILED",
+ userMessage: "Failed to load video processing components. Please check your internet connection and try again.",
+ });
+
+ expect(testState.loadImpl).toHaveBeenCalledTimes(3);
+ });
+
+ it("does not retry non-retryable instantiation failures", async () => {
+ testState.loadImpl.mockRejectedValue(new Error("WebAssembly compile error"));
+
+ await expect(loadFFmpeg({ retryDelayMs: 0, timeoutMs: 1000 })).rejects.toMatchObject({
+ name: "FFmpegLoadError",
+ code: "WASM_INSTANTIATION_FAILED",
+ });
+
+ expect(testState.loadImpl).toHaveBeenCalledTimes(1);
+ });
+
+ it("includes timeout metadata when loading takes too long", async () => {
+ testState.loadImpl.mockImplementation(() => new Promise(() => undefined));
+
+ await expect(loadFFmpeg({ retryDelayMs: 0, timeoutMs: 1 })).rejects.toMatchObject({
+ name: "FFmpegLoadError",
+ code: "FFMPEG_TIMEOUT",
+ });
+ });
+});
\ No newline at end of file
diff --git a/src/lib/tests/ffmpeg.test.ts b/src/lib/tests/ffmpeg.test.ts
index bdde5d41..1bf39bb5 100644
--- a/src/lib/tests/ffmpeg.test.ts
+++ b/src/lib/tests/ffmpeg.test.ts
@@ -1,4 +1,18 @@
-import { describe, it, expect } from "vitest";
+import { describe, it, expect, vi } from "vitest";
+
+vi.mock("@ffmpeg/ffmpeg", () => ({
+ FFmpeg: class MockFFmpeg {},
+}));
+
+vi.mock("@ffmpeg/util", () => ({
+ fetchFile: vi.fn(),
+ toBlobURL: vi.fn(async (url: string) => url),
+}));
+
+vi.mock("wasm-feature-detect", () => ({
+ simd: vi.fn(async () => true),
+}));
+
import { buildAudioFilter } from "../ffmpeg";
describe("buildAudioFilter", () => {
diff --git a/src/types/vendor.d.ts b/src/types/vendor.d.ts
new file mode 100644
index 00000000..c322f54c
--- /dev/null
+++ b/src/types/vendor.d.ts
@@ -0,0 +1,31 @@
+declare module "@ffmpeg/ffmpeg" {
+ export interface FFmpegLoadOptions {
+ coreURL: string;
+ wasmURL: string;
+ }
+
+ export interface FFmpegExecOptions {
+ signal?: AbortSignal;
+ }
+
+ export class FFmpeg {
+ loaded: boolean;
+ load(options: FFmpegLoadOptions, config?: FFmpegExecOptions): Promise;
+ terminate(): void;
+ writeFile(path: string, data: unknown, config?: FFmpegExecOptions): Promise;
+ readFile(path: string, encoding?: unknown, config?: FFmpegExecOptions): Promise;
+ exec(args: string[], timeout?: unknown, config?: FFmpegExecOptions): Promise;
+ deleteFile(path: string): Promise;
+ on(event: string, callback: (...args: any[]) => void): void;
+ off(event: string, callback: (...args: any[]) => void): void;
+ }
+}
+
+declare module "@ffmpeg/util" {
+ export function fetchFile(file: Blob | File | string): Promise;
+ export function toBlobURL(url: string, mimeType: string): Promise;
+}
+
+declare module "wasm-feature-detect" {
+ export function simd(): Promise;
+}
\ No newline at end of file
From 9cb9ce57e0ee48c6d6b47fb1b2b6d262513e6e2e Mon Sep 17 00:00:00 2001
From: Viraj Jadav <12402130601074@adit.ac.in>
Date: Mon, 25 May 2026 20:31:32 +0530
Subject: [PATCH 2/3] =?UTF-8?q?chore:=20resolve=20merge=20conflicts=20?=
=?UTF-8?q?=E2=80=94=20unify=20ffmpeg=20worker=20+=20FFmpegLoadError,=20co?=
=?UTF-8?q?mbine=20hook=20+=20UI=20changes?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/components/VideoEditor.tsx | 89 ++++--------
src/hooks/useVideoEditor.ts | 69 ++++++++++
src/lib/ffmpeg.ts | 243 ++-------------------------------
3 files changed, 105 insertions(+), 296 deletions(-)
diff --git a/src/components/VideoEditor.tsx b/src/components/VideoEditor.tsx
index 70821596..bf870e66 100644
--- a/src/components/VideoEditor.tsx
+++ b/src/components/VideoEditor.tsx
@@ -35,42 +35,7 @@ interface SectionProps {
}
function Section({ icon, title, children, delay = 0 }: SectionProps) {
- return (
-
-
-
{icon}
-
- {title}
-
-
-
- {children}
-
- );
-}
-
-/** Accordion section with collapsible content. */
-function AccordionSection({
- id,
- icon,
- title,
- children,
- isOpen,
- onToggle,
- delay = 0,
-}: {
- id: string;
- icon: React.ReactNode;
- title: string;
- children: React.ReactNode;
- isOpen: boolean;
- onToggle: () => void;
- delay?: number;
-}) {
- return (
+ }
>>>>>> upstream/main
} = useVideoEditor();
useKeyboardShortcuts({
@@ -589,16 +566,11 @@ export default function VideoEditor() {
{status === "error" && error && (
>>>>>> upstream/main
className="flex items-start gap-3 p-4 bg-film-50 border border-film-200 rounded-xl text-film-800 text-sm animate-fade-in"
>
-<<<<<<< HEAD
{errorInfo?.code ? "Video engine failed to load" : "Error"}
@@ -613,10 +585,6 @@ export default function VideoEditor() {
Learn more about troubleshooting
)}
-=======
-
Error
-
{error}
->>>>>>> upstream/main
{
console.error("Failed to copy error to clipboard:", err);
});
- }}
-<<<<<<< HEAD
+ }
className="px-3 py-1.5 bg-[var(--border)] border border-[var(--border)] rounded-lg text-xs font-semibold hover:opacity-80 transition-colors shrink-0 whitespace-nowrap"
aria-label={errorInfo?.debugMessage ? "Copy error details to clipboard" : "Copy error message to clipboard"}
-=======
- className="px-3 py-1.5 bg-[var(--border)] border border-[var(--border)] rounded-lg text-sm font-semibold hover:opacity-80 transition-colors shrink-0 whitespace-nowrap"
- aria-label="Copy error message to clipboard"
->>>>>>> upstream/main
>
{copied ? "Copied!" : errorInfo?.debugMessage ? "Copy details" : "Copy error"}
diff --git a/src/hooks/useVideoEditor.ts b/src/hooks/useVideoEditor.ts
index 5ebf3e83..bcdedbb0 100644
--- a/src/hooks/useVideoEditor.ts
+++ b/src/hooks/useVideoEditor.ts
@@ -64,6 +64,73 @@ function verifyMagicBytes(file: File): Promise
{
});
}
+function validateRecipe(recipe: EditRecipe, duration: number ): string | null {
+ const validations: Array<[boolean, string]> = [
+ [
+ recipe.trimStart < 0,
+ "Trim start time cannot be less than 0 seconds.",
+ ],
+ [
+ recipe.trimEnd !== null && duration > 0 && recipe.trimEnd > duration,
+ `Trim end time cannot exceed the video duration (${Math.floor(duration)}s).`,
+ ],
+ [
+ recipe.trimEnd !== null
+ ? recipe.trimStart >= recipe.trimEnd
+ : (duration > 0 && recipe.trimStart >= duration),
+ "Trim start time must be earlier than the end time.",
+ ],
+ [
+ recipe.preset === "custom" && (Number.isNaN(recipe.customWidth) || recipe.customWidth < 16 || recipe.customWidth > 7680),
+ "Width must be between 16px and 7680px.",
+ ],
+ [
+ recipe.preset === "custom" && (Number.isNaN(recipe.customHeight) || recipe.customHeight < 16 || recipe.customHeight > 7680),
+ "Height must be between 16px and 7680px.",
+ ],
+ [
+ !(SPEED_STEPS as readonly number[]).includes(recipe.speed),
+ "Please select a valid playback speed.",
+ ],
+ [
+ recipe.quality < 18 || recipe.quality > 30,
+ "Quality must be between 18 and 30.",
+ ],
+ [
+ recipe.brightness < -1 || recipe.brightness > 1,
+ "Brightness must be between -1 and 1.",
+ ],
+
+ [
+ recipe.contrast < 0 || recipe.contrast > 2,
+ "Contrast must be between 0 and 2.",
+ ],
+
+ [
+ recipe.saturation < 0 || recipe.saturation > 3,
+ "Saturation must be between 0 and 3.",
+ ],
+ ];
+
+ return (
+ validations.find(([condition]) => condition)?.[1] ??
+ null
+ );
+}
+
+function encodeRecipe(recipe: EditRecipe): string {
+ return btoa(JSON.stringify(recipe));
+}
+
+function decodeRecipe(encoded: string): Partial | null {
+ try {
+ const decoded = JSON.parse(atob(encoded));
+ return decoded as Partial;
+ } catch {
+ return null;
+ }
+}
+
interface ExportErrorInfo {
code?: string;
userMessage: string;
@@ -99,6 +166,8 @@ export function useVideoEditor() {
const [result, setResult] = useState(null);
const [error, setError] = useState(null);
const [errorInfo, setErrorInfo] = useState(null);
+ const [fileError, setFileError] = useState("");
+ const [exportStartedAt, setExportStartedAt] = useState(null);
const exportAbortControllerRef = useRef(null);
const exportCancelledRef = useRef(false);
const videoRef = useRef(null);
diff --git a/src/lib/ffmpeg.ts b/src/lib/ffmpeg.ts
index 5f86921c..ede6e363 100644
--- a/src/lib/ffmpeg.ts
+++ b/src/lib/ffmpeg.ts
@@ -1,23 +1,10 @@
import { EditRecipe, ExportResult, BackgroundMusicOptions, ImageOverlayOptions } from "./types";
import { getPresetById } from "./presets";
-import { buildTextFilter } from "./text-overlay";
-
-<<<<<<< HEAD
-const CORE_BASE_URL = "https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.10/dist/umd";
-const DEFAULT_LOAD_RETRIES = 3;
-const DEFAULT_RETRY_DELAY_MS = 250;
-const DEFAULT_RETRY_BACKOFF = 2;
-const DEFAULT_LOAD_TIMEOUT_MS = 30000;
-=======
-export class FFmpegLoadError extends Error {}
->>>>>>> upstream/main
-
const FFMPEG_WORKER_URL =
typeof window !== "undefined"
? new URL("./ffmpeg.worker.ts", import.meta.url)
: null;
-<<<<<<< HEAD
export type FFmpegLoadErrorCode =
| "NETWORK_LOAD_FAILED"
| "WASM_INSTANTIATION_FAILED"
@@ -25,176 +12,27 @@ export type FFmpegLoadErrorCode =
| "CDN_UNREACHABLE";
export interface FFmpegLoadErrorContext {
- attempt: number;
- maxAttempts: number;
- coreName: string;
- retryable: boolean;
+ attempt?: number;
+ maxAttempts?: number;
+ coreName?: string;
+ retryable?: boolean;
originalError?: string;
}
-export interface LoadFFmpegOptions {
- signal?: AbortSignal;
- retries?: number;
- retryDelayMs?: number;
- retryBackoffFactor?: number;
- timeoutMs?: number;
-}
-
-/**
- * Error thrown when the FFmpeg WebAssembly core fails to load.
- * This typically happens when the user is offline, the CDN is unreachable (or if the url is wrong),
- * or there are network interruptions during the initialization phase.
- */
export class FFmpegLoadError extends Error {
- code: FFmpegLoadErrorCode;
-
+ code?: FFmpegLoadErrorCode;
userMessage: string;
-
context?: FFmpegLoadErrorContext;
- constructor(code: FFmpegLoadErrorCode, userMessage: string, context?: FFmpegLoadErrorContext) {
+ constructor(userMessage: string, code?: FFmpegLoadErrorCode, context?: FFmpegLoadErrorContext) {
super(userMessage);
this.name = "FFmpegLoadError";
- this.code = code;
this.userMessage = userMessage;
+ this.code = code;
this.context = context;
}
}
-function sleep(ms: number, signal?: AbortSignal): Promise {
- if (ms <= 0) return Promise.resolve();
-
- return new Promise((resolve, reject) => {
- const timer = globalThis.setTimeout(() => {
- signal?.removeEventListener("abort", onAbort);
- resolve();
- }, ms);
-
- const onAbort = () => {
- globalThis.clearTimeout(timer);
- signal?.removeEventListener("abort", onAbort);
- reject(new DOMException("Aborted", "AbortError"));
- };
-
- if (signal?.aborted) {
- onAbort();
- return;
- }
-
- signal?.addEventListener("abort", onAbort, { once: true });
- });
-}
-
-function withTimeout(promise: Promise, timeoutMs: number, signal?: AbortSignal): Promise {
- if (timeoutMs <= 0 || !Number.isFinite(timeoutMs)) {
- return promise;
- }
-
- return new Promise((resolve, reject) => {
- const timeoutId = globalThis.setTimeout(() => {
- signal?.removeEventListener("abort", onAbort);
- reject(new FFmpegLoadError("FFMPEG_TIMEOUT", "Failed to load the video engine in time."));
- }, timeoutMs);
-
- const onAbort = () => {
- globalThis.clearTimeout(timeoutId);
- signal?.removeEventListener("abort", onAbort);
- reject(new DOMException("Aborted", "AbortError"));
- };
-
- if (signal?.aborted) {
- onAbort();
- return;
- }
-
- signal?.addEventListener("abort", onAbort, { once: true });
-
- promise.then(
- (value) => {
- globalThis.clearTimeout(timeoutId);
- signal?.removeEventListener("abort", onAbort);
- resolve(value);
- },
- (error) => {
- globalThis.clearTimeout(timeoutId);
- signal?.removeEventListener("abort", onAbort);
- reject(error);
- }
- );
- });
-}
-
-function describeError(err: unknown): string {
- if (err instanceof Error) {
- return `${err.name}: ${err.message}`;
- }
- return typeof err === "string" ? err : "Unknown error";
-}
-
-function classifyLoadFailure(err: unknown): { code: FFmpegLoadErrorCode; userMessage: string; retryable: boolean } {
- const text = describeError(err).toLowerCase();
-
- if (text.includes("timeout")) {
- return {
- code: "FFMPEG_TIMEOUT",
- userMessage: "Loading the video engine took too long. Please try again on a more stable connection.",
- retryable: true,
- };
- }
-
- if (text.includes("network") || text.includes("failed to fetch") || text.includes("fetch")) {
- return {
- code: "NETWORK_LOAD_FAILED",
- userMessage: "Failed to load video processing components. Please check your internet connection and try again.",
- retryable: true,
- };
- }
-
- if (text.includes("cdn") || text.includes("bloburl") || text.includes("404") || text.includes("503")) {
- return {
- code: "CDN_UNREACHABLE",
- userMessage: "The video engine could not be downloaded right now. Please try again in a moment.",
- retryable: true,
- };
- }
-
- return {
- code: "WASM_INSTANTIATION_FAILED",
- userMessage: "Your browser could not initialize the video engine. Please try a recent version of Chrome, Edge, or Firefox.",
- retryable: false,
- };
-}
-
-async function loadFFmpegAttempt(
- ffmpeg: FFmpeg,
- signal: AbortSignal | undefined,
- coreName: string,
- timeoutMs: number
-): Promise {
- const loadPromise = ffmpeg.load(
- {
- coreURL: await toBlobURL(`${CORE_BASE_URL}/${coreName}.js`, "text/javascript"),
- wasmURL: await toBlobURL(`${CORE_BASE_URL}/${coreName}.wasm`, "application/wasm"),
- },
- { signal }
- );
-
- await withTimeout(loadPromise, timeoutMs, signal);
-}
-
-export async function loadFFmpeg(signalOrOptions?: AbortSignal | LoadFFmpegOptions): Promise {
- const options: LoadFFmpegOptions =
- signalOrOptions instanceof AbortSignal || !signalOrOptions
- ? { signal: signalOrOptions ?? undefined }
- : signalOrOptions;
- const signal = options.signal;
- const retries = options.retries ?? DEFAULT_LOAD_RETRIES;
- const retryDelayMs = options.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS;
- const retryBackoffFactor = options.retryBackoffFactor ?? DEFAULT_RETRY_BACKOFF;
- const timeoutMs = options.timeoutMs ?? DEFAULT_LOAD_TIMEOUT_MS;
-
- if (ffmpegInstance?.loaded) return ffmpegInstance;
-=======
type SerializedFile = {
name: string;
type: string;
@@ -341,7 +179,7 @@ async function ensureWorker() {
createWorker();
}
}
->>>>>>> upstream/main
+
export async function loadFFmpeg(
signal?: AbortSignal,
@@ -378,74 +216,13 @@ export async function loadFFmpeg(
signal?.addEventListener("abort", onAbort, { once: true });
let coreName = "ffmpeg-core";
-
try {
-<<<<<<< HEAD
- // Check if the user's browser supports WebAssembly SIMD
- const isSimdSupported = await simd();
-
- // Dynamically set the core filename
- coreName = isSimdSupported ? "ffmpeg-core-simd" : "ffmpeg-core";
-
- const maxAttempts = Math.max(1, retries);
-
- for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
- try {
- await loadFFmpegAttempt(ffmpeg, signal, coreName, timeoutMs);
- return ffmpeg;
- } catch (err) {
- if (err instanceof FFmpegLoadError) {
- throw err;
- }
-
- if (err instanceof DOMException && err.name === "AbortError") {
- throw err;
- }
-
- const failure = classifyLoadFailure(err);
- const context: FFmpegLoadErrorContext = {
- attempt,
- maxAttempts,
- coreName,
- retryable: failure.retryable,
- originalError: describeError(err),
- };
-
- if (attempt < maxAttempts && failure.retryable) {
- console.warn("[FFmpeg] load attempt failed; retrying", {
- attempt,
- maxAttempts,
- code: failure.code,
- originalError: context.originalError,
- });
- await sleep(retryDelayMs * (retryBackoffFactor ** (attempt - 1)), signal);
- continue;
- }
-
- throw new FFmpegLoadError(failure.code, failure.userMessage, context);
- }
- }
-
- const failure = classifyLoadFailure(new Error("Unknown FFmpeg load failure"));
- throw new FFmpegLoadError(failure.code, failure.userMessage, {
- attempt: maxAttempts,
- maxAttempts,
- coreName,
- retryable: failure.retryable,
- originalError: "Unknown FFmpeg load failure",
- });
- } catch (err) {
- if (ffmpegInstance === ffmpeg) {
- ffmpegInstance = null;
- }
- throw err;
-=======
await workerReady;
} finally {
cleanup();
->>>>>>> upstream/main
}
-}
+ }
+
function cancelPendingExport(reason?: unknown) {
if (pendingExport) {
From 7c4302a835210f3457657030dab9ac648486cc08 Mon Sep 17 00:00:00 2001
From: Viraj Jadav <12402130601074@adit.ac.in>
Date: Mon, 25 May 2026 21:12:00 +0530
Subject: [PATCH 3/3] fix: enhance ErrorBoundary export type, improve Section
component props and functionality, and update FFmpeg load options
---
src/components/ErrorBoundary.tsx | 2 +-
src/components/VideoEditor.tsx | 30 +++++++++++++++++++++---------
src/hooks/useVideoEditor.ts | 12 +++++++-----
src/lib/ffmpeg.ts | 8 +++++++-
src/types/shims.d.ts | 30 ++++++++++++++++++++++++++++++
tsconfig.json | 1 -
6 files changed, 66 insertions(+), 17 deletions(-)
create mode 100644 src/types/shims.d.ts
diff --git a/src/components/ErrorBoundary.tsx b/src/components/ErrorBoundary.tsx
index 520bf95d..a90450ba 100644
--- a/src/components/ErrorBoundary.tsx
+++ b/src/components/ErrorBoundary.tsx
@@ -94,4 +94,4 @@ class ErrorBoundary extends React.Component);
\ No newline at end of file
diff --git a/src/components/VideoEditor.tsx b/src/components/VideoEditor.tsx
index bf870e66..e1611dca 100644
--- a/src/components/VideoEditor.tsx
+++ b/src/components/VideoEditor.tsx
@@ -32,16 +32,26 @@ interface SectionProps {
title: string;
children: React.ReactNode;
delay?: number;
+ id?: string;
+ isOpen?: boolean;
+ onToggle?: () => void;
}
-function Section({ icon, title, children, delay = 0 }: SectionProps) {
- }
+function Section({ icon, title, children, delay = 0, id, isOpen, onToggle }: SectionProps) {
+ const [internalOpen, setInternalOpen] = useState(false);
+ const generatedId = useMemo(() => title.replace(/\s+/g, "-").toLowerCase(), [title]);
+ const panelId = id ?? generatedId;
+ const controlled = typeof isOpen === "boolean" && typeof onToggle === "function";
+ const open = controlled ? !!isOpen : internalOpen;
+ const localToggle = controlled ? onToggle! : () => setInternalOpen((v) => !v);
+
+ return (
@@ -54,17 +64,17 @@ function Section({ icon, title, children, delay = 0 }: SectionProps) {
height="12"
viewBox="0 0 12 12"
fill="none"
- className={cn("text-[var(--muted)] transition-transform duration-200", isOpen && "rotate-180")}
+ className={cn("text-[var(--muted)] transition-transform duration-200", open && "rotate-180")}
>
{children}
@@ -73,6 +83,8 @@ function Section({ icon, title, children, delay = 0 }: SectionProps) {
);
}
+const AccordionSection = Section;
+
/** Inline keyboard hint badge. */
function Kbd({ children }: { children: React.ReactNode }) {
return (
@@ -597,7 +609,7 @@ export default function VideoEditor() {
}).catch((err) => {
console.error("Failed to copy error to clipboard:", err);
});
- }
+ }}
className="px-3 py-1.5 bg-[var(--border)] border border-[var(--border)] rounded-lg text-xs font-semibold hover:opacity-80 transition-colors shrink-0 whitespace-nowrap"
aria-label={errorInfo?.debugMessage ? "Copy error details to clipboard" : "Copy error message to clipboard"}
>
diff --git a/src/hooks/useVideoEditor.ts b/src/hooks/useVideoEditor.ts
index bcdedbb0..0db917f8 100644
--- a/src/hooks/useVideoEditor.ts
+++ b/src/hooks/useVideoEditor.ts
@@ -183,7 +183,7 @@ export function useVideoEditor() {
const [overlayOpacity, setOverlayOpacity] = useState(100);
const [currentTime, setCurrentTime] = useState(0);
const updateRecipe = useCallback((patch: Partial
) => {
- setRecipe((prev) => {
+ setRecipe((prev: EditRecipe) => {
const next = { ...prev, ...patch };
// GIF has no audio — force keepAudio off
if (next.format === "gif") {
@@ -255,7 +255,7 @@ export function useVideoEditor() {
});
if (Object.keys(updatedPatch).length > 0) {
- setRecipe(prev => ({
+ setRecipe((prev: EditRecipe) => ({
...prev,
...updatedPatch
}));
@@ -283,7 +283,7 @@ export function useVideoEditor() {
const n = Number(val);
return Number.isFinite(n) && n >= 16 && n <= 7680 ? n : fallback;
};
- setRecipe(prev => ({
+ setRecipe((prev: EditRecipe) => ({
...prev,
preset: parsed.preset ?? prev.preset,
quality: parsed.quality ?? prev.quality,
@@ -424,7 +424,7 @@ export function useVideoEditor() {
if (dimensionCheck === "warning") {
console.warn(`[Reframe] High resolution video detected (${width}×${height}). Export may be slow.`);
}
- setRecipe((prev) => {
+ setRecipe((prev: EditRecipe) => {
const suggestedPreset = suggestPreset(width, height);
const shouldApplySuggestion = prev.preset === DEFAULT_RECIPE.preset;
@@ -621,7 +621,7 @@ export function useVideoEditor() {
return;
}
- setRecipe((prev) => ({ ...prev, keepAudio: !prev.keepAudio }));
+ setRecipe((prev: EditRecipe) => ({ ...prev, keepAudio: !prev.keepAudio }));
};
document.addEventListener("keydown", handleMuteShortcut);
@@ -734,6 +734,8 @@ export function useVideoEditor() {
setOverlayOpacity,
recommendedPreset,
currentTime,
+ videoRef,
+ seekTo,
toggleSound,
};
}
diff --git a/src/lib/ffmpeg.ts b/src/lib/ffmpeg.ts
index ede6e363..fc86838a 100644
--- a/src/lib/ffmpeg.ts
+++ b/src/lib/ffmpeg.ts
@@ -1,4 +1,5 @@
import { EditRecipe, ExportResult, BackgroundMusicOptions, ImageOverlayOptions } from "./types";
+import { buildTextFilter } from "./text-overlay";
import { getPresetById } from "./presets";
const FFMPEG_WORKER_URL =
typeof window !== "undefined"
@@ -182,9 +183,14 @@ async function ensureWorker() {
export async function loadFFmpeg(
- signal?: AbortSignal,
+ opts?: AbortSignal | { signal?: AbortSignal; retryDelayMs?: number; timeoutMs?: number },
onProgress?: (percent: number) => void
): Promise {
+ const signal: AbortSignal | undefined =
+ opts && typeof (opts as AbortSignal).addEventListener === "function"
+ ? (opts as AbortSignal)
+ : (opts as any)?.signal;
+
await ensureWorker();
if (workerReady && workerReadyResolve === null) {
diff --git a/src/types/shims.d.ts b/src/types/shims.d.ts
new file mode 100644
index 00000000..81639347
--- /dev/null
+++ b/src/types/shims.d.ts
@@ -0,0 +1,30 @@
+declare namespace React {
+ type ReactNode = any;
+ type ReactElement = any;
+ type FC = any;
+ interface RefObject {
+ current: T | null;
+ }
+}
+
+declare module "react" {
+ export const useState: any;
+ export const useEffect: any;
+ export const useRef: any;
+ export const useCallback: any;
+ export const useMemo: any;
+ const ReactNamespace: any;
+ export default ReactNamespace;
+}
+
+declare namespace JSX {
+ interface IntrinsicElements {
+ [elemName: string]: any;
+ }
+}
+
+declare module "lucide-react";
+declare module "@ffmpeg/ffmpeg";
+declare module "wasm-feature-detect";
+
+export {};
diff --git a/tsconfig.json b/tsconfig.json
index 5fe25c1c..10789bc7 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -14,7 +14,6 @@
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
- "types": ["bun-types"],
"plugins": [
{
"name": "next"