diff --git a/sidecar/ffmpeg/run.ts b/sidecar/ffmpeg/run.ts index b0362d9..0a2bcf6 100644 --- a/sidecar/ffmpeg/run.ts +++ b/sidecar/ffmpeg/run.ts @@ -38,8 +38,12 @@ export type FfmpegRequest = { const BITRATE = /^[0-9]+(\.[0-9]+)?[kKmM]?$/; -function value(spec: FfmpegParam, raw: unknown, path: string): string { +/** `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); @@ -71,7 +75,11 @@ function options(table: Record, raw: unknown, path: string) if (!spec) { throw invalid(`Unknown option ${JSON.stringify(key)}. Accepted here: ${Object.keys(table).join(", ")}`, `${path}.${key}`); } - args.push(`-${key}`, value(spec, entry, `${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; } @@ -199,7 +207,9 @@ export async function runFfmpeg(body: FfmpegRequest): Promise { ...presetOptions, // After the preset, so an explicit option wins. ...outputArgs, - "-f", format, + // 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, ]; diff --git a/sidecar/ffmpeg/vocabulary.ts b/sidecar/ffmpeg/vocabulary.ts index 7dab49e..00603a2 100644 --- a/sidecar/ffmpeg/vocabulary.ts +++ b/sidecar/ffmpeg/vocabulary.ts @@ -11,7 +11,15 @@ export type FfmpegParam = | { kind: "int"; min: number; max: number; note?: string } | { kind: "number"; min: number; max: number; note?: string } | { kind: "enum"; values: readonly string[]; note?: string } - | { kind: "bitrate"; note?: string }; + | { kind: "bitrate"; note?: string } + /** + * A switch that takes NO value — `-vn`, `-an`. Given `true`, it emits the flag alone. + * + * These were declared as `enum` with a single value "1", which emitted `-vn 1`. ffmpeg reads the + * `1` as an output filename and dies with "Error opening output files: Invalid argument", which + * names neither the option nor the real problem. `an` had shipped that way and had never worked. + */ + | { kind: "flag"; note?: string }; /** Options that go BEFORE `-i`: seeking and trimming, which are far cheaper applied to the input. */ export const INPUT_OPTIONS: Record = { @@ -28,7 +36,11 @@ export const INPUT_OPTIONS: Record = { */ export const OUTPUT_OPTIONS: Record = { "c:v": { kind: "enum", values: ["libx264", "libvpx-vp9", "libaom-av1", "copy"], note: "Video encoder. libx264 is the one every browser plays." }, - "c:a": { kind: "enum", values: ["aac", "libopus", "copy"], note: "Audio encoder." }, + "c:a": { kind: "enum", values: ["aac", "libopus", "libmp3lame", "libvorbis", "flac", "alac", "pcm_s16le", "copy"], note: "Audio encoder. libmp3lame/flac/pcm_s16le are for plain conversions; aac and libopus are the web ones." }, + "q:a": { kind: "int", min: 0, max: 10, note: "Quality for encoders that use it rather than a bitrate — libvorbis wants ~6." }, + vn: { kind: "flag", note: "Drop video entirely. What extracting an audio track is." }, + id3v2_version: { kind: "enum", values: ["3", "4"], note: "id3v2.3 is the one every player reads." }, + map_metadata: { kind: "enum", values: ["0", "-1"], note: "`0` copies tags from the input; `-1` strips them." }, crf: { kind: "int", min: 0, max: 63, note: "Quality. Lower is better and bigger; 23 is a sane default for x264." }, preset: { kind: "enum", values: ["ultrafast", "superfast", "veryfast", "faster", "fast", "medium", "slow", "slower", "veryslow"], note: "Encoder effort. Slower is smaller at the same quality." }, tune: { kind: "enum", values: ["film", "animation", "grain", "stillimage", "fastdecode", "zerolatency"] }, @@ -45,14 +57,37 @@ export const OUTPUT_OPTIONS: Record = { ac: { kind: "int", min: 1, max: 2 }, "frames:v": { kind: "int", min: 1, max: 10_000, note: "Stop after this many frames — 1 gives a poster." }, movflags: { kind: "enum", values: ["+faststart"], note: "Moves the index to the front so playback can start before the file has arrived. Always want this for web." }, - an: { kind: "enum", values: ["1"], note: "Drop audio entirely." }, + an: { kind: "flag", note: "Drop audio entirely." }, }; /** Output containers. `image2` is how a single frame comes out as a still. */ -export const FORMATS: Record = { +/** + * Output containers. + * + * The KEY is what a caller asks for and what the file is named after. `container` is what ffmpeg is + * actually told, because ffmpeg's own names for two of these are not what anybody calls them — + * `matroska` for .mkv, `ipod` for .m4a — and making a caller know that is making them learn ffmpeg + * trivia to convert a file. + * + * This list was mp4/webm/mp3/image2 until 2026-08-29: a web-delivery vocabulary, because that is what + * av-club was first for. The rest are here because the basic conversions are too — a right-click on a + * .mkv that offers only "mp4 or webm" is not a conversion tool. + */ +export const FORMATS: Record = { + // Video mp4: { extension: "mp4", describe: "H.264/AAC in MP4 — plays everywhere." }, + mkv: { extension: "mkv", container: "matroska", describe: "Matroska. Carries anything, including subtitles." }, webm: { extension: "webm", describe: "VP9/Opus. Smaller, but not on older Safari." }, - mp3: { extension: "mp3", describe: "Audio only." }, + mov: { extension: "mov", describe: "QuickTime. What Apple tools expect." }, + + // Audio + mp3: { extension: "mp3", describe: "Plays on anything with a speaker." }, + m4a: { extension: "m4a", container: "ipod", describe: "AAC in an MP4 container — Apple's default." }, + flac: { extension: "flac", describe: "Lossless, and about half the size of wav." }, + wav: { extension: "wav", describe: "Uncompressed PCM. Big, and universally editable." }, + ogg: { extension: "ogg", describe: "Vorbis in Ogg." }, + opus: { extension: "opus", describe: "Best quality per byte at low bitrates." }, + image2: { extension: "jpg", describe: "A single frame, for poster images." }, };