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>
172 lines
9.3 KiB
TypeScript
172 lines
9.3 KiB
TypeScript
/**
|
|
* The ffmpeg surface av-club exposes, as data — the same shape as the sharp vocabulary.
|
|
*
|
|
* Options are spelled the way ffmpeg spells them (`c:v`, `crf`, `pix_fmt`), so ffmpeg's own
|
|
* documentation is the reference. What is NOT here is anything that names a file: no `-i`, no output
|
|
* path, no protocol. See docs/SECURITY.md — that single restriction is what makes the rest of this
|
|
* safe to open up.
|
|
*/
|
|
|
|
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 }
|
|
/**
|
|
* 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<string, FfmpegParam> = {
|
|
ss: { kind: "number", min: 0, max: 86_400, note: "Start at this many seconds." },
|
|
t: { kind: "number", min: 0.001, max: 86_400, note: "Take this many seconds." },
|
|
to: { kind: "number", min: 0, max: 86_400, note: "Stop at this timestamp." },
|
|
};
|
|
|
|
/**
|
|
* Output options.
|
|
*
|
|
* The encoder list is short on purpose: these are the codecs a browser plays. An encoder ffmpeg
|
|
* happens to have compiled in is not automatically something worth publishing.
|
|
*/
|
|
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: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"] },
|
|
"profile:v": { kind: "enum", values: ["baseline", "main", "high"], note: "`high` is fine everywhere current." },
|
|
level: { kind: "enum", values: ["3.0", "3.1", "4.0", "4.1", "4.2", "5.0", "5.1"] },
|
|
pix_fmt: { kind: "enum", values: ["yuv420p", "yuv422p", "yuv444p"], note: "yuv420p, or Safari and QuickTime will not play it." },
|
|
"b:v": { kind: "bitrate", note: "Target video bitrate, e.g. 2M." },
|
|
"b:a": { kind: "bitrate", note: "Target audio bitrate, e.g. 128k." },
|
|
maxrate: { kind: "bitrate" },
|
|
bufsize: { kind: "bitrate" },
|
|
r: { kind: "number", min: 1, max: 120, note: "Output frame rate." },
|
|
g: { kind: "int", min: 1, max: 600, note: "Keyframe interval." },
|
|
ar: { kind: "enum", values: ["22050", "44100", "48000"], note: "Audio sample rate." },
|
|
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: "flag", note: "Drop audio entirely." },
|
|
};
|
|
|
|
/** Output containers. `image2` is how a single frame comes out as a still. */
|
|
/**
|
|
* 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." },
|
|
mkv: { extension: "mkv", container: "matroska", describe: "Matroska. Carries anything, including subtitles." },
|
|
webm: { extension: "webm", describe: "VP9/Opus. Smaller, but not on older Safari." },
|
|
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." },
|
|
};
|
|
|
|
/**
|
|
* Filters, default-deny.
|
|
*
|
|
* ffmpeg's filter graph is a language, and most of it is arithmetic on pixels. A small, specific part
|
|
* of it reads files or loads native code — `movie`, `subtitles`, `frei0r`, `ladspa` and relatives —
|
|
* and those are not in this list, so they are refused. A filter nobody has classified is refused too:
|
|
* a blocklist would silently admit whatever the next ffmpeg release adds.
|
|
*/
|
|
export const VIDEO_FILTERS: Record<string, Record<string, FfmpegParam>> = {
|
|
scale: {
|
|
w: { kind: "int", min: -2, max: 8192, note: "-2 keeps the aspect ratio and an even number of pixels, which h264 requires." },
|
|
h: { kind: "int", min: -2, max: 8192 },
|
|
},
|
|
crop: {
|
|
w: { kind: "int", min: 1, max: 8192 },
|
|
h: { kind: "int", min: 1, max: 8192 },
|
|
x: { kind: "int", min: 0, max: 8192 },
|
|
y: { kind: "int", min: 0, max: 8192 },
|
|
},
|
|
fps: { fps: { kind: "number", min: 1, max: 120 } },
|
|
format: { pix_fmts: { kind: "enum", values: ["yuv420p", "yuv422p", "yuv444p", "rgb24"] } },
|
|
transpose: { dir: { kind: "enum", values: ["0", "1", "2", "3"] } },
|
|
hflip: {},
|
|
vflip: {},
|
|
eq: {
|
|
brightness: { kind: "number", min: -1, max: 1 },
|
|
contrast: { kind: "number", min: -1000, max: 1000 },
|
|
saturation: { kind: "number", min: 0, max: 3 },
|
|
gamma: { kind: "number", min: 0.1, max: 10 },
|
|
},
|
|
unsharp: {
|
|
luma_msize_x: { kind: "int", min: 3, max: 23 },
|
|
luma_msize_y: { kind: "int", min: 3, max: 23 },
|
|
luma_amount: { kind: "number", min: -2, max: 5 },
|
|
},
|
|
};
|
|
|
|
export const AUDIO_FILTERS: Record<string, Record<string, FfmpegParam>> = {
|
|
volume: { volume: { kind: "number", min: 0, max: 10 } },
|
|
aresample: { osr: { kind: "enum", values: ["22050", "44100", "48000"] } },
|
|
loudnorm: {
|
|
I: { kind: "number", min: -70, max: -5, note: "Target loudness in LUFS. -16 suits the web." },
|
|
TP: { kind: "number", min: -9, max: 0 },
|
|
},
|
|
};
|
|
|
|
/**
|
|
* Named presets, so a caller does not have to know encoder settings to get a web-playable file.
|
|
*
|
|
* This is where the media knowledge lives — the whole reason av-club is a separate repo. A consumer
|
|
* says what the media is FOR; what that means in flags is decided here and can be revised in one
|
|
* place for every site that uses it.
|
|
*/
|
|
export const PRESETS: Record<string, { describe: string; format: string; options: Record<string, string | number>; filters?: { v?: { name: string; args: Record<string, number | string> }[] } }> = {
|
|
"web-1080": {
|
|
describe: "H.264 up to 1080p. The safe default — plays in every browser.",
|
|
format: "mp4",
|
|
options: { "c:v": "libx264", crf: 23, preset: "medium", "profile:v": "high", pix_fmt: "yuv420p", "c:a": "aac", "b:a": "128k", movflags: "+faststart" },
|
|
filters: { v: [{ name: "scale", args: { w: -2, h: 1080 } }] },
|
|
},
|
|
"web-720": {
|
|
describe: "H.264 up to 720p.",
|
|
format: "mp4",
|
|
options: { "c:v": "libx264", crf: 23, preset: "medium", "profile:v": "high", pix_fmt: "yuv420p", "c:a": "aac", "b:a": "128k", movflags: "+faststart" },
|
|
filters: { v: [{ name: "scale", args: { w: -2, h: 720 } }] },
|
|
},
|
|
"web-480": {
|
|
describe: "H.264 up to 480p, for slow connections.",
|
|
format: "mp4",
|
|
options: { "c:v": "libx264", crf: 26, preset: "medium", "profile:v": "main", pix_fmt: "yuv420p", "c:a": "aac", "b:a": "96k", movflags: "+faststart" },
|
|
filters: { v: [{ name: "scale", args: { w: -2, h: 480 } }] },
|
|
},
|
|
poster: {
|
|
describe: "One frame as a JPEG, for a video's poster image.",
|
|
format: "image2",
|
|
options: { "frames:v": 1 },
|
|
},
|
|
};
|