audio joins the vocabulary, and -vn is a flag rather than an option
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>
This commit is contained in:
+13
-3
@@ -38,8 +38,12 @@ export type FfmpegRequest = {
|
|||||||
|
|
||||||
const BITRATE = /^[0-9]+(\.[0-9]+)?[kKmM]?$/;
|
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) {
|
switch (spec.kind) {
|
||||||
|
case "flag":
|
||||||
|
if (raw !== true) throw invalid(`${path} must be true, or left out`, path);
|
||||||
|
return null;
|
||||||
case "int":
|
case "int":
|
||||||
if (typeof raw !== "number" || !Number.isInteger(raw)) throw invalid(`${path} must be an integer`, path);
|
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);
|
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<string, FfmpegParam>, raw: unknown, path: string)
|
|||||||
if (!spec) {
|
if (!spec) {
|
||||||
throw invalid(`Unknown option ${JSON.stringify(key)}. Accepted here: ${Object.keys(table).join(", ")}`, `${path}.${key}`);
|
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;
|
return args;
|
||||||
}
|
}
|
||||||
@@ -199,7 +207,9 @@ export async function runFfmpeg(body: FfmpegRequest): Promise<FfmpegResult> {
|
|||||||
...presetOptions,
|
...presetOptions,
|
||||||
// After the preset, so an explicit option wins.
|
// After the preset, so an explicit option wins.
|
||||||
...outputArgs,
|
...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,
|
output,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,15 @@ export type FfmpegParam =
|
|||||||
| { kind: "int"; min: number; max: number; note?: string }
|
| { kind: "int"; min: number; max: number; note?: string }
|
||||||
| { kind: "number"; min: number; max: number; note?: string }
|
| { kind: "number"; min: number; max: number; note?: string }
|
||||||
| { kind: "enum"; values: readonly string[]; 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. */
|
/** Options that go BEFORE `-i`: seeking and trimming, which are far cheaper applied to the input. */
|
||||||
export const INPUT_OPTIONS: Record<string, FfmpegParam> = {
|
export const INPUT_OPTIONS: Record<string, FfmpegParam> = {
|
||||||
@@ -28,7 +36,11 @@ export const INPUT_OPTIONS: Record<string, FfmpegParam> = {
|
|||||||
*/
|
*/
|
||||||
export const OUTPUT_OPTIONS: Record<string, FfmpegParam> = {
|
export const OUTPUT_OPTIONS: Record<string, FfmpegParam> = {
|
||||||
"c:v": { kind: "enum", values: ["libx264", "libvpx-vp9", "libaom-av1", "copy"], note: "Video encoder. libx264 is the one every browser plays." },
|
"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." },
|
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." },
|
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"] },
|
tune: { kind: "enum", values: ["film", "animation", "grain", "stillimage", "fastdecode", "zerolatency"] },
|
||||||
@@ -45,14 +57,37 @@ export const OUTPUT_OPTIONS: Record<string, FfmpegParam> = {
|
|||||||
ac: { kind: "int", min: 1, max: 2 },
|
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." },
|
"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." },
|
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. */
|
/** Output containers. `image2` is how a single frame comes out as a still. */
|
||||||
export const FORMATS: Record<string, { extension: string; describe: string }> = {
|
/**
|
||||||
|
* 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<string, { extension: string; container?: string; describe: string }> = {
|
||||||
|
// Video
|
||||||
mp4: { extension: "mp4", describe: "H.264/AAC in MP4 — plays everywhere." },
|
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." },
|
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." },
|
image2: { extension: "jpg", describe: "A single frame, for poster images." },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user