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 streams.subtitle_streams' existing type and meaning (the int count) are unchanged. No writing tool selects among existing embedded subtitle streams yet; this is aprobe.py-only enrichment. Closes #63. -
caption.py --audio-stream N: explicit multi-audio-track selection. Confirmedcaption.pydid silently pick a track on a multi-audio-track input (dubbed languages, M&E stems): burn mode had no-mapat all (ffmpeg's own automatic stream-selection heuristic, not necessarily index 0, decided), mux mode and the karaoke energy-timing/--transcribeaudio extraction both hardcoded0:a:0.--audio-stream N(default 0, matchingaudio.py's existing flag and unchanged prior behaviour) now threads the same explicit track index through all four: burn's re-encoded audio, mux's stream-copied audio,--transcribe's speech-to-text source, and karaoke's energy-timing analysis, refusing an out-of-range index the same wayaudio.py --audio-streamalready does. Closes #55. -
caption.py --text: SMPTE non-drop-frame timecode cues. Cues were positioned by decimal seconds only; broadcast-style deliverables often supply cue timing ashh:mm:ss:ffframe timecode instead.--textcue lines now also accept that format (e.g.00:00:03:15 --> 00:00:06:00 ...) — the frame count is converted to seconds with--fps, or the input video's own probed fps when--inputis given and--fpsis not. A cue that is shaped like a timecode but has no fps available (no--fps, no--input) is refused with a clear error naming the missing--fps, rather than silently misread as a plain text line the way an ordinary unparseable cue line already is._common.pygains the reusable pieces other tools can build on later:parse_time()takes an optionalfpsargument for thehh:mm:ss:ffcase (raising the newMissingFpsError, aValueErrorsubclass, when fps is needed but absent), andfmt_smpte_time()formats seconds back tohh:mm:ss:ff— used here to echo the interpreted cue range in thewrote ... .srtreport line so a caller can confirm the timecode was read correctly. The written.srtitself stays decimal-millisecond SRT timing, since that is the only timing SRT/mux subtitle codecs actually carry; nothing claims frame-exact precision it can't hold. Drop-frame (29.97/59.94 fps) counting is out of scope. Closes #54. -
references/process-pitfalls.md: development-process mistakes already made once. Distinct fromreferences/ci-platform-pitfalls.md(ffmpeg/CI behaviour differences): this is about the process of making a change safely, not FFmpeg itself. Three entries to start: narrowing aTOOL_META[...]["required"]capability list without first greppingtests/test_contract.py'sDoctorDetectionTestsfor the fixture-pinnedusableoutcome it protects (nearly broken twice, in #51 and #52, caught before landing both times); retrying a git tag push or GitHub Release creation in this environment, where both are scoped out (branch pushes work, tag pushes 403 at the git-receive-pack level; nocreate_release/create_tagMCP tool exists; the outbound proxy itself blocks a raw REST API call to the releases endpoint) rather than accepting it after one confirmation; and redesigning a test fixture a third time instead of recognising, after two independently-redesigned fixtures failed differently on the same platform, that the platform's real behaviour (not the fixture) is the actual cause (stabilize.py's macOS libvidstab test). A living document — add to it whenever one of these recurs. Docs-only; no behaviour changed. -
doctor --json:gpu_encoders. No tool here uses GPU-accelerated encoding — every tool assumes CPU x264/x265 — butdoctorhad no way to answer "is GPU encoding available on this machine" at all, unlike every other capability it already reportsyes/no/unknownfor.gpu_encodersreports GPU-backed encoders (nvenc,videotoolbox,qsv,vaapi,amf) present in this ffmpeg build, read from the same-encoderslistingdoctoralready parses —{"status": "parsed"|"unparsed"|"failed"|"missing", "present": [...]}. Deliberately build-presence only: proving a real GPU/driver will accept a job would need an actual encode, whichdoctor's introspection never runs (matching its existing 10s-timeout, listing-only philosophy). Purely informational — no tool declares or requires a GPU encoder, so this field never affectsokor any tool'susable. The human-readabledoctoroutput gets one line naming what's present (or "none"). Closes #52. -
caption.py --mode mux: soft subtitle stream instead of burn-in. Every caption call previously re-encoded both streams to render pixels (reencodes_video/reencodes_audio:"always") even when the caller only wanted a subtitle track added, not the picture changed.--mode mux(new;--mode burnstays the default) copies video and audio untouched (-c:v copy -c:a copy) and adds the SRT as a separate, player-toggleable subtitle stream —reencodes_video/reencodes_audioare now"conditional"with a note explaining the split. Only takes a plain SRT (--srt/--text/--transcribe), not--ass,--animateor--karaoke: styling and animation render pixels, so they have no soft-subtitle equivalent and are refused with a pointer to--mode burn. The subtitle codec is picked from the output container (mov_textfor.mp4/.m4v/.mov,srtfor.mkv,webvttfor.webm); an unrecognized container is refused rather than guessed at.contract --jsongains three new optional capabilities (encoder:mov_text/encoder:webvtt/encoder:srt, each"when"-gated to the matching output container) sodoctorcan report them honestly; the existing required capabilities (encoder:libx264,encoder:aac,filter:subtitles) are unchanged, since--mode burnis still the default and doctor's usability model doesn't vary by flag. Closes #51 — timecode-aware cue timing and explicit multi-audio-track selection were split out to #54 and #55 to keep this change reviewable. -
Test: the prose tool count in README/
docs/contract.md/package.jsonis now checked against the real tool list. 0.11.0 was cut to fix exactly this drift (README said 28 twice and 22 once;package.jsonsaid 21) by hand, with nothing to stop it recurring.tests/test_contract.py'stest_docs_tool_count_matches_the_real_tool_listscans all three for"<N> tools"wording and fails if any number doesn't matchscripts/'s actual public-tool count, so the next tool added/removed without updating every mention fails CI instead of drifting silently.contract_version/skill.versionwere already split (0.9.0) and already documented as "additive keepscontract_version, breaking bumps it" —docs/contract.mdnow also repeats the tagged-version pin guidance next to that table, sincecapability_mapmakes this the most cross-repo-facing part of the contract. Closes #50. -
Tests:
overlay.py --imageon an audio-less video is now covered. Every existing overlay test used a
source with audio; investigating a downstream report ofoverlay.py"hanging" on audio-less input (the
historical 0.9.x defect this tool's own-t <duration>fix, added in 0.10.0, was meant to close) found the
fix already works — the run had just been mistaken for a hang under a too-short timeout while it was still
transcoding a 1080p60 frame with a fade filter. No code change;test_overlay_on_audio_less_video_terminates
closes the coverage gap so this defect class can't silently regress.
What's Changed
- tests: cover overlay.py --image on an audio-less video by @kajisho5 in #49
- Mechanically check the tool count wording against the real tool list by @kajisho5 in #57
- caption.py: add --mode mux for a soft, toggleable subtitle stream by @kajisho5 in #58
- doctor: report GPU-backed encoders present in the ffmpeg build by @kajisho5 in #59
- Document development-process pitfalls already made once by @kajisho5 in #60
- caption.py: SMPTE non-drop-frame timecode cues by @kajisho5 in #61
- caption.py: --audio-stream for explicit multi-audio-track selection by @kajisho5 in #64
- fix(docs): correct stale skill.version example in docs/contract.md by @kajisho5 in #65
- probe.py: add subtitle_stream_details, mirroring audio_streams by @kajisho5 in #68
- Hardening Phase 2: input-clobber safety, sync reencode fix, additive error taxonomy by @kajisho5 in #69
- test: exercise non-ASCII filenames end-to-end through probe/cut argv by @kajisho5 in #70
- doctor: add font-availability detection for drawtext defaults by @kajisho5 in #71
- overlay.py/graphics.py/color.py: --audio-stream for multi-track consistency; fit.py: explicit audio mapping by @kajisho5 in #72
- Final defect audit fixes: stream preservation, partial-output cleanup, JSON completeness by @kajisho5 in #78
- evals: fix f05-unsupported teaching agents to refuse a supported reverse.py request by @kajisho5 in #83
- SKILL.md: fix dry-run overclaim, add doctor/contract to workflow, split Look into mechanical/judgement by @kajisho5 in #84
- docs: CONTRIBUTING.md, automated GitHub Releases from tags, honest GPU roadmap note by @kajisho5 in #85
- release: 0.12.0 — hardening pass by @kajisho5 in #86
- SKILL.md: complete the dry-run exception list in step 3, fix stale retag description by @kajisho5 in #87
- Fix join.py/fit.py aspect-ratio division by zero, restore honest dry-run dims by @kajisho5 in #88
- CHANGELOG: fold #77's dry-run dims fix into the unreleased 0.12.0 entry by @kajisho5 in #89
- Catch CHANGELOG drift automatically; document the pitfall by @kajisho5 in #90
Full Changelog: v0.11.0...v0.12.0