FORMATS was mp4/webm/mp3/image2 — a web-delivery list, because that is what av-club was first for. a right-click on a .mkv that offers "mp4 or webm" is not a conversion tool, so: mkv, mov for video; m4a, flac, wav, ogg, opus beside mp3 for audio. the KEY is what a caller asks for; `container` is what ffmpeg is told, because ffmpeg's names for two of them are not what anybody calls them — `matroska` for .mkv, `ipod` for .m4a. making a caller know that is making them learn ffmpeg trivia to convert a file. audio encoders too: libmp3lame, libvorbis, flac, alac, pcm_s16le beside aac and libopus, plus q:a for the encoders that want a quality rather than a bitrate, and map_metadata/id3v2_version so tags survive a conversion. A REAL BUG FOUND ON THE WAY, and it had shipped. `an` was declared as an enum whose only value was "1", so it emitted `-an 1` — and `-an` takes no argument, so ffmpeg read the `1` as an output filename and died with "Error opening output files: Invalid argument", naming neither the option nor the problem. `an` had never worked. I reproduced it exactly by adding `vn` the same way. so there is a `flag` kind now: given `true` it emits the option alone, and anything else is refused with a message that says so. `vn: "1"` is a 400 rather than a mystery. verified through the running service: mkv remux, mov, and extraction to all six audio formats — mp3, flac, wav, ogg, opus, m4a — all 200. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
261 lines
11 KiB
TypeScript
261 lines
11 KiB
TypeScript
import { join } from "node:path";
|
|
import { mkdtemp, rename, rm, stat } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { JOB_TIMEOUT_MS } from "../config";
|
|
import { invalid, unprocessable, ApiError } from "../errors";
|
|
import { contentPath, putAsset, readAsset, type Asset } from "../assets/store";
|
|
import { safeInputPath, safeOutputPath } from "../paths";
|
|
import {
|
|
AUDIO_FILTERS,
|
|
FORMATS,
|
|
INPUT_OPTIONS,
|
|
OUTPUT_OPTIONS,
|
|
PRESETS,
|
|
VIDEO_FILTERS,
|
|
type FfmpegParam,
|
|
} from "./vocabulary";
|
|
|
|
/**
|
|
* Running ffmpeg.
|
|
*
|
|
* Everything the caller supplies is a NAME or a NUMBER from the vocabulary. Paths are constructed
|
|
* here from an asset id, and the process is spawned with an argv array so there is no shell to quote
|
|
* for. See docs/SECURITY.md.
|
|
*/
|
|
|
|
export type FfmpegRequest = {
|
|
input?: unknown;
|
|
/** A path on disk, instead of `input`. Confined by `safeInputPath`. */
|
|
inputPath?: unknown;
|
|
/** Where to write. Only with `inputPath`; without it the result is stored as an asset. */
|
|
outputPath?: unknown;
|
|
preset?: unknown;
|
|
inputOptions?: unknown;
|
|
outputOptions?: unknown;
|
|
filters?: unknown;
|
|
format?: unknown;
|
|
};
|
|
|
|
const BITRATE = /^[0-9]+(\.[0-9]+)?[kKmM]?$/;
|
|
|
|
/** `null` means "emit the flag with no value" — see the `flag` kind in vocabulary.ts. */
|
|
function value(spec: FfmpegParam, raw: unknown, path: string): string | null {
|
|
switch (spec.kind) {
|
|
case "flag":
|
|
if (raw !== true) throw invalid(`${path} must be true, or left out`, path);
|
|
return null;
|
|
case "int":
|
|
if (typeof raw !== "number" || !Number.isInteger(raw)) throw invalid(`${path} must be an integer`, path);
|
|
if (raw < spec.min || raw > spec.max) throw invalid(`${path} must be between ${spec.min} and ${spec.max}`, path);
|
|
return String(raw);
|
|
case "number":
|
|
if (typeof raw !== "number" || !Number.isFinite(raw)) throw invalid(`${path} must be a number`, path);
|
|
if (raw < spec.min || raw > spec.max) throw invalid(`${path} must be between ${spec.min} and ${spec.max}`, path);
|
|
return String(raw);
|
|
case "enum":
|
|
if (typeof raw !== "string" || !spec.values.includes(raw)) {
|
|
throw invalid(`${path} must be one of: ${spec.values.join(", ")}`, path);
|
|
}
|
|
return raw;
|
|
case "bitrate":
|
|
if (typeof raw !== "string" || !BITRATE.test(raw)) {
|
|
throw invalid(`${path} must be a bitrate like "2M" or "128k"`, path);
|
|
}
|
|
return raw;
|
|
}
|
|
}
|
|
|
|
function options(table: Record<string, FfmpegParam>, raw: unknown, path: string): string[] {
|
|
if (raw === undefined || raw === null) return [];
|
|
if (typeof raw !== "object" || Array.isArray(raw)) throw invalid(`${path} must be an object`, path);
|
|
|
|
const args: string[] = [];
|
|
for (const [key, entry] of Object.entries(raw as Record<string, unknown>)) {
|
|
const spec = table[key];
|
|
if (!spec) {
|
|
throw invalid(`Unknown option ${JSON.stringify(key)}. Accepted here: ${Object.keys(table).join(", ")}`, `${path}.${key}`);
|
|
}
|
|
const resolved = value(spec, entry, `${path}.${key}`);
|
|
// A flag is the option and nothing else. Pushing an empty string here would hand ffmpeg an
|
|
// argument it reads as a filename, which is the bug this replaced.
|
|
if (resolved === null) args.push(`-${key}`);
|
|
else args.push(`-${key}`, resolved);
|
|
}
|
|
return args;
|
|
}
|
|
|
|
/**
|
|
* A filter chain, built from structured objects rather than parsed from a string.
|
|
*
|
|
* ffmpeg's filter syntax has its own escaping rules, and parsing it well enough to validate it is
|
|
* harder than accepting `{ name: args }`. The names and parameters are ffmpeg's; only the
|
|
* punctuation is ours — and every filter that can read a file or load a plugin is simply absent from
|
|
* the table, so it cannot be named.
|
|
*/
|
|
function filterChain(table: Record<string, Record<string, FfmpegParam>>, raw: unknown, path: string): string {
|
|
if (raw === undefined || raw === null) return "";
|
|
if (!Array.isArray(raw)) throw invalid(`${path} must be an array of filters`, path);
|
|
|
|
return raw
|
|
.map((entry, i) => {
|
|
const at = `${path}[${i}]`;
|
|
if (!entry || typeof entry !== "object") throw invalid(`${at} must be an object`, at);
|
|
|
|
const pairs = Object.entries(entry as Record<string, unknown>);
|
|
if (pairs.length !== 1) throw invalid(`${at} must name exactly one filter`, at);
|
|
|
|
const [name, rawArgs] = pairs[0]!;
|
|
const spec = table[name];
|
|
if (!spec) throw invalid(`Unknown filter ${JSON.stringify(name)}. See docs/FFMPEG.md.`, `${at}.${name}`);
|
|
|
|
const args = Object.entries((rawArgs ?? {}) as Record<string, unknown>).map(([key, v]) => {
|
|
const argSpec = spec[key];
|
|
if (!argSpec) {
|
|
throw invalid(`Unknown parameter ${JSON.stringify(key)} for ${name}`, `${at}.${name}.${key}`);
|
|
}
|
|
return `${key}=${value(argSpec, v, `${at}.${name}.${key}`)}`;
|
|
});
|
|
|
|
return args.length ? `${name}=${args.join(":")}` : name;
|
|
})
|
|
.join(",");
|
|
}
|
|
|
|
export type FfmpegResult = Asset | { path: string; bytes: number };
|
|
|
|
/**
|
|
* Run ffmpeg, either on a stored asset or on a file already on disk.
|
|
*
|
|
* TWO MODES, and exactly one of them per call:
|
|
*
|
|
* { input: "as_…" } → result stored as a new asset
|
|
* { inputPath: "/…", outputPath: "/…" } → result written where the caller said
|
|
*
|
|
* The second exists because both real consumers already have the file: a right-click in the file
|
|
* browser, and a commander watching a folder. See `sidecar/paths.ts` for what confines them.
|
|
*
|
|
* **The input is never modified in either mode.** ffmpeg writes to a temp file and it is moved into
|
|
* place at the end, so a job that dies halfway leaves the original untouched rather than a truncated
|
|
* output where a good file used to be. Deleting a source is the caller's business and not this
|
|
* service's — the tasks do it themselves, after checking the result.
|
|
*/
|
|
export async function runFfmpeg(body: FfmpegRequest): Promise<FfmpegResult> {
|
|
const byPath = body.inputPath !== undefined;
|
|
if (byPath && body.input !== undefined) {
|
|
throw invalid("give either input or inputPath, not both", "input");
|
|
}
|
|
if (!byPath && typeof body.input !== "string") throw invalid("input must be an asset id", "input");
|
|
|
|
const sourcePath = byPath ? await safeInputPath(body.inputPath, "inputPath") : null;
|
|
const destination = body.outputPath !== undefined ? await safeOutputPath(body.outputPath, "outputPath") : null;
|
|
if (destination && !byPath) throw invalid("outputPath needs inputPath", "outputPath");
|
|
|
|
const source = byPath ? null : await readAsset(body.input as string);
|
|
if (source && source.kind === "image") {
|
|
throw invalid(`Asset ${source.id} is an image. Use /v1/sharp for images.`, "input");
|
|
}
|
|
|
|
// A preset supplies the options; anything given explicitly overrides it. So "web-1080 but crf 20"
|
|
// does not mean writing out every flag again.
|
|
let format = "mp4";
|
|
let presetOptions: string[] = [];
|
|
let presetFilters = "";
|
|
|
|
if (body.preset !== undefined) {
|
|
if (typeof body.preset !== "string" || !PRESETS[body.preset]) {
|
|
throw invalid(`preset must be one of: ${Object.keys(PRESETS).join(", ")}`, "preset");
|
|
}
|
|
const preset = PRESETS[body.preset]!;
|
|
format = preset.format;
|
|
presetOptions = options(OUTPUT_OPTIONS, preset.options, "preset");
|
|
presetFilters = preset.filters?.v
|
|
? filterChain(VIDEO_FILTERS, preset.filters.v.map((f) => ({ [f.name]: f.args })), "preset.filters")
|
|
: "";
|
|
}
|
|
|
|
if (body.format !== undefined) {
|
|
if (typeof body.format !== "string" || !FORMATS[body.format]) {
|
|
throw invalid(`format must be one of: ${Object.keys(FORMATS).join(", ")}`, "format");
|
|
}
|
|
format = body.format;
|
|
}
|
|
|
|
const inputArgs = options(INPUT_OPTIONS, body.inputOptions, "inputOptions");
|
|
const outputArgs = options(OUTPUT_OPTIONS, body.outputOptions, "outputOptions");
|
|
|
|
const filters = (body.filters ?? {}) as Record<string, unknown>;
|
|
const videoChain = filterChain(VIDEO_FILTERS, filters.v, "filters.v") || presetFilters;
|
|
const audioChain = filterChain(AUDIO_FILTERS, filters.a, "filters.a");
|
|
|
|
// One directory per job, removed however this ends — otherwise disk fills with the failures.
|
|
const workDir = await mkdtemp(join(tmpdir(), "avclub-"));
|
|
const output = join(workDir, `out.${FORMATS[format]!.extension}`);
|
|
|
|
try {
|
|
const argv = [
|
|
"ffmpeg",
|
|
"-nostdin",
|
|
"-y",
|
|
"-v", "error",
|
|
// Nothing but local files. Kills SSRF and the concat:/subfile: tricks outright — the second
|
|
// lock behind "the caller never names a path".
|
|
"-protocol_whitelist", "file",
|
|
...inputArgs,
|
|
"-i", sourcePath ?? contentPath(source!.id),
|
|
...(videoChain ? ["-vf", videoChain] : []),
|
|
...(audioChain ? ["-af", audioChain] : []),
|
|
...presetOptions,
|
|
// After the preset, so an explicit option wins.
|
|
...outputArgs,
|
|
// ffmpeg's own container name, which is not always what the format is called — `matroska` for
|
|
// mkv, `ipod` for m4a. The caller says the familiar one.
|
|
"-f", FORMATS[format]!.container ?? format,
|
|
output,
|
|
];
|
|
|
|
const proc = Bun.spawn(argv, { stdout: "pipe", stderr: "pipe", stdin: "ignore" });
|
|
|
|
// A crafted file can make an encoder run effectively forever.
|
|
let timedOut = false;
|
|
const timer = setTimeout(() => {
|
|
timedOut = true;
|
|
proc.kill();
|
|
}, JOB_TIMEOUT_MS);
|
|
|
|
const [stderr, code] = await Promise.all([new Response(proc.stderr).text(), proc.exited]);
|
|
clearTimeout(timer);
|
|
|
|
if (timedOut) {
|
|
throw new ApiError("timeout", `Encoding took longer than ${JOB_TIMEOUT_MS / 1000}s and was stopped.`);
|
|
}
|
|
if (code !== 0) {
|
|
throw unprocessable(`ffmpeg failed: ${stderr.trim().split("\n").at(-1) ?? `exit ${code}`}`);
|
|
}
|
|
|
|
if (!destination) return await putAsset(await Bun.file(output).arrayBuffer());
|
|
|
|
/*
|
|
Into place at the END, never written there directly.
|
|
|
|
ffmpeg has been encoding into a temp directory this whole time, so a job that is killed — by the
|
|
timeout above, by a restart, by the machine — leaves the caller's directory exactly as it was. The
|
|
alternative, pointing ffmpeg at the destination, means a crash leaves a truncated file sitting
|
|
where a good one might have been, and a task that then deletes its source has destroyed something.
|
|
|
|
`rename` first because it is atomic when both sides are on one filesystem. They usually are not —
|
|
the temp dir is often tmpfs and the destination is somebody's home — so EXDEV is expected rather
|
|
than exceptional, and the copy is the normal path rather than the error path.
|
|
*/
|
|
try {
|
|
await rename(output, destination);
|
|
} catch (error) {
|
|
if ((error as { code?: string }).code !== "EXDEV") throw error;
|
|
await Bun.write(destination, Bun.file(output));
|
|
}
|
|
|
|
return { path: destination, bytes: (await stat(destination)).size };
|
|
} finally {
|
|
await rm(workDir, { recursive: true, force: true });
|
|
}
|
|
}
|