115 lines
4.5 KiB
TypeScript
115 lines
4.5 KiB
TypeScript
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<FFmpeg> | null = null;
|
|
|
|
function load(onLog?: (msg: string) => void): Promise<FFmpeg> {
|
|
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<File> {
|
|
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);
|
|
}
|
|
}
|