From 4f4c3c0e63a44ae72860cf56e458321784d995da Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 14 Sep 2026 22:48:49 +0000 Subject: [PATCH 1/2] Fix desktop 1080p/original export by using growable ffmpeg core The multi-threaded @ffmpeg/core-mt hard-commits a fixed 1 GiB shared heap, which is what made high-res export fail in the desktop app (Discord: "ffmpeg not starting") after PR #92 stopped the hang. Ship @ffmpeg/core for export (growable to 2 GiB), keep MT for audio extraction with ST fallback, and drop the transcription worker once ASR finishes so its ORT heaps are not still competing for memory. Co-authored-by: Wassim Gharbi --- hooks/useTranscriber.ts | 7 ++ lib/ffmpeg.ts | 234 +++++++++++++++++++++++++------------- package-lock.json | 10 ++ package.json | 3 +- patches/README.md | 8 ++ scripts/copy-assets.mjs | 27 +++-- tests/ffmpeg-core-test.ts | 57 ++++++++++ 7 files changed, 255 insertions(+), 91 deletions(-) create mode 100644 tests/ffmpeg-core-test.ts diff --git a/hooks/useTranscriber.ts b/hooks/useTranscriber.ts index 2c9d18de..ca50235c 100644 --- a/hooks/useTranscriber.ts +++ b/hooks/useTranscriber.ts @@ -67,6 +67,13 @@ export function useTranscriber() { model, language: transcriptLanguage, }); + // Drop the worker (and its ORT heaps) before the user exports. + // Every transcribe() already starts a fresh worker, so keeping this + // one alive only competes with ffmpeg.wasm for renderer memory — + // especially painful in Electron, where a second 1 GiB core load + // after ASR is what made "ffmpeg not starting" show up on export. + cancelTranscription(); + workerRef.current = null; break; case "error": s.setError(msg.message); diff --git a/lib/ffmpeg.ts b/lib/ffmpeg.ts index c7f666be..15f51d18 100644 --- a/lib/ffmpeg.ts +++ b/lib/ffmpeg.ts @@ -5,7 +5,23 @@ import { en } from "@/lib/i18n/messages/en"; import { hasWasmSimd } from "@/lib/wasmFeatures"; import type { TimeRange } from "./types"; -const CORE_BASE = "/vendor/ffmpeg"; +/** + * Which ffmpeg.wasm core binary to load. + * + * - `"mt"` — `@ffmpeg/core-mt`: multi-threaded, fixed 1 GiB shared heap. + * Faster, but the heap is committed up front and cannot grow, so 1080p / + * original exports reliably OOM (or fail to instantiate under Electron + * memory pressure after ASR). Companion to the hang-watchdog in PR #92. + * - `"st"` — `@ffmpeg/core`: single-threaded, growable 32 MiB → 2 GiB. + * Slower encode, but the headroom that high-res export needs. + */ +export type FFmpegCoreKind = "mt" | "st"; + +const CORE_BASE: Record = { + mt: "/vendor/ffmpeg", + st: "/vendor/ffmpeg-st", +}; + const INPUT_NAME = "input_video"; const MOUNT_DIR = "/mnt_input"; @@ -39,96 +55,147 @@ const EXEC_STALL_TIMEOUT_MS = 120_000; const LOAD_TIMEOUT_MS = 60_000; let ffmpegPromise: Promise | null = null; +/** Which core `ffmpegPromise` resolved (or is resolving) to. */ +let loadedKind: FFmpegCoreKind | null = null; let writtenFor: File | null = null; /** Path `writtenFor`'s media is readable at, and whether it came from a mount. */ let inputPath = INPUT_NAME; let inputMounted = false; -/** Lazily load a singleton multi-threaded ffmpeg.wasm instance. */ -export async function getFFmpeg(): Promise { - if (!ffmpegPromise) { - ffmpegPromise = (async () => { - const [{ FFmpeg }, { toBlobURL }] = await Promise.all([ - import("@ffmpeg/ffmpeg"), - import("@ffmpeg/util"), - ]); - // Multi-threaded ffmpeg.wasm needs SharedArrayBuffer, i.e. a - // cross-origin-isolated page (COOP/COEP from vercel.json on the web, from - // the app:// handler in Electron). Without it the core throws a bare - // "SharedArrayBuffer is not defined" from deep inside the worker. - if (!self.crossOriginIsolated || typeof SharedArrayBuffer === "undefined") { - throw new Error(en["error.mediaEngineNotReady"]); - } - // ffmpeg-core is a SIMD build, so on an engine without it the core fails - // to compile — as an emscripten abort() wrapping "CompileError: - // ... Wasm SIMD unsupported", which reaches the caller as an opaque - // string and ends up shown as the generic "Failed to process this file." - // Check first so the message names the actual problem. UploadScreen gates - // on the same check, so this only fires if support changed underneath us. - if (!hasWasmSimd()) { - throw new Error(en["error.simdUnsupported"]); - } - const ffmpeg = new FFmpeg(); - const coreURL = await toBlobURL( - `${CORE_BASE}/ffmpeg-core.js`, - "text/javascript" - ); - const wasmURL = await toBlobURL( - `${CORE_BASE}/ffmpeg-core.wasm`, - "application/wasm" - ); - const workerURL = await toBlobURL( - `${CORE_BASE}/ffmpeg-core.worker.js`, - "text/javascript" - ); - // A corrupt / stale cached wasm (common right after an in-app update - // before the session cache is cleared) makes `load` hang forever, which - // looked like the app freezing on "add media". Bound it so the user gets - // an error they can recover from instead of an endless spinner. - await new Promise((resolve, reject) => { - const timer = setTimeout(() => { - reject(new Error(en["error.mediaEngineStalled"])); - }, LOAD_TIMEOUT_MS); - ffmpeg - .load({ - coreURL, - wasmURL, - workerURL, - // Served same-origin (copied on postinstall): the bundled class worker - // contains a dynamic import() that Next's bundler cannot handle. - classWorkerURL: new URL( - "/vendor/ffmpeg-class/worker.js", - location.href - ).href, - }) - .then(() => { - clearTimeout(timer); - resolve(); - }) - .catch((err: unknown) => { - clearTimeout(timer); - reject(err); - }); +/** Public asset directory for a core kind — exported for tests / copy-assets. */ +export function coreAssetBase(kind: FFmpegCoreKind): string { + return CORE_BASE[kind]; +} + +/** + * Core used for video/audio export. Always the growable single-threaded build: + * the multi-threaded 1 GiB ceiling is what made 1080p/original fail in the + * desktop app (and hang before the PR #92 watchdog). + */ +export function exportCoreKind(): FFmpegCoreKind { + return "st"; +} + +async function loadFFmpegInstance(kind: FFmpegCoreKind): Promise { + const [{ FFmpeg }, { toBlobURL }] = await Promise.all([ + import("@ffmpeg/ffmpeg"), + import("@ffmpeg/util"), + ]); + // Multi-threaded ffmpeg.wasm needs SharedArrayBuffer, i.e. a + // cross-origin-isolated page (COOP/COEP from vercel.json on the web, from + // the app:// handler in Electron). Without it the core throws a bare + // "SharedArrayBuffer is not defined" from deep inside the worker. The + // single-threaded core does not need SAB for pthreads, but the rest of the + // editor (ORT) still does, and UploadScreen already gates on isolation. + if (!self.crossOriginIsolated || typeof SharedArrayBuffer === "undefined") { + throw new Error(en["error.mediaEngineNotReady"]); + } + // Both cores are SIMD builds, so on an engine without it they fail to + // compile — as an emscripten abort() wrapping "CompileError: ... Wasm SIMD + // unsupported", which reaches the caller as an opaque string and ends up + // shown as the generic "Failed to process this file." Check first so the + // message names the actual problem. UploadScreen gates on the same check, + // so this only fires if support changed underneath us. + if (!hasWasmSimd()) { + throw new Error(en["error.simdUnsupported"]); + } + const base = CORE_BASE[kind]; + const ffmpeg = new FFmpeg(); + const coreURL = await toBlobURL(`${base}/ffmpeg-core.js`, "text/javascript"); + const wasmURL = await toBlobURL( + `${base}/ffmpeg-core.wasm`, + "application/wasm" + ); + // Only the multi-threaded build ships a pthread worker. + const workerURL = + kind === "mt" + ? await toBlobURL(`${base}/ffmpeg-core.worker.js`, "text/javascript") + : undefined; + // A corrupt / stale cached wasm (common right after an in-app update + // before the session cache is cleared) makes `load` hang forever, which + // looked like the app freezing on "add media". Bound it so the user gets + // an error they can recover from instead of an endless spinner. + await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(new Error(en["error.mediaEngineStalled"])); + }, LOAD_TIMEOUT_MS); + ffmpeg + .load({ + coreURL, + wasmURL, + ...(workerURL ? { workerURL } : {}), + // Served same-origin (copied on postinstall): the bundled class worker + // contains a dynamic import() that Next's bundler cannot handle. + classWorkerURL: new URL( + "/vendor/ffmpeg-class/worker.js", + location.href + ).href, + }) + .then(() => { + clearTimeout(timer); + resolve(); + }) + .catch((err: unknown) => { + clearTimeout(timer); + reject(err); }); - return ffmpeg; - })(); - ffmpegPromise.catch(() => { - ffmpegPromise = null; - }); + }); + return ffmpeg; +} + +/** + * Lazily load a singleton ffmpeg.wasm instance. + * + * `kind` selects the core binary. If a different kind is already loaded it is + * terminated first. Requesting `"mt"` falls back to `"st"` when the fixed 1 GiB + * shared heap cannot be allocated — the failure mode Discord reported as + * "ffmpeg not starting" in the desktop app after transcription. + */ +export async function getFFmpeg(kind: FFmpegCoreKind = "mt"): Promise { + // A growable ST instance already satisfies any MT request, and swapping back + // to MT would re-introduce the 1 GiB allocation that may have just failed. + if (ffmpegPromise && (loadedKind === kind || loadedKind === "st")) { + return ffmpegPromise; + } + if (ffmpegPromise) { + await releaseFFmpeg(); } + + const requested = kind; + loadedKind = requested; + ffmpegPromise = (async () => { + try { + return await loadFFmpegInstance(requested); + } catch (err) { + if (requested !== "mt") throw err; + // MT instantiate failed (typically can't reserve the 1 GiB SharedArrayBuffer). + // Hand the half-built worker back and retry with the growable core. + console.warn( + "Multi-threaded ffmpeg core failed to load; retrying single-threaded.", + err + ); + loadedKind = "st"; + return await loadFFmpegInstance("st"); + } + })(); + ffmpegPromise.catch(() => { + ffmpegPromise = null; + loadedKind = null; + }); return ffmpegPromise; } /** * Terminate the ffmpeg worker and hand its heap back to the browser. * - * ffmpeg-core is built with INITIAL_MEMORY === MAXIMUM_MEMORY === 1 GiB on a - * shared WebAssembly.Memory, so the full gigabyte is committed the moment the - * core instantiates and never shrinks — deleting MEMFS files frees nothing. - * Held across transcription it sits alongside onnxruntime's heap, the model - * weights and the decoded PCM, and WebKit kills the tab for it ("This webpage - * was reloaded because it was using significant memory"). Nothing needs ffmpeg - * between audio extraction and export, so drop it there and pay one re-init. + * The multi-threaded core is built with INITIAL_MEMORY === MAXIMUM_MEMORY === + * 1 GiB on a shared WebAssembly.Memory, so the full gigabyte is committed the + * moment the core instantiates and never shrinks — deleting MEMFS files frees + * nothing. Held across transcription it sits alongside onnxruntime's heap, the + * model weights and the decoded PCM, and WebKit kills the tab for it ("This + * webpage was reloaded because it was using significant memory"). Nothing needs + * ffmpeg between audio extraction and export, so drop it there and pay one + * re-init (export then loads the growable single-threaded core). */ export async function releaseFFmpeg(): Promise { const pending = ffmpegPromise; @@ -136,6 +203,7 @@ export async function releaseFFmpeg(): Promise { // Clear first so a concurrent getFFmpeg() builds a fresh instance rather than // handing out the one we are about to terminate. ffmpegPromise = null; + loadedKind = null; writtenFor = null; // The worker owns the filesystem, so its mounts and MEMFS files die with it. inputMounted = false; @@ -287,7 +355,9 @@ async function ensureInput(ffmpeg: FFmpeg, file: File): Promise { * has no audio track — those still open for editing with an empty transcript. */ export async function extractAudio(file: File): Promise { - const ffmpeg = await getFFmpeg(); + // Prefer the multi-threaded core for decode speed; getFFmpeg falls back to + // the growable single-threaded build if the 1 GiB shared heap won't allocate. + const ffmpeg = await getFFmpeg("mt"); const input = await ensureInput(ffmpeg, file); const out = "audio.pcm"; let sawAudioStream = false; @@ -378,7 +448,9 @@ export async function exportVideo( if (keepRanges.length === 0) { throw new Error(en["error.nothingToExport"]); } - const ffmpeg = await getFFmpeg(); + // Growable heap: the MT core's fixed 1 GiB is what failed 1080p/original + // exports (Discord: "ffmpeg not starting" in the desktop app; browser OK). + const ffmpeg = await getFFmpeg(exportCoreKind()); const input = await ensureInput(ffmpeg, file); const out = format === "webm" ? "output.webm" : "output.mp4"; const scale = scaleFilter(resolution); @@ -464,7 +536,7 @@ export async function exportAudio( if (keepRanges.length === 0) { throw new Error(en["error.nothingToExport"]); } - const ffmpeg = await getFFmpeg(); + const ffmpeg = await getFFmpeg(exportCoreKind()); const input = await ensureInput(ffmpeg, file); const out = format === "mp3" ? "output.mp3" : format === "wav" ? "output.wav" : "output.m4a"; diff --git a/package-lock.json b/package-lock.json index eac61cd6..9bf0a71a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "license": "PolyForm-Noncommercial-1.0.0", "dependencies": { "@chatoctopus/timeline": "^0.3.0", + "@ffmpeg/core": "0.12.10", "@ffmpeg/core-mt": "^0.12.10", "@ffmpeg/ffmpeg": "0.12.15", "@ffmpeg/util": "^0.12.2", @@ -1471,6 +1472,15 @@ "node": ">=14" } }, + "node_modules/@ffmpeg/core": { + "version": "0.12.10", + "resolved": "https://registry.npmjs.org/@ffmpeg/core/-/core-0.12.10.tgz", + "integrity": "sha512-dzNplnn2Nxle2c2i2rrDhqcB19q9cglCkWnoMTDN9Q9l3PvdjZWd1HfSPjCNWc/p8Q3CT+Es9fWOR0UhAeYQZA==", + "license": "GPL-2.0-or-later", + "engines": { + "node": ">=16.x" + } + }, "node_modules/@ffmpeg/core-mt": { "version": "0.12.10", "resolved": "https://registry.npmjs.org/@ffmpeg/core-mt/-/core-mt-0.12.10.tgz", diff --git a/package.json b/package.json index 0f1ee1c0..703d85d6 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "lint": "eslint", "test:i18n": "tsx tests/i18n-test.ts", "test:timeline": "tsx tests/serialize-timeline-test.ts", - "test:ffmpeg": "tsx tests/ffmpeg-watchdog-test.ts", + "test:ffmpeg": "tsx tests/ffmpeg-watchdog-test.ts && tsx tests/ffmpeg-core-test.ts", "postinstall": "patch-package && node scripts/copy-assets.mjs", "build:electron": "node scripts/build-electron.mjs", "typecheck:electron": "tsc -p electron/tsconfig.json --noEmit", @@ -37,6 +37,7 @@ }, "dependencies": { "@chatoctopus/timeline": "^0.3.0", + "@ffmpeg/core": "0.12.10", "@ffmpeg/core-mt": "^0.12.10", "@ffmpeg/ffmpeg": "0.12.15", "@ffmpeg/util": "^0.12.2", diff --git a/patches/README.md b/patches/README.md index 78cfbf75..0244dc1d 100644 --- a/patches/README.md +++ b/patches/README.md @@ -105,5 +105,13 @@ This covers the class worker dying. A trap inside a nested emscripten pthread does not bubble to the parent `Worker`, so `lib/ffmpeg.ts` also runs a liveness watchdog over `exec()`; see the comment on `execWithWatchdog` there. +**Follow-up (export memory).** The watchdog stops the hang; it does not raise +the 1 GiB ceiling. Video/audio export now loads the single-threaded +`@ffmpeg/core` (growable up to 2 GiB) from `/vendor/ffmpeg-st/`, while audio +extraction still prefers `@ffmpeg/core-mt` and falls back to the growable core +when the 1 GiB `SharedArrayBuffer` cannot be reserved. That is what made +desktop-app 1080p/original export fail with "ffmpeg not starting" while the +same project exported in the browser. + **Upstreaming.** Worth a PR — this is a bug in any consumer, not something specific to Rescript. diff --git a/scripts/copy-assets.mjs b/scripts/copy-assets.mjs index 1d181939..003fba17 100644 --- a/scripts/copy-assets.mjs +++ b/scripts/copy-assets.mjs @@ -1,10 +1,11 @@ /** * Copies WASM runtime assets from node_modules into public/ so the app can be * served fully offline (no CDN requests at runtime): - * - @ffmpeg/core-mt -> public/vendor/ffmpeg/ (audio extraction + export) - * - onnxruntime-web -> public/vendor/ort/ (transformers.js inference) + * - @ffmpeg/core-mt -> public/vendor/ffmpeg/ (fast audio extraction) + * - @ffmpeg/core -> public/vendor/ffmpeg-st/ (growable heap for export) + * - onnxruntime-web -> public/vendor/ort/ (transformers.js inference) * - parakeet.js ORT -> public/vendor/ort-parakeet/ (Parakeet TDT inference) - * - assets/aaf -> public/vendor/aaf/ (Pro Tools / Logic AAF scaffold) + * - assets/aaf -> public/vendor/aaf/ (Pro Tools / Logic AAF scaffold) * Runs automatically on `npm install` (postinstall). */ import { @@ -20,13 +21,21 @@ import { fileURLToPath } from "node:url"; const root = join(dirname(fileURLToPath(import.meta.url)), ".."); -const ffmpegSrc = join(root, "node_modules/@ffmpeg/core-mt/dist/esm"); -const ffmpegDst = join(root, "public/vendor/ffmpeg"); -mkdirSync(ffmpegDst, { recursive: true }); -for (const f of readdirSync(ffmpegSrc)) { - cpSync(join(ffmpegSrc, f), join(ffmpegDst, f)); +function copyFfmpegCore(pkgName, dstName) { + const src = join(root, "node_modules", pkgName, "dist/esm"); + const dst = join(root, "public/vendor", dstName); + mkdirSync(dst, { recursive: true }); + for (const f of readdirSync(src)) { + cpSync(join(src, f), join(dst, f)); + } } +// Multi-threaded: fixed 1 GiB shared heap — fine for audio extraction, too +// small for many 1080p/original video exports (see lib/ffmpeg.ts). +copyFfmpegCore("@ffmpeg/core-mt", "ffmpeg"); +// Single-threaded: growable up to 2 GiB — used for video/audio export. +copyFfmpegCore("@ffmpeg/core", "ffmpeg-st"); + // The @ffmpeg/ffmpeg "class worker" contains a dynamic import() that bundlers // cannot process; serve the package's own ESM build and point classWorkerURL // at it instead (see lib/ffmpeg.ts). @@ -112,5 +121,5 @@ for (const f of readdirSync(aafSrc)) { } console.log( - "[copy-assets] ffmpeg core + onnxruntime wasm + aaf scaffold copied to public/" + "[copy-assets] ffmpeg cores (mt+st) + onnxruntime wasm + aaf scaffold copied to public/" ); diff --git a/tests/ffmpeg-core-test.ts b/tests/ffmpeg-core-test.ts new file mode 100644 index 00000000..23fc043a --- /dev/null +++ b/tests/ffmpeg-core-test.ts @@ -0,0 +1,57 @@ +/** + * Regression: video export must use the growable single-threaded ffmpeg core. + * + * `@ffmpeg/core-mt` hard-declares a fixed 1 GiB shared WebAssembly.Memory. + * High-res exports (1080p / original) and Electron sessions that still hold + * ASR heaps either fail to instantiate that buffer ("ffmpeg not starting") or + * OOM mid-encode. `@ffmpeg/core` grows 32 MiB → 2 GiB and is what export + * selects; copy-assets must ship both binaries. + */ +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import { + coreAssetBase, + exportCoreKind, + type FFmpegCoreKind, +} from "../lib/ffmpeg"; + +function assert(value: unknown, message: string): asserts value { + if (!value) throw new Error(message); +} + +function main() { + assert(exportCoreKind() === "st", "export uses the single-threaded core"); + assert( + coreAssetBase("st") === "/vendor/ffmpeg-st", + "ST assets are served from /vendor/ffmpeg-st" + ); + assert( + coreAssetBase("mt") === "/vendor/ffmpeg", + "MT assets stay at /vendor/ffmpeg" + ); + + const root = join(import.meta.dirname, ".."); + const required: Record = { + mt: ["ffmpeg-core.js", "ffmpeg-core.wasm", "ffmpeg-core.worker.js"], + st: ["ffmpeg-core.js", "ffmpeg-core.wasm"], + }; + for (const kind of Object.keys(required) as FFmpegCoreKind[]) { + const dir = join(root, "public", ...coreAssetBase(kind).split("/").filter(Boolean)); + for (const file of required[kind]) { + assert( + existsSync(join(dir, file)), + `missing ${kind} asset ${file} under ${dir} (run postinstall / copy-assets)` + ); + } + if (kind === "st") { + assert( + !existsSync(join(dir, "ffmpeg-core.worker.js")), + "single-threaded core must not ship a pthread worker" + ); + } + } + + console.log("ALL FFMPEG CORE TESTS PASSED"); +} + +main(); From aecf17d2d728c6773dbd10e991ee55459fdd72f0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 14 Sep 2026 22:49:42 +0000 Subject: [PATCH 2/2] =?UTF-8?q?Terminate=20half-loaded=20ffmpeg=20workers?= =?UTF-8?q?=20before=20MT=E2=86=92ST=20fallback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit If the multi-threaded core fails mid-load, drop its class worker before retrying the growable single-threaded core so the failed attempt does not keep its allocation. Co-authored-by: Wassim Gharbi --- lib/ffmpeg.ts | 89 +++++++++++++++++++++++++++++---------------------- 1 file changed, 50 insertions(+), 39 deletions(-) diff --git a/lib/ffmpeg.ts b/lib/ffmpeg.ts index 15f51d18..5d1d306f 100644 --- a/lib/ffmpeg.ts +++ b/lib/ffmpeg.ts @@ -101,45 +101,56 @@ async function loadFFmpegInstance(kind: FFmpegCoreKind): Promise { } const base = CORE_BASE[kind]; const ffmpeg = new FFmpeg(); - const coreURL = await toBlobURL(`${base}/ffmpeg-core.js`, "text/javascript"); - const wasmURL = await toBlobURL( - `${base}/ffmpeg-core.wasm`, - "application/wasm" - ); - // Only the multi-threaded build ships a pthread worker. - const workerURL = - kind === "mt" - ? await toBlobURL(`${base}/ffmpeg-core.worker.js`, "text/javascript") - : undefined; - // A corrupt / stale cached wasm (common right after an in-app update - // before the session cache is cleared) makes `load` hang forever, which - // looked like the app freezing on "add media". Bound it so the user gets - // an error they can recover from instead of an endless spinner. - await new Promise((resolve, reject) => { - const timer = setTimeout(() => { - reject(new Error(en["error.mediaEngineStalled"])); - }, LOAD_TIMEOUT_MS); - ffmpeg - .load({ - coreURL, - wasmURL, - ...(workerURL ? { workerURL } : {}), - // Served same-origin (copied on postinstall): the bundled class worker - // contains a dynamic import() that Next's bundler cannot handle. - classWorkerURL: new URL( - "/vendor/ffmpeg-class/worker.js", - location.href - ).href, - }) - .then(() => { - clearTimeout(timer); - resolve(); - }) - .catch((err: unknown) => { - clearTimeout(timer); - reject(err); - }); - }); + try { + const coreURL = await toBlobURL(`${base}/ffmpeg-core.js`, "text/javascript"); + const wasmURL = await toBlobURL( + `${base}/ffmpeg-core.wasm`, + "application/wasm" + ); + // Only the multi-threaded build ships a pthread worker. + const workerURL = + kind === "mt" + ? await toBlobURL(`${base}/ffmpeg-core.worker.js`, "text/javascript") + : undefined; + // A corrupt / stale cached wasm (common right after an in-app update + // before the session cache is cleared) makes `load` hang forever, which + // looked like the app freezing on "add media". Bound it so the user gets + // an error they can recover from instead of an endless spinner. + await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(new Error(en["error.mediaEngineStalled"])); + }, LOAD_TIMEOUT_MS); + ffmpeg + .load({ + coreURL, + wasmURL, + ...(workerURL ? { workerURL } : {}), + // Served same-origin (copied on postinstall): the bundled class worker + // contains a dynamic import() that Next's bundler cannot handle. + classWorkerURL: new URL( + "/vendor/ffmpeg-class/worker.js", + location.href + ).href, + }) + .then(() => { + clearTimeout(timer); + resolve(); + }) + .catch((err: unknown) => { + clearTimeout(timer); + reject(err); + }); + }); + } catch (err) { + // Drop a half-started class worker before the MT→ST fallback retries, + // otherwise the failed attempt keeps whatever it allocated. + try { + ffmpeg.terminate(); + } catch { + // Already gone. + } + throw err; + } return ffmpeg; }