diff --git a/README.md b/README.md index 6e4a29a8..7432bfdc 100644 --- a/README.md +++ b/README.md @@ -297,6 +297,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/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 1e4e9f0d..e1611dca 100644 --- a/src/components/VideoEditor.tsx +++ b/src/components/VideoEditor.tsx @@ -32,51 +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) { - return ( -
-
- {icon} -

- {title} -

-
-
- {children} -
- ); -} +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); -/** 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 (
{children}
@@ -108,6 +83,8 @@ function AccordionSection({ ); } +const AccordionSection = Section; + /** Inline keyboard hint badge. */ function Kbd({ children }: { children: React.ReactNode }) { return ( @@ -198,15 +175,32 @@ function KeyboardShortcutsPanel() { export default function VideoEditor() { const { - file, duration, recipe, status, progress, - result, error, exportStartedAt, updateRecipe, - handleFileSelect, fileError, handleExport, cancelExport, reset, resetSettings, + file, + duration, + recipe, + status, + progress, + result, + error, + errorInfo, + exportStartedAt, + updateRecipe, + handleFileSelect, + fileError, + handleExport, + cancelExport, + reset, + resetSettings, videoRef, seekTo, - overlayFile, setOverlayFile, - overlayPosition, setOverlayPosition, - overlaySize, setOverlaySize, - overlayOpacity, setOverlayOpacity, + overlayFile, + setOverlayFile, + overlayPosition, + setOverlayPosition, + overlaySize, + setOverlaySize, + overlayOpacity, + setOverlayOpacity, recommendedPreset, currentTime, toggleSound, @@ -584,28 +578,42 @@ export default function VideoEditor() { {status === "error" && 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 a86c35f2..0db917f8 100644 --- a/src/hooks/useVideoEditor.ts +++ b/src/hooks/useVideoEditor.ts @@ -131,6 +131,13 @@ function decodeRecipe(encoded: string): Partial | null { } } +interface ExportErrorInfo { + code?: string; + userMessage: string; + debugMessage?: string; + troubleshootingUrl?: string; +} + export function useVideoEditor() { const [file, setFile] = useState(null); const [duration, setDuration] = useState(0); @@ -158,6 +165,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 [fileError, setFileError] = useState(""); const [exportStartedAt, setExportStartedAt] = useState(null); const exportAbortControllerRef = useRef(null); @@ -175,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") { @@ -247,7 +255,7 @@ export function useVideoEditor() { }); if (Object.keys(updatedPatch).length > 0) { - setRecipe(prev => ({ + setRecipe((prev: EditRecipe) => ({ ...prev, ...updatedPatch })); @@ -275,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, @@ -355,6 +363,7 @@ export function useVideoEditor() { setResult(null); setStatus("idle"); setError(null); + setErrorInfo(null); setFile(null); setVideoMetadata(null); if (!selectedFile.type.startsWith("video/")) { @@ -415,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; @@ -453,7 +462,7 @@ export function useVideoEditor() { setStatus("loading-engine"); setProgress(0); setError(null); - setExportStartedAt(null); + setErrorInfo(null); if (result?.blobUrl) URL.revokeObjectURL(result.blobUrl); setResult(null); @@ -494,13 +503,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.', + }); } setExportStartedAt(null); setStatus("error"); @@ -595,7 +621,7 @@ export function useVideoEditor() { return; } - setRecipe((prev) => ({ ...prev, keepAudio: !prev.keepAudio })); + setRecipe((prev: EditRecipe) => ({ ...prev, keepAudio: !prev.keepAudio })); }; document.addEventListener("keydown", handleMuteShortcut); @@ -635,7 +661,7 @@ export function useVideoEditor() { setStatus("idle"); setProgress(0); setError(null); - setExportStartedAt(null); + setErrorInfo(null); }, []); @@ -649,12 +675,7 @@ export function useVideoEditor() { setProgress(0); setResult(null); setError(null); - setExportStartedAt(null); - try { - localStorage.removeItem(STORAGE_KEY); - } catch { - // ignore - } + setErrorInfo(null); }, [result]); @@ -687,8 +708,7 @@ export function useVideoEditor() { exportStartedAt, result, error, - videoRef, - seekTo, + errorInfo, updateRecipe, handleFileSelect, fileError, @@ -714,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 5cac4044..fc86838a 100644 --- a/src/lib/ffmpeg.ts +++ b/src/lib/ffmpeg.ts @@ -1,14 +1,39 @@ import { EditRecipe, ExportResult, BackgroundMusicOptions, ImageOverlayOptions } from "./types"; -import { getPresetById } from "./presets"; import { buildTextFilter } from "./text-overlay"; - -export class FFmpegLoadError extends Error {} - +import { getPresetById } from "./presets"; const FFMPEG_WORKER_URL = typeof window !== "undefined" ? new URL("./ffmpeg.worker.ts", import.meta.url) : 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 class FFmpegLoadError extends Error { + code?: FFmpegLoadErrorCode; + userMessage: string; + context?: FFmpegLoadErrorContext; + + constructor(userMessage: string, code?: FFmpegLoadErrorCode, context?: FFmpegLoadErrorContext) { + super(userMessage); + this.name = "FFmpegLoadError"; + this.userMessage = userMessage; + this.code = code; + this.context = context; + } +} + type SerializedFile = { name: string; type: string; @@ -156,10 +181,16 @@ 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) { @@ -190,12 +221,14 @@ export async function loadFFmpeg( signal?.addEventListener("abort", onAbort, { once: true }); + let coreName = "ffmpeg-core"; try { await workerReady; } finally { cleanup(); } -} + } + function cancelPendingExport(reason?: unknown) { if (pendingExport) { 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 c2e42e74..6a19ff1e 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/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/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 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"