npx ffmpeg-skill
Left half is the input, right half is what the command produced. All 66 before/after demos, with the exact command under each one β β all of it generated from synthetic footage by python3 demos/build.py, so you can rebuild every frame of it yourself.
ffmpeg-skill is an Agent Skill for Claude Code, Cursor, Codex and any agent that reads SKILL.md. It teaches the agent a fixed workflow (probe β edit losslessly where possible β check β verify) and ships 42 tools that do the actual work with ffmpeg / ffprobe: cut, join, silence removal, fit to duration and aspect, captions and karaoke, overlays and motion graphics, HDR β SDR and LUTs, audio clean-up and typed dynamics, sync with drift correction, multicam, loudness, delivery checks, whole-edit project rendering, batch folders. Every tool is also callable as an MCP tool (tools/list advertises a core 12 by default to keep client context small, with FFMPEG_SKILL_MCP_FULL=1 listing all 42; every tool is reachable by name through tools/call either way), and the whole set is described by a machine-readable contract.
If ffmpeg and python3 are on your PATH, it works: offline, on footage you would rather not upload.
SPEC (Self-Producing Execution Contract): each tool's
input_schemaβ the part of its contract and MCP tool definition that has to track the CLI flag-for-flag β is never hand-authored beside the code. It is derived, at run time, from the sameargparseparser that already defines the CLI, and CI fails the build if any of it drifts. β full explanation
Standalone, and in an ecosystem
Standalone, this is a local FFmpeg engine: probe β edit β verify, npx ffmpeg-skill and nothing else. No API key, no account, no other repo required. Everything above and below this section describes that standalone tool, and none of it changes if you never read the rest of this one.
In kajisho5's wider video-production ecosystem, this repo is the hands: it cuts, measures and exports files, and reports back in structured JSON. It does not decide what to cut, whether a deliverable is approvable, what makes a highlight interesting, or what a caption should say (the user's cue text is burned as written, never rewritten to fit) β those are a brain's job, sitting in front of this engine, not inside it.
Other repos in the ecosystem β media-analysis-skill, transcription-skill, subtitle-skill, thumbnail-skill, color-grading-skill, motion-graphics-skill, qc-skill β read this repo's contract --json, its tools' --json output and doctor, the same way any agent framework would; this repo does not call into any of them. The dependency runs one way.
Contents Standalone, and in an ecosystem Β· Why Β· Quick start Β· How it works Β· Design principles Β· Tools Β· Audio Β· Built for agents Β· FAQ Β· FFmpeg compatibility Β· Tested on real footage Β· Install Β· Requirements Β· Development Β· Docs
Why
An agent that "knows FFmpeg" still guesses: it assumes a frame rate, picks a codec the container cannot hold, re-encodes a file that only needed a stream copy, and reports "done" without opening the result. This skill takes the guessing out:
- Real files first. Every job starts with
probe.py; the agent decides from the measured duration, fps, resolution, colour and audio layout, not from the file name. - Structured tools, not shell strings. Each operation is a script with typed arguments. Nothing runs through a shell; no filter graph is accepted from the caller.
- A contract the agent can read.
contract --jsonstates, for every tool, what it takes, what it writes, which FFmpeg components it needs and how the result is verified. The MCP surface is derived from it. - Verification after execution. The result is probed, checked against the destination's spec and, when the picture changed, looked at as a contact sheet.
- Local first. No cloud, no API keys, no Python dependencies. Optional local transcription is used when a speech engine (Parakeet or a whisper) is installed, never required β by
caption.py --transcribeand, since 1.17, bysilence.py --filler --transcribe; both take a transcript you already have instead, and both refuse with the install lines rather than guessing. Both take--engineand--model;FFMPEG_SKILL_ASR_ENGINEandFFMPEG_SKILL_ASR_MODELset this machine's defaults (e.g.large-v3-turbowhere that whisper.cpp model is installed; without either, auto andbase). The engine runs on your machine and the audio is never uploaded; parakeet-mlx, faster-whisper and openai-whisper download their model weights the first time they run (parakeet-mlx's default from Hugging Face).
Paper
The design discipline behind this skill β per-operation verification for AI agents β is written up as a position paper. The paper scores ffmpeg-skill itself as only partially conformant with the discipline it defines.
Kajiyama, S. (2026). What Is Verification-Driven Coding? β A Discipline for Not Trusting Generation. Zenodo. https://doi.org/10.5281/zenodo.23720216
Quick start
# 1. install the skill for Claude Code (Cursor: --cursor, Codex: --codex, all three: --all)
npx ffmpeg-skill
# 2. check the machine: ffmpeg, ffprobe and every FFmpeg component the tools need
npx ffmpeg-skill doctor
# 3. (for agent frameworks) read the machine-readable contract
npx ffmpeg-skill contract --json | head -40
Already installed? re-run npx ffmpeg-skill to refresh ~/.claude/skills/ffmpeg-skill. Copies are not updated automatically.
doctor's overall ok and a single tool's usable: no are different signals: ok means nothing required by every tool is missing, but a plain Homebrew ffmpeg on macOS can still be ok while caption.py specifically can't run (no subtitles filter) β check doctor --json's tools field for the per-tool answer, not just ok.
Then talk to your agent:
"Take
interview.mp4, keep 0:45β3:10 and 5:00β6:30, and make it exactly 60 seconds for Reels."
The agent runs probe.py, cut.py --segments 0:45-3:10,5:00-6:30, fit.py --duration 60 --aspect 9:16 --fit crop, export.py --preset reels, check.py --platform reels and look.py, then reports "final.mp4: 59.98 s, 1080Γ1920, 30 fps, AAC stereo" with the contact sheet it inspected.
Deliver to a platform
One command per destination, with the app's own UI taken into account:
python3 $S/render.py talk.mp4 --template tiktok --cues cues.txt
That fills the shipped templates/tiktok.json: 9:16 crop, captions popped word by word above
TikTok's description bar and clear of its like column, β14 LUFS, the tiktok export preset, and
a check.py --platform tiktok on the file it wrote. The caption size comes from the delivery
table, so since 1.17.1 the filled project also states "fit_size": "on": a size nobody asked for
shrinks to fit the cue instead of splitting the sentence across two cues. A template that states
its own fit_size, and a --brand that states a caption size, still win, and a project with
"fit_size": "off" renders the captions 1.17.0 rendered. Templates ship for tiktok, reels,
shorts, youtube-shorts, youtube, x, linkedin, facebook and podcast;
--template all (or a comma-separated list) renders every destination from the same edit and
writes a <name>_pack.md table of what each one produced. Files land next to the input unless
-o says otherwise, and --dry-run shows every planned command rather than a result.
render.py --list-templates prints them with their frames, limits and safe zones. Alias
spellings work everywhere a platform is named (youtube-shorts = shorts, ig = reels,
twitter = x, fb = facebook).
The tools also work on their own, from any shell:
S=~/.claude/skills/ffmpeg-skill/scripts
python3 $S/probe.py input.mp4 --compact
python3 $S/fit.py input.mp4 --duration 60 --aspect 9:16 --dry-run # print the plan, run nothing
python3 $S/export.py input.mp4 --preset reels --json # structured result with a probe of the output
On Windows in Git Bash, python3 is only on PATH if Python was installed from the Microsoft Store; a python.org install exposes python (or the py launcher) instead β replace python3 with python above if you see a "command not found". bin/install.js and doctor/contract already handle this for you; only the raw script examples above need it spelled out manually.
More requests and the commands behind them: examples/README.md. To see everything run end-to-end on generated footage: npm run demo (the gallery in docs/demos.md).
How it works
flowchart TD
U[User request] --> A[AI agent<br/>Claude Code Β· Cursor Β· Codex]
A -->|reads| S[SKILL.md<br/>workflow, request β tool map, report format]
A -->|runs| T[Structured tool<br/>scripts/<name>.py, typed argparse flags]
T --> C[Contract<br/>input schema Β· role Β· capabilities Β· verification policy]
C --> D[Capability detection<br/>doctor: available / missing / unknown]
D --> F[FFmpeg execution<br/>no shell, stream copy when possible]
F --> V[Verification<br/>probe Β· check Β· look.py contact sheet]
V --> R[Structured result<br/>--json: status, output, commands, probe]
R --> A
Over MCP the same tools are reached through a transport that holds no tool table of its own:
flowchart LR
M[MCP client<br/>Claude Desktop Β· Cursor Β· any client] --> P[mcp/server.py<br/>stdio JSON-RPC]
P -->|tools/list| C[Contract-derived ToolSpecs<br/>names Β· order Β· inputSchema]
P -->|tools/call| T[scripts/<name>.py]
C -.derived from.-> K[scripts/_contract.py]
T -.described by.-> K
Names, order and inputSchema in tools/list are translated from each tool's argparse parser at start-up, so a new flag or a new script appears in MCP with no edit to mcp/. A test copies the skill, adds, removes and edits a script, and reads tools/list again to prove it.
Design principles
These are the rules the skill file gives the agent and the code enforces.
- Probe first. No tool decides from the file name.
probe.pymeasures duration, fps (with variable-frame-rate detection), resolution, rotation, bit depth, HDR format including Dolby Vision, colour tags and every audio stream before anything is cut. - Lossless when possible.
cut.py,join.pyandloudness.pystream-copy what they do not need to touch. Re-encoding happens only when it must: frame-accurate cuts, filters, format changes, or a keyframe farther than the tolerance. - Plan before render. Every tool takes
--dry-run(print the ffmpeg command lines, write nothing),--plan FILE(the dry run saved as a plan with fingerprinted inputs thatrender.py FILEexecutes later, refusing if an input changed),--json(structured result with a probe of the output),--fast(preview quality),--progress(percent and ETA),--timeout(a hung ffmpeg is killed and reported, never waited on forever; Ctrl-C or SIGTERM likewise stops the running ffmpeg, removes its partial output and reportskind: interrupted) and--overwrite(explicit consent before an existing output is replaced). A test runs every tool under--dry-runbehind a fake ffmpeg and asserts that no ffmpeg call happened and no file appeared. - Machine-readable contract.
contract --jsondescribes all 42 tools: input schema generated from the parser, output schema, role, required and conditional FFmpeg capabilities, dry-run support, the verification tools to run afterwards, whether a visual check is required,mutates_input: false.provideslists all 42 by a cross-repository Capability id (ffmpeg-skill.cut,ffmpeg-skill.loudness, ...) forkajisho5/AI-video-production-OS'sCapabilityContract.providesβ seedocs/contract.md. - Contract-derived MCP.
mcp/server.pybuilds itstools/listfrom the contract. Tool names, order andinputSchemacannot drift from the scripts; a test keeps the two byte-identical. - Capability detection.
doctorreadsffmpeg -encoders / -filters / -bsfsand reports which of the components the tools need are present on this build (libx264, libass, zscale, loudnorm, xfade, β¦), before a job fails inside ffmpeg. - Unknown is not missing. When a listing cannot be read (a layout the parser does not know, ffmpeg exiting non-zero) the affected capabilities are
unknown: nevermissing, never silentlyavailable. An installed filter is not reported absent; a failed detection is not a pass. - Verify the result. The output is probed, and when the picture changed (captions, overlays, crops, colour, transitions) the agent runs
look.pyand inspects the PNG. The report is not finished until itsLook:line names that image; audio-only jobs sayLook: not needed. "Inspects" means the calling agent's own vision, not a feature of this skill:look.pyonly renders a PNG; nothing in this repository detects faces, products, subjects, or "the interesting part" of a frame or a scene. When a crop or reframe needs to keep a specific part of the frame (fit.py --fit crop --crop-x/-y, see Tools), it is the multimodal agent looking at that PNG and choosing the anchor β a non-visual caller (a script, a CLI user without eyes on the sheet) has to supply that decision itself, and the default is a plain centre crop. Likewisescenes.py --highlightsranks candidate scenes by a measured proxy (--rank-by audioor--rank-by duration), never by content; it is the agent that turns a look at the sheet into a judgement. - One label per report. A finished job is
Done:, a failure or a refusal isFailed:, and a partial result isDone:with the shortfall named inNotes:β never a third label such asDone (partially):. - Keep originals. No tool overwrites its input. Outputs are new files named
<input>_<operation>.<ext>unless told otherwise, and a test hashes every input after the run.
Tools
42 public tools, all Python 3.9 standard library, all with --help, --dry-run, --json, --plan FILE (a dry run written as a plan render.py executes later), non-zero exit and a reason on stderr on failure. --json-brief (1.11.0) prints the same result document trimmed to what a caller acts on β status, output, verified, a compact summary of the output probe, the tool's own keys and the command count instead of the command lines β for roughly a third of the bytes; --json itself is unchanged. Every re-encoding tool takes --codec h264|hevc|av1|prores and --quality N (1.8) and, with export.py, --hw/--no-hw (Apple VideoToolbox on Apple Silicon, opt-in; see doctor below), and every time flag takes seconds, mm:ss, hh:mm:ss.fff or SMPTE hh:mm:ss:ff with an optional @fps suffix (1.9).
Analysis and inspection
Editing
Audio
Picture
Delivery
Orchestration
Not tools, but part of the surface: mcp/server.py (the MCP transport) and scripts/_contract.py (contract --json, doctor). Per-flag reference for every tool: references/scripts.md.
Audio is a first-class input
WAV, FLAC, MP3, M4A/AAC, OGG and Opus go through probe, cut, join, silence, loudness, audio, sync and check --platform podcast with the same commands as video. The output extension picks the codec: -o out.wav writes PCM, -o out.flac FLAC, -o out.mp3 MP3, -o out.m4a AAC.
- Extraction. An audio extension on a video input drops the picture:
audio.py talk.mp4 -o talk.wav, or--voice -o talk.m4ato clean it on the way.--audio-stream Npicks a track;probelists them underaudio_streams. - Join.
join.py intro.wav episode.m4a outro.wav -o full.flacresamples every clip to one rate and channel layout and crossfades them (--transition nonefor a butt join). Audio and video inputs cannot be mixed in one join. - Sample-accurate trims.
cut.py talk.wav --start 1.2345 --end 2.3456 --accuratetrims at the sample; the JSON reportsprecision(packetfor a stream copy,samplefor PCM / FLAC,codec_framewhen a lossy encoder frames the audio again,framefor video) and the measuredduration_error_ms. A.wavnever receives compressed packets. - Typed dynamics.
audio.py --compress --comp-threshold -20 --comp-ratio 4,--limit --limit-ceiling -1,--gate --gate-threshold -45. Each flag is one documented option of FFmpeg'sacompressor,alimiteroragate, range-checked before ffmpeg runs; no filter string is accepted from the caller. - Loudness.
loudness.py talk.wav -I -16 --tp -1.5 -o talk.m4afor podcast levels;check.py talk.m4a --platform podcastmeasures LUFS and true peak and reports the chapter markers and channel count.
Picture tools (fit, caption, overlay, graphics, color, export, scenes, look) refuse an audio file with "input has no video stream" instead of inventing a picture.
Built for agents
What is SPEC?
SPEC (Self-Producing Execution Contract) is the name this project's author,
kajisho5, gave the pattern the tool layer is built on: each tool's
input_schema β the part of its contract that has to track the CLI exactly, flag for flag β is
never hand-authored side by side with the code. It is derived, at run time, from the one thing
that has to be correct for the CLI to work at all: the script's own argparse parser.
Concretely, scripts/_contract.py's _capture_parser() imports every tool script and
intercepts its parse_args() call to get the live, fully-built parser object β flags, types,
choices, defaults, required/positional, mutually exclusive groups, all of it. input_schema is
built straight from that object. (The rest of a ToolSpec β role, capabilities, inputs,
outputs, output_schema β comes from a hand-authored table, TOOL_META, since those facts
aren't things a parser can express; only input_schema is parser-derived.)
- The contract's
input_schemafor every tool is generated from the live parser directly. - SKILL.md is two-tier (1.11.0): the file the agent loads every session keeps the workflow, the
requestβscript table and one line per gotcha; the long-form detail lives in
references/gotchas.mdand the otherreferences/files, read only when a job needs it. - The MCP server (
mcp/server.py) carries no schema of its own;tools/listis translated straight from the contract,input_schemaincluded. - The docs (
docs/contract.md's field reference, this README's tool table) describe the same shape.tests/test_contract.pyruns on every CI run and fails the build if any of them drift out of sync with what the code actually does β it catches drift, it doesn't fix it for you.
So adding a flag to a script's argparse block updates input_schema and the MCP tool
definition with no second edit, and a docs page or TOOL_META entry that falls behind fails CI
rather than drifting silently. There is no separate input_schema file to forget to update.
Machine-readable contract
npx ffmpeg-skill contract --json # or: python3 scripts/_contract.py --json
npx ffmpeg-skill contract --json --static # without environment detection
The contract is generated from the code that runs, not maintained beside it. For each of the 42 tools (ffmpeg-skill/<name>) it states:
Next to the tool list the document carries a top-level deprecated list (1.10): what 2.0.0 removes, since when, the replacement and the surface it lives on. docs/contract.md "What 2.0 changes" is written from it.
contract_version (1.0) is separate from the skill version, so a consumer can pin the shape and read the version for provenance. The document also states the invocation mapping (structured arguments β argv), the JSON shapes for success and failure ({"status": "failed", "error": {"kind": "input | ffmpeg | output | missing_tool | timeout | verification | interrupted", "message": β¦}}; a refusal adds error.reason, a stable code such as output_exists or plan_inputs_changed, and error.details, the facts behind it; warnings, fallbacks and skipped steps a run decided on its own are in diagnostics), and that no tool runs a shell or executes anything other than the named script, ffmpeg and ffprobe. Field-by-field reference: docs/contract.md.
MCP
{"mcpServers": {"ffmpeg-skill": {"command": "python3", "args": ["/Users/you/.claude/skills/ffmpeg-skill/mcp/server.py"]}}}
On Windows, python3 is only on PATH if Python was installed from the Microsoft Store; a python.org install exposes python (or the py launcher) instead β if your MCP client reports the server failed to start, change "command" above to "python" (or the full path from where python).
mcp/server.py is a stdio JSON-RPC transport with no tool table of its own. tools/list is derived from the contract at start-up, in contract order, with inputSchema translated from each tool's input_schema. By default it lists only the core 12 (render, look, caption, export, check, fit, cut, audio, loudness, graphics, silence, probe) so a client doesn't pay context for 30 schemas it rarely calls directly; set FFMPEG_SKILL_MCP_FULL=1 to list all 42. Every tool, listed or not, is callable through tools/call, which maps structured arguments to argv and runs the named script; a raw argv form is accepted for compatibility and marked non-canonical. python3 mcp/server.py --list prints the tools; --call probe '{"inputs": ["a.mp4"]}' runs one from the shell.
No inputSchema lists json or progress: they are transport flags the server sets itself, not tool arguments (2.0; 1.x listed them unless FFMPEG_SKILL_MCP_LEAN=1). A client that still sends them is not refused.
The server also advertises a prompts capability: five canned workflow recipes an agent can ask for by name instead of composing the individual tool calls itself β reel, podcast, multicam, delivery_check, hdr. prompts/list returns each one's name, description and arguments; prompts/get {name, arguments} fills the matching template and returns one text message naming the actual command lines to run and what to check afterward. These are recipes, not new tool calls β every command line a prompt names is one tools/call (or the CLI) can already run.
Capability detection
npx ffmpeg-skill doctor # human-readable
npx ffmpeg-skill doctor --json # available / missing / missing_optional / unknown / detection / errors / tools / gpu_encoders / ffmpeg_security
doctor reads ffmpeg -encoders, -filters and -bsfs and resolves every capability the contract declares against this machine's build. Three states per capability: available, missing, unknown. Exit 0 when everything required is available, 1 when something required is missing, 2 when nothing is proven missing but a required capability is unknown. With detection on (the default), contract --json carries the same lists under capabilities. doctor also reports fonts: the default drawtext family, and fonts.scripts β one available/missing/unknown per writing system (ja, zh, ko, ar, he, hi, th, ru, el) with the file it would use β so "can this machine render Korean captions" is answered before the job, not after. doctor --json's tools field folds that down to one answer per tool β {"caption": {"usable": "no", "missing": ["filter:subtitles"], "fix": "..."}, ...} β so "is doctor overall ok" and "can I run caption.py on this machine" are answered separately: a plain Homebrew ffmpeg is ok for tools that don't need subtitles/drawtext/zscale, while caption's own usable is "no".
doctor --json's gpu_encoders reports which GPU-backed encoders (nvenc, videotoolbox, qsv, vaapi, amf) this ffmpeg build was compiled with β read from -encoders alone, so it proves the capability shipped, not that the GPU/driver on this machine will actually accept a job (that needs a real encode, which doctor's introspection never runs). It never affects ok or any tool's usable. The one GPU path a tool uses is Apple VideoToolbox, opt-in: --hw on every re-encoding tool (h264/hevc/prores on the GPU, AV1 stays on SVT-AV1), FFMPEG_SKILL_HW=1 to make it this machine's default for everything except export.py's delivery presets (those take --hw explicitly, or render.py --hw for a whole project). It runs only on Apple Silicon and never by default, except on a build with no libx264/libx265 (an LGPL ffmpeg that has only h264_videotoolbox/hevc_videotoolbox): there it is the only encoder, so every re-encoding tool and export.py's presets use it without --hw, the result says hw.source: "auto", and doctor lists the missing x264/x265 under covered_by instead of calling the tools unusable (--no-hw still asks for the CPU). It falls back to the CPU encoder β reported in the result's hw.fallback/hw.notes β only when VideoToolbox itself refuses a job (any other ffmpeg failure is reported as it happened, not retried); every result names the encoder that actually ran (batch.py, which encodes nothing itself: each results row), and a GPU encode adds a notes line that its quality is not CRF-equivalent. Measured on an M4 Max, 2β7Γ faster than x264/x265 medium; at matched SSIM (tests/bench_vt.py) 1.1β2.9Γ the bytes on SDR and 1.9β3.5Γ on HDR, which is why it is a flag and not the default (docs/design-decisions.md). doctor --json's hw says whether this machine can run it (platform_ok) and whether it is the default here (default_on).
doctor also compares the FFmpeg version with a small, dated table of high-severity FFmpeg CVEs (doctor --json's ffmpeg_security; source ffmpeg.org/security.html, checked 2026-10-22). ffmpeg-skill does not ship FFmpeg, so a decoder bug in the build you have stays yours until you upgrade it. When the version is older than the first upstream release on its branch that carries a fix (today: CVE-2026-8461, the MagicYUV decoder heap write, fixed in 8.0.3, 8.1.2 and 9.0), doctor prints one warning: line on stderr with the update command and lists it under ffmpeg_security.warnings. It is advice, never a failure: ok, each tool's usable and the exit code do not change. A distribution package (Ubuntu 8.0.1-3ubuntu2, Debian +deb13u1) may carry the fix as a backport under the old version number, so the line says the build is older than the fix, not that it is vulnerable. A version it cannot read (a git snapshot such as N-12345-gβ¦) gets no warning, and nothing is fetched: the table ships with the skill.
Gotchas and best practices
The short list for humans. The agent-facing version, with the reasoning, is the "Things that look right but are wrong" and "Gotchas" sections of SKILL.md.
- Variable frame rate (phone and screen recordings).
probe.pyflags it; every re-encoding tool conforms to a constant rate automatically.cut.pyswitches to frame-accurate mode on it, because copy-cuts on VFR land on the wrong frame, and samples the frame timing to say whether that was needed (--vfr-guard sampleddecides on the sample instead;--vfr-guard offkeeps the copy). Choose the rate yourself withfit.py input.mp4 --fps 30when the measured average is odd. - Lossless cuts snap to keyframes. A stream-copy cut can start up to one GOP earlier than asked.
cut.pyre-encodes when the snap exceeds 0.5 s (--tolerancechanges the limit). For a strictly lossless file pass--tolerance -1(plus--vfr-guard offon a variable-frame-rate source), and expect the cut to land on the nearest earlier keyframe; the JSON result lists them undernearest_keyframes. A--segmentsjoin can still be re-cut: open-GOP video, a later segment of B-frame.mp4/.movvideo that starts between keyframes (start it on a keyframe, or write.mkv), or a join that fails its check. For a single.mp4/.movcut,--edit-listkeeps the copy and starts the picture where asked: the keyframe's pre-roll is stored but hidden by an MP4 edit list, which a player that ignores edit lists shows. - HDR stays HDR. When the probe reports HDR (HDR10, HLG, Dolby Vision, BT.2020), the tools keep it rather than flatten it. Convert deliberately with
color.py --to-sdrbefore H.264 deliverables or LUT work.export.pyplatform presets are SDR and warn on HDR input. - Loudness targets. β14 LUFS / β1 dBTP for YouTube and social platforms (the
loudness.pydefault),-I -16 --tp -1.5for podcasts,-I -23for broadcast. A clip measured at β40 LUFS or below is room tone, not content; raising it raises the noise. Check true peak as well as LUFS:check.py file --platform podcastmeasures both. - Frame changes first, text second. Captions and overlays burned before a crop or resize end up off-frame. Reframe, then caption.
- Cropping 16:9 to 9:16 discards 70 % of the width.
fit.py --fit cropcentres by default; pass--crop-x/--crop-ytoward the subject, or pad with--fit pad --pad-fill blur. Look at the contact sheet before deciding. - Phrase-aware caption breaking (1.16).
caption.py/graphics.py --wrap phrase(the default) never breaks inside a word or on the wrong side of a hyphen, never leaves a lone digit, kana or punctuation pair on a line, prefers Japanese sentence ends and particles over a mid-word break, and never ends a line on an article or preposition. All four are penalties over break positions that already fit, so no line is widened and the line count never changes;--wrap measuredrestores 1.15's width-only wrap. The text itself is never rewritten or shortened. Since 1.16.1 a Thai run and a katakana word are never broken inside (Thai writes no space inside a phrase and there is no dictionary: the break goes where you put a space or|), and a line wider than the safe width is reported asoverlongwith the fix named. - Caption size fitted to the cue (1.17, reachable from the templates since 1.17.1). At a platform caption size a line holds about six em, so an ordinary sentence needs four lines and
--max-lines 2used to cut it into consecutive cues β half the sentence arriving late.caption.py --fit-size(defaultauto) walks the size down until every cue fits, before laying the cues out, with a legibility floor of 4.5 % of the frame height (--min-size, default 13 ASS units). An explicit--sizeor abrand.jsonsize is a statement about the look and is never overridden β which in 1.17.0 also silenced the fitter on everyrender.py --templaterun, since a template fills the size from the platform table; 1.17.1 marks that size as the default it is ("fit_size": "on"in the filled project), so the type shrinks and no cue is split.--fit-size offrestores that earlier behaviour byte for byte, and the caption text is still never rewritten to make it fit. - Beat-synced cuts (1.17).
scenes.py --beatsreports the measured grid β tempo, beat times, and a confidence built from how far the winning autocorrelation lag stands above the others and how many onsets land on it.cut.py --snap beats(and a"snap"block in arender.pyproject) moves in/out points to the nearest beat within--snap-toleranceβ and only onto the grid points a measured onset actually marks, never onto the regular grid's continuation through a passage with no music in it. Below--min-confidenceit refuses: a cut point may move to a measured beat and may not appear from one, so speech and ambience get an honest "no steady pulse here" instead of an invented grid. - Filler words (1.17).
silence.py --filler --words transcript.jsonremoves "um" and "uh" through the same cut graph the silences use. Never without measured word timings β there is no heuristic that finds an "um" without them that would not also cut real speech β andlike,tipoandcioΓ¨are deliberately not in the built-in lists, because a discourse marker is a content word. Whisper stays optional:--transcribewith no engine installed refuses and names the five installs. - Throughput (1.17).
batch.py --jobs Nruns several files at once, capped atmin(N, cpu_count, 8)and sharing one--timeoutbudget rather than one per item; the per-item table keeps its order.render.py --cache DIRreuses stages whose inputs and arguments did not change, so swapping an export preset re-runs export only. The cache is opt-in with no default directory, and the ffmpeg, skill and contract versions are part of every key, so a cache is never reused across them. - Audiogram (1.16).
waveform.py --image cover.png(orrender.py --template audiogram) puts the waveform over a still plate for an episode that has no picture, with--platformfor the frame,--titleand burnt-in captions. The image is a local file you give: nothing is fetched and no cover art is ever invented. - Emoji in captions and titles (1.15).
caption.py/graphics.py --emoji-assets DIRcomposites a PNG per emoji (Twemoji/Noto naming,1f389.png) on top of the text, because drawtext cannot load a colour emoji font at all and an installed one does not prove libass will draw it in colour βdoctor --json'sfonts.emojianswers that from a render probe. Without assets the run still succeeds and reportsmode: mono. Nothing is ever downloaded. - Indic and Thai text shaped correctly in titles and lower-thirds (1.15).
graphics.pyrenders Devanagari, Bengali, Tamil, Thai and Lao through libass automatically (text_renderer: "ass"), because drawtext never reorders matras or re-clusters marks; Arabic and Hebrew were already correct on a fribidi build.--text-render drawtextwith such a script is refused, never rendered wrongly. - Non-Latin text picks a font by script (1.12). Japanese, Chinese, Korean, Arabic, Hebrew, Devanagari, Thai, Cyrillic and Greek cues, titles and overlays resolve a font file that covers them automatically, and a machine with no such font fails the job (
kind: input) instead of rendering boxes.doctor --json'sfonts.scriptssays which languages this machine can render;--lang ja|kodisambiguates Han-only text; an explicit--font/--font-fileis always kept. - Silence detection finds nothing? The default threshold is β35 dBFS. The tool prints a hint with the track's measured level; raise the threshold (
silence.py --threshold -25) or shorten--min-silence. - Sync results carry a confidence. Below 0.3, or an offset near the edge of the analysis window, is probably wrong: enlarge
--analyze-secondsor find a clap. Recordings over ten minutes from separate devices needsync.py --fix-drift. - Outputs are never overwritten silently. An existing output path is refused (
kind: input,error.reason: output_exists, before anything runs); pass--overwritewhere a replacement is intended. - Nothing happens silently. A refusal names its
error.reasonanderror.details; a stale output lock taken over, a run without a lock, a plan input nobody could fingerprint, a fallback (an odd-sized frame scaled to even, a substituted font, libaom for SVT-AV1), a skipped step and every warning are recorded indiagnosticsand on stderr (codes:json_output.diagnostic_codes); tools that draw text name the font they drew with (font_used). A refused plan and a lock takeover also append a line to an audit log (<plan>.audit.jsonl, orrender.py --audit-log FILE). A degraded or partial result says so:render.py'sverifiedis every stage's, an unmeasured loudness or a report section that could not be built is a failing verification step,export.pyreportstrimmed,multicam.py'scutsname the camera rendered, andbatch.pyrows carry the failing step'serror,verified, and a row for every input after Ctrl-C. - Long masters need a longer
--timeout. The default is 1800 s per ffmpeg call, which is there to catch a hang. A full re-encode of an hour of 1080p at the default preset ran past it on a 4-core machine (see A one-hour recording). Pass--timeout 0or a larger value, or--fast. Analysis and stream-copy cuts are far below it. - Long chains belong in a plan. Three hand-chained re-encodes lose quality and are hard to change;
render.pyruns the whole edit from one JSON file, and--dry-runshows every ffmpeg command before anything is written.
FAQ
Can I pass raw ffmpeg arguments, or a filter_complex?
Each operation is a script whose flags are its argparse parser, and that parser is what contract --json and the MCP inputSchema are built from. CI fails the build when those drift from the scripts. A caller-supplied filter graph, -vf string, or other ffmpeg option is not an argument any script accepts. doctor reads ffmpeg -filters (and -encoders, -bsfs) and reports a filter a tool needs before a job runs into it. MCP does accept an argv array, and that array is the named script's own CLI flags, still bound to that script: the script's flags, never an ffmpeg command line and never a shell.
How does ffmpeg-skill compare with Remotion?
ffmpeg-skill edits footage you already have, on this machine, with FFmpeg tools whose flags are a machine-readable contract (contract --json). Remotion renders a video from React components. They complement each other: this repository does not call Remotion, and a file Remotion writes is an ordinary input (probe.py, cut.py, fit.py, export.py, check.py). remotion-ffmpeg-skill joins the two. It measures the footage with this repo, draws the graphics in Remotion from those measurements, and checks and delivers the result with this repo again. Install both (npx ffmpeg-skill, then npx remotion-ffmpeg-skill). As with the other skills, the dependency runs one way: it calls these tools, and nothing here calls it. HyperFrames renders a video from HTML and is the same kind of neighbour: a file it writes is an ordinary input too.
Does it validate the filter graph before running?
No. --dry-run prints the ffmpeg command a writing tool would run and does not start ffmpeg, so ffmpeg never parses that filter graph. ffprobe still runs when the tool probes an input that exists. Unless --overwrite is passed, an existing output is refused (kind: input) before ffmpeg. An output that is the same file as an input is refused either way. No media file is written, and verified is false; --plan FILE does write the plan JSON. A path that is not on disk yet is not measured: the probe is a stub (0Γ0, duration 0), so a number taken from it is not a measurement. The measuring tools (probe, check, sync, multicam, scenes, cropdetect, report, silence, loudness, stabilize) still run their measurements; only the artifact is skipped, and silence with --filler --transcribe also runs the speech engine, which may download a model. overlay measures the lines of a multi-line --text with one drawtext on a generated frame on FFmpeg 8.0, 8.0.1 and 8.1.0, where it draws them one by one. verify accepts --dry-run and ignores it: its steps run. --plan FILE is that dry run written down β the planned argv, the command lines, and a fingerprint of each existing input (size, plus a sha256 of the first and last 8 MiB). render.py and verify.py refuse --plan. render.py FILE runs a plan only when those fingerprints still match (a mismatch is refused with each input's expected and actual fingerprint in error.details, and logged to <plan>.audit.jsonl), by running the tool again with the planned argv, not by executing the recorded ffmpeg lines; --dry-run on that run only prints them. doctor reports whether the filters, encoders and bitstream filters the contract names are in this build (ffmpeg -filters, -encoders, -bsfs) and which fonts are installed. A name in that listing is not a check of the graph this command would build, and a listed GPU encoder is not a successful encode.
Can it remove burned-in subtitles or logos?
A rectangle you already know, sitting still for the whole clip, can be filled from the picture around it with redact.py --mode delogo (ffmpeg's delogo filter). That is a 1 px border blend. It is not inpainting: a logo that moves, or burned-in subtitles whose position changes, is out of scope. The same known box can be obscured instead, with --mode blur or --mode pixelate. None of the three finds the box. delogo also needs the box at least 1 px inside the frame, and it only interpolates 8-bit YUV, so an HDR or 10-bit source is refused until color.py --to-sdr.
Does it auto-track faces or subjects for reframing?
fit.py --fit crop centres the crop unless you pass --crop-x and --crop-y. crop.py cuts the rectangle you pass. cropdetect.py measures letterbox bars, and --motion-centre reports where motion sat each second; it writes no file and picks no crop. sphere.py points a 360 view where you aim it. look.py writes a contact sheet. Nothing in this repository detects a face, a product or a subject. Choosing what to keep in frame is the caller's.
Does it encode on the GPU?
The encode path a tool will actually take is Apple VideoToolbox, for h264, hevc and prores, and only on Apple Silicon. --hw asks for it on that call. FFMPEG_SKILL_HW=1 makes it the default for every re-encoding tool except export.py's delivery presets, which take --hw on their own (or render.py --hw for a whole project). AV1 stays on SVT-AV1. nvenc, QSV, VAAPI and AMF are reported by doctor --json's gpu_encoders when the ffmpeg build was compiled with them, and no tool encodes with them. One build is the exception to opt-in: with no libx264 and no libx265, and with h264_videotoolbox or hevc_videotoolbox, those encodes use VideoToolbox without --hw and the result says hw.source is "auto". --no-hw still asks for the CPU encoder. A VideoToolbox job the hardware refuses is re-encoded on the CPU and the result says so (hw.fallback).
FFmpeg compatibility
The tools need FFmpeg 5.0 or later and Python 3.9 or later (standard library only). FFmpeg 6.1+ is recommended: FFmpeg 5.x still works but is deprecated since 2.14.0, doctor and the npx installer warn on it, and 3.0 requires 6.1. Python 3.10+ is recommended too: 3.9 still works but is deprecated since 2.10.9 (CPython 3.9 is end of life), doctor and the npx installer warn on it, and 3.0 drops it. What CI actually exercises on every pull request is FFmpeg 5.1.1 (static build), 6.1 (Ubuntu apt), 7.1 (Debian trixie apt), 8.x (macOS Homebrew) and 9.x (Windows gyan.dev), on Python 3.9 and 3.13 (the two ends of the supported range). The capability parser has been run against the listings of these builds:
FFmpeg 8 shortened the flag column of ffmpeg -filters. A parser anchored on the old width matches nothing on FFmpeg 8 and, if "nothing matched" is read as "nothing installed", reports every filter missing; that is what 0.9.0 did on macOS and Windows. Since 0.9.1 rows are recognised by their io-spec token (A->A, AA->A, |->V, N->N), so the flag width, the legend and the separator do not matter, and a listing that still cannot be read yields unknown rather than missing. The captured listings live in tests/fixtures/ with their provenance; CI uploads each runner's listing and doctor --json as an artifact so a new layout is visible before it bites.
Tested on real footage
What is tested where. The contract and the test suite (tests/test_contract.py,
tests/test_all.py, which aggregates one module per tool group β test_analysis.py,
test_editing.py, test_audio.py, test_picture.py, test_delivery.py,
test_orchestration.py, test_accel.py, test_asr.py, test_cut_copy.py β over the shared footage in tests/_fixtures.py) run on Linux, macOS
and Windows on every pull request, minus the handful of POSIX-shim tests listed under
Development. The real-device media corpus
(tests/corpus.py) has been run on Linux and macOS; the full corpus has not been run on
Windows yet, and neither has an install by someone other than the maintainer been reproduced
there β issue #143 tracks both. Treat the
numbers below as measured on Linux (and, where stated, macOS), not as a claim about every file
type on every OS.
python3 tests/corpus.py --fetch --verify # ~1.4 GB download, then verify (slow on 4K)
python3 tests/bench_sync.py --cases 100
python3 tests/bench_silence.py
python3 tests/bench_scenes.py
Benchmarks live in tests/bench_*.py, agent evals in evals/, results by iteration in evals/results/.
A one-hour recording
Measured at 2.17.0 (2026-10-25) on one 60-minute 1080p30 H.264 file (4.3 GB, AAC mono 48 kHz,
synthetic testsrc2 picture and a tone). The machine was a 4-vCPU Xeon at 2.1 GHz with FFmpeg
6.1.1 on Linux, and each tool ran alone. The analysis rows read the whole hour
(silence.py, scenes.py and check.py report 3600 s). The edit and export rows used 10-minute segments cut from it with
stream copy, and the last row is the whole hour.
A full-length re-encode of an hour does not fit in the default --timeout. The limit is
1800 s per ffmpeg call. On this machine export.py --preset youtube on the whole hour was
killed at 1802 s with kind: timeout (exit 124), and nothing was written. At the 10-minute
segment's rate it would have needed about 50 minutes. A whole-hour join.py would land