Files
av-club/sidecar/ffmpeg/vocabulary.ts
T
pastilhasandClaude Opus 5 79ff366802 become an Officer plugin
av-club keeps doing exactly what it did — sharp, ffmpeg and ffprobe behind one
API, and the invariant that the caller never names a path. What changes is how it
is reached and who decides who may reach it.

`src/` is `sidecar/`, because that is the name the platform looks for:
`sidecar/index.ts` present means "this plugin ships a process", and presence is
the declaration for everything structural here. `manifest.ts` at the root is the
other half of that rule.

api/router.ts is a proxy and nothing else. the platform authenticates, checks the
`av-club` permission, and forwards — so the sidecar now accepts a request
carrying `X-Officer-User` and no longer demands its own bearer token from
callers who came that way. it binds LOOPBACK to make that trustworthy, which is
the same boundary caldav's DAV door already relies on. AV_CLUB_TOKEN stays, and
still guards anything calling the port directly: fofos/platform is the stated
first consumer and is not Officer.

THREE THINGS HAD TO GO, and each one broke it in a way worth writing down.

package.json and bun.lock: a package.json inside a plugin is a package boundary,
so bun stops walking up for the platform's tsconfig and `@@/sidecar/connect`
stops resolving. the sidecar died on its first import, fifteen times. npm
dependencies go in the manifest instead, where npm-deps.ts intersects them across
plugins into one plugins/node_modules — which is also how this surfaced a real
conflict: sharp ^0.33.5 (atelier) and ^0.35.3 here do not overlap, since a 0.x
caret pins the minor. atelier moved up; it calls one method.

tsconfig.json: same failure one layer earlier, shadowing the platform's `@@/*`
paths so api/router.ts would not mount. no other plugin has one.

no screens, deliberately. this is an API and a body of documentation; its callers
are a commander watching a folder, an agent, or curl. a dock tile would advertise
a page with nothing on it.

verified through /api/av-club with an agent key: a 3000x2000 jpeg uploaded,
probed, and resized to a 1200x800 avif — 166 KB to 1 KB — without the caller ever
holding av-club's own token.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-28 22:36:53 +01:00

137 lines
7.0 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 };
/** 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", "copy"], note: "Audio encoder." },
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: "enum", values: ["1"], note: "Drop audio entirely." },
};
/** Output containers. `image2` is how a single frame comes out as a still. */
export const FORMATS: Record<string, { extension: string; describe: string }> = {
mp4: { extension: "mp4", describe: "H.264/AAC in MP4 — plays everywhere." },
webm: { extension: "webm", describe: "VP9/Opus. Smaller, but not on older Safari." },
mp3: { extension: "mp3", describe: "Audio only." },
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 },
},
};