Releases: kajisho5/ffmpeg-skill
Release list
v0.12.5
look.py, scenes.py --sheet, overlay.py --text and graphics.py could crash on real Windows
FFmpeg builds (confirmed on winget's gyan.dev 9.x): drawtext's own fontconfig resolution dies
with an access violation whenever it has to resolve a font by family name, with or without a
valid fonts.conf — and doctor reported missing required: none, since -filters correctly
lists drawtext as present; the crash only ever surfaced as a runtime failure, the exact thing
the step-0 capability check exists to prevent.
- All four tools now resolve a concrete font file by default (
default_font_file()in
_common.py: a well-known system font path on Windows,fc-matchon Linux/macOS) and emit
fontfile=instead offont=whenever one can be found —fontfile=skips fontconfig
entirely, the one form confirmed not to crash.font=remains the fallback when nothing can be
resolved, unchanged from before. doctornow actually renders one frame throughdrawtextinstead of trusting the-filters
listing alone. A confirmed crash (killed by signal on POSIX, an access-violation-style exit on
Windows) downgradesfilter:drawtextfrom "listed" tomissing, with the crash detail in
errors[]; an ordinary nonzero exit proves nothing either way and leaves the listing-based
result standing (same "unknown is not missing" principle used everywhere else in capability
detection).scenes.py --sheetgained--no-timecode, matchinglook.py, as a way out if drawtext is
ever genuinely unusable on a machine.- Fixed a
SyntaxWarning: invalid escape sequence '\;'in_common.py'sshell_quote()(a stray
backslash before an already-unescaped character; harmless today, an error in a future Python). - README's Quick Start script examples now note that Windows/Git Bash needs
python, not
python3(bin/install.jsanddoctor/contractalready handled this; the raw examples
didn't say so). - SKILL.md's Gotchas section documents the crash and the fixes above.
Thanks to @willy92wins for the detailed repro in #100.
What's Changed
- fit/color/graphics/overlay: stop silently dropping subtitle/data streams by @kajisho5 in #92
- caption.py --mode mux: keep the input's existing subtitle track(s) by @kajisho5 in #94
- color.py --correct: add gamma, lift/gain, levels, and curves by @kajisho5 in #95
- stabilize.py: add --tripod and --crop (keep/black) by @kajisho5 in #97
- README: name the contract-derivation pattern SPEC by @kajisho5 in #98
- README: surface SPEC near the top by @kajisho5 in #99
- fix drawtext crash on Windows fontconfig resolution by @kajisho5 in #101
Full Changelog: v0.12.0...v0.12.5
0.12.0
A hardening-focused release: no new tools, no new features. Everything here closes a gap between
what the contract/docs/evals claimed and what the implementation actually did, or fixes a real
runtime defect found by reproducing it first. contract_version is unchanged — every contract
field addition here is additive.
-
Safety: a tool could be made to overwrite its own input via a same-file-different-string
output path.-o ./same.mp4against an input opened assame.mp4(or any relative/absolute
pair,..segment, or symlink) resolves to the same file but passed ffmpeg's own
byte-identical-string "Output same as Input" guard —-ythen silently clobbered the source
mid-encode. Reproduced oncrop.pybefore the fix._check_no_overwrite_input()in
_common.py'srun()comparesos.path.realpath()of every-iargument against the output
path and refuses before ffmpeg starts, covering every writing tool from one choke point. -
Safety: a failed
run()call could leave a partial (often 0-byte) output file behind.
verify_output()'s cleanup only ran on the success path; a failure after ffmpeg had already
opened the output (muxer header written, then a mid-stream error) left a stray file a caller
could mistake for a real artifact._cleanup_partial_output()now runs for every nonzero
ffmpeg exit,check=Trueorcheck=False. -
color.py --retag's re-encode fallback used to silently drop every stream beyond
video+audio-0. The stream-copy path (-map 0 -c copy) keeps every stream — extra audio
tracks, subtitles, chapters; the re-encode fallback (triggered when the copy fails) dropped all
of them with no signal in--json. Added a middle tier that tries to keep subtitle/data
streams via-c:s/-c:d copyalongside the required video/audio re-encode, and--jsonnow
reportsreencoded/dropped_non_av_streamshonestly instead of a bare"completed". -
sync.py'sREENCODE_METAclaimedvideo="never";--trim-secondactually re-encodes
video whenever the second recording starts later than the reference (the common case,
offset>=0) or--fix-driftis used — only theoffset<0stream-copy path leaves video
untouched. Fixed to"conditional"/"conditional"with a note. -
Error taxonomy (additive):
error.codeanderror.retryablenow sit alongside every
failure's existingerror.kind/error.message—codeis a static relabelling of the same 4
kinds this codebase has always used (INPUT_INVALID/DEPENDENCY_MISSING/
FFMPEG_EXECUTION_FAILED/OUTPUT_INVALID,INTERNAL_ERRORfallback), not a new taxonomy the
code can't back up;retryableis currently alwaysfalse(no kind is distinguishable from a
deterministic failure without exit-code/stderr sniffing this codebase doesn't do).kind's
existing values and the rest of the JSON shape are unchanged. -
loudness.py --jsonnow includes the second-pass (post-normalization) measurement as a
resultfield — it was computed but only ever printed to stderr, so a caller had to make a
separate--measure-onlycall to learn what loudness was actually achieved. -
--audio-stream Nextended tooverlay.py,graphics.py,color.py;fit.pygained
explicit audio mapping. Every tool that re-encodes audio from a multi-track input now behaves
consistently instead of silently defaulting to track 0 (or, forfit.py, to ffmpeg's own
implicit "best stream" heuristic, which for audio favours channel count over track order).
join.py/multicam.pyare out of scope — they combine separate input files, a different
problem shape. Closes #62 (the same gap
caption.py/audio.pyalready closed in #55). -
doctor --jsongains afontsfield, informational likegpu_encoders: drawtext's default
font (caption.py --animate/--karaoke,graphics.py) can silently substitute a different
family when the requested one isn't installed — a drawtext exit code can't detect this
(fontconfig substitutes for any name, valid or not), sofc-matchis queried directly.
Never gatesok/usable; a substituted font doesn't make the tool unusable, just possibly
styled differently than intended. -
probe.pygainssubtitle_stream_details, a detailed per-subtitle-stream array
(index/codec/language/title) mirroringaudio_streams' shape —subtitle_streams'
existing int-count type and meaning are unchanged. -
Doc-vs-implementation drift fixes, each with a regression test pinning the doc text against
the live code so the same drift can't recur silently:docs/contract.md's hand-copiedskill.versionexample had drifted to a stale0.9.1
whilepackage.jsonhad moved to0.11.0.docs/contract.md's failure-JSON example was missing thecode/retryablefields above.SKILL.md/references/scripts.mdclaimed unconditionally that every script's--dry-run
runs nothing;sync/multicam/scenes/reportgenuinely run ffmpeg/ffprobe to measure or
analyse under--dry-run(they just don't write the final artifact), andverifyaccepts
the flag but ignores it — all three doc locations now name the real exception set.SKILL.md's Workflow section never mentioneddoctor/contractat all, so an agent on an
unfamiliar machine had no documented step to check capability before running a tool that
depends on an optional filter/encoder. Added a step 0.SKILL.md's "Look at the picture" step told the agent to judge subject framing and
text-over-faces as part of its own job, directly contradicting "What this skill does and does
not decide"'s statement that this belongs to the calling agent. Split into a mechanical tier
this skill verifies directly and a judgement tier reported to the calling agent — explicitly
not flaggingfit.py --fit pad's letterboxing as a defect, since that's that mode's correct
output. Also formalizedLook: PATH (pixels not inspected; agent has no image view)for an
execution environment that can't actually view images.bin/install.js'scontract/doctorsubcommand hardcodedpython3; Windows Python
installers commonly exposepython/pyinstead (only the Microsoft Store package ships
python3) — now falls back throughpython3→python→pyon Windows. The same
hardcoding was also present, unfixed by that change, inmcp/server.py's and README's MCP
client config examples — both now note the Windows alternative.evals/agent_prompts_exec.json'sf05-unsupportedclaimed "no reverse tool in the skill"
and scored refusing a reverse request as correct —reverse.pyhas existed the whole time
andSKILL.md's own routing table names it. It also endorsed a hand-written raw-ffmpeg
fallback as acceptable, contradicting this project's own "never fall back to raw ffmpeg"
policy stated elsewhere. Replaced with a genuinely unsupported case and a normalreverse.py
success case;evals/results/exec-1.json's historical record is left unedited with a note
explaining the old grading was wrong.
-
Cross-platform: end-to-end non-ASCII filename coverage. Filter-graph string escaping for
Unicode paths was already tested; nothing exercised a non-ASCII filename as the actual-i/
output argument throughsubprocessargv. Added a test copying a fixture to a CJK/accented
filename and runningprobe/cutagainst it both directions — passes on all 3 CI platforms. -
Docs:
CONTRIBUTING.md, a.github/workflows/release.ymlthat automates GitHub Release
creation once a version tag is pushed (tag creation itself stays a manual, deliberate act), and
a one-line honest note that GPU-accelerated encoding stays off the roadmap without a
real-hardware-verified design (build-presence detection, whichgpu_encodersalready limits
itself to, is not proof a job succeeds). -
--dry-run's probe stub no longer fabricates plausible-looking1920x1080/30fpsdimensions
for a not-yet-written output. A first attempt at this (reporting the honest0/0.0"not
measured" value instead, matchingduration/size_bytes's existing convention in the same
stub) had to be reverted mid-pass:join.pyandfit.pyboth divide by a probed source
width/height when computing the other dimension from an aspect ratio, and dry-run probes chain
across multi-stage pipelines (a prior stage's still-unwritten dry-run output gets probed as the
next stage's input), so a zero source dimension reached those divisions and crashed with
ZeroDivisionError. Root-cause fixed instead: both division sites now treat a zero/unknown
source dimension as "can't compute a ratio" and fall back to the requested dimension rather than
dividing by it; every other tool touching probed width/height for aspect-ratio math was audited
and either doesn't divide by it or hands it straight to an ffmpeg filter (moot under--dry-run,
since ffmpeg never runs).--jsonwas never affected by any of this — it always omitted the
placeholder; only a dry-run's human-readable summary line could echo the fake number. Closes
#77. -
probe.py:subtitle_stream_details.subtitle_streamswas a plain integer count whileaudio_streamswas already a detailed array, so nothing could tell which subtitle index was which language on a multi-track input (e.g. an MKV with Japanese and English subs already muxed in).subtitle_stream_detailsadds that detail as a new, purely additive array —[{"index", "codec", "language", "title"}, ...], one entry per embedded subtitle stream in file order (index n is-map 0:s:n), mirroringaudio_streams' shape minus the audio-only fields (channels, layout, sample rate) ffprobe doesn't expose for subtitle str...
0.11.0
Closes the video-editing-skill ADR-002/ADR-003 gap investigation (11 confirmed gaps): crop.py, insert.py (incl. Ken Burns), fit.py --rotate/--flip, overlay.py --video/--chromakey (video-on-video PiP, chroma key), reverse.py, stabilize.py, sequence.py, background.py, proxy.py, contract --json's capability_map, and a repo-wide "fail loudly" pass making verify_output() the single success criterion for every writing tool. 28 tools total (was 21 at 0.10.0); README/package.json's stale "21"/"22" tool-count strings are also corrected to the real count here — see #50 for making that count self-maintaining going forward.
• SKILL.md: two more worked examples of the mechanical-vs-judgement line. "What this skill does and does not decide" already named categories (which cut is right, highlight ranking, thumbnails, content understanding) but not the line itself. Adds "apply this LUT" (mechanical, in scope) vs. "grade this scene to look cinematic" (judgement, belongs to a colour-grading skill) and "crop to this exact box" vs. "crop to keep the speaker in frame" (needs a subject decision this skill doesn't make), plus one sentence stating the general rule: same input + same explicit parameters -> same verifiable output stays here; anything depending on taste or understanding goes to whichever skill or agent makes that call. Docs-only; no behaviour changed. Closes #53.
• references/ci-platform-pitfalls.md: known per-OS ffmpeg/CI behaviour differences. Several fixes in this repo exist only because of platform-specific, empirically observed behaviour (Windows Chocolatey ffmpeg lacking -pattern_type glob support, the concat demuxer's end-of-list duration trick over-counting by a frame, #!/bin/sh PATH shims not being portable to Windows, Windows reporting a crashed ffmpeg subprocess's exit code differently from what this repo captured, and macOS's libvidstab build disagreeing with Linux's on whether a shake fixture got better or worse) — each was independently diagnosed once, at the cost of a full CI cycle and log-reading. Writing them down means the next platform-only test failure gets checked against this list before spending another cycle re-diagnosing it. Docs-only; no behaviour changed.
• proxy.py: low-bitrate proxy for downstream AI analysis, preview and editing decisions. No tool here served a "cheap for a machine to decode" output distinct from export.py's delivery presets, which all target near-visually-lossless platform delivery (CRF 18-24) rather than size/speed. proxy.py resizes to --width (default 640) or by --scale factor, re-encodes at a proxy-grade --crf (default 30) with the fastest x264/x265 preset, supports --fps and --no-audio, and keeps the source's own dynamic range (an HDR source proxies to HEVC10, same as every other re-encoding tool here — run color.py --to-sdr first if SDR is wanted). Pure mechanical resize+re-encode, same primitives fit.py/export.py already use: no new dependency, no GPU requirement, works identically regardless of source resolution (1080p/4K/6K/8K) or codec (H.264/H.265/ProRes, since ffmpeg's own decoders are already codec-agnostic everywhere in this repo). This tool only executes the spec it is given — it does not decide which asset should be proxied or what the proxy will be used for; that stays with the calling agent. capability_map gains media.proxy -> proxy (see below). Tests cover default width/CRF, --scale/--no-audio, forcing CFR on a VFR source, HDR passthrough, and dry-run.
• contract --json: capability_map. provides re-indexes each tool by an id shaped like its own name (ffmpeg-skill.cut); it doesn't let a planner that only knows an abstract goal ("I need to trim a video") find the right tool. capability_map is a new, small, hand-authored table: [{"capability": ".", "tool_id": "ffmpeg-skill/", "params": {...}}, ...], covering video.trim, video.reframe (pins fit to fit=crop, since fit.py also duration-fits and pads), audio.loudness, subtitle.burn, media.stream.inspect, media.frames.extract and media.proxy. It is purely descriptive — a caller still builds and runs the named tool's own CLI/MCP call from its input_schema; this skill never picks a capability or executes on the caller's behalf. Deliberately excludes anything that would require judgment to resolve (no video.highlight, since scenes.py --highlights ranks by a measured proxy, not understood content). docs/contract.md documents the table and why it stays short; tests/test_contract.py verifies every entry resolves to a real tool and real params. Additive; no existing field changed.
• doctor: this installed copy's own version. doctor --json and the human-readable doctor now report the version of the copy answering, read locally from its own package.json (same value contract --json's skill.version reports) — never fetched from the network, never compared against the latest published release. A copy installed with npx ffmpeg-skill is not updated automatically; the human-readable output and the installer --help/README Quick start now say to re-run the installer to refresh it. Additive; every existing doctor key is unchanged.
• doctor: ok vs. per-tool usable clarified. The human-readable doctor output now adds one line when ok is true but at least one tool's usable isn't "yes" (e.g. a plain Homebrew ffmpeg on macOS: overall ok since nothing required by every tool is missing, while caption.py specifically can't run). README Quick start says the same. No field changed, no behaviour changed — a caller reading doctor no longer has to already know to check tools separately from ok.
• doctor's Windows fix hint for a missing subtitles/drawtext/zscale filter now names the same remedy README documents for that platform (winget install Gyan.FFmpeg, since the gyan.dev full build carries them and a plain choco package can lack them) instead of falling through to a generic "install/build it" message — matching the existing macOS brew install ffmpeg-full hint. No change on any other platform.
• doctor's own introspection calls get a 10s timeout. ffmpeg -filters/-encoders/-bsfs/-version are meant to be fast, bounded, non-media operations; a hang here would silently freeze the one tool meant to report whether the machine is broken. They now time out and report failed rather than blocking forever. Deliberately NOT applied to any tool's actual media-processing ffmpeg invocation (cut, fit, caption, ...): a legitimate long re-encode must not be killed by an arbitrary ceiling. -nostdin was already passed everywhere (0.9.0), so a hang waiting on stdin was not possible; this closes the other silent-hang path. See README, "Requirements", for what remains the caller's own responsibility.
• tests/test_contract.py now runs on Windows CI. Only two spots (test_dry_run_never_runs_ffmpeg_and_writes_nothing and the whole DoctorDetectionTests class) actually depend on a POSIX #!/bin/sh PATH shim to force specific FFmpeg fixture layouts; everything else in the file — contract schema, reencodes_*, doctor.tools, MCP derivation, and every tool exercised through the contract including cut.py's provenance fields — already ran against the real ffmpeg on whichever OS the test ran on, but CI skipped the entire file on Windows regardless. The two shim-dependent spots are now skipIf'd individually (visible as skipped in the Windows job's log, not silently absent) and CI runs the rest of the file on all three OSes. Doing this surfaced a real, previously-invisible bug: test_contract_from_installed_copy redirects the installer's target directory by overriding HOME, which Node's os.homedir() ignores on Windows (it reads USERPROFILE), so the test silently installed into the runner's real home directory instead of its temp one and then failed to find the file it expected — fixed by setting both. See README, "Development".
• Test: cut.py's copy-mode keyframe snap can genuinely change the output's duration, not just its precision label. A non-keyframe-aligned --start/--end within --tolerance stays in fast stream-copy mode (mode: "copy"), but the underlying -ss seek still snaps to an earlier keyframe and pulls in extra content — output_duration and requested_duration can diverge by more than a rounding error while keyframe_snapped stays true. This was already reported in cut.py --json (0.10.0); a new test in tests/test_all.py pins a real, measured, non-trivial divergence so a regression that silently reports duration_delta_seconds: 0.0 in this scenario would be caught. No field or behaviour changed.
• Fail loudly: verify_output() is now the single success criterion for every writing tool.
Audit of the execution chain (natural language → script → real ffmpeg → exit status → output
verification → report) against 17 input/ffmpeg failure scenarios and a fake ffmpeg that exits 0
with an empty output. Every scenario already failed with a non-zero exit; the fixes below make
the failures precise and leave nothing misleading behind.
• verify_output() in _common.py: exists, non-empty, ffprobe reads a stream. emit() runs it
before printing any success, with or without --json.
• Output problems are reported as kind: "output" ("output verification failed: : not
written | 0 bytes | ffprobe cannot read it"), no longer as an input error; a 0-byte artifact
is removed.
• Failure JSON carries exit_code and commands (what was planned or run) next to
error.kind / error.message. ffmpeg failures raised by cut, loudness, silence and sync
carry kind: "ffmpeg".
• fit.py --fps 0 was silently treated as "no fps requested"; it is now an error.
• SKILL.md: what "done" means (exit 0 and a probe that matches the request), and a Failed:
report shape.
• Tests: input failures (missing, corrupt, empty, wrong stream, beyond duration, bad fps),
ffmpeg failures (invalid LUT, unwritable directory, unknown container), output verification
with a fake ffmpeg across nine tools, no partial files left behind.
• Evals: evals/agent_prompts_exec.jso...
0.10.0
Standalone FFmpeg engine for agents. Additive contract for the kajisho5 ecosystem.
- README: role as Engine / Hands; other repos decide
- cut --json: requested_*, mode, keyframe_snapped, duration delta
- contract: reencodes_video / reencodes_audio
- doctor --json: per-tool usable / missing / fix
- check.py: plain-language FAIL reasons
- SKILL.md: this skill does not decide cuts or delivery
- render.py: non-zero if check stage fails
Breaking: none
Requires: Python 3.9+, FFmpeg 5+