import { FFmpeg } from "@ffmpeg/ffmpeg"; import { fetchFile, toBlobURL } from "@ffmpeg/util"; // Single-threaded core: works without SharedArrayBuffer, so no COOP/COEP // cross-origin-isolation headers are required. Slower than the MT core, which // is fine for occasional admin-side transcodes. const CORE_VERSION = "0.12.10"; // @ffmpeg/ffmpeg 0.12.x always spawns a `type: "module"` worker. A module // worker can't `importScripts`, so the worker dynamically `import()`s the core — // which means the core must be the ESM build, not UMD. const CORE_BASE = `https://unpkg.com/@ffmpeg/core@${CORE_VERSION}/dist/esm`; // Self-hosted worker, bundled from @ffmpeg/ffmpeg's ESM `worker.js` via the // `build:ffmpeg-worker` package script (rerun it when bumping @ffmpeg/ffmpeg). // The shipped UMD worker is unusable here: its // dynamic import is webpack-compiled into a stub that throws MODULE_NOT_FOUND, // and letting Next bundle the worker hits the same problem. Serving our own // same-origin module worker keeps the native `import()` intact. // Must be an absolute URL: the library resolves classWorkerURL against // `import.meta.url`, which is a file:// base under Next, so a root-relative path // would wrongly become `file:///ffmpeg/worker.js`. const WORKER_PATH = "/ffmpeg/worker.js"; let instance: FFmpeg | null = null; let loadPromise: Promise | null = null; function load(onLog?: (msg: string) => void): Promise { if (instance) return Promise.resolve(instance); if (!loadPromise) { loadPromise = (async () => { const ffmpeg = new FFmpeg(); ffmpeg.on("log", ({ message }) => { console.debug("[ffmpeg]", message); onLog?.(message); }); const ok = await ffmpeg.load({ coreURL: await toBlobURL(`${CORE_BASE}/ffmpeg-core.js`, "text/javascript"), wasmURL: await toBlobURL(`${CORE_BASE}/ffmpeg-core.wasm`, "application/wasm"), classWorkerURL: new URL(WORKER_PATH, window.location.origin).href, }); if (!ok) throw new Error("ffmpeg.load() returned false — core failed to initialise"); instance = ffmpeg; return ffmpeg; })(); // If loading fails, clear the cached promise so the next click retries. loadPromise.catch(() => { loadPromise = null; }); } return loadPromise; } /** * Transcodes a (FLAC) source URL to a streaming-friendly AAC/m4a file in the * browser. Returns a File ready to hand to UploadThing's startUpload. */ export async function transcodeToAac(opts: { sourceUrl: string; outputName?: string; bitrate?: string; onProgress?: (ratio: number) => void; onLog?: (msg: string) => void; }): Promise { let lastLog = ""; const onLog = (msg: string) => { if (msg.trim()) lastLog = msg; opts.onLog?.(msg); }; const ffmpeg = await load(onLog); // `load` only wires the persisted instance's log handler on first init; make // sure this call's logs are captured even when the instance was cached. const logHandler = ({ message }: { message: string }) => onLog(message); ffmpeg.on("log", logHandler); const progressHandler = opts.onProgress ? ({ progress }: { progress: number }) => opts.onProgress?.(Math.min(1, Math.max(0, progress))) : null; if (progressHandler) ffmpeg.on("progress", progressHandler); try { // fetchFile pulls the source cross-origin; surface a clear message if the // host (UploadThing) blocks it. let sourceData: Uint8Array; try { sourceData = await fetchFile(opts.sourceUrl); } catch (e) { throw new Error( `Could not fetch source file (CORS or network): ${e instanceof Error ? e.message : String(e)}`, ); } await ffmpeg.writeFile("input.flac", sourceData); const code = await ffmpeg.exec([ "-i", "input.flac", "-c:a", "aac", "-b:a", opts.bitrate ?? "192k", // Move the moov atom to the front so the player can start before the // whole file has downloaded — the whole point of the stream version. "-movflags", "+faststart", "output.m4a", ]); if (code !== 0) { throw new Error(`ffmpeg exited with code ${code}: ${lastLog || "no log output"}`); } const data = await ffmpeg.readFile("output.m4a"); await ffmpeg.deleteFile("input.flac"); await ffmpeg.deleteFile("output.m4a"); // Copy into a fresh ArrayBuffer-backed view so the Blob typing is happy // (readFile may be typed against a SharedArrayBuffer-backed Uint8Array). const bytes = new Uint8Array(data as Uint8Array); const blob = new Blob([bytes], { type: "audio/mp4" }); return new File([blob], opts.outputName ?? "stream.m4a", { type: "audio/mp4" }); } finally { ffmpeg.off("log", logHandler); if (progressHandler) ffmpeg.off("progress", progressHandler); } }