convert flacs to aac for streaming
This commit is contained in:
114
src/lib/ffmpeg/transcode.ts
Normal file
114
src/lib/ffmpeg/transcode.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user