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 && ( -
+
-

Error

+

+ {errorInfo?.code ? "Video engine failed to load" : "Error"} +

{error}

+ {errorInfo?.troubleshootingUrl && ( + + Learn more about troubleshooting + + )}
{!error.includes("Validation Failed") && ( )}
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 ( + }
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 (
{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"