Changelog
mainmoves ahead of the last published npm/GitHub release; a dependent repo should pin a tagged version, notmain. See README § Development, “Releasing”.
Unreleased
Fixed
cut.py --segmentsjoins no longer click. Every join the re-encode makes now fades the sound 20 ms on each side. Before, two cuts butted together left a step in the waveform that played as a click: on a 440 Hz tone the step was 14x the tone’s own largest.--declick-mssets the length,--no-declickrestores the butt join, and the result reportsdeclick_ms. A stream-copy join copies the sound and is not faded.
Documentation
- A project site on GitHub Pages.
pages.ymlpublishes https://kajisho5.github.io/ffmpeg-skill/ on every push tomainthat touches the docs..github/scripts/build_pages.pyassembles the site from the repository’s own Markdown: the README, the demo gallery, the contract, the design decisions, the roadmap and this changelog. It rewrites relative links and copies the images they use, so nothing on the site is written by hand. A relative link to a file that does not exist fails the build, andtest_pages_site_builds_with_no_dead_linksruns the same build in CI. - Measured on a one-hour recording. README § “A one-hour recording” times the main tools on a 60-minute 1080p30 file. Every analysis tool and the stream-copy cut finished, the slowest being
check.py --contentat 308 s. A whole-hourexport.py --preset youtubeat the default preset hit the default 1800 s--timeoutand failed cleanly withkind: timeout. The README’s gotchas and SKILL.md’s shared-flags line now say to pass--timeout 0or--fastfor an hour-long re-encode.
2.17.1
Automated release: version and notes generated from pull requests merged since 2.17.0.
Fixed
- The Claude Code plugin can actually be installed. The README’s
claude plugin install kajisho5/ffmpeg-skillfailed with “not found in any configured marketplace”: the repository shipped a plugin manifest but no marketplace..claude-plugin/marketplace.jsonnow lists the plugin from the repository root. The install is/plugin install ffmpeg-skill --marketplace kajisho5/ffmpeg-skill, or on older Claude Codeclaude plugin marketplace add kajisho5/ffmpeg-skillfollowed byclaude plugin install ffmpeg-skill@ffmpeg-skill. - Claude Code plugin: add the marketplace the README’s install line needed (#361)
2.17.0
Automated release: version and notes generated from pull requests merged since 2.16.1.
Added
check.py --contentreports audio dropouts. A newdropoutsrow counts short gaps of digital silence with sound on both sides: at or below -80 dBFS, lasting 20 ms to 1 s. These come from lost audio at a join or a glitched decode, which the silence row (a share of the running time) did not catch. It WARNs when there is any gap, and never FAILs, because a noise gate makes such gaps on purpose. The check costs one extra audio-only pass.- check.py –content: a dropouts row for short gaps of digital silence (#360)
- README: restore the Sponsor badge the #337 header redesign dropped (#359)
2.16.1
Automated release: version and notes generated from pull requests merged since 2.16.0.
Fixed
contract --jsondeclares every key arender.pyresult carries.intermediate_preset(2.15),captionandsnapwere in docs/contract.md but missing from render’soutput_schema. A test now runs a template render, both whole and with an early--stop-after, and fails on any result key the contract does not declare.- contract: render’s output_schema declares intermediate_preset, caption and snap; README: remotion-ffmpeg-skill (#358)
2.16.0
Automated release: version and notes generated from pull requests merged since 2.15.0.
Changed
render.py --intermediate-presetdefaults tosuperfast(wasveryfastin 2.15). On FFmpeg 8.0.1 a 30 s 1080p reels render took 30 s instead of 33 s, with SSIM against a lossless reference of 0.9868 vs 0.9863; on 6.1 the two took the same time.ultrafastwas measured too: 26 s, but SSIM 0.9814, so it is not the default.--intermediate-preset veryfastrestores 2.15’s choice.- render.py: –intermediate-preset defaults to superfast (#356)
2.15.0
Automated release: version and notes generated from pull requests merged since 2.14.0.
Changed
render.pyencodes the stages before an encoding export with x264/x265veryfast(--intermediate-preset, defaultveryfast;mediumrestores the old behaviour). export.py re-encodes them and its own preset sets the delivered quality, so a 30 s 1080p reels render took 41.7 s instead of 51.8 s on FFmpeg 8.0.1, with SSIM 0.997 between the two deliveries. Not applied without an export stage, with--stop-afterbefore it, with--fast, or for acopyexport; the result reportsintermediate_preset.- render.py: the stages before an encoding export use x264 veryfast (#355)
2.14.0
Automated release: version and notes generated from pull requests merged since 2.13.0.
Deprecated
- Running on FFmpeg 5.x. It still works in 2.x:
requirements.ffmpegstays>=5.0and CI keeps its 5.1.1 job. 3.0.0 requires FFmpeg 6.1. On a 5.x build,doctorand thenpxinstaller print one stderr warning, andcontract --json’sdeprecatedlists it. - Deprecate FFmpeg 5.x: doctor and the installer warn; 3.0 requires 6.1 (#354)
2.13.0
Automated release: version and notes generated from pull requests merged since 2.12.1.
Added
overlay.py --place SRC_START-SRC_END@AT(with--video, repeatable): show that range of the layer starting atATon the main timeline, hidden elsewhere. One pass lays several pieces of one layer at their own times – graphics rendered back to back (only the seconds that have any) placed where each belongs – instead of a full-length mostly-transparent layer. Overlapping pieces, ranges past the layer’s end, and--start/--end/--fadealongside are refused.- overlay.py –place: lay ranges of a video layer at chosen times in one pass (#353)
2.12.1
Automated release: version and notes generated from pull requests merged since 2.12.0.
Fixed
look.pycontact-sheet timecodes match the frames shown. The sheet starts reading half a slice in (-ssbefore-i, so the first tile is not a black lead-in), which resets timestamps to 0 at that point: every burned-in label was half a slice early (an 8 s clip as 3x2 labelled a frame at 0.667 s00:00:00.000). The timecode now adds the seek back (%{pts:hms:<seek>}); a sheet that starts at 0 is unchanged.
Changed
docs/design-decisions.mdopens with a contents list (by topic, then by release), built from its headings by.github/scripts/design_decisions_index.py; a test fails when it is stale. Eleven sections that still said “Unreleased” long after they shipped (2.3.0 to 2.12.0) carry their version, and the release workflow now renames such a heading to the version that ships it, as it does for CHANGELOG.md.npm run test:fastruns the contract suite alone (a few minutes, no real encodes); CONTRIBUTING.md says how to run one test group or one case while working on a tool.- look.py: contact-sheet timecodes add back the mid-slice seek (#352)
- docs: a contents list for design-decisions, released section headings, npm run test:fast (#351)
2.12.0
Automated release: version and notes generated from pull requests merged since 2.11.0.
Added
- The remaining silent fallbacks, skips and warnings are recorded. Each still prints its stderr line and is now also a
diagnosticsentry, andcontract --jsonlists every code with its bucket underjson_output.diagnostic_codes: an odd-sized frame scaled to even before encoding (odd_dimensions_scaled_even), subtitle/data streams dropped on a retry with the first attempt’s error (non_av_streams_dropped, besidedropped_non_av_streams), a--dry-runmeasurement skipped for an input an earlier stage would write (measurement_input_pending),ffmpeg -encoders/-bsfsthat could not be read (ffmpeg_list_unreadable), an invalid or negativeFFMPEG_SKILL_TIMEOUT(timeout_env_invalid), the libaom fallback for a missing SVT-AV1 (av1_encoder_libaom), a CRF capped at the encoder’s maximum (crf_clamped), a timeline export that does not read back (timeline_parse_failed, with the reason), and every tool’s former stderr-onlywarning:line (emoji fallbacks, silent, short or duplicate inputs, memory-heavy jobs,--strip-doviwithout Dolby Vision, a numbering gap insequence.py,render.py’s frame-from-preset and default--normalize). font_usedon the tools that draw text.overlay.py --text,graphics.py,caption.py(burn) andgrid.py --label autoreport{asked, file, family, how, substituted}; a family that is not installed and was drawn in another (fontconfig’s answer, or Arial on Windows) is afont_substitutedfallback.- Counts for what was skipped.
cropdetect.pywindows/windows_failed;sync.py --fix-driftdrift_skipped_reason(too_short/low_confidence);caption.py --asscaption.ass_shifted/ass_unshifted/ass_malformed;silence.py --fillerfiller.matched_untimed/matched_too_long. --codec av1/proresrefusals say why.error.reason: encoder_missing(kind: missing_tool, as before) when the build’s encoder list lacks the encoder;encoder_list_unreadable(kind: ffmpeg) when the list could not be read, instead of claiming the build lacks it.--hw’s CPU note says the same.- The MCP server answers a line that is not JSON with a JSON-RPC parse error (
-32700,id: null) instead of dropping it.
Fixed
- A degraded or partial result is no longer reported as the asked-for one (nothing happens silently, part 2). Additive fields, plus the
batch.pyrowerrornoted below:render.py’sverifiedis the conjunction of every stage’s. A stage’s own verification steps (beyond its intermediate’s probe) are in the result’sverification, tagged withstage, and its notes innotes, prefixed with its tool: an export that missed its platform loudness used to leave the renderverified: true. An executed plan and a pack carry their children’s the same way, and a--cachehit replays what the cached stage verified.render.py’s"check": {"content": true}without a platform runscheck.py --content(it was skipped without a word; a plan’s check step already ran it). A check not run under--dry-runis acheck_dry_runskip, and check.py output that does not parse is a failure document (error.reason: check_output_unparsed) instead of a bare string.render.py --cachesays why each stage missed (cache.miss_reasons:not_cached,sidecar_missing,artifact_missing,sidecar_corrupt,artifact_truncated,no_output_path); a damaged entry is acache_entry_damagedwarning and an audit line in the cache directory; a failed store, an input that cannot be fingerprinted and an unreadable ffprobe banner are recorded.export.pyreportstrimmed: {from, to}(and a note) when a platform maximum cut the clip. A loudness measurement that cannot be read isloudness.measured: falsewith a failing{step: loudness, ok: false, reason: not_measured}and aloudness_not_measuredwarning, instead of no loudness block andverified: true; after--normalize, the measurement from before the pass is no longer reused.multicam.py’scutsname the camera actually rendered (camera 0 where the asked-for camera had not started), with acamera_not_startedfallback naming the one asked for.report.pylists each section it built as a verification step (loudness,compliancewithpassed,contact_sheet); one it could not build isok: falsewith the child’s reason and areport_section_missingwarning, so the report is no longerverified: truewith a section missing.batch.py: a failed row’serroris the failing step’s own{step, tool, kind, message, code, reason, details, hint, exit_code}(it was absent, or a string such as"1"for a worker that exited); rows addverifiedandunverified(a completed but unverified step staysok, as design-decisions decides), and the batch’sverificationhas oneitemstep per row. Ctrl-C reports a row for every input (interrupted: true, orskipped: "interrupted") and atotalthat counts them all. A cache hit is served only while its output’s size and mtime are the ones the batch wrote (cache_misson the row, a warning), so an entry written by 2.10.11 or earlier runs once more; a cache file that does not parse is abatch_cache_resetwarning and an audit line. A recipe error that is the same for every item is refused before any item runs.verify.pyruns each step with--jsonand keeps the tool’s ownkind,reason,messageandexit_codeinstead of the last stderr line or a fixed string; steps that do not apply are listed under each file’sskippedwith a reason.export.py’s andmulticam.py’s last stderr-onlywarning:lines are records too (colour_not_converted,output_audio_silent,loudness_outside_spec,sync_low_confidence), so no tool prints a warning that--jsondoes not carry.
- Nothing happens silently, parts 2 and 3: degraded results, and the remaining fallbacks, skips and warnings (#350)
2.11.0
Automated release: version and notes generated from pull requests merged since 2.10.11.
Added
- Refusals say why in fields, not only in prose. A refusal’s
errornow carriesreason, a stable code (output_exists,output_is_input,output_is_directory,output_dir_missing,output_dir_not_writable,output_locked, andrender.py’splan_inputs_changed,plan_version_unsupported,plan_unknown_tool,plan_cwd_missing,plan_argv_invalid), anddetails, the facts the message is written from.contract --jsonlists the codes underjson_output.error_reasons.render.pyforwards a stage’s or a plan’s tool’sreasonanddetailsin its ownerror. Additive:kind,message,codeandhintare unchanged. diagnostics: what a run decided on its own. Success and failure documents carrydiagnostics: {warnings, fallbacks, skipped}(each entry{code, message, ...facts}) when the run recorded any, beside the stderr line it always printed.render.pycarries its stages’ entries, tagged withstage.- A refused plan is explained and logged.
render.py PLANrefusing a plan whose inputs changed lists every input as{path, status, expected: {size, sha256_head_tail}, actual}(actual: "missing"for a file that is gone) withplan_sha256andchecked_atinerror.details, and appends one JSON line to<plan>.audit.jsonlnext to the plan, or to the newrender.py --audit-log FILE. A log that cannot be written is adiagnostics.warningsentry, never the failure; a--dry-runwrites none. - Plans name the inputs they could not bind.
--planwritesunbound_inputs, the inputs that did not exist yet (an earlier step’s output) and so carry no fingerprint; executing the plan reports them asunbound_inputsand aplan_unbound_inputswarning.plan_versionstays 1.
Changed
- A live run keeps its output lock until its own time limit has passed. A lock older than an hour was taken over even while its pid was running, so an encode longer than an hour could lose its lock to a second writer. The writer now dates its lock to its deadline (4x
--timeout+ 60 s; never with--timeout 0), and a live holder’s lock is taken over only after that; a dead pid’s lock is still taken over at once. A lock written by an earlier version is read as before (an hour). A takeover is recorded (diagnostics.fallbacks,stale_lock_taken_over, and alock_takeoverline in the hidden.<output>.ffskill-audit.jsonlor--audit-log), and so is running without a lock where none can be created (output_unlocked). A lock refusal names the holder’s pid and the lock file inerror.details. - feat: structured refusals, diagnostics and an audit log (silent paths, part 1 of 3) (#349)
2.10.11
Automated release: version and notes generated from pull requests merged since 2.10.10.
Fixed
color.py --retagchanges the tags a player reads, and checks that it did. A stream copy set only the container’s colour tags (-colorspace/-color_primaries/-color_trc). H.264, HEVC, MPEG-2, AV1 and VP9 carry their own colour description in the stream, which decoders and ffprobe read first. So a source already tagged there came out with its old tags while the result saidcompleted(FFmpeg 6.1 and 8.0.1 alike). The copy now also rewrites the stream’s own description with the codec’s bitstream filter (h264_metadata,hevc_metadata,mpeg2_metadata,av1_metadata,vp9_metadata), so it is still a lossless copy. Every run then probes its output, both the stream’s tags and the first decoded frame’s, since FFmpeg 8.1’s ffprobe shows the container’s tag at stream level while the decoder still outputs the VUI’s (verification:color_tags). If either still reads other tags (a codec with no such filter, or a build without it), the video is re-encoded with the tags andreencoded: trueplus anotesline say why. If the re-encode is wrong too, the run fails withkind: verification.contract --jsonlists the five filters as optional capabilities ofcolor.color.py --retag’s re-encode keeps the pixels and writes the tags on FFmpeg 7.1 and later. It set the tags with output options. From 7.1 those drive the filter graph’s colourspace negotiation, so a bt709 source retagged bt601 had its matrix converted and kept bt709 primaries and transfer (measured on 8.0.1 and 8.1.3). The frames are now labelled withsetparams, which gives identical pixels and the requested tags on 6.1.1, 7.0.2, 8.0.1 and 8.1.3.overlay.py --textdraws multi-line text without a box at the line breaks on FFmpeg 8.0, 8.0.1 and 8.1.0. 2.10.9 only warned. On those releases each line now gets its owndrawtext, placed where one multi-line drawtext puts it on a fixed release. The positions come from one small drawtext run that measures each line alone (width, first line’s ascent, last line’s descent, line height); it runs under--dry-runtoo, so the plan is the run. Outlines go first, and the--boxis drawn once at the block’s size. The frame is bit-identical to a single multi-line drawtext on 8.1.3 (and on 6.1.1 with the route forced), and 8.0.1’s output matches 8.1.3’s. A font whose glyph boxes are not whole pixels can sit one row lower. The warning and thenotesline are gone, andoverlayis listed among the tools that may measure under--dry-run. Other releases plan the same command as before, byte for byte.- fix: –retag rewrites the stream’s own colour tags; overlay draws multi-line text per line on 8.0-8.1.0 (#348)
2.10.10
Automated release: version and notes generated from pull requests merged since 2.10.9.
Fixed
- The Python 3.9 deprecation is dated 2.10.9, the release that shipped it. The warning,
contract --json’sdeprecatedentry and the docs said 2.11.0, the version the change was written for before the automated release numbered it 2.10.9. - fix: date the Python 3.9 deprecation 2.10.9, the release that shipped it (#347)
2.10.9
Automated release: version and notes generated from pull requests merged since 2.10.8.
Added
doctorwarns when the FFmpeg it runs is older than the upstream fix for a known high-severity CVE. ffmpeg-skill does not ship FFmpeg, so a decoder bug in the installed build stays until the user upgrades it.doctor --jsonhas a newffmpeg_securityfield (source,checked, per-binaryversion/status/advisories,warnings), and each older version gets onewarning:line on stderr. The table is small, dated and hand-maintained from https://ffmpeg.org/security.html (checked 2026-10-22). It holds CVE-2026-8461 (“PixelSmash”, MagicYUV decoder heap out-of-bounds write, CVSS 8.8), fixed in 8.0.3, 8.1.2 and 9.0. A version on an older branch with no fixed release (7.1, 6.1, …) is compared with 8.0.3. The line never says the build is vulnerable: an Ubuntu (8.0.1-3ubuntu2) or Debian (+deb13u1) package may carry the fix as a backport under the old number, and the line says so and names the update command. An unparseable version (a git snapshot) gets no warning. It is advice only:ok, every tool’susableand the exit code are unchanged, and nothing is fetched. AISLE’s CVE-2026-64830..64835 are not on security.html yet and have no row.
Deprecated
- Python 3.9 (since 2.10.9, removed in 3.0.0). CPython 3.9 reached end of life in October 2025 and gets no security fixes. It still works throughout 2.x: the floor stays
>=3.9and CI keeps testing it. On 3.9,doctorand thenpxinstaller print one stderr line naming the replacement, Python 3.10+.contract --json’sdeprecatedlist has the entry, andrequirements.pythonsays 3.10+ is recommended.
Fixed
Three failures on Ubuntu 26.04 (ubuntu-latest since 2026-10-21), whose apt FFmpeg is 8.0.1. Each is an FFmpeg bug in specific releases; the tools now behave the same on 5.1.1, 6.1.1, 7.0.2, 8.0.1 and 8.1.3, the builds they were run on.
pad.pyno longer hangs on a clip with sound. FFmpeg 8.0.1 (and conda-forge’s 7.1.1, though not Debian’s 7.1.5) deadlocks with no CPU use when one input feeds a-vfgraph and an-afgraph whose sound both starts late (adelay) and is padded at the end (apad). 8.1.3 does not. The job sat until--timeout(1800 s), with--end 0too. This is the conda-forge 7.1.1 hangreferences/ci-platform-pitfalls.mdrecorded as one build’s quirk. Both streams now go through one-filter_complex. An odd-sized source gets its even scale in that graph, becauserun()’s retry adds one only to a-vf.silence.pycuts the sound as well as the picture. FFmpeg 8.0 and 8.0.1 ship anaselectthat passes every frame through: itsactivatecallback was lost when the filter was converted, and 8.0.2 and 8.1 restore it. On those builds a 12 s talk came out with 6.75 s of picture and 12 s of sound (the container reported 12.01 s), and--fillerremoved nothing. The sound is now cut by tagging the frames the samebetween()expression keeps (ametadata=mode=addwith a timelineenable) and keeping the tagged ones (ametadata=mode=select). That keeps exactly the framesaselectkept on every other release, in a command no longer than before.- Multi-line titles and lower-thirds have no box at the line break. FFmpeg 8.0, 8.0.1 and 8.1.0 pass each line of a multi-line
drawtextto HarfBuzz with its newline, so the font’s missing-glyph box is drawn at the end of every line but the last and counted intext_w. 8.0.2 and 8.1.1 fix it. On those releasesgraphics.py --text-render autorenders a label that is drawn on more than one line through libass (text_renderer: "ass"), whether the newline was given or added by the wrap. A pinned--text-render drawtextkeeps drawtext and warns.overlay.py --text, which has no libass route, warns and adds anotesline. The release is read with a newffmpeg_release(), becauseffmpeg_version()’s major.minor cannot tell 8.1.0 from 8.1.1. - ci: the Python 3.9 jobs run on
ubuntu-24.04, since Ubuntu 26.04 has no Python 3.9 build. - fix: FFmpeg 8.0.1 (Ubuntu 26.04) failures; doctor CVE advisory; deprecate Python 3.9 (#346)
2.10.8
Automated release: version and notes generated from pull requests merged since 2.10.7.
Added
- README FAQ. How ffmpeg-skill compares with Remotion: it edits existing footage locally with FFmpeg tools and a machine-readable contract; Remotion renders from React; a file Remotion or HyperFrames writes is an ordinary input here. Whether the filter graph is validated before a run: no.
--dry-runprints a writing tool’s ffmpeg command and does not start ffmpeg.--planrecords that dry run and fingerprints inputs;render.pyre-runs the tool later, it does not ask ffmpeg to parse the graph.doctorchecks capability names againstffmpeg -filters,-encodersand-bsfs. - feat(mcp): add ToolAnnotations (readOnly/destructive/idempotent/openWorld hints) to tools/list
- docs: README FAQ for Remotion and filter-graph checks (#345)
2.10.7
Automated release: version and notes generated from pull requests merged since 2.10.6.
Fixed
- A rotated file is measured and composited at the size it is shown. ffmpeg applies a 90° or 270° display rotation before the filter graph, and
probe.pystill reports the stored width and height.overlay.py --scale-percenttook half of that stored width (a 50% logo on a 320×180 phone file shown as 180×320 was 160 px, almost the whole frame).--platformmargins used the same stored size, so TikTok’s 14% right margin was 14% of the landscape width.broll.pyscaled the cutaway to the stored frame and clipped it onto the portrait one.cropdetect.pyreportedsource_width/source_heightas the stored size while the rectangle was in the displayed frame (a 176×320 crop against a 320×180 source).look.py --safewarned that a 9:16 phone file stored as 16:9 was the wrong shape for TikTok. The displayed size is what those tools use now. - The colour-emoji probe survives a path that contains a colon, and a Windows font path is not split on the drive letter.
subtitles=C:/...is read as the optionCplus a following option, so the probe that decides whether libass can draw colour emoji failed on every Windows temp directory and reported the capability unknown. The samefc-listlineC:\Windows\Fonts\seguiemj.ttf: Segoe UI Emojiwas split on the first colon, so the font file wasCand a monochrome fallback was never found. The probe now uses the filter-path escape, and the listing is split on colon-space, which is the separator fontconfig prints. - fix: measure rotated frames at the size they are shown (#344)
2.10.6
Automated release: version and notes generated from pull requests merged since 2.10.5.
- fix: release-check catalogue, overlay scale, sync wording, MCP timeout (#343)
2.10.5
Automated release: version and notes generated from pull requests merged since 2.10.4.
Added
- README FAQ. Answers the questions that followed the tool being shared: a caller cannot pass a filter graph or other raw ffmpeg arguments (schemas come from each script’s argparse, and CI fails on drift;
doctorchecks the filters beforehand); Remotion and HyperFrames render a picture from code, and a file they write is an ordinary input here; a fixed logo or burned-in line can be interpolated withredact.py --mode delogo, while inpainting and anything that moves stay out of scope; nothing tracks a face or a subject; the GPU encode path is Apple VideoToolbox. redact.py --mode delogo. ffmpeg’sdelogofilter, with the same typed rectangle as blur and pixelate (--x,--y,--width,--height). It fills that rectangle from the picture around it. The box has to sit at least 1 px inside the frame (the filter’s band is fixed at 1, and the option is gone), and an HDR or 10-bit source is refused. A separate script was not added: this is the known-box operationredact.pyalready owns, and the tool count stays 42.
Fixed
npm run release-checklists the full MCP catalogue. Since the defaulttools/listbecame the core 12, step 4 required at least 15 names and step 6 required every contract tool, both against the default list. Both steps now setFFMPEG_SKILL_MCP_FULL=1. The same script refuses a tarball that containsreferences/process-pitfalls.md.- A text overlay no longer ignores
scale.--scale/--scale-percentsize an image, logo or video layer. On text they were accepted and the draw stayed at the default font size, including a render project that forwardedoverlays[].scalefor a text entry. That combination is now refused (kind: input); text size is--font-size/font_size. sync.pysays two files that start together start together. A measured offset of 0 used to read “second starts 0.000s earlier than reference”.- MCP
--timeout 0in a rawargvlist clears the outer ceiling. The structuredtimeoutkey already did. The argv form left the dispatch ceiling at 4×1800 s while the child ran with no limit. fit.py --fit bluron HDR prints-15%, not-15%%.speedramp.pycuts the last segment where it was asked to when that end is more than 50 ms before the file. The end bound used to be omitted for every last segment, so an end inside the 0.5 s coverage slack played the tail at that segment’s speed. An end that is the file’s end still runs to the last packet.npx ffmpeg-skill doctoron Windows tries the next Python whenpython3is the Store alias. That alias spawns and exits non-zero, which the installer used to treat as a found interpreter.cut.py --accuratestarts the picture at 0 (#336). The re-encode passed-avoid_negative_ts make_zero, which shifts every stream so the earliest timestamp is 0. That timestamp is the AAC encoder’s priming (21.3 ms at 48 kHz) or a negative B-frame decode time, so the picture started after 0 and the sound at 0.fit.py --fpsthen changed the frame count. The video re-encode no longer passes the flag. When--startlands on a frame, that frame is at 0. A start between frames begins on the next source frame and may keep up to one frame of its timestamp. An MP4/MOV edit list hides the priming, and a source whose audio starts late keeps that offset. A stream copy still usesmake_zero(or--edit-list). An audio-only--accuratecut still uses it, which is the primingprecision: codec_framecounts in the length.- Windows 11 resolves Nirmala UI for Hindi, Bengali and Tamil (#339). The font ships as
C:\Windows\Fonts\Nirmala.ttc.WINDOWS_FONTSonly namedNirmala.ttfandNirmalaB.ttf, sodoctorreported no font forhi,bnandta. The collection is now in those three lists, after the.ttfnames, so a machine that still has Mangal orNirmala.ttfkeeps that file. - fix(cut): start an accurate re-encode’s picture at 0 (#342)
- docs: answer the shared questions, and remove a fixed logo as a redact mode (#341)
Closes: #336, #339
2.10.4
Automated release: version and notes generated from pull requests merged since 2.10.3.
Fixed
join.pyno longer leaves a one-frame hole where a clip’s picture starts after 0. A cut part often has its first video packet a little after its first audio packet (0.08 s against 0.058 s here). Every clip’s sound chain already started at 0 (asetpts=PTS-STARTPTS), but its picture chain kept the offset, so the concat filter (andxfade’s offsets) placed it one frame late: a--transition nonejoin of a 2.53 s and a 3.0 s clip at 30 fps came out 166 frames over 5.567 s (29.82 fps,verified: false). video-editing-skill’s CONCAT correctly refused the file. This was introduced in 2.4.1 (#302), when the plain cut started trimming every clip toclip_length. The picture now starts at 0 like its sound: 166 frames over 5.533 s at exactly 30 fps.- fix(join): start each clip’s picture at 0 so a late first frame leaves no hole (#338)
- docs(readme): fresnel-style header with a Liquid Glass app icon (#337)
2.10.3
Automated release: version and notes generated from pull requests merged since 2.10.2.
Fixed
- A colour the skill draws lands in the BT.709 its output is tagged with. A
--pad-color(fit.py,export.py,join.py,broll.py),pad.py --color,straighten.py --fill-color,grid.py --background/--font-color,overlay.py --textfont/border/box colours,caption.py’s burned colours,graphics.py’s brand colours (boxes, bars, text),background.py --color/--gradientandwaveform.py’s background and waves were turned into YUV by FFmpeg’s drawing code with BT.601 under the output’s BT.709 tag: pure red came out (255,24,0), pure green (0,216,0) (mean ΔRGB 6.6-33.5, max 34.9-64.0 depending on the tool, on FFmpeg 5.1.1, 6.1, 7.1.1 and 8.1.3; on 7.1+ a BT.709-tagged or converted source’s pads were already right). From FFmpeg 7.1 the drawing filters follow the picture’s label, so an untagged picture is labelled BT.709 (tags only); before 7.1 nothing makes them use another matrix, so the drawing runs on an RGB copy and only the pixels it touched are taken from it (the rest of the picture is bit-identical). Boxes and backgrounds are colour sources made in RGB and converted;graphics.py’s translucent boxes with chroma are overlaid colour layers now, which also blend their colour at the alpha they state (drawboxblended 4:2:0 chroma four times over). The ASS filescaption.pyandgraphics.pywrite sayYCbCr Matrix: None, which FFmpeg 8.x needs to draw them in the picture’s own matrix (and libass-based players read the same way). After the fix every measured colour is within 1.8 of the truth (mean 0.3-0.95), and within 3.6 for the thin graphics bars. A grey – black and white pads and text, the default black@0.5 boxes, graphics’ near-black background – is the same under both matrices and plans the command it did; a GIF export, a prores or HDR line and a caller’s own--assfile are unchanged. join.py,multicam.py,broll.pyandoverlay.py --videolabel an untagged clip BT.709 beside a converted one, asgrid.pydoes since #333: on FFmpeg 7.1+ concat/overlay converted one of the two pictures to the other’s “unknown” (BT.601) matrix (7.08 mean / 25.0 max ΔRGB with the BT.601 clip first, 9.12 / 40.1 with it second, on 7.1.1 and 8.1.3). The colour tests’ “untagged” clips are now untagged on FFmpeg 7.1+ too (they came out tagged bt709 there, so CI on 7.1/8.x/9.x never ran the untagged case).- fix(color): draw pad, text, box and background colours in BT.709; label untagged clips in mixed joins (#334)
2.10.2
Automated release: version and notes generated from pull requests merged since 2.10.1.
Fixed
- An RGB picture, a JPEG still and every
grid.pycell are converted to the BT.709 their output is tagged with. 2.10.1 converted a BT.601-tagged video source; everything else was still made YUV by FFmpeg’s own default, BT.601, under a BT.709 tag, so pure red came out (255,24,0) and pure green (0,216,0) to any player that honours the tag (mean ΔRGB 8.8, max 38.9 on FFmpeg 5.1.1, 6.1, 7.1 and 8.1). Now converted explicitly (scale=out_color_matrix=bt709): a PNG/JPEG still (insert.py), a PNG sequence (sequence.py), an RGB clip (qtrle, PNG-in-MOV) through any re-encode, the logo or image ofoverlay.py --image(andrender.py’s image/logo layers: there the RGBA logo also madeoverlay=format=autocomposite in RGB, so the whole video came back BT.601, 9.04 mean / 40.6 max ΔRGB on a BT.709 source with 6.1; it now composites in YUV and leaves the video’s pixels alone),waveform.py --image(the audiogram template) and the emoji PNGs ofgraphics.py/caption.py; a palette PNG is expanded first (swscale converts a palette with fixed BT.601).grid.pyconverts each cell by its own tags: it relabelled them on 5.1/6.1, and on 7.1+xstackconverted the cells to one another’s matrix. Where a converted picture meets an untagged one (an untagged main under a logo, an untagged grid cell beside a converted one), the untagged one is labelled BT.709 (setparams, tags only), because FFmpeg 7.1+ otherwise converts one to the other taking “unknown” as BT.601 (the untagged main shifted by 7.08 mean / 25 max ΔRGB on 7.1.1 and 8.1.3). After the fix every measured patch is within 1.6 of its colour (2.6 for a dithered palette PNG). An untagged or BT.709 video source re-encoded alone, and a grid of untagged/BT.709 clips, plan the commands they did. - fix(color): convert RGB pictures, JPEG stills and grid cells to BT.709 (#333)
2.10.1
Automated release: version and notes generated from pull requests merged since 2.10.0.
Fixed
- A BT.601 source is converted to the BT.709 its re-encode is tagged with, not just relabelled. Every SDR re-encode (
export.py’s presets, andfit,cut --accurate,crop,caption,overlay,graphics,color --lut/--correctand the other tools that re-encode a video source) tags its output BT.709, and those tags never touch the pixels; through 2.10.0 nothing else did either, so a source taggedsmpte170morbt470bg(fully, or with only the matrix tag) came outbt709/bt709/bt709over unconverted BT.601 pixels (48.5 dB YUV PSNR against the source on FFmpeg 6.1), and every player that honours the tags showed shifted colours. The matrix is now converted with FFmpeg’scolorspacefilter as the first step of the tool’s own graph (_common.bt709_matrix_filter();fccandsmpte240mmatrices too); primaries, transfer and range pass through.join,multicam,brollandoverlay --videoconvert each input by its own tags. An untagged or BT.709 source, an HDR/BT.2020 source, a stream copy and theproresmaster are unchanged: their commands are what they were.check.py’s colour row for a BT.601 matrix now namesexport.pyas the fix (any re-encode converts) and sayscolor.py --retag bt709only relabels. - fix(color): convert a BT.601 source to the BT.709 its re-encode is tagged with (#332)
2.10.0
Automated release: version and notes generated from pull requests merged since 2.9.0.
Added
- A
render.pyproject can carry a video layer (#326). Anoverlays[]entry takes"video": "webcam.mp4"as a fourth kind next tologo/image/text, mapped tooverlay.py --videowith the sameposition/scale/opacity/start/end/margin/platformkeys, plus"chromakey","chromakey_similarity"and"chromakey_blend"for green-screen removal. The layer is composited in the overlays stage, afterfit, so its size is relative to the delivered frame and a template’s safe margins apply;--cachekeys the stage on the layer file’s content,--dry-runplans theoverlay.pycommand, and--export-timelinenames the layer undernot_exported. Refused before the first stage (kind: input):videowith another kind in the same entry, achromakey*key withoutvideo, a similarity or blend withoutchromakey, and a layer file that does not exist. No existing key changes meaning; a project withoutvideorenders as before. Reported by @rymalia. - feat(render): a video layer in overlays[] (overlay.py –video / –chromakey) (#331)
2.9.0
Automated release: version and notes generated from pull requests merged since 2.8.7.
Added
- A per-machine speech model, and
--modelonsilence.py --filler --transcribe(#328).FFMPEG_SKILL_ASR_MODELis the modelcaption.py --transcribeandsilence.py --filler --transcribeuse when no--modelis given (elsebase, as before), so a machine withggml-large-v3-turbo.binno longer needs--modelon every call, and filler timing is no longer stuck onbase. An explicit--modelstill wins and still goes to whichever engine runs. A Parakeet model in the variable (anmlx-community/parakeet-*repo or a.gguf) is offered to the Parakeet engines only and the Whisper engines keepbase, so a machine default never makes auto’s Whisper route for another language fail on a model it cannot load; a Whisper name leaves Parakeet routing and Parakeet’s own model as they were. The result’stranscription.modelnames the model that ran.caption.py --modelno longer advertises abasedefault in its schema (it is resolved at run time);silence.py’s MCP schema gainsmodel. - feat: per-machine ASR model (FFMPEG_SKILL_ASR_MODEL) and silence.py –model (#330)
2.8.7
Automated release: version and notes generated from pull requests merged since 2.8.6.
Fixed
check.py’s colour row reads the matrix tag. It decided “untagged” from the primaries and transfer alone, so a file whose only colour tag is a BT.601 matrix (smpte170m/bt470bg, as some renderers write) passed asuntagged (players assume bt709)although a player that honours the tag decodes it as BT.601. A non-BT.709 matrix is now a tag: the row is WARN, and so is a BT.709 primaries/transfer file with a BT.601 matrix. For a BT.601 matrix the fix no longer offerscolor.py --retag bt709, which only relabels the pixels (as every SDR re-encode here does); it says to re-export from the master as BT.709. A file tagged only with a BT.709 matrix still passes, wordedbt709 matrix, primaries/transfer untagged (players assume bt709)instead of “untagged” (#325).- fix(check): read the matrix tag in the colour row (#329)
- chore: expand package keywords and plugin description (#327)
2.8.6
Automated release: version and notes generated from pull requests merged since 2.8.5.
Added
- A build without libx264/libx265 encodes on VideoToolbox by itself (#314). An LGPL ffmpeg such as the one a desktop app ships has
h264_videotoolbox/hevc_videotoolboxand no x264/x265, so every H.264/HEVC line (fit,cut --accurate, the other re-encoding tools, andexport.py’syoutube/reels/h265/… presets) failed with “Unknown encoder”. Whenffmpeg -encoderslacks the CPU encoder, has the matching VideoToolbox one, and the machine is Apple Silicon (the check--hwuses), the tool now uses VideoToolbox without--hw, built as under--hw. The result’shwis{requested: false, source: "auto", used, ...}with a note naming the missing and the used encoder, plus the usual not-CRF-equivalentnotesline (without the--no-hwadvice). A build that has libx264/libx265 is unchanged, and so are--no-hw(still the CPU),FFMPEG_SKILL_HW(still does not moveexport.py’s presets) and AV1/ProRes. doctoragrees. A missingencoder:libx264/encoder:libx265whose VideoToolbox encoder is in the build, on a machine that can run it, is left out ofavailable/missing/missing_optionaland named in the newcovered_by({}otherwise), sookand each tool’susabledescribe what will run; a tool that needs it also carriescovered_by, andnotessays the output is not CRF-equivalent. The contract’s capability lists are unchanged.- fix: use VideoToolbox when the ffmpeg build has no libx264/libx265 (#324)
2.8.5
Automated release: version and notes generated from pull requests merged since 2.8.4.
- fix(asr): decode whisper.cpp transcripts with -mc 0 (#323)
2.8.4
Automated release: version and notes generated from pull requests merged since 2.8.3.
Fixed
cut.py --segments --accurateno longer hangs when a later segment crosses a mid-stream colour-tag change. The concat re-cut opens the source once per segment; on FFmpeg 8.1.2 a source whose tags change partway (untagged to bt470bg/tv on a new SPS) deadlocked at 0% CPU in the second input’s graph rebuild and ended at--timeoutwith nothing written. Every segment input now carries-reinit_filter 0, so the graph keeps its first format; the output’s colour tags were already written explicitly, so no pixels change. A source whose frame size changes mid-stream is no longer refused cleanly by the concat filter (seedocs/design-decisions.md) (#316).- fix(cut): -reinit_filter 0 on every –segments –accurate input (#322)
Changed
- whisper.cpp transcripts are decoded with
-mc 0(no previous-text context). This is a decoding change for whisper.cpp users:caption.py --transcribeandsilence.py --filler --transcribepass-mc 0to thewhisper-clicalls that write the SRT and the word timings (not to language detection), so a transcript can differ from 2.8.3’s. Whisper conditions on its own previous text, and a hallucinated line can then repeat to the end of the file; in the measurement large-v3-turbo repeated one sentence ~300 times on two of six sets and lost about 7 minutes of speech on one. With-mc 0both loops are gone and every model’s word-weighted WER over the six sets falls: base 19.87 to 18.33, small 17.07 to 15.42, large-v3-turbo 69.12 to 12.52. It is not better everywhere: gigaspeech is slightly worse for all three models, and some single sets are for some models. faster-whisper, openai-whisper and Parakeet are unchanged. Measured and proposed by @rymalia in #318 (whisper.cpp 1.9.5, 58 min of English from the ESB test sets).
2.8.3
Automated release: version and notes generated from pull requests merged since 2.8.2.
Fixed
- macOS: a lean Homebrew
ffmpegonPATHno longer hidesffmpeg-full. When theffmpegonPATHis Homebrew’sffmpeg/ffmpeg@Nformula and the same prefix has the keg-onlyffmpeg-full, every tool (anddoctor) runsffmpeg-full’sffmpegandffprobe, sobrew install ffmpeg-fullis enough to getdrawtext,subtitlesandzscale. Any otherffmpegonPATHis used unchanged.doctor --jsongainsffmpeg_path,ffmpeg_source,ffprobe_pathandffprobe_source; the existingffmpeg/ffprobekeys are unchanged (#317). doctorno longer says drawtext “crashed” when it is simply absent. A build without thedrawtextfilter now gets the install hint; the crash wording (#100) is kept for a build that lists it and then crashes.- fix(macos): use Homebrew’s keg-only ffmpeg-full when PATH has the lean ffmpeg (#321)
2.8.2
Automated release: version and notes generated from pull requests merged since 2.8.1.
- fix(install): macOS ffmpeg-full is keg-only, so say how to put it on PATH (#319)
2.8.1
Automated release: version and notes generated from pull requests merged since 2.8.0.
Fixed
- Wrapped titles on Windows. The text file drawtext reads is now written with bare line feeds. Text mode wrote each kept line break as CRLF there, and drawtext from FFmpeg 6.1 draws that as a blank line plus a line break, so a wrapped hook, title or meme caption came out at twice its height. Trailing newlines are dropped (drawtext ignores one and draws a second as a blank line), and every route normalises CRLF and CR to LF before it lays out a label.
graphics.pylays out by line count. Thetitletemplate put its accent bar and subtitle for one line of text, so a two-line title was struck through and covered;lower-thirdprinted a two-line name over its title; thehookband stayed at 30% of the short side whatever the wrapped title needed, so a 9:16 hook of four or more lines ran out of it onto the bare video. Each now follows the number of lines. A one-line label draws as before; the hook band grows only when the wrapped text needs more than the base band holds.silence.py --filler --transcribeuses the transcript’s language. With the default--filler-lang autoit always took the English list, so French or Japanese speech was transcribed correctly and its fillers stayed in the cut. The language the engine decoded picks the list: whisper.cpp’sresult.language, faster-whisper’s detected language and openai-whisper’slanguage, when nothing was named. A transcript in a language with no built-in list is refused (kind: input), as--filler-langnames it, with--filler-words FILEas the way forward; the English list is not applied to speech it does not know.- Speech language handling.
--language autois no language (it was treated as a non-English one, which skipped Parakeet or refused on a Parakeet-only machine);en-US,engandEnglishreach Whisper asen, and a regional code (ja-JP) as its primary language (ja). Quantised English-only models (ggml-base.en-q5_1.bin,tiny.en-q8_0) count as English-only for routing, so a multilingual Whisper is preferred on undetected speech. Known gap: with whisper.cpp as the only engine and an English-only model, undetected speech is still decoded as English and the run adds no note. - A VideoToolbox refusal is reported by its outcome.
hw.fallbackis true only when the CPU retry succeeded. A failure whose CPU retry also failed names it (“the CPU retry failed too”) and leavesfallbackfalse; a retry that times out keeps the neutral “retrying on the CPU encoder” line. A job of many encodes, or a process that ran the same refusal twice, names it once with its latest outcome. - Contract and docs.
doctor’s plain-text summary no longer says no tool uses a GPU encoder;output_schemapublishesnotes(the VideoToolbox quality line) for every tool that takes--hw; the dry-run note forsilence.pyandexecution.model_downloads.whensay that--filler --transcriberuns the speech engine under--dry-run;docs/contract.mdlistsloop.py’sboomerang/notesandcropdetectasenvironment_dependent; the advice to pass--hwfor an export is given only where--hwcan run; the test-suite description counts all nine group modules; a test that needs no speech engine skips when any speech engine is installed. - fix: line-break, speech-language and VideoToolbox fallback bugs (#315)
2.8.0
Automated release: version and notes generated from pull requests merged since 2.7.0.
- feat(cut):
--vfr-guard average|sampled|offchooses what keeps a variable-frame-rate source from a stream copy.average(the default) is the guard every 2.x release had, unchanged: a source whose nominal and average frame rates differ (variable_frame_rate_suspected) re-encodes as--accurate(now reasonvfr); its packet timestamps are then sampled in up to five 6 s windows (no decoding) and reported invfr_check(measured:sampled_cfr/vfr/inconclusive,guard,windows,deltas), with a note naming--vfr-guard sampledwhen the sample is constant, as on an iPhone clip at 29.98 against a nominal 30.sampleddecides on the sample instead:sampled_cfrcopies,vfrre-encodes with reasonvfrandinconclusive(too few frames, or ffprobe failed) withvfr_inconclusive;offsamples and keeps the copy, with a note. Variable timing between the sampled windows is not seen. The sample runs under--dry-runand is skipped when nothing would copy (audio-only,--accurate,--codec).probe.py’svariable_frame_rate_suspectedkeeps its meaning. - fix(cut): a
--segmentssegment that runs past the end of the video no longer opens a hole at its join. A segment (not the last) that runs past the end of the video left a gap before the next one: 0.355 s for 0.3 s of sound with no picture, reported asmode: copy. It now ends with the video when it runs less than a frame past it, and otherwise holds the last frame for its sound, which re-cuts the join from the source (concat_fallback, with a note). Also,--accuratejoins went through per-part encodes and a copy join, leaving a 23 ms hole (one AAC frame) at every join; they now re-cut every segment in one encode, as the fallback does, reported only asrequested. The video’s end is measured from the file’s own start, so offset timestamps do not move it. - fix(cut): a
--segmentsstream-copy join is exact, or it is not a copy. On B-frame video each part carried the next GOP’s keyframe and first P-frame (an input-tstops in decode order) and itsmake_zerostart shifted it by the reorder delay: 186 frames for 180, holes in the picture and the sound drifting 67 then 133 ms, reported asmode: copy(287 frames for 224 on iPhone HEVC)..mp4/.movparts of B-frame video now keep their edit list and end at their end keyframe’s decode time; an end is snapped to the first keyframe at or after it, never an earlier one, within--tolerance(each end judged on its own; past it, the join is re-cut from the source), a part that reaches the end of the video keeps its length, and a later segment that starts between keyframes is re-cut (the concat demuxer crushes its hidden pre-roll into the join). A source with no B-frames (iPhone “Most Compatible” H.264) cuts its parts as 2.5.1 did, which was already frame-exact (0-1.8,4-6is 114 frames, as before). A source with open GOPs (x265’s default; iPhone “High Efficiency” HEVC at every keyframe) cannot be cut exactly by stream copy, so its joins are re-cut from the source (concat_fallback, with a note), keeping HEVC, 10 bits and HDR tags. Every copy join is then measured by demuxing:join_check(packets,expected_packetscounted in the source,max_step_seconds,audio_offset_ms,audio_parts_checked,ok); each step at a join must be the one its parts predict (within 1 ms), and each part’s sound is matched to the source’s audio packets and must sit within 5 ms of its picture (a B-frame H.264 + PCM.movjoin copied every frame exactly with its sound 16 and 53 ms late); a failed check re-cuts the join. Only windows around the segments are read from the source (±10 s, widened up to three times, then the whole file), so a long source costs what its segments do: 0.5 s for three 1-minute segments of a 25-minute source. Matroska/MPEG-TS parts keep the old cut and rely on that check. New keysegment_end_snap_seconds. A join whose parts would re-encode is re-cut in one encode instead of copy-joining separately encoded parts.duration_error_mson an exact AAC copy join reads about +21 ms: one AAC frame of priming. A checked join is written to a hidden file beside the output and renamed into place only once it passes, so a failed check never costs an--overwritetarget; placing a finished file (this, andrender.py’s last step) now holds the output’s lock like any ffmpeg write. - feat(cut):
--keep-hevc(opt-in) re-encodes an SDR HEVC source as HEVC (x265 8-bit, BT.709-tagged, on VideoToolbox under--hw) instead of H.264; it covers--accurate, the tolerance fallback and the--segmentsre-cut join. Without it every SDR re-encode stays x264, as before; HDR and BT.2020 sources keep their HEVC Main10 line either way, and--codecstill overrides. The contract’scutx265 capability adds “with--keep-hevcon an HEVC source”. - feat(cut):
--edit-listkeeps the MP4 edit list on a single-segment.mp4/.movstream copy instead of-avoid_negative_ts make_zero, which shows the keyframe’s pre-roll (on Core Media HEVC, 3.7 s of sound with no picture from a run that exited 0): the pre-roll is stored but hidden, so the picture starts at--start. The default is unchanged (make_zero). New keys on every cut:edit_list(falsewithout the flag),stored_preroll_seconds(the hidden pre-roll, from the keyframe the demuxer really seeks to;nullwithout the flag),av_start_skew_seconds(audio start minus video start; past max(2 frames, 0.1 s) a note names--accurate, and--edit-listfor an.mp4/.movcopy cut without it: the skew is reported, not repaired) andnotes. Under--edit-lista copy is judged by its video’s length, not the container’s, and one that starts where asked but ends past--tolerancere-encodes without offering another--start.keyframe_snappedkeeps its meaning (a stream copy end to end); the newstart_snappedis measured:truewhen a copied picture starts more than a frame from--start(an--edit-listcopy whose edit list hides the pre-roll is not; reading a source’s keyframes no longer seeks to its start, which skipped an edit-listed file’s negative-pts keyframe and reported its start as snapped),nullunder--dry-run. The compact probe gains per-streamstart_time(video.start_time,audio.start_time). - fix(cut):
-ss/-tare passed to the microsecond (they were rounded to the millisecond):--start 00:00:02:01@30on a keyframe at 2.0333 s seeked to 2.033, before it, and snapped back to the keyframe at 0 (atolerancere-encode); it now copies from that keyframe. - fix(cut):
--accurateon video seeks a second early and drops the margin on the output side. Seeking straight to--startlanded, by decode time, on a keyframe after it when the start was a few frames before a keyframe on B-frame video, losing those frames. - fix(cut): a
--segmentsjoin of mismatched parts no longer goes through the concat demuxer, which takes the first part’s parameters for all of them: a copied HEVC segment next to a re-encoded H.264 one decoded with errors from a run that exited 0. Parts are joined by stream copy only when their codec parameters, rotation, colour tags and extradata match; otherwise every segment is re-cut from the source through the concat filter (at most 32 per ffmpeg call), keeping an audio track’s offset from the video, on the source’s frame grid and at its stated frame rate (FFmpeg 7.0 otherwise wrote 25 fps). Each segment is seeked a second early and trimmed to its start, because the MP4 demuxer seeks by decode time and a start inside the B-frame reorder delay before a keyframe would land on that keyframe. A join into.mp4/.movkeeps HEVC’shvc1tag (an HDR source’s re-cut is HEVC by default), including when more than 32 segments are encoded as Matroska chunks and copied together. Subtitle and data streams are dropped from that re-cut and reported (dropped_non_av_streams). - fix(cut): a video cut or
--segmentssegment shorter than one frame is refused (kind: input) before ffmpeg runs; 2.5.1 wrote a whole frame for it and reported success (--start 1 --end 1.01on 30 fps video wrote one frame, 44 ms longer than asked). A cut to an audio output is not affected. - feat(cut):
reencode_reasonsays why anything was re-encoded: a list ofrequested,codec,vfr,vfr_inconclusive,pcm_container,copy_failed,tolerance,concat_fallbackin first-seen order,[]for a clean copy.--segmentscuts addsegment_precision, and every cut addsleast_exact_precision, the least exact segment’s;precisionkeeps its formula.requested_segments/requested_durationstill report the request clamped to the media’s duration; a segment ended with the video shows its trim induration_delta_secondsand a note.batch.pyrows gaincut_reencode_reasons, its top level counts them incut_reencode_reasons(cut_stream_copyis unchanged), and its log no longer calls every re-encode a “hybrid tolerance fallback”. - fix(cut): a
--segmentscut whose parts copied losslessly but whose join had to re-encode reportedmode: "copy",reencoded: falseandprecision: "packet"; it now reportshybrid,reencoded: true, the re-encode’s precision andconcat_fallback. - The
cut.pywork above, fromreencode_reasonand theconcat_fallbackreport to the edit-list, HEVC and frame-timing paths and the exact--segmentscopy joins with theirjoin_check, was built by @rymalia in #306, measured frame by frame on fixtures and iPhone footage. The maintainers’ takeover kept 2.x’s defaults behind--edit-list,--keep-hevcand--vfr-guard, and added the no-B-frame path, the audio check and the bounded packet read. - feat(cut): edit-list copies, –keep-hevc, –vfr-guard and a checked join (takeover of #306) (#313)
2.7.0
Automated release: version and notes generated from pull requests merged since 2.6.0.
- feat: Parakeet speech engines, built by @rymalia in #305.
parakeet-mlxandparakeet.cpp(parakeet-cli+ a.gguf) forcaption.py --transcribeandsilence.py --filler --transcribe;--engine/FFMPEG_SKILL_ASR_ENGINEpick one, and a named engine runs as asked.autoruns Parakeet for English speech (an English--language, else whisper.cpp’s language detector) and Whisper otherwise. Speech whose language nobody named and nothing here could detect goes to a Whisper engine that can run; Parakeet runs on English assumed only when there is none or every one failed, and the result’s top-levelnotesthen says the transcript is wrong if the speech is not English (new forsilence.py). Results carrytranscription(engine, model, language, routing) in every caption--mode;silence.py’sfiller.sourceisparakeet:ENGINEfor them. A Parakeet engine whose output is not a readable transcript is a failed run and the next engine is tried; its own empty answer is theno_speechrefusal.transcribe_result()/transcribe_words_result()return what a call used as aTranscription, andtranscribe()/transcribe_words()keep their return shapes. New optional contract capabilityexternal:parakeet. - fix: whisper.cpp is told to detect the language (
-l auto) when--language/--filler-langis not given. It was given no-l, and its own default is English, socaption.py --transcribeandsilence.py --filler --transcribedecoded speech in any other language as English. The result’stranscription.languagestaysnullfor a language the engine identified itself. - fix: the “no local speech-to-text engine found” refusal, with its install lines, now only means no engine was found. A whisper.cpp that was installed but failed, with nothing after it, got that refusal; now the refusal names each engine that was found and why it did not transcribe (
kind: input,reason: "engine_failed"or"english_only",engines[].detailwith the engine’s own error line). - fix: a faster-whisper that raises while loading or running its model (an offline first run, a broken install) is a failed engine:
caption.py --transcribelogs “faster-whisper found but failed” and tries the next engine instead of refusing with “faster-whisper found no speech”, andsilence.py --filler --transcribecan fall back to Parakeet. The exception used to be swallowed in the engine’s thread. An engine that ran and found no segment is still theno_speechrefusal. - docs: parakeet-mlx downloads its default model (
mlx-community/parakeet-tdt-0.6b-v2) from Hugging Face on first use, as faster-whisper does, and openai-whisper downloads its model from OpenAI. The install hint, README, docs/contract.md and references/scripts.md now say so, and that the audio never leaves the machine (inference is local). The skill’s own code opens no connection, but faster-whisper runs inside the skill’s process, so its first-run download is made from that process. The refusal’s “all run offline” is gone: a first run that downloads is not offline. - feat(contract):
execution.model_downloadssays which speech engine fetches its model on first use, from where, and in which process (faster-whisper in the skill’s own; parakeet-mlx and openai-whisper in their own child processes; whisper.cpp and parakeet.cpp never).execution.network: falsekeeps its meaning, the skill’s own code, and no longer reads as a promise that a first--transcribeis network-free. - feat: Parakeet speech engines for –transcribe (#312)
2.6.0
Automated release: version and notes generated from pull requests merged since 2.5.1.
- feat: opt-in Apple VideoToolbox encoding.
--hw/--no-hwon every re-encoding tool andexport.py(two booleans in the contract and MCP:hw,no_hw);FFMPEG_SKILL_HW=1makes it the default for everything butexport.py’s delivery presets (an export under it says so inhw,requested: false);render.py/batch.py --hwmake it explicit for every stage, andwaveform.pypasses its choice to the caption/title stage that writes its file. h264/hevc (HDR Main10 with the source’s tags and HDR10 side data)/prores; AV1 stays on SVT-AV1; anexport.pypreset keeps its own format on the GPU (an h264/h265 preset stays 8-bit BT.709,proreskeeps the source’s tags). CRF maps to-q:vby SSIM against x264/x265medium(tests/bench_vt.py): SDR H.264 18/23/28 → 75/64/53, SDR HEVC → 78/68/58, and HDR its own curve, 63/55/47, because its CPU line is x265 Main10 at CRF+2; matched quality costs 1.1–2.9× the CPU bytes on SDR and 1.9–3.5× on HDR. BT.709 tags go throughh264_metadata/hevc_metadata, so FFmpeg ≥7.1 never converts an untagged source. Apple Silicon only (the hardware, also under a Rosetta Python). Only a job VideoToolbox itself refuses (the encoder could not open, or its compression session failed, in FFmpeg 5.1 to master’s wordings) is re-encoded on the CPU, with the odd-dimension retry a CPU run gets; any other failure fails once, names its encoder and carriesencoder/hw. Every result names itsencoder;hwsays what was asked and what ran (requested,source,used— null under--dry-run—fallback,notes); a GPU encode adds a top-levelnotesline that its quality is not CRF-equivalent, and one chosen byFFMPEG_SKILL_HW=1notes the size cost and--no-hw.render.pycarries its stages’ and an executed plan’shw,batch.pyits items’ (eachresultsrow names itsencoder), and both caches key on the GPU setting.doctor --jsongainshw. Built by @rymalia in #304, including the VideoToolbox quality bench (tests/bench_vt.py) and its fitted curves. - fix: a
--dry-runthat needs the encoder list (--codec av1/prores,--hw) reads it fromffprobe -encodersand no longer runsffmpeg -encoders, keeping the promise that a writing tool’s dry run runs no ffmpeg; a real run keeps readingffmpeg -encoders, the build that encodes (on a mixed install, a Homebrew ffprobe next to a static ffmpeg, ffprobe’s list is another build’s). - fix(batch): a cache entry whose output a later run rewrote (another recipe for the same file) is dropped, so switching back re-processes the item instead of serving the other run’s file as cached.
- fix(waveform):
commandslists the title/caption stages’ command lines too, asrender.py’s does, so it holds every ffmpeg command the run planned or ran. - feat: opt-in Apple VideoToolbox encoding (–hw) (#311)
2.5.1
Automated release: version and notes generated from pull requests merged since 2.5.0.
- fix(graphics): a wrapped title is drawn on its lines.
graphics.pywrapped a title that did not fit and sized its card for two lines, but the drawtext text file had every control character stripped, newline included, so “WHO SHOWS UP?”, wrapped as “WHO\nSHOWS UP?”, lost its line break and rendered on one line as “WHOSHOWS UP?”, off both edges of the frame. Newlines are kept (\r\nand\rbecome\n; a tab, vertical tab or form feed becomes a space; other control characters are still stripped), and the centred templates (title,hook,meme) centre each line with drawtext’stext_alignon FFmpeg ≥ 6.1; on 5.1 and 6.0 the block is centred with its lines left-aligned.overlay.py --textkeeps a user’s newline too. - fix(graphics): a wrapped title is drawn on its lines (#309)
2.5.0
Automated release: version and notes generated from pull requests merged since 2.4.2.
- feat(loop):
--boomerangplays the clip forward then backward (frames 0..N-1, then N-2..1, repeated), so any clip loops without a jump. Each turnaround frame is shown once, unlike the common split/reverse/concat recipe, which shows the last frame twice and, on every repeat, the first frame twice.--timescounts round trips (1 is allowed) and--durationstill hits its target. The output is constant-rate at the source’s nominal frame rate: a variable-frame-rate source (phone footage) is retimed by frame index, because a CFR conform after the reverse duplicated a turnaround frame. The audio is dropped, with anotesline (reversed sound plays backwards). The result gainsboomerang. It warns when the decoded frames it holds would pass ~2 GiB and refuses clips under 3 frames or cycles over theloopfilter’s 32767 frames. - feat(loop): –boomerang plays a clip forward then backward (#310)
2.4.2
Automated release: version and notes generated from pull requests merged since 2.4.1.
- fix(join): a
--dry-runplain cut (--transition none) with a pending input now notes that the plannedtrimlengths are the stub’s 0 s placeholders, as the crossfade path already did for its offsets. - note (2.4.1): a plain cut’s
expected_durationis now the sum of each clip’sclip_length(the length both streams are trimmed or held to), not the container durations. A value that callers see changed; the old one could not be met, soverifiedwas false. - fix(join): plain-cut dry run names a pending clip’s trim as a placeholder; #302 follow-ups (#308)
2.4.1
Automated release: version and notes generated from pull requests merged since 2.4.0.
- fix(join): a plain cut (
--transition none) no longer leaves a hole in the video where a clip’s sound runs past its picture. The concat filter starts each clip after the longer stream of the one before, so a music bed padded to the container left 0.3 s of missing video (an odd average rate downstream), and even a 20 ms AAC tail left a one-frame gap; the run succeeded withverifiedfalse. Every clip now gets the crossfade path’s one length for both streams: the last frame held when the sound runs more than a frame past the picture, the sound trimmed when it runs less.expected_durationcounts those lengths, not container durations. - fix(join): a plain cut leaves no hole where a clip’s sound outruns its picture (#302)
- chore(deps): Bump github/codeql-action from 3 to 4 (#307)
2.4.0
Automated release: version and notes generated from pull requests merged since 2.3.2.
- feat(join): each
silententry carriesat/end, where that input sits in the output (seconds, transition overlap included), and--allow-silent N[,N](1-based) marks a planned pause: always joined, listed withintended: true, no warning or note, whatever--on-silentsays. (nothing yet) - feat(join): silent entries carry at/end; –allow-silent marks a planned pause (#300)
2.3.2
Automated release: version and notes generated from pull requests merged since 2.3.1.
- fix(doctor):
filter:drawtextis probed with thefontfile=the tools pass by default, so a build whose fontconfig lookup crashes (winget’s gyan.dev 9.x) is no longer reported as missing a required capability while every drawtext tool renders; the fontconfig-only crash is anotesentry instead (#298). - fix: docstrings in
_common/drawtext.pyand_ass_overlay.pyare raw strings, so Python 3.12+ no longer printsSyntaxWarning: invalid escape sequenceon import, and-W errorno longer fails it (#297). A test compiles every tracked.pywith warnings as errors. - docs(SKILL.md): MCP line said “the other 42” stay callable (it is the other 30, as
mcp/server.pyand the tests say);--fit-size offdescribed by what it does rather than “1.16 behaviour” (also incaption.py --help); workflow heading drops “always” (steps 0 and 8 are conditional). From a prompt audit. - fix: doctor probes drawtext with the tools’ fontfile=; raw docstrings for invalid escapes (#299)
- docs(SKILL.md): fix MCP tool count, describe –fit-size off, drop ‘always’ (#296)
2.3.1
Automated release: version and notes generated from pull requests merged since 2.3.0.
- fix(render): a plan’s
checkverify step honours"content": true(check.py --content), with or without aplatform, the same key a project’scheckstage takes. Before,render.py PLANran a check step only when it named a platform and never passed--content. - fix(render): plan check step honours “content”: true (#295)
2.3.0
Automated release: version and notes generated from pull requests merged since 2.2.5.
- feat(join):
--on-silent warn|fail|skip(defaultwarn) and--silence-threshold DB(default -50 dBFS). The preflight measures every input with audio (one volumedetect pass on a real run;--dry-runmeasures nothing and says so) and treats a peak at or below the threshold as silent – the trace of a TTS step that wrote a valid but empty wav, which the 2.2.0 missing/empty/unreadable checks let through.warnjoins it and names it under the newsilent: [{index, path, peak_db}]key and innotes;failrefuses it in the samekind: inputdocument as the other problems (silent (peak -91.0 dBFS));skipdrops it intoskipped. Inputs without an audio stream are never silent. The join’s own ffmpeg command is unchanged, and render.py forwards nothing new. - fix:
audio.pyno longer passes a silent--music/--replace/--effectsfile, or a silent voice under--duck, as verified. Each is peak-measured in the 2.2.4 preflight (real runs; a dry run measures nothing); new--on-silent warn|fail(default warn:silent: [{flag, path, peak_db}]+ note; fail: reasonsilent (peak X dBFS)in the one refusal) and--silence-threshold(default -50 dBFS). - fix: no
--jsondocument contains-Infinity/NaNany more.print_jsonwrites non-finite floats as"-inf"/"inf"/null(asloudness.pyalready did) withallow_nan=False;export.pyon silent audio reportsloudness.lufs: "-inf",silent: true, and a note saying “output audio is silent” instead of recommending loudness.py. - fix(caption): a burn whose cues are never visible is refused with
kind: inputinstead of reportingverified: true– every cue outside[0, duration], every cue blank, or--asswith noDialoguelines; applies towaveform.py --srttoo. Thecaptionblock reportscues_burnedandcues_outside, and cues partly outside get a warning note - fix(graphics, overlay): whitespace-only
--title,--name,--text(and the other text flags) count as missing and are refused instead of burning an empty graphic - feat(check):
--content(opt-in) addsblack(blackdetect share: WARN > 10%, FAIL >= 95%),frozen(freezedetect longest span: WARN > max(3 s, 30%), FAIL when the whole video is frozen) andsilence(silencedetect share at -50 dB: WARN > 50%, FAIL >= 95%) rows from one decode pass. Without it the row set is unchanged. render.py’schecksection takes"content": trueto forward it. - fix(check): the
audiorow no longer PASSes “present” for a silent track: FAIL under a platform/--lufsloudness target, WARN with none (a muted screen recording is legitimate), when the track is silent (peak <= -50 dBFS) – read off the loudness pass, or from one volumedetect pass when--no-loudnessor a spec without a loudness target skips it. - fix(caption): the “re-run without –dry-run to produce X_adjusted.srt” note appears only on dry runs
- fix(join): a segment shorter than 2 frames (audio-only: 0.05 s) and a path listed twice no longer join unremarked: new
short_segments: [{index, path, duration}]andduplicates: [{path, indices}]keys (always present,[]when none) plus notes. Warnings only; the join command is unchanged. - fix(waveform): silent input audio no longer renders a flat-line audiogram without a word. A real run peak-measures the input;
--on-silent warn|fail(default warn) and--silence-threshold(default -50 dBFS) as inaudio.py; newsilentkey (nullunder--dry-run) and a note. - fix(asr): a speech engine that ran and found no speech says
<engine> found no speech in <input>(kind: input,reason: "no_speech",engine) instead of faster-whisper’s “no local speech-to-text engine found” or whisper.cpp / openai-whisper’s “no cues found in /tmp/ffskill_asr_*/audio.srt”. - feat: catch valid-but-empty content (silent TTS, invisible captions, -Infinity JSON) (#294)
2.2.5
Automated release: version and notes generated from pull requests merged since 2.2.4.
- SKILL.md and README:
multicam.py --edlis a plain cut list, not an EDL an editor opens. The multicam row now routes “for Premiere/Resolve” through--write-project p.jsonthenrender.py --export-timeline, and the “open it in Premiere / Resolve / Final Cut” row says to write a project first when there is none (found reading the eval-25 tl2 prompt). - fix: route “timeline for Premiere” from multicam through –write-project; add eval 25 kit (#293)
2.2.4
Automated release: version and notes generated from pull requests merged since 2.2.3.
- caption.py: an existing
.srt/.assside file is refused without--overwrite, like the video. Through 2.2.3 only ffmpeg’s output was checked:--transcribewarned and then replaced a hand-corrected transcript, and--text,--write-srtalone, the generated/--write-assASS,_adjusted.srtand_offset.asswere overwritten without a word – even by a run that was then refused for the existing video. Every file the run will write is now checked up front (kindinput, each existing one named), before speech recognition starts and before the first write; the dry run predicts the same refusal. New_common.refuse_existing_outputs(). - waveform.py:
--titleand--srt/--textstages get the shared flags. graphics.py and caption.py are now launched with--overwrite,--timeout,--fastand--dry-runforwarded (as render.py’s stages are), so a second run with--overwriteno longer fails in the caption or title stage. A failed stage keeps the child’s own kind and hint instead of alwayskind: ffmpeg; the_vis/_titledintermediates are removed on failure too; a missing--srt/--textfile iskind: inputbefore anything is encoded; and--title --dry-runcompletes. - audio.py:
--music,--effectsand--replacefiles that are missing, empty, unreadable or have no audio stream are refused together in onekind: inputfailure withproblems: [{flag, path, reason}], before ffmpeg runs (dry run included). - render.py: a real run names every unreadable clip source in its preflight refusal, not only the first one.
- sync.py / multicam.py: “not enough audio to analyse” now names the file.
- fix: caption never overwrites sidecars silently; waveform, audio, sync and render name every bad input (#292)
2.2.3
Automated release: version and notes generated from pull requests merged since 2.2.2.
color.py --to-sdrno longer tone-maps BT.2020 SDR input. A file with BT.2020 primaries on an SDR transfer (bt709, bt2020-10) went through the PQ tone map, which took white from Y 235 to 151 and mid-grey from 126 to 90 and still reportedverified: true– and export, check, verify and the gotchas all send wide-gamut SDR files there. It now gets a gamut conversion only (its own transfer to linear, BT.2020 to BT.709 primaries, back to the BT.709 transfer); PQ, HLG, Dolby Vision and--forceon an untagged file are tone-mapped as before. The result says which path ran in the newsdr_pathkey (tonemap/gamut) and innotes.- join.py: a crossfaded join keeps each clip’s sound with its picture. Every clip now has one
length for both streams – its video stream’s, or its audio’s when the sound runs more than a
frame past the picture – and the shorter stream is padded to it (silence, or the last frame
held) before
xfade/acrossfade, whose offsets both come from it. Clips with 5 s of picture and 3 s of narration used to speak clip 2 at ~2.6 s instead of 4.5 s; audio longer than the picture could lose a clip’s picture; parts gained a frame or two per join from the container’s AAC tail.verifiednow includes adurationstep comparing the output’s video-stream length withexpected_duration, andprobereports each stream’s ownduration. The audio-only join and--transition nonebuild the same commands as before. - fix: color –to-sdr keeps BT.2020 SDR bright; crossfaded joins keep sound with picture (#291)
2.2.2
Automated release: version and notes generated from pull requests merged since 2.2.1.
join.py --dry-runno longer fails a plan because one of its inputs is not written yet. The pending file’s placeholder probe always claimed a video stream, so a TTS list with one line still to come was refused as an audio/video mix (“tts_01.wav has no video stream while tts_03.wav has one”), a list of nothing but pending.wavlines planned a libx264 join intovoice.wav, and arender.py --dry-runof an audio project with one trimmed and one untrimmed clip failed at the join. A pending input’s extension now stands in for its streams: any audio extension the skill reads (.wav,.m4a,.aiff,.caf, …) means no picture. A join’s audio rate and layout come from the inputs that exist, and each pending input is named – a stderr note, the newpendingkey andnotes– instead of being passed over in silence. A pending.mp4next to a measured.wavis still refused as the mix a real run refuses, now naming the.wavand the file expected to hold a picture. With either--on-missinga dry run plans on a pending input and never lists it underskipped; a real run still refuses or skips a file that is missing when it runs, and underskipthe note says when skipping would leave fewer than two inputs, which a real run refuses.expected_durationisnullwhile an input is pending: the placeholder’s 0 s made it negative, or shorter than the one clip that exists. In a video join,notesalso says when the planned frame and rate (a pending first input) or xfade offsets (after a pending input) are the placeholder’s unmeasured values. Real runs are unchanged.render.py --export-timelinewrites FCPXML that validates against Apple’s FCPXML 1.10 DTD: the music bed now comes before the chapter markers inside the first clip (the DTD’s order istimeMap, connected clips, markers). 2.1.0-2.2.1 wrote them the other way round, which does not validate – and Final Cut validates what it imports against that DTD.--export-timelinereads clipin/outand chapter times the way the render does ("0:05",hh:mm:ss:ffat the source’s fps,@fps). Before, any string time was a Python traceback with nothing on stdout under--json– including the"in": "0:00"ofrender.py --init’s own starter project. A time that does not parse is now akind: inputfailure naming the field (clips[0].in). A clip with no picture has no fps of its own, andframe.fpsdoes not stand in for one in either path: itshh:mm:ss:ffneeds@fps, ascut.pyasks.render.pyrefuses, askind: inputnaming the key, a value that is not an object where the run reads it. 2.2.1 died there with a traceback and nothing on stdout under--json. In the render and--export-timelinealike, that covers a stringframe, a clip written as a bare path, a clip with nosrc, a stringaudioand a stringtransitionbetween two or more clips. In the render it also covers every stage section ("export": "reels","captions": "subs.srt") and thesnapof a clip it cuts, up to the--stop-afterstage (a--stop-after fitpreview never readscaptions). Nothing a full run never reads is refused. A single clip’s"transition": "none"and an uncut clip’ssnapstill render and export.--export-timelinestill writes a timeline for a stage section that is not an object and lists it innot_exported, as 2.2.1 did.- The exported sequence is the frame the project asks for, sized by fit.py’s own rule:
frame: {aspect: "16:9", width: 1920}is 1920x1080 over any source (it was 1920x2160 over a 4K one, the height taken from the source). An aspect-only frame takes the export preset’s size when the preset has that aspect, as the render’s does. fit.py now sizes its output with the same shared function (frame_size()) and reads--aspectwith the same parser (aspect_ratio()), which reads each side withint()as fit.py did, so fit.py accepts and sizes every--aspectexactly as 2.2.1 did. The export now refuses aframe.aspectof16/9or2.39:1when the render would hand it to fit.py, which refuses it too. The render’s own preset match (frame_from_preset()) keeps 2.2.1’s looser reading, so a16/9frame replaced by the project’sfit.aspectrenders exactly as before, and the export writes its frame. - A render of several clips delivers that frame too. When the frame gives an aspect and one
side, and the project’s
fitobject gives no width, height or other aspect,render.pynow gives fit.py that side. Before, join.py sized the join at the first clip’s aspect and fit.py fitted the new aspect inside it:{aspect: "9:16", width: 1080}(render.py’s own docstring frame) over 16:9 clips came out 342x608 – the delivered size without an export preset, and the size captions were burned at with one. Every other multi-clip project renders at the size 2.2.1 rendered. -
FCPXML describes each video asset as its own source (size and frame rate) and the sequence as the frame; one shared format made a 320x180 file under a 9:16 frame claim to be 1080x1920. A clip of another aspect states its spatial conform,
adjust-conformfillforframe.fit: cropandfitfor pad; the DTD reads a missing one as fit, so a crop project was exported as a fit. EDL and OTIO cannot state a conform, so there the reframe is listed innot_exportedwith the project’sframe.fit, as isblur’s blurred background in all three. - Known, carried into a minor release (
docs/design-decisions.mdrecords each one):- A multi-clip render with
frame.fit: cropkeeps bars on every clip of another aspect than the joined frame, even when all the clips share one aspect: join.py pads the clips into the frame before fit.py crops. The exported timeline statesfill, the project’s choice. - A project
fitobject with its own width, height or aspect is still fitted inside a multi-clip join’s picture, as in 2.2.1 (fit {aspect 4:5}under{aspect 9:16, width 72}renders 32x40 against a 72x128 sequence). Which offrameandfitwins waits for that release. - For a value that is not an object in a section a path never reads, the export and the render can still disagree, as in 2.2.1. Refusing it in both paths would refuse projects 2.2.1 rendered or exported.
batch.pynames every intermediate of a.ogg,.opus,.aac,.aif/.aiff,.cafor.wmasource.mp4, so a--dry-runof a join step over those sources is refused as an audio/video mix that the real batch completes (as in 2.2.1).
- A multi-clip render with
- fix: join dry-run plans pending inputs; –export-timeline times, frame and FCPXML order (#290)
- ci: the auto-bump moves the roadmap’s released-version sentence (#289)
- docs: roadmap names 2.2.1 as the released version (#288)
2.2.1
Automated release: version and notes generated from pull requests merged since 2.2.0.
- The same check-everything-first rule as
join.py --list, where one bad input used to cost a whole run:render.pychecks every clip source before cutting the first one and names every missing, empty or directory source in one refusal (problems[]) – before, a missing clip 7 surfaced after clips 0-6 were cut, one missing source per rerun.caption.py --mode muxnames every missing extra--srttrack at once, before any work.sequence.pywarns when frames exist past a gap in the numbering instead of silently stopping at the gap. - fix: render/caption/sequence name every bad input before any work (#287)
2.2.0
Added
join.py --list parts.txtjoins the segments a TTS or render step wrote, one path per line (relative to the list;#comments and ffmpeg’sfile 'x.wav'lines accepted). Every input is now checked before anything runs, and a missing, empty or unreadable one is named together with every other problem in a single refusal (kind: input,problems[]) instead of being found one rerun at a time;--on-missing skipjoins the rest and lists what it left out under the newskippedkey. An empty list is refused as such. Asked for by a user whose VPS short-video pipeline crashed when one TTS line failed and emptied its concat list.
2.1.0
Added
render.py project.json --export-timeline edit.fcpxml|edit.edl|edit.otiowrites the project’s cut as an editor timeline instead of rendering it – FCPXML 1.10 (Final Cut Pro, DaVinci Resolve), CMX 3600 EDL (Premiere, Resolve, Avid) or OpenTimelineIO JSON. Clips keep their in/out and speed,transitionbecomes a cross dissolve centred on each cut (trimmed so the timeline is exactlyjoin.py’s rendered length),audio.musicis its own track and inlinechaptersare markers. Nothing is encoded. Everything else the project asks for (captions, graphics, overlays, the silence cut, fit, audio processing, loudness, export) is listed in the newtimelineresult block’snot_exportedand on stderr, never dropped silently; a file that exists is refused without--overwrite, like every output. Verified: the three formats read back at the right length through the referenceopentimelineiolibrary and its FCPXML and CMX 3600 adapters. Not verified here: Final Cut, Resolve or Premiere opening them (#143).
Fixed
export.py’s colour warning called a BT.2020 SDR source “HDR (BT.2020 SDR)” – 1.x wording that 2.0’shdrcontradicts. It now says “wide-gamut SDR, not HDR” for that case, and still points atcolor.py --to-sdr(found by eval 24 h1).docs/demos.mdstill printedproxy.py --crf 34, a flag 2.0 removed; the gallery is regenerated.
2.0.0
What 1.10.0 announced for 2.0 (contract --json’s deprecated list): four changes made, one
withdrawn (result_v2, below). No new feature; tool count still 42. docs/contract.md “What 2.0 changed” is the migration table, and
contract --json now lists the same under removed, with the version that deprecated each.
Removed
--crfon every re-encoding tool that has--quality(deprecated in 1.10.0): it was an alias of--quality, and argparse now refuses it.--quality’s default is what--crf’s was (18;proxy.py30).export.pykeeps its own--crf– its preset chooses the encoder, so it is not an alias. Arender.pyproject’sexport.crfstill reachesexport.py --crf.jsonandprogressfrom every MCPinputSchema(deprecated in 1.10.0): they are transport flags the server sets itself, asFFMPEG_SKILL_MCP_LEAN=1showed since 1.10.0; that variable no longer does anything. A client that still sends them is not refused. The frozen MCP snapshot is regenerated.- The
result_v2preview key andFFMPEG_SKILL_RESULT_V2(1.10.0 deprecated the flat keys in its favour). It was withdrawn rather than promoted: it put a key inmetricsordetailsby its value (a number in one, the same key holdingnullin the other), so a caller could not know where to look without the value. The flat top-level keys, typed per tool inoutput_schema, are the 2.0 shape – unchanged from 1.x.
Changed
- An existing output is refused without
--overwrite(1.x warned; deprecated in 1.10.0):kind: input, before any ffmpeg runs, dry runs included, with a hint naming the flag.FFMPEG_SKILL_NO_OVERWRITEno longer does anything. probe’shdris true only for a PQ / HLG transfer or Dolby Vision (deprecated in 1.10.0), equal tohdr_signal, which stays. The newbt2020_or_hdrcarries the 1.x meaning (BT.2020 primaries count), and every editing tool routes on it, so a BT.2020 SDR source still re-encodes as HEVC Main10 with its own tags: tool behaviour is unchanged.hdr_formatstill namesBT.2020 SDR.docs/contract.md: “Stability guarantee (2.x)”; the deprecation policy records that 2.0.0 waived the 90-day half of its window (the deprecations date from 1.10.0, 2026-09-13; sixteen minor releases carried them), andtests/test_contract.pyrequires such a waiver to be stated there.ctxonrun()/emit()/die()stays optional (docs/design-decisions.md says why).render.pyrefuses an existing project output before its first stage runs, not at the final copy after every stage had.demos/build.pyclears a demo’s own outputs before rebuilding it, since a tool now refuses to replace them.
Also in this release
- Fix:
docs/contract.md’s historical versions no longer move with every release. The auto-bump inrelease.ymlreplaced every occurrence of the outgoing version in that file, so each “Since” in “What 2.0 changes” (all 1.10.0), “Per-tool keys added in 1.17.1 / 1.18.0”,broken_inside_word(1.18.4), “every version before 1.18.3”,examples[](1.21.0) andsupports_json_brief(1.11.0) had been rewritten to 1.26.0. They are restored from git history, and the bump now lives in.github/scripts/bump_contract_md.py, which moves only theskill.versionrow and the example’sversion, each required exactly once. - Tests: the 2.0 table’s Since column is pinned to
_contract.DEPRECATED, and a package.json major bump fails before the policy’s window has passed (two further minors and 90 days after 1.10.0: 2026-12-12) or while a deprecated entry is still listed.
1.26.0
Automated release: version and notes generated from pull requests merged since 1.25.0.
- docs: eval 23b at 1.25.0 — real-execution coverage for
caption.py --karaoke-style word(#276) and the+faststartfixes (#275, #277, #279), continuing eval 23’s partial close at 1.19.3; Opus-grader and trigger-judge passes remain blocked on model access this sandbox lacks - feat(skills): add repo-local code-review skill (#268)
- evals: cross-vendor regex-only eval runner, runnable from Cursor/Codex (#281)
- docs: eval 23b at 1.25.0 — karaoke-style word + faststart-fix coverage (#280)
1.25.0
Automated release: version and notes generated from pull requests merged since 1.24.1.
- Add -movflags +faststart to remaining mp4 stream-copy paths (#279)
1.24.1
Automated release: version and notes generated from pull requests merged since 1.24.0.
- feat(caption):
--karaoke-style word– one ASS Dialogue event per word, active word scaled/emboldened, past/upcoming colours (#276) - fix(loudness): stream-copy branch was dropping -movflags +faststart (#277)
- caption.py: –karaoke-style word for per-word Dialogue events (scale + 3-colour highlight) (#278)
- docs(skill): regroup the request table by intent, not script name (#274)
- docs: README documents the MCP prompts capability; roadmap truth-up to 1.24.0 (#273)
Closes: #276
1.24.0
Automated release: version and notes generated from pull requests merged since 1.23.0.
- feat(mcp): add a prompts capability with five workflow recipes (#272)
1.23.0
Automated release: version and notes generated from pull requests merged since 1.22.0.
- feat(mcp): shorten tools/list descriptions to one line, move structured-arguments note to initialize (#271)
1.22.0
Automated release: version and notes generated from pull requests merged since 1.21.0.
- feat(batch): report cut.py’s stream-copy vs hybrid re-encode rate across a folder (#269)
- docs(roadmap): truth up the released-version sentence and table to 1.21.0 (#270)
1.21.0
Automated release: version and notes generated from pull requests merged since 1.20.0.
- feat: contract –json gains per-tool examples, parsed from SKILL.md’s own table (#267)
- docs: demo gallery entries for –shots, –speech-aware, –motion-centre, –switch energy (#263)
- docs: eval iteration-23 partial results (Phase 3, Set A + Set C) (#265)
- test: promote eval 22 Set B (b1-b8) into evals/agent_prompts_24.json (#264)
- test: expand trigger set with 5 symptom-only 1.18 prompts (#262)
1.20.0
Automated release: version and notes generated from pull requests merged since 1.19.3.
- feat: look.py –ink measures non-background pixels per PNG (#261)
1.19.3
Automated release: version and notes generated from pull requests merged since 1.19.2.
- fix: graphics.py slices an unbreakable overlong word at the column edge, matching caption.py (#259)
1.19.2
Automated release: version and notes generated from pull requests merged since 1.19.1.
- fix: SKILL.md rows for the overlong-word break and MCP core-12 rule; roadmap truth-up at 1.19.1 (#260)
1.19.1
Automated release: version and notes generated from pull requests merged since 1.19.0.
- fix: –filler no longer jump-cuts unrelated silence gaps (#257)
1.19.0
Automated release: version and notes generated from pull requests merged since 1.18.4.
- feat: multicam.py –write-project reproduces its cut decision as a render.py project (#258)
- docs: roadmap truth-up at 1.18.4, broken_inside_word in the contract, version-drift test (#256)
1.18.4
Automated release: version and notes generated from pull requests merged since 1.18.3.
- fix: caption word wider than the column breaks at the edge instead of clipping (#255)
- docs: eval 22 at 1.18.3 — routing-fix re-check and cs1/cs3 recheck (#254)
- chore: gitignore scratchpad/ (eval staging, not repo content) (#253)
1.18.3
Automated release: version and notes generated from pull requests merged since 1.18.2.
- fix: MCP default tools/list is the core 12, opt-in for the full 42 (#252)
1.18.2
Automated release: version and notes generated from pull requests merged since 1.18.1.
- fix: README routing for scenes/cropdetect/silence/sync/multicam 1.18.0 flags (#251)
- docs: move 1.17.2 caption-margin write-up out of 1.17.1 (#250)
- docs: eval 21 at 1.18.0 (12 prompts, discoverability gap found) and 1.18.1 shipped (#249)
1.18.1
Automated release: version and notes generated from pull requests merged since 1.18.0.
- fix: 1.18.1 — SKILL.md routing rows for the five 1.18.0 flags (#248)
- docs: roadmap — 1.18.0 shipped (eval pending) (#247)
1.18.0
Automated release: version and notes generated from pull requests merged since 1.17.3.
- feat: 1.18.0 — measured analysis and multicam at scale (#246)
- docs: roadmap — text.py split refactor shipped (#245)
- refactor: split _common/text.py into fonts, emoji, drawtext and wrap (no behaviour change) (#244)
- docs: roadmap — 1.17.3 shipped (eval pending), released-version paragraph (#243)
1.17.3
Automated release: version and notes generated from pull requests merged since 1.17.2.
- fix: 1.17.3 — SKILL.md: the cue text is burned as written; keep the template’s max-lines and let the size drop (#242)
- docs: eval 20 at 1.17.2 (20 caption runs, the picture fixed) (#241)
1.17.2
Automated release: version and notes generated from pull requests merged since 1.17.1.
- fix: 1.17.2 — caption ASS side margins from the horizontal safe zone (#239)
- docs: eval 19 at 1.17.1 (26 runs, the margin root cause, 1.17.2 queued) (#238)
Fixed
- Caption side margins are the horizontal safe zone, not the vertical one (eval 19 headline,
present since 1.14).
caption.py’s generated ASS (--animate,--karaoke, the emoji overlay route) wrote--margininto the Style’sMarginLandMarginRas well asMarginV. But--marginis the vertical safe margin:--platform tiktokmakes it 22 % of the frame height = 63 ASS units = 420 px on a 1080x1920 frame, so libass was given a 1080 - 840 = 240 px text column and broke “Hello world” into “Hello” over “world”, one word per line — while the fitter and the wrapper measured against the horizontal safe width, reportedsplit: 0and put no\Nin the file.MarginL/MarginRnow come from the destination’s ownsafe.left/safe.right(TikTok: 54 px and 151 px on a 1080-wide frame, the like/share rail included), or from the conventional(1 - SAFE_WIDTH_FRACTION)/2border with no--platform, and the fitter’s budget is exactlyplay_w - MarginL - MarginR, so the wrap the ASS states is the wrap libass draws. The SRTforce_styleburn path never set side margins and is unchanged. Behaviour change, allowed by the stability paragraph ofdocs/contract.mdbecause it fixes a defect: the pinned--fit-size offfixture (tests/fixtures/caption_1_16_1_fitsize_off.ass) carried the wrong margins and has been re-pinned;--fit-size offstill means “the size is never shrunk”.
1.17.1
Automated release: version and notes generated from pull requests merged since 1.17.0.
Fixed
- The caption size is fitted on the template path too.
render.py --template NAMEfills the captionsizefrom the delivery table and forwarded it as an explicit--size, which is howcaption.pyis told “a human chose this size” — so--fit-size autonever ran and a long cue was split across two consecutive cues on every template run (eval 18 cw1/dl1/dl3/dl4), the one path a “make this a TikTok” request takes. A template’s filled project now states"fit_size": "on"; a template file that states its ownfit_size, and a brand file that actually statescaption.size, still win —--brandon its own is not a stated size, so a brand of colours or a font keeps the fit — and"fit_size": "off"reproduces 1.17.0’s captions byte for byte. render.pyproject captions acceptfit_size,min_sizeandfit_size_scope(eval 18 cs1: the keys were refused as unknown, so the fit policy could not be stated in a project at all and the four stages had to be run by hand).render.py’s result also carries the caption stage’s own block ascaption.- SKILL.md routes the 1.17 features. Filler words →
silence.py --filler --words, cutting to the music →scenes.py --beatsthencut.py --snap beats, a folder and the cores →batch.py --jobs auto, one stage changed →render.py --cache DIR. None of them appeared in the routing table, so runs rebuilt them by hand or reported the feature does not exist (eval 18 bt2/fw1/fw3/rc1). Paid for with duplicated wording elsewhere: the file is still under the 30,000-byte budget. - One label for a partial result. SKILL.md now states it: a partial result is
Done:with the shortfall inNotes:, never a third label likeDone (partially):. caption.pysays when the text is unchanged:caption text unchangedin the summary andtext_unchanged: truein the result when the drawn text equals the cues that were handed in — nothing transcribed, no cue dropped, no cue split across two cues and no glyph stripped by--emoji none. Wrapping, line breaks and timing do not count as a change; the key is burn mode only (--mode muxnever touches the text and omits it).- #234: child-process output is decoded as UTF-8, never as the machine’s code page. On a
Windows cp932 locale
text=Truedecoded ffprobe’s UTF-8 JSON with the console code page, the reader thread raisedUnicodeDecodeError,communicate()returned an empty stdout andprobe.pyprinted?s | no video | no audioand exited 0 — an unmeasured file reported as measured. Every child capture inscripts/(ffmpeg, ffprobe, fc-list/fc-match, whisper, the sibling tools render.py and batch.py run) now passesencoding="utf-8", errors="replace"– includingrun_analysis(), the capture every ffmpeg measurement goes through, and the two captures inevals/– and a probe whose ffprobe printed nothing refuses withkind: inputnaming the unreadable output instead of returning a document of nulls. - evals:
write_fixtures.pystages a batch recipe’soutput_dirabsolute under the prompt’s own directory — a relative"out"resolved against the caller’s cwd, so the staged recipe wrote outside the prompt folder and had to be rewritten before the prompt could be answered. - fix: 1.17.1 — fit-size reachable from templates, SKILL.md routes the 1.17 features, UTF-8 child output (#234) (#237)
- docs: eval 18 at 1.17.0 (100 prompts, focused Opus grader, trigger 50/50) (#236)
1.17.0
Automated release: version and notes generated from pull requests merged since 1.16.1.
1.17.0 — throughput, plus the caption-size half of the eval-17 finding. The release workflow turns this heading into the version number.
Added
caption.py --fit-size auto|on|off,--min-size N,--fit-size-scope file|cue. The caption size is fitted to the cue before the cue is split. At a platform caption size (24 ASS units, about six em per line at 1080x1920) an ordinary sentence needs four lines, so--max-lines 2cut it into consecutive cues and half of it arrived late — the size, not the breaker, was what did not fit.--min-sizedefaults toass_units(0.045) = 13, the 4.5 %-of-frame-height legibility floor, one floor for every destination. New keys inside the existingcaptionblock:fit_size,size_requested,size_used,size_floor,size_pct_height,shrunk,fit_scope,fit_exhausted. The caption text is never rewritten, shortened or paraphrased to make it fit; below the floor the cue is split exactly as before andfit_exhaustedsays so.scenes.py --beats,--beat-step,--beat-range,--min-confidence. The measured beat grid — onset flux, median+MAD peak picking, an autocorrelation tempo with octave disambiguation, and a confidence that is half how far the winning lag stands above the others and half the fraction of onsets that land on the grid. New keysbeatsandbeat_grid. With--beatsthe file is decoded once and both envelopes come from that pass.cut.py --snap beats,--snap-tolerance,--snap-source,--min-confidence. In/out points move to the nearest measured beat before the keyframe decision. Below the confidence threshold the run refuses and names the measured number and--snap none: a cut point may move to a measured grid point and may not appear from one. New keysnap.render.pyforwards a project’s"snap": {"to": "beats", ...}and reports what came back.silence.py --fillerand its family (--filler-lang,--filler-words,--filler-extra,--filler-keep,--filler-pad,--words,--transcribe,--filler-list,--max-cuts). Filler words are removed through the existingkeep_ranges()/aselectgraph — and only where a speech engine measured a start/end pair for the word. Built-in lists for en, ja, es, de, fr, pt and it;like,tipoandcioèare deliberately not in them (discourse markers are not disfluencies) and are reachable with--filler-extra. New keysfillerandremoved_seconds_total(silence plus filler);removed_secondsis unchanged and still holds the silence-only figure. Whisper stays optional for this tool exactly as it is forcaption.py.--transcribedrives whichever engine is installed with its own word-timestamp option (whisper.cpp--output-json-full, faster-whisperword_timestamps=True, openai-whisper--word_timestamps True); an engine whose build produces none refuses by name rather than reporting an empty removal as a success.batch.py --jobs N|auto. Parallel items under one shared--timeoutbudget, capped atmin(N, cpu_count, 8)with the applied value reported. The per-item table keeps its shape and its order, and each item’s log is flushed in file order. New keysjobs,jobs_requested,wall_seconds,item_seconds_total,timed_out.render.py --cache DIRand--from STAGE. An opt-in stage cache keyed on the stage, its arguments, its inputs’ content hashes, the flags render forwards to its children (--fast,--codec, …), the output’s extension and the ffmpeg build banner plus the skill and contract versions, so a cache is never reused across any of them. New keycache. There is no default cache directory.
Changed
caption.pynow fits the caption size to the cue before splitting it when the size was not stated (--fit-size auto, the default). A run that passed--sizeexplicitly, or that took a size from abrand.json, is unchanged;--fit-size offrestores 1.16.1 byte-for-byte (a pinned ASS fixture asserts it). This changes the rendered ASS for platform caption runs that took the default size — the defect eval 17 measured.- The optional local speech-to-text bridge and the SRT reader/writer moved from
caption.pyintoscripts/_common/asr.pyand are re-exported through the_commonfacade, sosilence.py --filler --transcribeshares one engine probe and one “no engine found” message.caption.parse_srt,caption.transcribeandcaption.whisper_word_timingsare unchanged as names;caption.py --helpis byte-identical.
Fixed
render.pyleft its auto-named work directory behind after a failed render: a function-localimport shutilshadowed the module-level one and theatexitcleanup raisedNameError. (Introduced by this release’s own--cachework; 1.16.1 never had it.)- feat: 1.17.0 — throughput (caption size that fits the cue, beat-synced cuts, filler-word removal, batch –jobs, render –cache) (#235)
1.16.1
Automated release: version and notes generated from pull requests merged since 1.16.0.
Fixed
- Caption breaking, from eval 17. A Thai run is no longer broken inside: Thai writes no space
inside a phrase and the wrapper has no dictionary, so every character-level break it took landed
inside a word (
ข|อง,เว|ลา); the break now goes where the writer put a space or a|, and a run with none stays long on its own line, the rule long Latin words already follow. A katakana word is one atom (タイ|ミングwas split like a run of kanji).caption.pyreports anoverlongcount in its caption stats and an info line naming the fix (a space or|where the line may break, or a smaller--size) when a wrapped line is wider than the safe width.--wrap measuredfollows the same atom rules (it is the post-passes that differ, not the pieces). - fix: captions never break inside a Thai run or a katakana word; over-wide lines are reported (eval 17) (#233)
- docs: eval 17 at 1.16.0 (90 prompts, focused Opus grader, trigger 45/45) (#232)
1.16.0
Automated release: version and notes generated from pull requests merged since 1.15.1.
Added
- Audiogram:
waveform.py --imageandrender.py --template audiogram. The visualisation over a still plate, which is what turns a podcast episode into something postable.--image PATH(a local file),--image-fit cover|contain|blur,--position bottom|centre|top|strip,--vis-height FRAC,--opacity,--platform NAME(the frame from the delivery table; a destination with no frame is refused),--title TEXT(drawn throughgraphics.py) and--srt/--text(burnt bycaption.pyafterwards, so neither code path is re-implemented). Result keyaudiogram. Nothing is fetched and no cover art is ever invented: give an image or a colour. No new tool — the tool count stays 42;docs/design-decisions.mdrecords why anaudiogram.pywould have been a second spelling ofwaveform.pyand, under the 1.x guarantee, permanent surface. metadata.py --auto-chapters: chapter markers proposed from measured structure. Pauses (silencedetect) and scene changes (scdet) become candidates; a scene cut within 1 s of a silence end merges into onesilence+scenemarker.--min-chapter(default 60),--max-chapters,--from silence|scenes|both,--silence-threshold/--silence-min,--scene-threshold,--chapters-out FILE(this tool’s own--chaptersformat, so the titles can be edited and fed back) and--description-out FILE(the YouTube00:00 Chapter 1block). Result keyauto_chapters, with each marker’s evidence. Every title isChapter Nand the result says"titles": "placeholder"— the skill proposes where a chapter starts, it cannot know what is in one; naming them is the caller’s job. Output is still-c copy; two detectors mean two decodes, which--helpandnotesboth say.- Several language-tagged subtitle tracks in one
caption.py --mode mux.--srtis repeatable and each file may carry a:langsuffix (--srt en.srt:en --srt ja.srt:ja). The suffix splits on the last colon and only when the tail is a BCP-47-shaped code and the whole token is not itself a file, soC:\subs\en.srtand a file nameda:b.srtsurvive.--track-title(a frozen display-name table by default — data, never a translation) and--default-track LANG. Result keystracksandsubtitle_tracks. Empirically (ffmpeg 6.1),.mp4/.movhold severalmov_texttracks but drop a two-letter language code without a word, so the code is converted to ISO-639-2 (en→eng); Matroska stores what it is given, and past two tracks in an MPEG-4 container the result recommends.mkvinnotes. The skill never translates and never generates a second language. check.py: an informationalsubtitlesrow on every platform.PASSwhen every soft subtitle stream carries a language tag,WARNwhen one does not or when there are none. Like the podcastchannels/chaptersrows it is never counted infailed.
Fixed
- The audiogram ignored
--fps/--platform. A looped still is fed to ffmpeg at 25 fps unless told otherwise, andoverlaytakes its rate from the first input, so--platform tiktokannounced 30 fps and wrote a 25 fps file (the colour-plate path was never affected:color=carries its ownr=). The still now gets-framerate, and the frame rate joined the frame size and duration in whataudiogram.verifiedactually checks. --mode muxmarked a trackdefaultthat nobody asked for. With two or more new subtitle streams and no--default-track, ffmpeg flags the first one itself — the opposite of what the flag promises, andtracks[].defaultthen described a file that did not exist. Every disposition is stated explicitly now (defaultfor the chosen track,0for the rest). MPEG-4 cannot express “no default subtitle track” at all — it always enables its first one — so there the track is reporteddefault: truewith a note, because the result document describes the file, not the request.audiogram.verifiedwastrueunder--dry-run, where nothing had been rendered to verify, while the common top-levelverifiedsaidfalsefor the same run.--imagethat ffmpeg cannot decode is refused withkind: inputnaming the file, before any encode starts, instead of surfacing as a raw ffmpeg failure.- A malformed
:langsuffix (--srt en.srt:zzzz) is reported as a bad language code naming the token, not as a missing file calleden.srt:zzzz. から,までandよりare matched as whole words. They had been left in the character table of Japanese particles, which madeか,ら,ま,で,よandりone-character particles of their own — none of them is — so a break beforeかwas forbidden and a break after it preferred.- MPEG-4 subtitle track titles are no longer claimed.
-metadata:s:s:N title=is dropped by the MPEG-4 muxer, sotracks[].titlereported a name the file did not carry; it isnullthere now, with a note saying.mkvkeeps the names. metadata.py --auto-chapters’ “no chapters found” hint no longer suggests the value already in use (--min-chapter 1was answered with “try--min-chapter 1”).graphics.py --template stickerwraps its label to the chip’s own width rather than the frame’s, so a long--textcannot overflow the plate it is drawn on.
Changed
caption.pyandgraphics.pywrap phrase-aware by default (--wrap measuredrestores 1.15.1). Four rules, all penalties over break positions that already fit, so no line is widened and the line count never changes: never inside a word or on the wrong side of a hyphen; no line that is a lone digit, one or two punctuation characters or a single kana, checked at every boundary rather than only the last; Japanese/Chinese breaks preferred after。、!?」』)and after a particle — a particle is enclitic, so kinsoku keeps it with the word before it and a break before one is forbidden — discouraged between a kanji stem and its okurigana and forbidden before a small kana; and an article or preposition kept with the phrase it governs, by preferring the break before it and penalising the break after it. Therebalancedcount in thecues:line can differ for an unchanged input, and a cue whose break moves is the point of the release — the text itself is never rewritten, shortened or translated. graphics.py’s hook card, meme lines and sticker chip are now wrapped to the frame’s safe width instead of running off the edge; a label that already fits is untouched. Eval 16’s two open cues come out whole:dl1breaks asA third line / the tool times for me(1.15:A third line the / tool times for me) anddl3as自動でタイミングが / 決まる行(1.15:自動でタイミングが決ま / る行).--wrap measuredreproduces the old splits.layout_cueswraps each cue once instead of three times. The greedy fill is identical for every mode, sowrap_variants()does it once and repeats only the post-passes, which is whatrebalancedandphrase_breaksneed to be counted against.- The caption breaker moved from
caption.pyinto_common/text.py, and the two structure detectors (silence.detect,scenes.detect_scenes) into_common/probe.py, sometadata.py --auto-chaptersmeasures without any tool inscripts/importing a sibling tool. Both moves are byte-for-byte;caption.wrap_text,silence.detectandscenes.detect_scenesstill resolve where they always did. - feat: 1.16.0 — long-form delivery (audiogram, auto chapters, multi-language subtitle tracks, phrase-aware captions) (#231)
1.15.1
Automated release: version and notes generated from pull requests merged since 1.15.0.
Changed
- refactor:
scripts/_common.pyis a package, andtests/test_all.pysplits by tool group. No behaviour change. A release of its own so that “nothing changed for a caller” is checkable in one diff (roadmap, “Refactor release after 1.15.0”). The 3072-line helper module is nowscripts/_common/with one module per responsibility —runner.py(process execution, timeouts, signals, the output lock and staging,run_tool, the drawtext text-file spool),probe.py(ffprobe and the measured facts:probe(),verify_output(), the level and envelope measurements),decision.py(the pure choices: encoder and codec arguments, times, paths, brand defaults — nothing here starts a subprocess),emit.py(emit(),die(),info(), the brief and 2.0 shapes, the plan file),color.py(bt709 tagging, the HDR-to-SDR path, colour-token validation) andtext.py(fonts per script, emoji clusters and assets, drawtext escaping and options, the advance table the caption wrap measures with).scripts/_common/__init__.pyis a facade re-exporting all 184 names the single file defined — public and underscore alike — so everyfrom _common import ...and every_common.<name>in the tools, the MCP server, the demos, the evals and the tests keeps working unchanged; rebinding a name on the facade (_common._FFMPEG_VERSION = (7, 1), amock.patch("_common.<name>")) rebinds it on the module that defines it, so the test seams that reached into the module still hold. Function bodies were moved, not edited.tests/test_all.pyis aload_testsaggregator overtests/test_analysis.py,test_editing.py,test_audio.py,test_picture.py,test_delivery.pyandtest_orchestration.pywith the shared footage intests/_fixtures.py(built once per process, not once per group); each group module also runs on its own.npm testand every CI job are unchanged and run the same 323 cases under the same names. Proof of no behaviour change:scripts/_contract.py --json, the MCPtools/listresponse and--helpfor all 42 tools are byte-identical to 1.15.0. The tool count stays 42 — a package directory is not a tool, and everyscripts/*.pyenumeration (the contract, the MCP server,demos/build.py, the every-script tests) skips it unchanged; the source scans that must not stop covering the helpers (shell=Trueand friends) now readscripts/_common/*.pytoo. - refactor: split _common.py into a package and tests by tool group (no behaviour change) (#230)
- docs: eval 16 at 1.15.0 (82 prompts, focused Opus grader, trigger 40/40) (#229)
1.15.0
Automated release: version and notes generated from pull requests merged since 1.14.0.
- feat: text people can see (emoji, complex-script shaping in graphics.py, ‘ and % kept, caption wrap without orphans, label rule) (#226)
- docs: demo gallery covers every tool (51 demos, inspection section, every-script test) (#227)
- docs: eval iteration 15 at 1.14.0 (delivery templates: 12/13 one encode, 13/13 platform pass) (#225)
1.15.0
Added
- Emoji in captions and titles.
caption.py,graphics.pyandoverlay.pygain--emoji auto|color|png|mono|none,--emoji-assets DIR,--emoji-scale FLOATand--emoji-max N. The colour route is a PNG overlay, not a font: drawtext cannot load a CBDT/sbix emoji face at all (it fails filter initialisation —Could not set font size to 48 pixels: invalid library handle, and at the font’s own strikeMonocromatic (1bpp) fonts are not supported.— and writes no file), and an installed colour emoji family proves nothing either (Noto Color Emoji installs cleanly on builds whose libass still draws a monochrome outline).--emoji-assets DIRis a directory of PNGs named by code point in the Twemoji/Noto convention (1f389.png,1f1ef-1f1f5.png,1f469-200d-1f4bb.png), also read frombrand.json(styles.caption.emoji_assets) andFFMPEG_SKILL_EMOJI_ASSETS. Nothing is ever downloaded: a missing directory iskind: inputnaming the two sets people already have, never a fetch. In a caption the text keeps its place in the generated ASS with an invisible placeholder reserving exactly the emoji’s box (measured: U+2588 FULL BLOCK is 0.66–0.83 em depending on the face, so the gap is reserved with alpha-hidden\fspspacing instead, which is exact in every face this repo resolves — including inside a\kfkaraoke run, where the placeholder is its own zero-duration segment), and each PNG is composited after theass=filter.caption.py/graphics.py --jsongain anemojiblock (mode,count,clusters,assets,missing,overlays);--json-briefcarriesmodeandcount. Emoji also count as a full em in the wrap, so an emoji-heavy line no longer overflows the safe area, and a wrap never breaks inside a ZWJ sequence, a flag pair or a skin-tone modifier. doctoranswers what this machine can do with emoji:doctor --json→.fonts.emoji(mode,color_font,color_font_file,libass_color,assets,detail,fix), wherelibass_colorcomes from a ~80 ms render probe (one 64×64 frame with an emoji cue throughsubtitles=, chroma-tested) rather than a font listing.contract --json --staticand every other static/JSON-only path skip the probe and reportlibass_color: null(doctoritself always probes – it has no--static). Informational like the rest offonts: it never movesokor a tool’susable. The plain-textdoctorgains one clause on its existingfonts:line.graphics.py --text-render auto|ass|drawtextand--write-ass PATH. drawtext does bidi and Arabic joining correctly on a build with--enable-libfribidi, so Arabic and Hebrew were already right; what it cannot do on any build is reorder and re-cluster — Devanagari matras, Thai/Lao mark stacking — because it does not use harfbuzz even in an--enable-libharfbuzzbuild.auto(the default) therefore renders a shaping script (hi bn ta te kn ml gu pa si th lo km my) through libass, writing<output>_gfx.assfrom the geometry the templates already computed (new private modulescripts/_ass_overlay.py— the public tool count is unchanged at 42) and reportingtext_renderer,scriptandassin the JSON. Latin, CJK and Arabic frames are pixel-identical to 1.14.0 (the command is not: every drawn label now goes throughtextfile=…:expansion=noneinstead oftext=…, which is what makes'and%survive).--text-render drawtextwith such a script is a refusal naming the script, never a wrongly shaped frame.bn(Bengali),ta(Tamil) andlo(Lao) join the scripts the tools detect and resolve fonts for:doctor --json.fonts.scriptshas three more entries.
Fixed
'and%survive the drawn text (eval 14).overlay.py --text "it's 100% done"renderedits 100 done:escape_drawtext()dropped both characters because neither has a backslash escape that is safe in every call shape. Drawn text now reaches drawtext astextfile=<path>:expansion=none, so the filter-graph parser never sees it and drawtext’s own%{…}scanner is off —',%,:,,,[,],;and\all render verbatim.escape_drawtext()remains, for the font-name fallback only.caption.pyrenders through libass and already kept both characters; 1.15.0 adds the regression lock on that path.- No one-character orphan lines, and better breaks (eval 14:
th1,dl3,dl1).caption.py’s wrap gains two post-passes: a last line that is a single atom narrower than 1.1 em pulls the previous line’s last atom down (the stranded Thaiลand Japanese行), and for spaced scripts the break is moved to the one that minimises the widest line instead of filling greedily ("A third line the tool times for me"now breaks between phrases). The line count never changes, non-spaced scripts keep greedy wrapping, andreport_layout()’s onecues:line reportsrebalanced. overlay.py --textrefuses a shaping script instead of drawing it wrongly, namingcaption.pyandgraphics.py. The ASS route foroverlay.pyis 1.16.0.- Every emoji PNG stayed on screen for one frame. The overlay chain carried
eof_action=pass, which switches off overlay’s default “hold the last frame of the secondary input” — and a PNG input is a single frame at pts 0, so the asset was composited on frame 0 and vanished for the rest of the cue (the firstdocs/demos/captions_emoji.gifshipped an empty reserved gap as the feature’s own advertisement). The chain now uses the defaulteof_action=repeatand letsenable='between(t,start,end)'confine the asset to its cue. The regression test renders a cue and samples the planned pixel in the middle of it and again after it ends. - A ZWJ no longer starts an emoji cluster. U+200D ZWJ and U+200C ZWNJ are ordinary Indic/Persian orthography, so
क्ष(ka + virama + ZWJ + ssa) was detected as the emoji cluster94d-200d: a Hindi cue was reported as containing emoji, warned about as monochrome, and on the PNG route had a real letter replaced by an invisible gap. A cluster may now only start at an emoji base (pictograph, regional indicator, or a0-9 # *that is actually followed by U+20E3); joiners, VS16 and skin-tone modifiers bind rightwards only. U+FE0E (VS15, text presentation) now means “not an emoji”, so❤︎is drawn as a character. - Emoji in an RTL caption land in the reserved gap. libass lays Arabic and Hebrew out right-to-left, so measuring the logical prefix from the left edge put the PNG on top of the text. The position is now measured from the rendered end of the line for
BIDI_SCRIPTS, incaption.pyandgraphics.pyalike. graphics.pyno longer claimsmode: monowhile drawing tofu, and--emoji noneactually strips. drawtext loads exactly one font file and has no fallback chain, so a monochrome emoji there is an empty box; anautorun with monochrome emoji is routed through libass (which does have a fallback chain) and a run pinned to--text-render drawtextdegrades tononeand says so.--emoji nonenow removes the clusters from--name/--title/--subtitle/--text/--top/--bottomthe waycaption.pydoes.- Drawn text goes to a private, cleaned-up temp file. 1.15.0’s first shape wrote every label into a world-shared, predictable
/tmp/ffmpeg-skill-textcreated withmakedirs(exist_ok=True)— an attacker-creatable directory with content-addressed names (a planted symlink was an arbitrary-file overwrite), aPermissionErrorout of filter construction for the second user of a shared box, and a directory that was never cleaned. The directory is nowtempfile.mkdtemp()(0700, unguessable, removed at exit), the file is openedO_NOFOLLOWat 0600, and it is written only when the command naming it actually runs — so--dry-runwrites nothing (the plan names the intended path) and the ASS route, which never reads the file, writes nothing either. --emoji-max 0means none (int(args.emoji_max or 60)swallowed it) incaption.pyandgraphics.py.--animatefades the emoji with the line. The PNG overlay gets an alpha fade matching the text’s\fad(andgraphics.py’s 0.3 s template fade) instead of popping in at full opacity.- The ASS route keeps
{,}and\.ass_text()deleted all three while the drawtext route now renders them verbatim, so the same title produced two different pictures depending on an automatic route choice. They are escaped (\{,\}, and a zero-width space where a backslash would otherwise start\N/\n/\h) rather than dropped — user text still cannot open an override block. doctor(detect=…)is gone. It was dead (--staticonly applies tocontract, which never callsdoctor()), and the test that exercised it went through the Python API rather than any CLI path; the replacement test asserts what is actually reachable,contract --json --static.
Known
Carried into the next release, all P2 from the 1.15.0 review:
emoji_placeholder()’s docstring is not a raw string, so the literal\fsp,\rand\kfin it become control characters inhelp()/pydocoutput.demos/build.pydraws its placeholder PNGs withgeq, which needs a GPL build; the tests’ own_emoji_assets()uses plaincolor=…,format=rgbaand the demo could do the same.emoji_support()has two branches that both setmode = "none"; thenot fc_answeredone is dead code (the behaviour is right, the branch reads as if it were doing something).text_overlay_ass(..., fonts_dir=…)takes a parameter it never uses.caption.py’s no-emoji command reordered harmlessly (-map 0:v:0 -map 0:a:0 -vf …→-map 0:v:0 -vf … -map 0:a:0) in the one path the release promises not to touch.
Docs
- SKILL.md gains the report-label rule (a refusal that still delivers something is
Failed:; the alternative goes inNotes:) and two gotcha lines, and drops the zh/ko restatement of the report shape and one redundant sentence to stay inside the 30 000-byte budget — now enforced bytests/test_contract.py::test_skill_md_stays_under_the_30kb_budgetrather than by convention. references/gotchas.mdgains an### Emojisection and two paragraphs on shaping under### Fonts by script;docs/contract.mddocumentsfonts.emoji, the new flags and the new result keys (additive —contract_versionstays 1.0);references/scripts.mdupdates the caption/graphics/overlay tables;docs/design-decisions.mdpins the three calls behind this release;docs/roadmap.mdmarks 1.15.0 shipped and corrects the drawtext claim; the README gains two feature bullets and a pending-eval row.demos/build.pyanddocs/demos.mdgaincaptions_emoji(colour PNGs against the monochrome fallback) andlower_third_hindi(--text-render drawtextagainst--text-render ass, the release’s headline picture).evals/agent_prompts_24.jsongainsem1–em4andsh1–sh2with their fixtures,evals/grade_runs_24.pythe honesty rule for the emoji prompts, andevals/trigger/prompts.jsontwo more prompts (40: an emoji caption request that must trigger, an “emoji sticker pack as PNGs” near-miss that must not).tests/fixtures/mcp_tools.jsonregenerated:caption,graphicsandoverlaygainemoji,emoji_assets,emoji_maxandemoji_scale;graphicsalso gainstext_renderandwrite_ass. Nothing was removed, renamed or made required.
1.14.0
Automated release: version and notes generated from pull requests merged since 1.13.0.
Added
- One delivery table (
scripts/_platforms.py, internal — the public tool count is unchanged at 42). Every destination is described once: frame, fps, the compliance speccheck.pyreports (duration, aspects, minimum height, fps, codecs, size, LUFS, true peak, SDR-only), the fraction of the frame the app’s own UI covers, caption defaults, the export preset and the check platform.check.py’sSPECSandexport.py’sPRESETS/PLATFORM_OFare now built from it — each platform preset’s frame and duration cap included — so the loudnessexport.py --normalizehits, the frame it writes, the cap it trims at and the speccheck.pyenforces are the same numbers by construction (two presets deliberately differ and say so in the code:youtube4kis YouTube at 2160p, and noyoutube*preset trims at YouTube’s 12-hour limit). The alias map (youtube-shorts/yt-shorts→shorts,yt→youtube,instagram/ig→reels,twitter→x,fb→facebook) is accepted bycheck.py --platform,export.py --preset,caption.py/graphics.py/overlay.py --platform,look.py --safeandrender.py --templatealike, and those tools now offer one platform vocabulary rather than three. Existing platform rows and preset command lines are byte-for-byte what they were. - Delivery templates:
render.py --template tiktok INPUT [--cues cues.txt | --srt subs.srt] [--logo] [--title] [--brand] [--chapters] [--fit crop|pad|blur] [-o OUT]fills a project shipped intemplates/and renders it — frame and fit, captions sized and placed clear of the platform’s UI, loudness, the platform export preset and that platform’scheck.pyin one command. Templates ship fortiktok,reels,shorts,youtube-shorts,youtube,x,linkedin,facebookandpodcast(audio: silence trim, −16 LUFS, chapters when given, the podcast check).--list-templatesprints them with their frames, limits and safe zones;--write-project FILEwrites the filled project for editing and renders nothing; an unknown name is refused (kind: input) with the list. - The social pack:
render.py --template all INPUT(or--template tiktok,reels,x) renders every named destination from the same edit into<stem>_<platform>.mp4next to the input (.m4afor an audio-only destination, and--chaptersreaches it), runs each platform’s own check, and writes<stem>_pack.mdwith one table row per destination (file, size, duration, check). Under--dry-runthe pack prints every child’s planned commands and its table readsplannedwith no size or duration, since nothing was encoded and nothing was verified.report.py --pack <stem>_pack.mdrenders that table as an HTML page. - Real presets per destination:
export.py --preset tiktok|shorts|linkedin|facebookare their own entries rather than aliases ofreels/youtube, each with its platform’s frame, duration limit and loudness spec.--preset youtube-hdrwrites HEVC Main10 keeping the source’s own HDR10/HLG tags and refuses an SDR source (kind: input, pointing at--preset youtube) instead of labelling SDR as HDR;--preset youtube-av1encodes AV1 with SVT-AV1 (libaom fallback) and refuses withkind: missing_toolon a build that has neither.check.py --platform facebookis new. - Text stays out of the app’s UI:
caption.py --platform NAME,graphics.py --platform NAMEandoverlay.py --platform NAME(so a template’s--logoclears TikTok’s status bar instead of landing 24 px under it) take their margins from that destination’s safe zone (TikTok covers the bottom 22 % with its description block and the right 14 % with the like column; Reels 20 %/12 %, Shorts 18 %/12 %; the feed destinations keep a 5 % title-safe border). An explicit--margin(new ongraphics.py) or--positionwins.render.pypasses--platformonly for projects filled from a template, so a hand-written project is unchanged.look.py --safe NAMEshades those zones on the sheet or frame, so “is the caption readable” can be checked against the app and not just the file. fit.py --fit blur: reach an aspect with the whole picture centred on a blurred, darkened (eq brightness=-0.15) copy of itself — nothing cropped, no black bars. The darkening is SDR-only: aneqon PQ/HLG code values is not the same perceptual dim, so an HDR source keeps an undimmed (and un-tone-mapped) blurred background, with oneinfoline saying so. Templates ask for it as"frame": {"aspect": "9:16", "fit": "blur"}, orrender.py --template tiktok clip.mp4 --fit blur.render.pyprojects now accept"frame": {"fit": ...}.graphics.py --template sticker|hook|meme: a filled chip of--textthat pops in at--position(the social sticker), the full-width opening card (--title --duration 3) with a thin progress bar along the top that empties as the card’s time runs out, and the classic meme layout (--top/--bottom, upper-case white with a heavy black outline). All three work inside arender.pyproject’sgraphics[]— which now takestext,top,bottom,duration,marginandplatformalongside the older keys — and honour--platform’s safe zone.render.pyprojects may carry"template"(the template they were filled from) and audio-only deliveries now carry their intermediates through the audio container instead of.mp4, which is what makes the podcast template render end to end from a.wav.
Docs
- SKILL.md gains the delivery-template rows and the safe-zone gotcha,
references/scripts.mda templates section with the safe-zone table,docs/contract.mdthe delivery-table shape, what is derived from it and the new arguments,references/gotchas.mda “Platform safe zones” section (the target of SKILL.md’s safe-zone pointer), and the README a “Deliver to a platform” example.demos/build.pyanddocs/demos.mdgain two demos:fit_blurandtemplate_tiktok.docs/roadmap.mdmarks 1.14.0 done and renumbers the remaining themes. tests/fixtures/mcp_tools.jsonregenerated:rendergainstemplate,list_templates,cues,srt,logo,title,brand,chapters,fit,outputandwrite_project;caption,graphicsandoverlaygainplatform(andgraphicsalsomargin,text,top,bottom,duration);lookgainssafe;reportgainspack. Nothing was removed, renamed or made required.- feat: delivery templates (render.py –template, safe zones, platform presets, blurred fit, social pack, sticker/hook/meme) (#224)
- docs: eval iteration 14 at 1.13.0 (76 prompts, 17 languages, delivery baseline) + roadmap states + external review filed (#223)
- docs: before/after demo gallery (23 demos, demos/build.py, docs/demos.md) (#222)
1.13.0
Automated release: version and notes generated from pull requests merged since 1.12.0.
Added
audio.py --voicetakes a strength:--voice light|medium|strong, with a bare--voicemeaningmedium— byte for byte the chain it has always produced, so existing calls and MCP requests are unchanged.lightishighpass=f=80,acompressor=threshold=-18dB:ratio=2:attack=5:release=80:makeup=1(rumble and level only, for a good room);mediumishighpass=f=80,deesser=i=0.4,afftdn=nf=-25:tn=1,acompressor=threshold=-18dB:ratio=3:attack=5:release=80:makeup=2;strongismediumfollowed bydeesser=i=0.6,acompressor=threshold=-24dB:ratio=4:attack=5:release=120:makeup=3,alimiter=limit=0.891251:level=disabled, for phone and laptop audio.- The ducking parameters are sayable:
--duck-threshold DB(default −26.02 dBFS, the 0.05 linear the bed has used since 1.4, so the default filter string is unchanged),--duck-attack MS(20) and--duck-release MS(400) next to the existing--duck-amount.--jsongains anaudioblock reporting the voice level, the stereo width, the effects bed and the duck settings the run actually used. audio.py --stereo-widen 0..1widens the stereo image (extrastereo=m=1+2*amount), applied after the channel layout is settled. It needs a real stereo source and says so: widening scales the side signal (L−R), which is exactly zero once a mono track has been duplicated to two identical channels, so a 1-channel input is refused (kind: input) whether or not--stereois given, and more than two channels are refused unless--downmixfolds them to stereo first — in which case the widening runs on the fold-down. Nothing is downmixed implicitly.audio.py --effects FILE/--effects-volume DBmixes a third track (sound effects, atmos) into the bed. It is never ducked: effects are cut to the picture, so dipping them under speech would move them off their own frames.loudness.py --jsonnow reports the loudness range on both sides (measured.input_lrafor the input,result.input_lrafor the written file) and echoes the requestedtargets(lufs, tp, lra);--lra Nis documented inreferences/scripts.mdand the README.check.py --platform podcastgains two informational rows:chapters(PASS when the container carries at least one marker, WARNnoneotherwise, withmetadata.py --chaptersas the fix) andchannels(PASS for mono or stereo, WARN above — podcast players downmix 5.1 unpredictably). Neither can FAIL a delivery and neither appears for another platform.render.pyproject keys:"audio": {"stems": {"dialogue": DB, "music": DB, "effects": DB}}names one level per element of the mix, mapping to--gain,--music-volumeand--effects-volume(an explicit flag next to a stem wins; a stemseffectslevel with no"audio": {"effects": "sfx.wav"}file is refused), and"chapters"— a chapters file path or an inline list of{"at": TIME, "title": STR}— runsmetadata.pyon the delivered file as the last stage beforecheck, so the markers are in the file that ships. The stage plans like every other one:--dry-run/--planprints themetadata.pycommand and listschaptersinstages, so the plan the user approves is the run."audio": {"voice": "light"|"medium"|"strong"}picks the voice strength;truestill meansmedium. Both new objects are validated against the project schema, so a misspelled stem or chapter key is refused by name.- The audio guards say what is missing instead of ignoring it: a duck parameter (
--duck-threshold,--duck-attack,--duck-release,--duck-amount) without--duck, and--duckwithout--music, are refused (kind: input) the way a typed dynamics parameter without its switch already was. In arender.pyproject, anaudio.stems.musiclevel with no"music"file is refused likestems.effectswithout an"effects"file, andchaptersentries (a missingatortitle, a chapters file that does not exist) are validated before the first ffmpeg call rather than after the render has already delivered an unchaptered file. tests/fixtures/mcp_tools.jsonregenerated:audiogainsduck_threshold,duck_attack,duck_release,stereo_widen,effectsandeffects_volumeandvoicebecomes alight|medium|strongenum. A client still sending the 1.12 boolean{"voice": true}keeps working:trueis the bare flag, which ismedium;docs/contract.mdandaudio.py --helpsay so.
Docs
- Not shipped: a
loudness.py --dialoguespeech gate.loudnorm’s EBU R128 integrated measurement already applies the −70 LUFS absolute and −10 LU relative gates, so gating the measurement on speech spans moved the result by at most 0.6 LU on every fixture (including one that is half digital silence) — insidecheck.py’s own ±1 LU tolerance — for the cost of a second decode pass.references/gotchas.md#loudness-and-ambiencerecords it. docs/roadmap.mdmarks 1.13.0 done and records the decision not to addaudio.py --chapters:metadata.py episode.mp4 --chapters chapters.txtalready writes chapter markers with every stream copied, and a second spelling inside a tool that re-encodes the audio would be the worse one.render.py’schapterskey is the project-level answer instead.- feat: the audio bed — voice levels, stereo widen, duck parameters, effects stem, loudness –dialogue, podcast rows, project chapters (#221)
- evals: 18 prompts in th hi he ru el vi id tr it, 8 delivery prompts, 9 trigger queries, grader rules (#220)
- docs: eval iteration 13 at 1.12.0 (50 prompts, nine languages) (#219)
1.12.0
Automated release: version and notes generated from pull requests merged since 1.11.1.
Added
- Fonts by script.
caption.py,graphics.pyandoverlay.py --textdetect the writing system of the text they are about to draw (Japanese, Chinese, Korean, Arabic, Hebrew, Devanagari, Thai, Cyrillic, Greek) and resolve a font file that actually covers it —fc-list :lang=xxon Linux/macOS, the known system fonts on Windows — printing one line:font: /usr/share/fonts/.../wqy-zenhei.ttc (covers ko). A machine where fontconfig reports no font for the script fails the job (kind: input, with per-OS install hints) instead of writing a video full of empty boxes that ffmpeg reports as a success; a machine with no working fontconfig at all isunknown, notmissing, and the job runs with the font as given behind one info line. An explicit--font, an explicit--font-file, or a font your brand file itself names is always kept (a brand file that never mentions a font is not a choice — the script still picks one); when fontconfig says the stated font does not cover the text, one info line says so.caption.py --fonts-diris searched first and checked withfc-scan: a directory that does not cover the script gets one line and a font resolved by script anyway. --lang XXoncaption.py(an alias of the existing--language, which keeps tagging the subtitle stream under--mode muxand setting--transcribe’s language) and ongraphics.py, plus"lang"in brand.json: the hint that says whether Han-only text is Chinese, Japanese or Korean. Everything else is read from the characters.doctor’sfontscapability gainsscripts:available/missing/unknownper writing system with the font file it would use, so “can this machine render Korean captions” is answered before the job. The plain-textdoctorsummarises it on one line and stays short.- Captions people can read. Every cue is wrapped to the safe area by measured width — a per-character table for Latin (read off DejaVu Sans, so a line of capitals measures as wide as it draws), a full em per CJK/Thai character, 0.6 for Arabic/Hebrew, 0.7 for Devanagari, and nothing at all for a combining mark, which never gets separated from the character it belongs to (Thai tone marks and vowel signs, Devanagari matras, Arabic and Hebrew points). Lines break between characters for CJK/Thai and at spaces otherwise.
--max-lines N(default 2) splits a cue that needs more lines into consecutive cues sharing its time;--min-duration S(default 1.0) holds a flashed cue longer, never past the next cue’s start;--offset TIMEshifts every cue for--text,--srtand--assand takes the skill’s timestamp grammar (seconds,mm:ss,hh:mm:ss.ms, SMPTEhh:mm:ss:ff, with a leading-for earlier). Onecues:line reports what changed, and a file you passed in is never edited in place — the adjusted copy is written next to the output and burned instead. Under--dry-run/--planthe planned command names that same copy and the plan records where it comes from, while nothing is written until the real run.--min-duration/--offsetalso work with--write-srtalone;--max-linesneeds the input video, whose frame size sets the line width. --karaokeuses real per-word timings when the transcript has them (a whisper<stem>.json/<stem>.words.jsonnext to the SRT), falling back to the existing energy or even split.- brand.json
styles.caption({font, size, colour, box, position}, either spelling of colour) — one caption look shared bycaption.pyand, forfontandcolour,graphics.py. The older top-levelcaptionblock still works and still carries the burn-in-only defaults. render.pyprojects passlang,offset,max_linesandmin_durationthrough to the captions stage, andlangto a graphics entry.
Changed
doctor’s plainfonts:line carries the default font’sdetailin brackets when the status is notavailableand the detail is short enough to keep the line to one screen width; longer detail stays--jsononly.references/gotchas.mdon RTL:caption.pyshapes and reorders Arabic and Hebrew by construction (everything it draws goes through libass);drawtext(overlay.py --text,graphics.py) does too on a build with--enable-libfribidi/--enable-libharfbuzz, and only an older build draws unjoined logical order.- The audio guards say what is missing instead of ignoring it: a duck parameter (
--duck-threshold,--duck-attack,--duck-release,--duck-amount) without--duck, and--duckwithout--music, are refused (kind: input) the way a typed dynamics parameter without its switch already was. In arender.pyproject, anaudio.stems.musiclevel with no"music"file is refused likestems.effectswithout an"effects"file, andchaptersentries (a missingatortitle, a chapters file that does not exist) are validated before the first ffmpeg call rather than after the render has already delivered an unchaptered file. tests/fixtures/mcp_tools.jsonregenerated:captiongainslanguage(spelled--langtoo),offset,max_lines,min_duration;graphicsgainslang.
Docs
- Correction to the 1.11.1 entry: iteration 11’s agents did not read
references/gotchas.mdorreferences/scripts.md(0 of 36 and 2 of 36 actual reads; the earlier counts matched the file names inside SKILL.md’s own text). What 1.11.1 changed in practice, measured in iteration 12:doctorbefore a job 23 of 36 runs → 0,--json-briefon a writing step 4 of 36 → 23. Tokens per run are flat (72.2k → 71.8k) because about 64k of every run is the harness’s own context, not the skill. - feat: fonts by script (ja zh ko ar he hi th ru el), doctor per-language fonts, captions that wrap and fit (#218)
- evals: 14 multilingual prompts (zh ko es pt fr de ar), 7 trigger queries, per-language grader rules (#217)
- docs: eval iteration 12 at 1.11.1 (36 prompts); correct iteration 11’s transcript counts (#216)
1.11.1
Automated release: version and notes generated from pull requests merged since 1.11.0.
Changed
- SKILL.md tells the agent that the file is enough to do a job and that
references/scripts.md/references/gotchas.mdcost as much to read as SKILL.md itself, so they are opened only for a question the file leaves open; step 0 no longer suggestsdoctorbefore the first job of a session, only after a failure or when the user asks. Eval iteration 11 measured why: at 1.11.0 agents readgotchas.mdin 31 of 36 runs (0 of 106 at 1.10.0),scripts.mdin 36 of 36 (73 of 106) and randoctorin 31 of 36 (41 of 106), so the mean run cost 72.2k tokens against 68.7k at 1.10.0 despite the smaller SKILL.md. - fix: SKILL.md stops sending agents to the reference files and doctor on every job (#215)
- docs: eval iteration 11 at 1.11.0 (36 prompts, tokens per run measured) (#214)
- docs: the token diet shipped as 1.11.0 (feat PR → minor); roadmap themes move up one, 1.10.2 mentions renamed (#213)
1.11.0
Automated release: version and notes generated from pull requests merged since 1.10.1.
Changed
- SKILL.md is two-tier (token diet, 1.11.0): 362 lines / 37.8 KB → 198 lines / 29.1 KB with no rule removed. The long “Things that look right but are wrong” and “Gotchas” prose, and the audio-only recipe table, moved to the new
references/gotchas.md; SKILL.md keeps one line per item with areferences/gotchas.md#anchorpointer. - Workflow guidance that saves calls: run
_contract.py doctoronly before the first job on a new machine or after akind: missing_toolfailure (not per job); no separateprobe.pybefore every edit, because every writing tool’s--jsonalready carries the input and a probe of the output; userender.pywith a project.json for jobs of three or more steps; verify withlook.py --tiles 3x2(or--at T), keeping the full 4x3 sheet for jobs about layout across the whole clip. python3 scripts/_contract.py doctorwithout--jsonprints counts and what is missing instead of the full capability list (1681 → 522 bytes on a healthy machine).doctor --jsonis unchanged.
Added
--json-briefon every tool: the same success document as--json, trimmed tostatus,output,dry_run,verified, a compactsummary(duration_s,width,height,fps,vcodec,acodec,channels, andlufswhen the tool measured one), the tool’s own keys and the count of commands instead of the command lines — 2372 → 784 bytes for acut.pyrun. It implies--json;--json’s own output is unchanged; failures print the same failure document as today.contract --json:supports_json_briefper tool, mirroring the measuredsupports_json. The MCPtools/listschema gains thejson_briefargument on every tool (an addition; the frozen 1.x snapshot was regenerated).- feat: token diet — two-tier SKILL.md, –json-brief on every tool, call-saving workflow guidance, short doctor summary (#212)
1.10.1
Automated release: version and notes generated from pull requests merged since 1.10.0.
Fixed
render.pynow refuses an unrecognised key in a project — top level or in any stage/clip object — naming the object, the key and the nearest valid key, instead of silently ignoring it (a clipstart/endtypo rendered the clip untrimmed; a stage typo dropped the stage, both reported as success).write_plan()and the at-exit plan hook use theContextemit()/die()were given:emit(ctx=…)no longer writes an empty plan, anddie(ctx=…)no longer leaves a plan behind for a failed run.- The nine tools that declared
--crfwithout a help string now show the(deprecated: use --quality)mark in--help(and in their MCPcrfdescription). - The
--crfdeprecation warning fires for the abbreviations argparse accepts (--cr,--c) as well, andbatch.pyno longer swallows a recipe step’s warning;export.pystays exempt. - A non-numeric time says
'zz': not a timeinstead of leaking the interpreter’scould not convert string to float. - docs/contract.md:
tools/listunderFFMPEG_SKILL_MCP_LEANis stable in tool and argument names andrequiredlists — what the frozen snapshot pins — not byte-identical (descriptions may change). - SKILL.md: the time-grammar sentence is scoped to the timestamp flags (
--start/--end/--at/--from/--duration/--offsetand cue/chapter files);doctor’s tool list isdoctor --json’stools. - references/scripts.md notes the
--crfdeprecation andFFMPEG_SKILL_NO_OVERWRITE; README marksproxy.py --crfas the deprecated alias. - fix: render.py refuses unknown project keys, ctx= reaches write_plan and the exit hook, –crf mark on every tool with –quality, time refusals say not a time (#211)
- docs: eval iteration 10 at 1.10.0, README catches up with 1.8-1.10, PR checklist names the README (#210)
1.10.0
Automated release: version and notes generated from pull requests merged since 1.9.1.
Deprecated
Announced per docs/contract.md’s three-step policy (also in contract --json’s new top-level
deprecated list and in docs/contract.md’s “What 2.0 changes”); everything below keeps working
unchanged in 1.x and is removed in 2.0.0:
- The per-tool v1 success keys next to
result_v2(output,probe,commands,verified,verificationand each tool’s own top-level keys) — useresult_v2(FFMPEG_SKILL_RESULT_V2=1). --crfas an alias of--qualityon every re-encoding tool that takes--quality(export.pykeeps--crf: its preset chooses the encoder) — use--quality N;--crfnow prints a one-line stderr warning when it is passed explicitly and is marked in--help.jsonandprogressin the MCPinputSchema— the transport sets them itself;FFMPEG_SKILL_MCP_LEAN=1drops them fromtools/listtoday (opt-in, default unchanged).probe’shdrmeaning “BT.2020 primaries or a PQ/HLG transfer” — usehdr_signal; 2.0 renames.- Overwriting an existing output with only a warning — pass
--overwrite, or setFFMPEG_SKILL_NO_OVERWRITE=1(the recommended agent setting) for the 2.0 behaviour today.
Other
run(),emit(),die()andinfo()accept an optionalctx=Context (issue #189 B);STATEstays the default instance, so no tool changes behaviour. 2.0 makes the argument required.- feat: deprecated list in the contract, ctx= on run/emit/die/info, FFMPEG_SKILL_MCP_LEAN, NO_OVERWRITE as the agent setting, eval-9 follow-ups (#209)
- docs: eval iteration 9 at 1.9.0 (108 runs, independent grader, trigger set) (#208)
1.9.1
Automated release: version and notes generated from pull requests merged since 1.9.0.
- fix: @fps in caption cue files, one @ only, time_arg() in metadata/multicam/speedramp, render normalize default documented (#207)
1.9.0
Automated release: version and notes generated from pull requests merged since 1.8.1.
- One time grammar (issue #189 B, docs/design-decisions.md “Time grammar”, roadmap 1.9.0):
broll.py,cut.pyandfreeze.pynow parse their times throughtime_arg()like every other tool, so a bad or fps-less value iskind: inputnaming the flag;hh:mm:ss:ff@fps(e.g.00:01:02:15@29.97) names a timecode’s rate explicitly and is accepted by every time-taking flag and cue file.references/scripts.mddocuments the grammar once. - Eval 8 follow-ups.
render.pyturns--normalizeon by default for a platform export preset when the project has noloudnessstage ("normalize": falseopts out); SKILL.md shows the Japanese shape of the report next to the English one, lists ajoin.pythat scaled or padded a clip under the Look rule, and says when--codec/--qualityare the right call. probegainshdr_signal(true only for a PQ / HLG transfer or Dolby Vision) next tohdr, which keeps counting BT.2020 primaries on an SDR transfer until 2.0 renames it (issue #189 A1, pre-shipped as a parallel key).- feat: one time grammar (@fps, time_arg in broll/cut/freeze), probe hdr_signal, eval-8 follow-ups (#206)
1.8.1
Automated release: version and notes generated from pull requests merged since 1.8.0.
- Seventh review (the 1.8.0 encoder flags and
--normalize).export.py --normalizewrote its temporary file underloudness.py’s own default output name and destroyed a pre-existing<output>_loudnorm.<ext>(P0; now a private per-process name, no forced overwrite, and the normalising encode is incommands);--normalizeon a preset without a loudness spec iskind: inputand under--dry-runsays what it would do;--codec proreswithout-ofailed inside ffmpeg (refused with the.movhint);waveform.pyignored--codec;cut.py --codecon the lossless path kept the source codec (switches to--accurate, said so);export.pyno longer advertises--codecin the contract; duplicatedencoder:libx265optional capabilities merged;x264_args(keep_bt709=False)honoured under--codec; HDR HEVC--quality 50/51no longer overflows CRF; the--crfrange message names the av1 bound;check.pytagstrue peakas a judgement row. - Eval 8 follow-ups. SKILL.md says how to run doctor (
python3 scripts/_contract.py doctor --json; two runs looked for a doctor.py);render.py --initwrites"normalize": trueon the export stage; the ProRes rule says-o NAME.movexplicitly. - fix: seventh review (–normalize temp name, –codec edges, contract) and eval-8 follow-ups (#204)
- docs: evals iteration 8 at 1.8.0 (108 runs, independent grader, trigger set) (#205)
1.8.0
Automated release: version and notes generated from pull requests merged since 1.7.2.
--codec h264|hevc|av1|proresand--quality Non every tool that re-encodes (issue #189 B, pre-shipped from the 2.0 decision in docs/design-decisions.md). One resolver,_common.encoder_args(): hevc keeps HDR sources Main10 with their tags and writes 8-bit BT.709 for SDR; av1 uses SVT-AV1 (libaom fallback), 10-bit for HDR; prores is 422 HQ and needs a.mov/.mkvoutput; h264 refuses HDR (kind: input, the hint namescolor.py --to-sdr).--qualityis the CRF scale and overrides--crf; without--codecnothing changes (x264 for SDR, x265 for HDR).export.pykeeps its presets and refuses--codec. The contract lists the encoder each--codecvalue needs under the tool’s optional capabilities.-
feat: –codec h264 hevc av1 prores and –quality on every re-encoding tool, resolved in encoder_args() (#203) - docs: roadmap from 1.7.1 to 1.20.0 and what 2.0 removes (#201)
1.7.2
Automated release: version and notes generated from pull requests merged since 1.7.1.
check.pywithout--platformassumes youtube and reports the judgement rows (duration, aspect, fps, resolution, loudness, true peak) as WARN with anotesline, not FAIL: a run that only wanted the format rows no longer fails on levels it was not asked about (eval 7). A named platform is unchanged.render.py:"export": {"preset": ..., "normalize": true}forwards--normalize, so a project renders a platform-compliant file in one export.- fix: check.py judgement rows are WARN without a named platform; render.py export.normalize (#202)
1.7.1
Automated release: version and notes generated from pull requests merged since 1.7.0.
- Eval iteration 7 follow-ups.
export.py --normalizerunsloudness.pyon the written file when a platform preset’s loudness spec is missed (audio re-encoded, video copied), so a platform export is one call instead of export, loudness, export again.render.pyfillsframe.width/heightfrom the export preset when the project gives only an aspect of the same shape, so captions and overlays are burned at the delivery size instead of the source’s short side (406x720 before a 1080x1920 reels export). SKILL.md: the report language is the language the request is written in, not a language it talks about or the language of an error (six English refusals came back in Spanish or Portuguese); the five report labels hold for the shortest failure too. - fix: eval-7 follow-ups – export –normalize, render frame from the export preset, report language and labels on failures (#200)
- docs: evals iteration 7 at 1.7.0 (108 runs, independent grader, trigger set) (#199)
1.7.0
Automated release: version and notes generated from pull requests merged since 1.6.0.
- Sixth review (plan and verified edges).
loudness.pysaidverified: trueafter missing its target by several LU (the step’soknow reflects the target);--planonprobe/scenes/sync/silence/cropdetect/loudness --measure-onlywrote nothing (a plan is now written at exit when the tool never reachedemit());verify.py --planran the whole chain for real (refused); a plan bound only the-ifiles, so an SRT, LUT, recipe or still could change underneath it (argv files and filter-read files are fingerprinted too);render.py plan.jsonfailed hard on a platform check the direct command reports asverified: false(aligned);render.py --planproduced an unrunnable plan (refused, the project is the plan); a vanished plancwd, a non-object JSON and a stringargvarekind: inputinstead of a silent relocation or a traceback; theplankey is in every tool’s output schema. - Every writing tool’s
--jsondocument now says what it verified itself:verification(probe;loudnessforloudness.pyandexport.py’s platform presets;checkforrender.py) andverified, true only when the artifact was written, probed and every self-check met its target (issue #189 C, “verify as part of the contract”).export.pywhose file misses the platform’s loudness spec stayscompletedwithverified: false. - feat: every writing tool reports what it verified (verified, verification); sixth-review fixes to the plan edges (#197)
- docs: record the 2.0 design decisions (time grammar, encoder abstraction, Context) (#198)
1.6.0
Automated release: version and notes generated from pull requests merged since 1.5.2.
--plan FILEon every tool: the dry run written as a plan (tool, argv, fingerprinted inputs, commands, expected output, verify steps) thatrender.py FILEexecutes later, refusing if an input changed since the plan was made and running the verify steps (probe,checkfor a platform) afterwards. “Plan → user confirms → execute” is one round trip (issue #189 C).- feat: –plan FILE writes the dry run as a plan that render.py executes (#196)
1.5.2
Automated release: version and notes generated from pull requests merged since 1.5.1.
- Eval iteration 6 follow-ups.
overlay.py --fadewithout--endno longer fades the overlay out again at the end of the clip (fade-in only; the fade-out belongs to--end).export.py’s platform presets (youtube / youtube4k / reels / x) measure the written file and reportloudnesswith the platform’s target plus anotesline naming theloudness.pycall when it is outside the spec, so an agent plans one pass instead of two.cut.pyreportslossless_alternative(the nearest keyframe--start) when the snap forced a re-encode. SKILL.md: a one-command job still gets itsDone:/Steps:lines in the user’s language. - fix: eval-6 follow-ups – fade-in-only –fade, export measures platform loudness, cut names the lossless alternative, report language on short jobs (#195)
1.5.1
Automated release: version and notes generated from pull requests merged since 1.5.0.
- Fifth review.
render.pyrefused nothing whenoutputnamed one of its own clip sources and overwrote the source (nowkind: input);fit.py --method trimstream-copied a subtitle track through the-sscut, so the file came out at twice the requested length with cues from the wrong part (dropped and reported likepad --start);caption.py --transcriberan the speech engine and wrote the.srtunder--dry-run, and put that sidecar next to the input, silently replacing a hand-written one (now planned only, written next to the output, with a warning before replacing);color.py --correct --dry-runran two real signalstats passes;multicam.py --audio Nout of range was a traceback;check.py’s fix for a broadcast codec failure named a preset that does not exist (proresnow);sequence.pyhadimport reabove its shebang, so its docstring never reached--helpor the contract;batch.py --watchcould not be stopped with Ctrl-C without an “interrupted, nothing written” exit;render.py --dry-runsaid “copied”;join.py’s audio path probed its output as an input;references/scripts.mddescribed karaoke timing as even when the default follows speech energy (--karaoke-timing). - fix: fifth review – render self-overwrite, fit trim subtitles, caption transcribe under dry-run, and nine smaller items (#194)
1.5.0
Automated release: version and notes generated from pull requests merged since 1.4.15.
FFMPEG_SKILL_RESULT_V2=1adds aresult_v2key to every writing tool’s--jsonsuccess document: the uniform shape planned for 2.0 (output,probe,commands,metrics,notes,dropped,details), built inemit()from the keys tools already report (#189). Nothing moves without it. (nothing yet)- feat: opt-in result_v2 key previews the 2.0 success-document shape (#193)
- docs: record eval iteration 6 (1.4.15) (#192)
1.4.15
Automated release: version and notes generated from pull requests merged since 1.4.14.
- Policies settled after the boundary sweep. An ffmpeg failure now always exits 1 (ffmpeg’s own code, which varied by build and stage, is
ffmpeg_returncodein the JSON).audio.py,loudness.pyandproxy.pykeep a subtitle/data track the container can hold, asfit/color/overlaydo;join.py,broll.pyandspeedramp.py, whose timeline moves, reportdropped_non_av_streams: truewhen the source had one. The rule is written down indocs/design-decisions.md. - fix: settle the ffmpeg exit-code and subtitle-track policies (#191)
1.4.14
Automated release: version and notes generated from pull requests merged since 1.4.13.
- Boundary sweep (561 runs, 42 tools × 23 inputs). Two runs writing the same output at once both reported
completedwhile one of them described the other’s file:run()now holds a lock file (.<name>.ffskill-lock, pid inside) next to the output and the second run is refused askind: input; a lock whose pid is dead or older than an hour is taken over. - Odd source dimensions (641x359 screen captures, 4:4:4 masters) failed 15 tools with “width not divisible by 2”:
run()retries the encode with an even-dimension scale in front of the filter chain; a-filter_complexgraph that cannot take it is refused askind: inputwith the fix named. cut.py’s re-encode path andpad.py --startstream-copied a subtitle track whose cues then fired early (cut’s container grew to 2 s for a 1 s cut): both drop it and reportdropped_non_av_streams, asfit/freezealready did.cut.pycounts a keyframe snap of exactly the tolerance as outside it.render.py --dry-runplanned ten commands for a clip whose source does not exist; the source is checked first.render.pyandreport.pyanswered an unwritable output directory with a traceback; an unwritable directory is now refused askind: inputby every writing tool before ffmpeg runs.verify.py --jsonprinted two JSON documents for a non-media input.color.py --correct/--luton an HDR source tagged PQ/HLG pixels as BT.709 without a tone map: refused askind: inputnaming--to-sdr(--forcegrades the raw values anyway).redact.py --mode blurfailed on regions under 40 px with the default radius (clamped, chroma planes get their own).loudness.py --measure-only --jsonprints the contract’s document shape.- Sweep P3s:
export.py’s HDR warning is in the JSON (notes);graphics.pyrefuses a frame under 64 px instead of building a 0-height bar;insert.pyrefuses a video where it wants a still;look.pybuilds a sheet from a one-frame clip instead of printing “wrote” and then failing;batch.py --dry-runleaves no directories behind andrender.pyremoves its auto-named work directory on every exit path. - fix: boundary sweep – output lock, even-dimension retry, subtitle drops in cut/pad, unwritable dirs, dry-run source check, verify single JSON, redact radius, HDR refusal in color, and the P3s (#190)
1.4.13
Automated release: version and notes generated from pull requests merged since 1.4.12.
- Fourth review, P3:
render.pyno longer re-encodes a clip whosespeedis 1.0; a brand’scaption.animatedefault is ignored over--mode muxinstead of making it fail;shell_quotequotes newlines so thecommandslog stays copy-pasteable;reverse.pywarns that a clip over 60 s is buffered whole in memory;analyze_levelsscales 16-bit sources correctly (10 / 12 / 16-bit tiers);sync.py/multicam.py --analyze-secondsis capped at 900 s; the shared--timeout/--overwritehelp is shorter, which trims every tool’s MCPinputSchema(118 KB → 112 KB fortools/list); SKILL.md warns against burning captions at an intermediate size thatexport.pythen upscales. - fix: fourth-review P3 batch – render speed 1.0, brand animation over mux, newline-safe command log, reverse warning, 16-bit levels, analyze-seconds ceiling, shorter common help (#188)
1.4.12
Automated release: version and notes generated from pull requests merged since 1.4.11.
- Fifth audit (100 items; 33 confirmed, 5 recorded decisions, 62 not reproduced). Fixed:
bit_depthreadyuv410pas 10-bit (the depth is the number that ends the pixel-format name);audio.py --monohalved an already-mono track (-6 dB) and now leaves 1-channel input alone and downmixes >2 channels through swresample;sync.py --replace-audiocut the reference to the length of a shorter or head-trimmed second file (-t <reference>+apadinstead of-shortest); 25+ tools raised TypeError formatting a missing duration after a successful encode (fmt_secs()); energy karaoke gave the last word a negative\kfon a cue shorter than 5 cs per word; the karaoke energy decode ran outside--timeout;render.py’s final copy bypassed the output-path, overwrite and temp-staging guards (place_output()); the videojoin.pyforced stereo and dropped the centre/LFE of 5.1 clips (the audio-only join already kept the widest layout);multicam.py -shortestcould end before the cut list; ffprobe output that is not JSON is akind: ffmpegdocument, not a traceback; a git/vendorffprobe version N-...string is mapped from the libavutil major (59 -> 7) instead of reading as 0.0, which on 7.1 skipped the BT.709 tagging workaround. freeze.py --mode insertdrops a stream-copied subtitle track (its cues after the freeze fired early) and reportsdropped_non_av_streams, asfit.py --method speeddoes;sequence.pysorts globbed frames naturally (img2beforeimg10);cut.py --segmentsrefuses a negative start;speedramp.py --segmentacceptsmm:ss; oneMEDIA_EXTlist (adds.mxf,.m2ts,.3gp,.wmv,.aif,.caf,.wma,.webp, soverifyprobes them); a colour alpha above 1 (red@2) is refused before ffmpeg;--pad-fill blurclamps the radius to what boxblur accepts on a tiny frame.- Not changed, recorded decisions: BT.2020-primaries SDR takes the HDR path until 2.0 (docs/design-decisions.md);
join.py/broll.pykeep the HDR path for mixed inputs;escape_drawtextdrops'and%;--overwritewarns until 2.0. - fix: fifth audit – bit depth, mono, replace-audio length, None durations, karaoke, render copy guards, 5.1 joins, multicam length, version fallback, freeze subtitles, natural frame order (#187)
1.4.11
Automated release: version and notes generated from pull requests merged since 1.4.10.
- An output that names a directory, or whose directory does not exist, is refused as
kind: inputbefore ffmpeg runs (it wasOUTPUT_INVALIDafter the run, or the muxer’s ENOENT askind: ffmpeg). batch.pywith a relative folder no longer doubles it in the default output directory (bdir/bdir/out).- fix: output-path mistakes are kind input before ffmpeg runs; batch default outdir no longer doubles a relative folder (#185)
1.4.10
Automated release: version and notes generated from pull requests merged since 1.4.9.
loudness.pyholds its--tpceiling in the file it writes, not just in loudnorm’s float output: the written file is measured and, when a lossy encoder overshot (ffmpeg’s AAC at 192k turned one transient of a 12-minute film from -2.4 to +3.7 dBFS, so--tp -1delivered +1.2 dBTP andcheck.py’s fix hint looped back), it re-encodes at 256k then 320k when--audio-bitratewas not pinned, then with the ceiling lowered by the overshoot.resultcarriestp_ceiling_used,audio_bitrate_used,encodesand anotewhen the loudness moved more than 1 LU.- SIGINT/SIGTERM stop the running ffmpeg (or sibling script), remove its partial output and exit with a failure document (
kind: interrupted, exit 130/143). Before, SIGTERM killed only the Python parent and ffmpeg carried on as an orphan finishing a file nobody verified; SIGINT was a traceback with the partial left on disk. - A path with an apostrophe (
Ryo's Mac/cues.srt,lu't.cube) reachessubtitles=,ass=,lut3d=andfontsdir=intact:escape_filter_pathnow writes the three backslashes the two-level filter parser needs for'(one was consumed as a quote, so ffmpeg openedRyos Mac/cues.srt). - fix: loudness holds –tp in the written file; SIGINT/SIGTERM stop ffmpeg cleanly; apostrophes in filter paths (#184)
1.4.9
Automated release: version and notes generated from pull requests merged since 1.4.8.
- README gains a “Gotchas and best practices” section for human readers: VFR, keyframe snapping, HDR, loudness targets, caption order, 9:16 crops, fonts, silence threshold, sync confidence, plans; every flag named is checked against the scripts’
--help. --dry-runwrites no side files either:silence.py --edl,scenes.py --edland the.assthatcaption.py --animate/--karaokegenerates were written while stderr said “would write”. The contract’s note for these tools was right; the code was not.- SMPTE
hh:mm:ss:fftimes resolve with the input’s fps in every tool that takes a time (overlay,graphics,look,insert,background,fit,loop), and a bad time is akind: inputfailure document naming the flag instead of a traceback (time_arg()in_common.py;cut/freezealready did this). pad.py --start/--endaccept the shared time grammar (1:30) like every other tool; junk is akind: inputrefusal, not an argparse exit 2 without JSON.cut.py --segmentsrefuses an output that is the same file as its input. The run() guard compares each ffmpeg command’s-iwith its output, and the final concat’s only-iis the temp list file, so-o in.mp4replaced the source with the join (fourth review, P0); the single-segment path already refused.- The dry-run exception list in SKILL.md and
docs/contract.mdnamessilence,loudnessandstabilizeagain; the pinning test now checks the sentence that states the exception in all three docs, not “the name appears somewhere in the file”, which is how the list drifted twice. - Release resolver: a PR labelled
dependenciesis never releasable, even when Dependabot also labelled itmajor(its bump of actions/checkout 4→7 was the action’s major, not this package’s, and blocked the 1.4.9 release). - ci: a dependencies PR never resolves a release, even when Dependabot labelled it major (#186)
- fix: fourth audit – dry-run side files, SMPTE in every tool, pad time grammar, cut –segments input guard (#183)
- docs: README gotchas and best practices for human readers (#182)
- build(deps): bump softprops/action-gh-release from 2 to 3 (#169)
- build(deps): bump actions/checkout from 4 to 7 (#168)
1.4.8
Automated release: version and notes generated from pull requests merged since 1.4.7.
- Multi-segment
cut.pywrites its concat list with forward slashes, so a Windows temp path (C:\Users\...) is not read as escape sequences by the concat demuxer;sequence.pyshares the helper. - SMPTE
hh:mm:ss:ffparsing counts frames per timecode-second likefmt_smpte_time()does, so the two agree at 29.97/59.94 over long files.cut.pypasses the input’s fps (so SMPTE works there) and refuses a bad time askind: input;freeze.py --ataccepts the shared time grammar. caption.py --transcribe: faster-whisper runs under--timeoutlike the CLI engines; an old whisper.cpp binary namedmainis used when it lives in a whisper directory.bin/install.jsmoves the old install aside and back on a failed swap, so an upgrade never leaves the target empty; the npm package and installer shipdocs/contract.md.join.pygoes 10-bit HEVC when any input is HDR (not only the first);broll.pykeeps a 10-bit cutaway over an HDR main clip;grid.pynotes that an HDR input is composited into an 8-bit SDR grid.look.py -onames the file for a single--atwhen it carries an image extension;audio.pyrefuses--monowith--stereoand an out-of-range--denoise-strength;render.pyrefuses a negative clip speed;batch.pyalso picks up.ogg/.opus/.ts/.gif/.aac/.aiff;report.pyshows a 0 s duration as 0 s;ffmpeg_version()has a probe timeout.- Windows:
default_font_file()looks for the requested family inC:\Windows\Fonts(and common CJK system fonts for a CJK request) before falling back to Arial. - fix: third audit’s confirmed items — Windows concat lists, SMPTE round trip, ASR timeout, installer swap, HDR joins, argument checks, shipped docs (#181)
1.4.7
Automated release: version and notes generated from pull requests merged since 1.4.6.
--dry-runplans rest on real measurements:silence.py,loudness.py,check.pyandstabilize.pyrun their measurement passes (silencedetect, loudnorm pass 1, vidstabdetect) under--dry-runand skip only the write. Before, a dry run reported 0 silences, a made-up -20 LUFS, an unmeasured loudness row and no stabilisation pass. The contract lists them asanalysis_only.render.py’s check stage andreport.py’s look/loudness/check children receive the shared flags (--timeout,--overwrite,--fast,--dry-run);render.py’s failure document carries the output probe.cropdetect.pyskips a sampled window ffmpeg cannot decode and measures the rest; only when every window fails is itkind: ffmpeg.caption.py --text/--srtandbatch.py’srecipe.projectread through the guarded reader: a missing, directory or non-UTF-8 file is akind: inputrefusal naming the flag.- fix: dry-run plans rest on real measurements; shared flags reach every child; guarded cue reads; cropdetect skips an undecodable window (#179)
1.4.6
Automated release: version and notes generated from pull requests merged since 1.4.5.
docs/design-decisions.mdlists behaviours that look like bugs but are decisions (timeout 0, dry-run measurements, BT.2020 handling, overwrite policy,speed: 0, packaging), each with its rationale and pinning test;AGENTS.md, CONTRIBUTING and the bug template point reviewers to it first.- Docs: README no longer claims media ffmpeg calls have no timeout; tool counts read 42 everywhere (the count test now catches the “all N by” / “same N names” phrasings);
docs/contract.mdlists every dry-run exception; the error-kind list is identical in SKILL.md, README and the contract; SKILL.md states the dry-run exceptions once and moves the Windows drawtext story toreferences/ci-platform-pitfalls.md; CODE_OF_CONDUCT.md added. - The npm package ships
references/scripts.md,devices.mdandci-platform-pitfalls.mdonly; the maintainer diaryprocess-pitfalls.mdstays in the repository. Internal: theContextdict-style shims are gone, every call site uses attributes. - Every sibling-script run (
render.py/batch.py/report.pystages and the MCP server’s dispatch) has an outer wall-clock ceiling of 4x the per-ffmpeg--timeoutplus 60 s, so a child hung for a reason other than ffmpeg is killed and reported askind: timeout. The MCP caller’stimeoutargument sets both. scenes.pyandsync.pyshare one PCM decode and RMS-envelope implementation (decode_pcm_mono/rms_envelopein_common.py); results are unchanged.- fix: outer wall-clock ceiling on every sibling-script run; scenes and sync share one PCM decode and envelope (#178)
- docs: add design-decisions record and AGENTS.md (#180)
- chore: attribute-only Context; ship only the agent-facing references in the npm package (#177)
- docs: remove the contradictions the 1.4.2 review found; count test catches more phrasings; code of conduct (#176)
1.4.5
Automated release: version and notes generated from pull requests merged since 1.4.4.
--presetis an argparse choice of the x264 presets in every encoding tool (the contract and MCP schema carry the enum) and--crfis range-checked (0-51) before ffmpeg runs; a typo is akind: inputrefusal, not an encoder error.silence.pysays why nothing was found: with zero silences the result carries ahintwith the track’s measured mean/peak level and a threshold to try.cut.pynames the nearest keyframes (nearest_keyframes) when a lossless cut had to re-encode, so the caller can move the cut instead. Failure JSON may carryerror.hint.broll.py --pad-coloris validated like every other colour flag (it was spliced into the filter graph unchecked; a value containing,could append a filter).- The MCP server attaches the tool’s own failure document as
structuredContenton a failed call, so a caller readserror.kind/codeinstead of parsing prose. render.pyre-raises a stage’s own failure (kind, exit code, hint,stage) instead of reporting every child failure askind: input;report.pyshows “check could not run” instead of crashing whencheck.pyfails to run;--commands/--notes/--chaptersfiles that are missing or not UTF-8 are akind: inputrefusal, not a traceback.- SKILL.md: a failed or refused report keeps the five labels (
Look: not needed (nothing written)), quoteserror.hintinNotes:, and several open questions are asked as one bundled proposal instead of one per turn. - fix: validate –preset/–crf before ffmpeg; silence.py and cut.py say what to change next; failure JSON may carry a hint (#175)
1.4.4
Automated release: version and notes generated from pull requests merged since 1.4.3.
check.py,render.py(check stage),batch.pyandverify.pyreport a failed result asstatus: failedwith the newkind: verification(VERIFICATION_FAILED, exit 1) and keep their detail fields (checks,check,results,files); before, they printedstatus: completednext to a non-zero exit code.scenes.py,cropdetect.py,sync.py,caption.py --transcribeand the colour-level probe run their ffmpeg measurements under--timeoutand report a decode failure askind: ffmpeginstead of an empty result or a traceback; the transcribe temp directory is removed afterwards.render.pyandbatch.pyforward--timeoutand--overwriteto every stage, not only--fast/--dry-run.- fix: failed check/render/batch/verify report status failed; analysis runs get –timeout; render/batch forward the shared flags (#174)
1.4.3
Automated release: version and notes generated from pull requests merged since 1.4.2.
- A failed ffmpeg run no longer costs the caller an output file that existed before the run. Such a file is now written through a hidden sibling temp file and replaced only on success; on failure the original is untouched and the temp removed. Previously the partial-output cleanup deleted it (any FFmpeg), and on FFmpeg 5.x ffmpeg itself truncated it to 0 bytes before a filter error, with or without
--overwrite. --timeoutis enforced under--progress: the deadline is checked on a clock, so a deadlocked ffmpeg that prints no progress lines is killed and reported askind: timeoutinstead of being waited on forever.- fix: a failed run never costs the caller an existing output; –timeout is enforced under –progress (#173)
- ci(release): bump on the tip of main so a merge during the run cannot reject the push (#172)
1.4.2
Automated release: version and notes generated from pull requests merged since 1.4.1.
color.py --correctno longer desaturates bt709-tagged sources: the RGB stages are wrapped in explicit, matching YUV<->RGB conversions instead of libavfilter’s auto-inserted pair, which used bt709 one way and bt601 the other (#159).- docs(pitfalls): a literal skip-ci marker in a PR body silences the squash merge (#171)
- fix(color): –correct no longer desaturates bt709-tagged sources (#167)
1.4.1
Automated release: version and notes generated from pull requests merged since 1.4.0.
- Every tool takes
--timeout SECONDS(default 1800,FFMPEG_SKILL_TIMEOUT): a single ffmpeg run past the limit is killed, its partial output removed, and the failure reported askind: timeout(exit 124) instead of hanging the caller. - Every tool takes
--overwrite. An output path that already exists (and was not written by this run) now prints a warning;FFMPEG_SKILL_NO_OVERWRITE=1makes it a refusal today, and 2.0 will refuse by default. audio.py --music(with or without--duck/--music-loop) no longer shortens the video: the mixed track is padded/trimmed to the source duration and a video-keeping output never uses-shortest(#164).- fix(audio): a music bed never shortens the video; pad the mix to the source duration (#165)
- ci(release): push the bump commit with RELEASE_PUSH_TOKEN so the main ruleset lets it through (#170)
- fix(release): fold hand-written Unreleased notes into the bump instead of asserting the placeholder (#166)
- fix: –timeout kills a hung ffmpeg and reports it; –overwrite guards existing outputs (#163)
- evals: iteration 5 against 1.4.0 (24-set, exec set, trigger set) and README row (#162)
- docs: badges for CodeQL, downloads, stars, last commit and the tested FFmpeg/Python versions (#160)
1.4.0
Automated release: version and notes generated from pull requests merged since 1.3.1.
- Add broll.py: cut away to a B-roll clip for a window and come back, A’s timeline untouched (#158)
1.3.1
Automated release: version and notes generated from pull requests merged since 1.3.0.
- fix: quote SKILL.md’s description so the frontmatter is valid strict YAML (#161)
1.3.0
Automated release: version and notes generated from pull requests merged since 1.2.0.
- Add metadata.py: container chapter markers and title/artist/comment tags, streams copied (#157)
1.2.0
Automated release: version and notes generated from pull requests merged since 1.1.1.
- Add –pad-fill blur to fit.py and export.py: blurred frame behind the letterbox bars (#155)
1.1.1
Automated release: version and notes generated from pull requests merged since 1.1.0.
- Fix three FFmpeg 5.x incompatibilities and run CI on FFmpeg 5.1.1 and 7.1 (#156)
- ci(release): tag the bump commit, not the commit that triggered the run (#154)
- chore(release): bump version to 1.1.0
1.1.0
Automated release: version and notes generated from pull requests merged since 1.0.4.
- Add a Claude Code plugin manifest so the repo installs with
claude plugin install(#153) - chore(tests): cover verify.py’s full plan and the MCP server’s error paths (#152)
- ci: run the test suite on Python 3.13 too (Ubuntu only) (#151)
- build(deps): bump release-drafter/release-drafter from 6 to 7 (#132)
- docs: state the 1.x stability guarantee and deprecation policy, pin the tool surface, verify each npm publish (#149)
- ci(release): resolve the next version ourselves; a chore-only merge really releases nothing (#150)
1.0.4
Automated release: version and notes generated from pull requests merged since 1.0.3.
- chore(release): never auto-bump the major, and stop releasing chore-only merges (#145)
1.0.3
Automated release: version and notes generated from pull requests merged since 1.0.2.
- Fix the Codex install path, a stale SKILL.md tool count, and two silent CI holes (#136)
1.0.2
Automated release: version and notes generated from pull requests merged since 1.0.1.
- build(deps): bump dependabot/fetch-metadata from 2 to 3 (#135)
1.0.1
Automated release: version and notes generated from pull requests merged since 1.0.0.
- build(deps): bump actions/setup-node from 4 to 7 (#134)
1.0.0
Accidental major. 1.0.0, 1.0.1 and 1.0.2 contain no user-facing or compatibility change over 0.16.15 – they are three routine CI dependency bumps that a release-automation bug labelled major (see references/process-pitfalls.md, fixed in #145). They are left published because npm never lets a version number be reused; treat 1.0.x as 0.16.x under a different name.
Automated release: version and notes generated from pull requests merged since 0.16.15.
- build(deps): bump actions/upload-artifact from 4 to 7 (#133)
0.16.15
Automated release: version and notes generated from pull requests merged since 0.16.14.
- build(deps): bump actions/setup-python from 5 to 7 (#131)
0.16.14
Automated release: version and notes generated from pull requests merged since 0.16.13.
- Add Dependabot, CodeQL, PR-labeling, CI concurrency, and more (#130)
0.16.13
Automated release: version and notes generated from pull requests merged since 0.16.12.
- Auto-bump the release version too, not just tag/release/npm publish (#129)
0.16.12 — verify the fully-automated release pipeline end to end
No functional code changes. .github/workflows/release.yml was rewritten to
auto-create the git tag, GitHub Release, and npm publish on every push to
main that bumps package.json’s version, but the first real run of it
(for 0.16.11) never got an npm publish to succeed: the NPM_TOKEN secret
wasn’t valid yet, and once the tag existed the workflow’s own guard
prevented a clean re-attempt under the same version. This bump exists
solely to give the pipeline a fresh, untagged version to run against so
the whole chain — tag, GitHub Release, and npm publish — can be verified
working in one pass.
0.16.11 — fix a contract/reality mismatch on AAC, three batch.py/render.py reliability bugs, and a non-atomic installer
Continuing the same external audit report’s architecture/data-integrity/reliability findings:
_contract.pydeclaredencoder:aacunconditionallyrequiredforaudio.py,loudness.pyandjoin.py, but all three pick their audio codec from the output extension viaaudio_codec_for()(falling back to AAC only when the extension isn’t otherwise covered) – sodoctorreported these tools entirely unusable on an ffmpeg build without an AAC encoder, even though they can still produce e.g. a.flacoutput with no AAC involved at all. Moved tooptionalwith awhen, matching the patterncut.py/silence.pyalready used;join.py’slibx264requirement was similarly conditioned on joining video inputs.export.py’s AACwhenalso named “any preset except gif”, thoughprores(pcm_s16le) andcopy(stream copy) don’t use AAC either – corrected.batch.py’s--recipeproject mode ({"project": "p.json", "clip_key": N}) computed its cache key by hashing only that small outer dict, never the referenced project file’s own content – so editingp.json(an export preset swapped fromcopyto a real re-encode, captions text, anything) without touching the recipe file itself left the cache key unchanged, and the stale cached output was silently served for the new settings. Fixed by folding the referenced file’s content into the key.batch.py’s cache file was written with a plainwrite_text(), not atomic – a process killed mid-write left a truncated file that the next run’sjson.loads()treats as corrupt and silently discards, losing every prior cache entry, not just the interrupted one. Fixed via a sibling temp file +os.replace().render.py’s default work directory name came only from the output path (e.g.final_work), with no PID or timestamp – two concurrentrender.pyruns targeting the same output (abatch.py“project” recipe processing several files in parallel, or simply two runs by mistake) shared the same work directory and clobbered each other’s same-named intermediates mid-run. Fixed by suffixing the auto-derived default with this process’s PID (an explicit--workis left as given, since the caller asked for that exact path).bin/install.jsdeleted an existing install (rmSync) before copying the new one in – normal install, not just--uninstall. A process killed partway through the copy (Ctrl-C, disk full, a permission error) left the target either empty or half-populated, destroying a working previous install for nothing worse than an interrupted upgrade. Confirmed live: a simulated crash mid-copy left the target directory completely empty, an existing marker file gone. Fixed by copying into a scratch directory next to the real target first, then swapping it into place with a single rename.
0.16.10 — fix batch.py arbitrary script execution, and four fit/background/caption/freeze correctness bugs
- Security:
batch.py’s recipestepsnamed the script to run for each step as a plain, untrusted string frombatch.json(run_step()builtHERE / argv[0]).pathlib’s/operator silently ignores the left side when the right side is itself an absolute path, and does nothing to stop a../traversal either – so abatch.jsonthe caller didn’t author themselves (a template, a shared config, anything from outside) could name any Python file on disk (absolute path or../traversal) and have it executed with the caller’s own privileges, once per matching media file. Confirmed with a live repro: a recipe step of["/tmp/evil.py"]executed and wrote a file outside the project. Fixed by validating each step’s script name against the real, non-underscore-prefixed scripts inscripts/before running it. fit.pywith only--aspectgiven (no--width/--height) bounded a narrower/taller target by the source’s width instead of its height – a 1280x720 source asked for--aspect 9:16came out 1280x2276, a ~3.16x unrequested upscale, in both--fit padand--fit crop. Fixed by bounding by whichever of the source’s dimensions the new aspect actually needs, so the output never exceeds the source’s own resolution.background.py --gradientused ffmpeg’sgradientssource filter without pinning itsspeedoption, which defaults to0.01– a slow rotation applied every frame. A “static” background (per this tool’s own purpose: a title card, a placeholder behind a logo) silently drifted frame to frame instead of staying put, breaking thebit_exact/deterministiccontract_contract.pydeclares for this tool. Confirmed live: the same pixel read a different value one second into a three-second clip. Fixed by pinningspeednear the filter’s own enforced floor (1e-05;0itself is refused) andseedto a fixed value.caption.py’s cue parser could match a line’s-->/text structure while one of its two timestamps still failed to parse (e.g. a malformed00:00:03.15.999); the fallback then used the entire raw line, broken timestamp included, as the caption text instead of the text already captured after the arrow. Fixed to fall back to just the parsed text.freeze.py’s mid-clip freeze rounds the video hold to a whole number of frames (n = round(hold * fps)) but padded the audio by the raw, unrounded--holdvalue – up to half a frame off from what the video actually holds for, a permanent A/V drift from that point on. Fixed by padding audio by the same frame-rounded duration the video gets.cut.pypassed--start/--endstraight toparse_time()with no sign check, unlikefreeze.py/background.py, which already refuse negative durations – a negative value reached ffmpeg’s-ssas-5.000000instead of being refused with a clear error naming the flag. Fixed by refusing negative--start/--endup front.
0.16.9 — fix the MCP server crashing on a non-object JSON-RPC line
mcp/server.py’s stdio loop parsed each line with json.loads(), which
accepts any valid JSON value, not just an object – a bare 42, null,
true or [1,2,3] line parses without error. The very next check,
"id" not in req, then raised an uncaught TypeError for a non-dict
req (an int/bool/None isn’t iterable the way in needs). That check
sat outside the try/except wrapping handle(), so the exception
propagated out of the stdin loop and killed the entire stdio server
process – not just that one malformed line, but every other in-flight
and future tool call in the session along with it. Fixed by skipping any
parsed JSON value that isn’t a dict before the "id" not in req check.
0.16.7 — fix a silently-dropped render.py fit height and a multicam.py drift-trim ordering bug
render.py’s single-clip fit path only ever inheritedwidth/fpsfromproject.frame(neverheight), and the flag-forwarding list that turnsproject.fit’s own keys intofit.pyargv had no entry forheightat all. Aproject.jsonspecifying"fit": {"height": N}alone built an emptyfit.pyargv and crashed with “nothing to do”; combined with anotherfitkey (e.g.duration),heightsilently never reachedfit.pyand the output’s height was left unchanged with no error. Fixed by adding the missingframe.get("height")inheritance and the("height", "--height")forwarding entry, matching the multi-clipjoinpath, which already handledheightcorrectly.multicam.py --fix-driftcomputed its audio trim start (a_start) in the source’s own pre-correction time axis, but applied it viaatrim= start=after theasetrate/aresampledrift-correction filters had already rescaled that axis in the same filter chain – so the trim landed on the wrong point once the timeline had been stretched or compressed by the drift ratio.sync.pyalready avoids this by seeking with-ss(an input-level operation) before its own drift filters;multicam.pynow appliesatrim=start=/asetptsbeforeasetrate/aresamplein the filter chain to match.
0.16.5 — fix silence.py breaking on audio-only WAV, unvalidated –fps, and a LUT-strength inversion in color.py
A deep line-by-line pass over the most-used editing tools:
silence.pyunconditionally appendedaac_args()(-c:a aac) to its final ffmpeg command regardless of the output container. AAC cannot be muxed into a.wavfile, so silence removal crashed outright on any audio-only WAV input or-o out.wavtarget – a very ordinary case (podcasts, voice memos) the existing test suite happened to only cover with.m4a(where AAC is always valid, masking the bug). Fixed by picking the codec from the output extension viaaudio_codec_for(), the same helper every sibling script already uses, and by adding-vnwhen the output is audio-only but the input has video.--fpsflowed straight intocfr_args()/ afps or source_fps or 30.0fallback incrop.py,denoise.py,redact.py,sphere.py,straighten.py,join.pyandmulticam.pywithout ever being validated.0is falsy in Python, so--fps 0was silently discarded and fell back to the source’s own fps instead of erroring; a negative value passed straight through to ffmpeg’s-r/fps=filter option, which rejects it with an unhelpful crash instead of a clear message naming--fps. Fixed by refusing--fps <= 0up front in all seven scripts, matching the guardfit.py/proxy.py/background.py/grid.py/insert.py/sequence.pyalready had.color.py --lut-strength(documented “blend LUT result with the original, 0..1”) only branched into its blend logic for the open interval(0, 1)– so--lut-strength 0, meant to mean “no LUT at all”, instead fell into the “apply at full strength” fallback and silently graded the picture at 100%, the opposite of what was asked. Any out-of-range value (2.5,-1) did the same instead of being refused. Fixed by validating the range up front and handling0explicitly as “leave the picture untouched.”verify.pybuilt every file’s output prefix from only its basename (stem = outdir / f.stem), so two files with the same name from different folders – ordinary for real footage pulled from multiple cameras/SD cards – resolved to the identical output prefix; with--keep, the file processed second silently overwrote the first one’s finished output, with the report still showing PASS for both and no collision ever flagged. Same bug class as 0.16.4’sbatch.pyfix. Fixed by disambiguating every colliding stem with a stable per- collision index before any file is processed.
0.16.4 — fix a silent false-PASS in check.py, an infinite loop in multicam.py, and a silent output collision in batch.py
Three unrelated bugs found in a wider audit past caption.py:
check.py:measure_loudness()returns{}when ffmpeg’sloudnormJSON doesn’t parse out of stderr (unexpected/garbled output).main()only appended theloudness/true peakrows when that measurement succeeded, so a failed measurement made those rows vanish entirely – not FAIL, not WARN, just absent – whilecheck.pystill reported an overall PASS for a platform with a loudness requirement it never actually verified. Fixed by reportingWARN(could not measure) instead of dropping the rows: a silent false PASS is worse than visible noise on a compliance tool.multicam.py:--autoalternates cameras withwhile t < ref_dur: ... t += args.auto, gated byelif args.auto:– which is only false for exactly0, so a negative value passed the check and entered the loop withtdecreasing every iteration, hanging forever instead of erroring on invalid input. Fixed by refusing--auto <= 0up front.batch.py: a recipe’s default output extension falls back to each source’s own extension, so files that only differ by extension don’t collide – but a recipe with a fixed"ext"(e.g. converting a folder of mixed.mp4/.movmasters to one format) makes two sources with the same stem (clip.mp4andclip.mov) resolve to the identical final path (clip_out.mp4).process()had no collision detection, so the file processed later in sorted order silently overwrote the earlier one’s finished output, with the cache still recording both entries as"ok": true. Fixed with a pre-flight collision check across the whole batch, refusing before any file is processed rather than after data is already lost.
0.16.3 — fix two more caption.py delimiter bugs: ASS override injection, SRT blank-line split
Following on from 0.16.2’s --font fix, a closer look at caption.py found
two more places where user-controlled cue text (from --text, an SRT
file, or ASR transcription) flows raw into a delimited text format:
- ASS
Dialogue:text treats a literal{...}as an override block – real style/animation commands (\pos,\fscx,\t, …), not literal characters. Cue text containing braces was interpreted as those commands instead of read out, letting a caption reposition, rescale, or recolor itself or later text. Fixed by dropping{/}from cue text before writing the ASSDialogue:line (also closes the same gap in the karaoke word-by-word path, which built its{\kf..}tags from the same unsanitised text). write_srt()wrote cue text raw.parse_text_cues()turns a bare|into a newline (the documented two-line-caption syntax), so two adjacent pipes (a||b) produced cue text containing a blank line – and a blank line is SRT’s own block separator. Writing it raw split one cue into two malformed half-blocks, silently dropping the text after the fake boundary when re-parsed. Fixed by collapsing any run of blank lines within a cue’s text to a single newline before writing.
0.16.2 — fix caption.py’s –font not sanitised for ASS Style/force_style
An attack-surface audit of every call site that embeds user-controlled text
into a filter/subtitle construct found that caption.py was the one script
that never routed --font through a sanitiser before using it, unlike
overlay.py/graphics.py/grid.py/look.py, which all call
escape_drawtext() first. caption.py doesn’t build a drawtext= filter,
though – it embeds --font into two different ASS constructs
escape_drawtext() was never designed for: the comma-delimited
[V4+ Styles] Style: line, and the comma-separated Key=Value list
inside a -vf subtitles=...:force_style='...' option. A font name
containing a comma shifted every field after it in the Style: line
(size, colours, bold flag, alignment, margins); a comma or colon inside
force_style’s FontName= broke the option-list/-vf parsing the same
way.
Fixed with a new ass_font_name() helper in caption.py that drops
, : \ ' and control characters from the font name outright – the same
“no real font name needs this character, so don’t chase a per-context
escape” call this codebase already made for escape_drawtext()’s '/%.
0.16.1 — fix two more escape_drawtext() gaps, and grid.py –pad’s audio
Adversarial testing (deliberately hostile filenames – very long, Unicode,
shell metacharacters, quotes, semicolons, % specifiers, literal filter-
graph syntax) found two more gaps in escape_drawtext(), the shared helper
every drawtext=text=... call site uses (overlay.py --text, the
--font/brand-font fallback, and 0.16.0’s grid.py labels):
- An unescaped
;split a filterchain exactly like an unescaped,does – minimal repro:overlay.py clip.mp4 --text "a'b;c"crashed real ffmpeg withNo such filter: 'c...'. Fixed by adding;to the backslash-escape set. - The quote character itself had no backslash escape that survives every
call shape this codebase uses it in: both the existing
\'-style escape and a POSIX-shell'\''close-insert-reopen escape parse fine in a simple-vfchain, but corrupt a-filter_complexchain with explicit[label]pads (grid.py’s shape) – confirmed by rendering the result: the text value doesn’t end where the quote closes it, and trailing option text (fontfile=...,fontsize=...) leaks into the picture as literal burnt-in text. Fixed by dropping the quote character outright instead of escaping it – losing one apostrophe from a label is a fair trade for the filter graph parsing correctly everywhere. %had the same problem the quote character did: the existing\%escape is not a real escape as far as drawtext’s own text-expansion scanner (on by default, for%{pts}/%{localtime}/etc., a separate pass from the graph-level backslash escaping) is concerned – a bare backslash-escaped%always logs “Stray % near …”, which is merely noisy on one ffmpeg build but a hard filtering failure that writes no output at all on another. No caller ever wants%{...}expansion, so%(and control characters, same underlying cause) are dropped outright instead of chasing a per-build-safe escape.
Also fixed a real bug CodeRabbit’s review of 0.16.0 caught before it was
acted on: grid.py --pad held each shorter cell’s video on its last frame
out to the longest clip, but a --audio-from track shorter than that was
mapped straight through with no padding at all – the release note’s “with
silence” claim wasn’t true. Now --pad pads the selected audio track with
apad/atrim to match, and the docstring/help text describe what --pad
actually does (holds the last frame; does not add black video).
0.16.0 — add grid.py
New tool: composite --colsx--rows clips into one grid (e.g. a 4x2 wall
of takes or angles), each cell letterboxed (not stretched) to a common
--cell-width/--cell-height so mismatched aspect ratios and resolutions
line up cleanly. --label auto (default) burns each clip’s own filename,
extension stripped, into its cell’s bottom-right corner; --label none
turns that off. No audio unless --audio-from picks one input’s track –
mixing every clip’s audio together is rarely what a comparison grid needs,
so this tool never does it silently. Runs only as long as the shortest
clip by default; --pad instead holds each shorter clip’s last frame (with
silence) out to the longest.
The per-cell label is filename-derived text reaching a drawtext=text=...
option, the same injection class fixed in 0.15.3 – wrapped with the
existing escape_drawtext() helper from the start, with a regression test
that builds a clip literally named to look like a filter-graph breakout
payload and confirms it renders as inert literal text (not a new filter).
0.15.3 — fix a real filter-graph injection via –font fallback
Found by the same adversarial pass that produced 0.15.2, this time auditing
file-path/text escaping instead of colour flags. overlay.py --font and
graphics.py’s brand-font fallback both accept a fontconfig family NAME (not
a file path) and try to resolve it to a concrete file via default_font_file()
(fc-match) first – but that resolution returns None whenever fc-match
isn’t on PATH (true on some real systems, and always true on Windows, per
default_font_file()’s own docstring). When it returns None, both tools
fell back to font='{args.font}' with zero escaping, unlike the adjacent
text='{escape_drawtext(args.text)}' one line above it in overlay.py.
Since drawtext options are comma/colon-delimited, a font value like
X',drawtext=text=OWNED doesn’t just fail to resolve a font – the comma
ends the option (and the whole filter) early and starts an entirely new
drawtext filter, which actually rendered. Confirmed this is a real, working
injection (not theoretical): forced the fc-match-missing fallback path,
ran overlay.py --font "X',drawtext=text=OWNED:fontcolor=yellow...", and the
injected “OWNED” text was actually burnt into the output picture.
Fixed by wrapping both fallback values with the existing escape_drawtext()
helper, matching the already-safe text= pattern next to it:
overlay.py’sfont='{args.font}'fallback (line ~250)graphics.py’sfont_opts()fallback (font='{font or brand.get(...)}')
0.15.2 — fix a real filter-graph injection via colour flags
Found by adversarial testing: every flag that string-formats a colour straight
into an ffmpeg filter graph accepted the value verbatim, with no validation.
Since ffmpeg filter options are comma/colon-delimited, a value like
black,drawtext=text=INJECTED doesn’t just set an odd colour – the comma
ends the colour filter early and starts an entirely new one. Confirmed this is
a real, working injection, not a theoretical one: rendered a frame with
pad.py --color 'black,drawtext=text=INJECTED:fontcolor=white' and the
injected text was actually burnt into the output picture.
Added validate_color() to _common.py (refuses anything that isn’t a named
colour, 0xRRGGBB[AA], or #RRGGBB[AA], optionally with an @alpha suffix)
and applied it to every colour-like flag that reaches a filter graph
unescaped:
pad.py --color,straighten.py --fill-color,waveform.py --backgroundand--color(new tools, this release cycle)background.py --color/--gradient,fit.py --pad-color,export.py --pad-color,join.py --pad-color,overlay.py --chromakey/--font-color/--border-color/--box-color(pre-existing tools – this gap predates the recent tool additions)
caption.py’s colour flags were already safe (routed through the existing
color_hex()/ass_color() strict RRGGBB validators) and needed no change.
This is the same “no filter graph accepted from the caller” invariant every other typed flag in this codebase already holds to – colour flags were the one place a free-form string still reached a filter graph unescaped. New regression test proves the exploit across all 9 fixed call sites and that real colour values still work; full suite (183 tests) and the contract suite (67 tests) both pass.
0.15.1 — bug-check pass on the 39-tool set
Found and fixed while auditing the tools added across 0.13.0-0.15.0:
freeze.py:--at 0(freeze on the very first frame – the default when no--mode/--atis given at all combines with a source that starts right where you’d freeze it) crashed ffmpeg: the general insert-mode filter graph trims an empty “head” segment whenat == 0, and filtering an empty stream fails. Added a dedicatedat == 0branch that pads the front of the clip instead (tpadstart_mode=clone), symmetric to--mode extend’s handling of the clip’s end.waveform.py:--audio-streamwas validated but never actually wired into the filter graph, which unconditionally read[0:a]– every multi-track input visualized track 0 regardless of which track was requested, while the output’s audio correctly followed--audio-stream. Now the filter reads[0:a:{audio_stream}].loop.py: re-encoded with a hardcodedlibx264/nocfr_args, unlike every other re-encoding tool – an HDR source silently became SDR mislabelled BT.709, and VFR sources weren’t conformed. Switched tovideo_args(meta, ...)andcfr_args(meta), and added the--crf/--presetflags every other tool exposes (previously--fastsilently had no effect)._contract.py:cropdetect.pyalways runs a realcropdetectmeasurement regardless of--dry-run(likescenes.py/sync.py/multicam.py/report.py), but wasn’t declared inDRY_RUN_ANALYSIS– the machine-readable contract falsely claimed--dry-runran no ffmpeg for it. Registered alongside the other analysis-only tools.
Found via a fresh worktree off main post-merge, adversarial testing of
edge cases (zero/boundary values, multi-track inputs) rather than only the
happy paths the original PRs’ tests covered. New regression tests for all
four; full suite (181 tests) and the contract suite (67 tests) both pass.
0.15.0 — 5 more tools: straighten, freeze, pad, speedramp, loop
Five more mechanical, typed-flag FFmpeg capabilities:
straighten.py— rotates by an arbitrary angle for horizon correction (rotatefilter),--fit crop(scale to fill, no visible gap) or--fit pad(keep the full picture, fill the corners). Distinct fromfit.py --rotate’s exact 90-degree turns.freeze.py— holds a frame for N seconds (tpad/concat),--mode insert(pushes the rest of the clip later) or--mode extend(only at the clip’s end, no push).pad.py— adds black/silent padding at the start and/or end of the timeline (tpad/apad). Distinct fromfit.py --fit pad’s per-frame letterbox bars.speedramp.py— steps through different constant speeds across a clip via repeatable--segment START-END:FACTORpieces (setpts/atempo per segment, concatenated). Distinct fromfit.py’s single whole-clip speed factor.loop.py— repeats a clip--timesN or to a target--duration(-stream_loop).
Same conventions as every other tool here: every numeric flag range-checked before ffmpeg runs, no subject detection or judgement (straighten doesn’t measure the tilt, loop doesn’t smooth the seam, freeze doesn’t pick where to hold – the calling agent supplies all of that).
Registered in _contract.py’s TOOL_META/REENCODE_META (39 tools total,
up from 34); 19 new regression tests in tests/test_all.py, including a
pixel-level check that straighten.py --fit crop leaves no black corner.
0.14.0 — 5 new tools: cropdetect, deinterlace, denoise, redact, waveform
Five mechanical, typed-flag FFmpeg capabilities that had no wrapper yet:
cropdetect.py— measures existing black letterbox/pillarbox bars (FFmpeg’scropdetectfilter) and reports thecrop.py-ready rectangle. Analysis only, writes no file. Distinct fromfit.py --fit crop, which crops to a target aspect ratio it computes itself with no black-bar measurement involved.deinterlace.py— deinterlaces old interlaced source footage (yadif),--mode frame(keeps fps) or--mode field(doubles fps),--parity.denoise.py— video noise/grain reduction (hqdn3d),--strength low/ medium/highor individual spatial/temporal luma/chroma overrides. Distinct fromaudio.py --denoise, which only touches audio.redact.py— blurs or pixelates an exact caller-given pixel rectangle for the whole clip (privacy/compliance redaction: faces, plates). Same rectangle convention ascrop.py; does not locate anything itself.waveform.py— renders an audio track as a waveform or spectrum visualization video (showwaves/showspectrum), for audio-only inputs with no picture worth showing.
All five follow this skill’s typed-flags-only convention: every numeric flag is range-checked against FFmpeg’s own real documented AVOptions before ffmpeg runs, and none introduces any subject detection or judgement – cropdetect measures existing bars, redact blurs the exact rectangle it’s given, neither decides what belongs in frame.
Registered in _contract.py’s TOOL_META/REENCODE_META (34 tools total,
up from 29); 22 new regression tests in tests/test_all.py cover the
functional path and validated ranges for each tool.
0.13.0 — add sphere.py: flat-viewport extraction from 360/spherical video
New tool wrapping FFmpeg’s v360 filter for the most common 360-video job: pointing a fixed,
typed camera (--yaw/--pitch/--roll, --h-fov/--v-fov) at an equirectangular (or
fisheye/cubemap/etc., via --input-projection) source and baking out an ordinary flat video.
Consistent with this skill’s design boundary: it aims and extracts a viewport, it does not detect
or track a subject — that decision stays with the calling agent. Registered in TOOL_META/
REENCODE_META, 29 tools total.
0.12.6 — stop shipping __pycache__ in the npm tarball
package.json’s files array scopes the tarball to bin/, scripts/, mcp/, references/,
SKILL.md, README.md and LICENSE, but a files-scoped pack does not automatically respect
.gitignore the way a plain git-tracked-files pack would — the 0.12.5 tarball on npm shipped
every scripts/__pycache__/*.pyc a local interpreter had produced (interpreter-version-specific,
harmless at runtime since Python regenerates them, but ~45% of the package’s unpacked size for
nothing). Added an explicit .npmignore for __pycache__/, *.pyc, *.pyo, which npm honors
even when files is set. Unpacked size drops from 913.7 kB / 64 files to 496.8 kB / 40 files.
0.12.5 — fix Windows drawtext crash (#100), and 4 smaller review findings
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 emitfontfile=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-filterslisting alone. A confirmed crash (killed by signal on POSIX, an access-violation-style exit on Windows) downgradesfilter:drawtextfrom “listed” tomissing, with the crash detail inerrors[]; 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, notpython3(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.
0.12.4 — stabilize.py gains --tripod and --crop (#96)
A follow-on audit of the same class of gap 0.12.3 closed in color.py --correct: scripts that
wrap a real FFmpeg filter but only expose a subset of what that filter actually supports.
stabilize.py wrapped vidstabdetect/vidstabtransform with only --shakiness/--smoothing/
--zoom, leaving two genuinely useful, real options unreachable:
--tripod: virtual tripod mode (vidstabdetect’stripod=1,vidstabtransform’s owntripod=1, equivalent torelative=0:smoothing=0) locks every frame to one fixed reference frame instead of following the camera’s intended motion — for a shot meant to be static but nudged, or one you want dead-locked rather than merely smoothed.--crop {keep,black}: what happens to whatever edge--zoomdoesn’t crop away.vidstabtransform’scrop=0(the previously hardcoded default, “keep”) stretches border pixels;crop=1(“black”) was unreachable — the only prior workaround was cropping in further with--zoom, at the cost of framing/resolution.
Both are typed flags (--crop restricted to the filter’s own two real option names via
choices), verified against ffmpeg -h filter=vidstabdetect/vidstabtransform’s real AVOptions
before implementation, with new regression tests that actually run both flags end-to-end and
check the output’s duration/resolution. (silence.py, audio.py, loudness.py, and fit.py’s
minterpolate usage were also checked against their real filters’ full option sets and found
either not applicable — silence.py doesn’t wrap silenceremove at all, it does its own
silencedetect + range-trim — or already adequately covered.)
0.12.3 — color.py --correct gains gamma, lift/gain, levels and curves
Closes a long-standing, documented gap: the downstream color-grading-skill has carried
GAMMA/LIFT/GAIN/LEVELS/CURVES in its own UNSUPPORTED_OPERATIONS because
“ffmpeg-skill exposes no typed X filter in its public contract”. --correct gains five more
typed flags, all folded into the same fixed filter chain the existing --exposure/--contrast/
--saturation/--temperature/--tint already build — no new mode, no filter string ever
accepted from the caller:
--gamma(0.1..10, default 1=unchanged):eq’s owngammaoption, added to the sameeq=contrast=...:saturation=...term contrast/saturation already use, not a secondeqcall.--lift/--gain(-1..1 each, default 0=unchanged): classic three-way colour correction, extending the samecolorbalancecall--tintalready used for midtones —--liftsets the shadow channels (rs=gs=bs),--gainthe highlight channels (rh=gh=bh), the same all-three-channels-together convention--tintuses forrm/gm/bm. Confirmed againstffmpeg -h filter=colorbalance:rs/gs/bs,rm/gm/bm,rh/gh/bh, each documented -1..1.--levels-in-black/--levels-in-white/--levels-out-black/--levels-out-white(0..255 each, defaults 0/255/0/255=unchanged; rejectsin_black >= in_whiteorout_black >= out_whitebefore ffmpeg runs):colorlevels, whose real parameters (ffmpeg -h filter=colorlevels) take fractional 0.0..1.0 input/output black/white points, not 0..255 — this tool exposes the familiar 8-bit unit and divides by 255.0 when building the filter, the same “human unit in, filter’s native unit out” convention--temperaturealready uses. Thecolorlevels=term is only added to the chain when at least one of the four flags is given; an all-default--correctcall adds nocolorlevelsterm, matching how every stage in this chain is either always present at its own no-op default or (for this new pair) omitted entirely when unused.--curves PRESET(argparsechoices, default: none, nocurves=term added): thecurvesfilter’s own built-in presets, read fromffmpeg -h filter=curvesrather than assumed:color_negative,cross_process,darker,increase_contrast,lighter,linear_contrast,medium_contrast,negative,strong_contrast,vintage(the filter’s own 11th choice,none, is omitted from--curves’s choices since leaving the flag unset already gets that identity result without adding a filter term for it).- Contract:
color’s optional capabilities gainfilter:colorlevels(when: "--correct with any --levels-*") andfilter:curves(when: "--correct --curves"). No change to any existing flag, contract field, MCP schema or tool semantics. - Tests: 5 real-media tests in
tests/test_all.pymirroring the existing--correctstyle — gamma/lift/gain run and measurably change signalstats luma, levels narrows the measured dynamic range, curves runs and preserves geometry/duration, and out-of-range or inverted values for every new flag (--gamma,--lift,--gain,--levels-in-black/--levels-in-whiteinverted,--levels-out-black/--levels-out-whiteinverted, an invalid--curveschoice) are each refused before ffmpeg ever runs, with no partial output.
0.12.2 — caption.py --mode mux no longer drops the input’s existing subtitle track(s)
Found while discussing a real use case (adding both an English and a Japanese soft subtitle
track to a foreign video) and reproduced directly: chaining --mode mux once per language —
the natural way to build a multi-language subtitle set — silently dropped every earlier
language but the last, because the mode’s -map list never included the main input’s own
existing subtitle stream(s), only the freshly-added one. Same class of bug fixed across
fit.py/color.py/graphics.py/overlay.py in 0.12.1/#91 (deliberately scoped out of that
pass since burn mode raises a real design question mux mode doesn’t have: mux mode explicitly
promises “copies video/audio untouched, adds the SRT as a soft, toggleable subtitle stream”, so
keeping what was already there has one obvious answer). Existing tracks are now mapped and
stream-copied (-c:s:i copy per existing index) ahead of the new one, whose own -c:s/
-metadata:s:s:N now target its real index instead of always 0. Closes
#93.
0.12.1 — Stream-preservation audit: subtitle/data streams no longer silently dropped by picture-only edits
Prompted by an external review pushing back that “feature-complete” for this project now means
proving reliability, not adding tools. Audited all 28 tools against a real fixture carrying
video + audio + subtitle + chapters, by actually running each tool and ffprobe-ing its output
rather than reading the code and guessing (an ad hoc pass, not itself checked in as a test). The
checked-in regression test below covers the four tools this pass actually changed, against a
narrower existing fixture (video + audio + two subtitle tracks, no chapters).
fit.py,color.py(--correct/--lut/--to-sdr),graphics.py,overlay.py: kept the source’s subtitle/data streams instead of silently dropping them. Each of these builds an explicit-maplist naming only the video and audio streams it re-encodes; a source with an embedded subtitle track (or a data stream) lost it with no signal to the caller, even though the operation never touched it.chaptersalready survived regardless (-map_chaptersdefaults independently of-map) — this was specifically about subtitle/data. Each of the four now tries-map 0:s? -map 0:d? -c:s copy -c:d copyalongside its existing maps first (a no-op via?when the source has none), falling back to the original video+audio-only command only if that combined attempt fails (e.g. a subtitle codec that can’t be stream-copied into a changed output container) — the same fallback shapecolor.py --retagalready used for this in 0.12.0.--jsongainsdropped_non_av_streams(trueonly when that fallback was actually needed), matching the field name--retagalready introduced.- Deliberately left as-is, tracked in #91:
caption.py’s burn mode (whether a pre-existing embedded subtitle should coexist with a newly burned-in one is a real design question, not a clear-cut preservation fix);audio.pywhen its output is a video container;join.py/multicam.py/sync.py, which combine multiple separate input files or replace the audio track outright — “which input’s subtitle survives” has no single correct answer the way a single-input picture/colour edit does, matching the existing “different problem shape” carve-out already used for the 0.12.0--audio-streamextension. Attachments (-map 0:t?, e.g. embedded ASS fonts) are also not covered yet — no tool here currently reads/writes ASS-with-fonts end to end, so the risk is theoretical for now. - Test:
test_picture_only_edits_keep_the_sources_subtitle_streamsruns all four fixed tools againstc_subbed.mkv(two real embedded SRT tracks, already used by other tests) and asserts BOTH subtitle tracks survive anddropped_non_av_streamsisfalse. - Two real regressions caught in code review before this shipped, both fixed and covered by a
new test:
overlay.py’s--image/--videobranches combined the pre-existing-shortestwith the new subtitle map – a subtitle ending before the main video could truncate the WHOLE output to the subtitle’s length (reproduced: 6s video, 1s subtitle -> 1.04s output).-shortestis now used only when the source’s duration is unknown; the existing-t <duration>(already there for FFmpeg 7+ precision) is used alone whenever it’s known, since it only bounds the main input. Separately,fit.py --method speedretimes video/audio (setpts/atempo) but a stream-copied subtitle keeps its original timestamps, so it would silently desync from the now-faster/slower picture;fit.pynow skips subtitle preservation specifically when changing speed and reportsdropped_non_av_streams: truehonestly instead.probe()gains an additivedata_streamscount (alongside the existingsubtitle_streams) so that determination also catches a data-only stream with no subtitle track.
0.12.0 — 2026-09-07 — Hardening pass: stream/input safety, contract-vs-implementation drift, SKILL.md/eval consistency
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 aresultfield — 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 gapcaption.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.1whilepackage.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-runruns 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 shipspython3) — 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.pysuccess 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 withZeroDivisionError. 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_terminatescloses the coverage gap so this defect class can’t silently regress.
0.11.0 — 2026-09-07 — Pixel crop, still-to-clip, rotate/flip/PiP/reverse/chromakey/stabilize/sequence/Ken Burns, fail-loudly output verification, capability map
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 globsupport, the concat demuxer’s end-of-list duration trick over-counting by a frame,#!/bin/shPATH 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 fromexport.py’s delivery presets, which all target near-visually-lossless platform delivery (CRF 18-24) rather than size/speed.proxy.pyresizes to--width(default 640) or by--scalefactor, re-encodes at a proxy-grade--crf(default 30) with the fastest x264/x265 preset, supports--fpsand--no-audio, and keeps the source’s own dynamic range (an HDR source proxies to HEVC10, same as every other re-encoding tool here — runcolor.py --to-sdrfirst if SDR is wanted). Pure mechanical resize+re-encode, same primitivesfit.py/export.pyalready 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_mapgainsmedia.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.providesre-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_mapis a new, small, hand-authored table:[{"capability": "<domain>.<verb>", "tool_id": "ffmpeg-skill/<tool>", "params": {...}}, ...], coveringvideo.trim,video.reframe(pinsfittofit=crop, sincefit.pyalso duration-fits and pads),audio.loudness,subtitle.burn,media.stream.inspect,media.frames.extractandmedia.proxy. It is purely descriptive — a caller still builds and runs the named tool’s own CLI/MCP call from itsinput_schema; this skill never picks a capability or executes on the caller’s behalf. Deliberately excludes anything that would require judgment to resolve (novideo.highlight, sincescenes.py --highlightsranks by a measured proxy, not understood content).docs/contract.mddocuments the table and why it stays short;tests/test_contract.pyverifies every entry resolves to a real tool and real params. Additive; no existing field changed.doctor: this installed copy’s ownversion.doctor --jsonand the human-readabledoctornow report the version of the copy answering, read locally from its ownpackage.json(same valuecontract --json’sskill.versionreports) — never fetched from the network, never compared against the latest published release. A copy installed withnpx ffmpeg-skillis 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 existingdoctorkey is unchanged.doctor:okvs. per-toolusableclarified. The human-readabledoctoroutput now adds one line whenokis true but at least one tool’susableisn’t"yes"(e.g. a plain Homebrewffmpegon macOS: overalloksince nothing required by every tool is missing, whilecaption.pyspecifically can’t run). README Quick start says the same. No field changed, no behaviour changed — a caller readingdoctorno longer has to already know to checktoolsseparately fromok.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 macOSbrew install ffmpeg-fullhint. No change on any other platform.doctor’s own introspection calls get a 10s timeout.ffmpeg -filters/-encoders/-bsfs/-versionare 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 reportfailedrather 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.-nostdinwas 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.pynow runs on Windows CI. Only two spots (test_dry_run_never_runs_ffmpeg_and_writes_nothingand the wholeDoctorDetectionTestsclass) actually depend on a POSIX#!/bin/shPATH 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 includingcut.py’s provenance fields — already ran against the realffmpegon whichever OS the test ran on, but CI skipped the entire file on Windows regardless. The two shim-dependent spots are nowskipIf‘d individually (visible asskippedin 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_copyredirects the installer’s target directory by overridingHOME, which Node’sos.homedir()ignores on Windows (it readsUSERPROFILE), 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/--endwithin--tolerancestays in fast stream-copy mode (mode: "copy"), but the underlying-ssseek still snaps to an earlier keyframe and pulls in extra content —output_durationandrequested_durationcan diverge by more than a rounding error whilekeyframe_snappedstaystrue. This was already reported incut.py --json(0.10.0); a new test intests/test_all.pypins a real, measured, non-trivial divergence so a regression that silently reportsduration_delta_seconds: 0.0in 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_codeandcommands(what was planned or run) next toerror.kind/error.message. ffmpeg failures raised by cut, loudness, silence and sync carrykind: "ffmpeg". fit.py --fps 0was 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.json, five success and five failure prompts graded for real execution (an ffprobe-readable output exists) and honest failure (noDone:and no output when the tool failed).
fit.py --rotate/--flip. New rotate 90/180/270 (clockwise; 90/270 swap width and height) and horizontal/vertical flip flags – distinct from the rotation metadata fit.py already reads to size a source correctly, which is never altered by these. Verified against a real red/left, blue/right test fixture:--flip hswaps the two halves and--rotate 90rotates the left column into the top row, both confirmed pixel-exact, not just by output dimensions.overlay.py --video: video-on-video picture-in-picture.overlay.pycould only composite a still image or text onto a video; there was no way to place a second video as a layer.--video CLIPcomposites it with the same--position/--scale/--opacity/--start/--endknobs--imagealready has (scale2ref-style scale +format=yuva420p+colorchannelmixerfor opacity +overlaywith a timelineenable). Only the main input’s audio is kept; the PiP layer’s own audio is dropped – mixing two audio tracks is a job foraudio.py. Verified with a real composite: a blue clip lands at the exact expected bottom-right pixel position, the rest of the frame is unaffected.overlay.py --chromakey: green-screen compositing. With--video,--chromakey COLOR(plus--chromakey-similarity/--chromakey-blend) keys that colour transparent before compositing, for green-screen foreground-over-background work. Verified: a green background behind a white square is correctly replaced by the destination clip’s colour, the white square is untouched.insert.py --zoom/--pan: Ken Burns effect. A slow linear zoom in/out (--zoom-amountsets the end/start factor, default 1.3) and, with--zoom, a pan across the image while zoomed, built from typed enums into a generatedzoompanexpression – never a raw expression from the caller. Verified against a real image with a centred marker at a known position: the exact screen pixel the marker’s edge should reach at the final zoom factor changes from background to marker colour between the first and last frame, and a panned clip differs (PSNR ~13) from the same zoom without pan at the same timestamp – proving the frame actually changes scale/position over the clip, not just that the command ran.background.py: generate a solid-colour or gradient clip. New tool, no input file: ffmpeg’s owncolor/gradientssource filters generate an exact-size, exact-duration clip directly, for a title-card background or a base layer foroverlay.pyto composite onto. Verified: a solid-colour clip’s pixel matches the requested colour; a gradient’s left and right edges are measurably different colours in the requested direction.reverse.py: reverse playback. New tool wrapping ffmpeg’sreverse/areversefilters (video always, audio unless--no-audio). These filters buffer the whole clip in memory, so this is for clips it makes sense to reverse (seconds to a couple of minutes) rather than something the tool limits for the caller. Verified against a real two-colour clip (first half red, second half blue): the reversed output starts with the original’s last half and ends with its first half, confirmed by sampled pixel colour, not just duration/dimensions.stabilize.py: motion stabilisation. New tool wrapping ffmpeg’s two-passvidstabdetect/vidstabtransform(--shakiness,--smoothing,--zoomto hide the black edges stabilizing can introduce). The transforms file passed between the two passes lives in atempfile.TemporaryDirectoryfor the run only – this is the first tool in the codebase to need an on-disk intermediate between two ffmpeg passes (existing two-pass tools, likeloudness.py, pass their intermediate measurement through stdout JSON instead). Requires an ffmpeg built with--enable-libvidstab;doctorcorrectly reportsstabilizeasusable: no(not a crash) on builds that lack it, such as Homebrew’s default macOS build – verified against this repo’s ownffmpeg_filters_8.1.2_macos.txtfixture, wherevidstabdetect/vidstabtransformare genuinely absent from the real-filterslisting. Verified the actual stabilizing effect, not just that the command runs: a synthetic shaky clip’s measured frame-to-frame motion (viasignalstatson atblend=differencepass) drops from ~7.4 to ~2.9 after stabilization.-
sequence.py: numbered/globbed image sequence to video. New tool:--patternaccepts either a printf-style numbered pattern (frame_%04d.png) or a glob (*.png, sorted alphabetically), with the match checked against the real filesystem before ffmpeg runs (an empty match or a missing first frame is refused here, not discovered from an opaque ffmpeg error). Verified frame order is preserved end to end with a real 5-frame red/blue/red/blue/red sequence, both in numbered and glob mode. fit.py --height. Only--widthexisted (“output width … height follows the aspect”). Added a symmetric--heightthat mirrorsjoin.py’s existing width/height resolution: give one and the other follows the aspect (the source aspect, or--aspectif also given); give both for an exact frame.--widthalone still behaves exactly as before.crop.py: crop to an exact pixel rectangle.fit.py --fit cropcrops to a target aspect ratio, computing the rectangle itself; there was no way to crop to a rectangle the caller already knows (a face-detection box, a saved crop, a hand-picked region). New tool takes--x --y --width --heightin source pixels, validated before ffmpeg runs: refuses negative offsets, non-positive or odd width/height (4:2:0 chroma, this codebase’s even-size convention — refused rather than silently rounded, since a caller-specified rectangle should do exactly what was asked or fail loudly), and a rectangle that doesn’t fit inside the source frame (accounting for display rotation).insert.py: still image to a timed silent video clip. Given one image, a duration, and optional target frame size / fps, produces a silent, constant-frame-rate clip of exactly that duration and size — for title cards, end slates, or placeholders alongside real footage injoin.py.--width/--heightresolve the same wayfit.py’s do (one given -> the other follows the image’s aspect; both given -> exact frame, scaled to fill and centre-cropped, never distorted). Refuses non-positive--duration/--fps.join.py: joining two or more audio-less clips together failed. Each clip missing an audio track gets a synthetic silent input (-f lavfi -i anullsrc=...) appended to the ffmpeg command; the filtergraph index for that input was computed asn + len(extra_inputs), butextra_inputsis a flat argv list (six tokens per synthetic input:-f,lavfi,-t, duration,-i,anullsrc=...), not a count of inputs added so far. With exactly one no-audio clip the two counts happen to coincide (n + 0); from the second no-audio clip onward the computed index overshoots the real one by a multiple of 6, and ffmpeg refused the whole command with “Invalid file index” naming an input far past the actual count. Found joining five real, audio-less camera samples (a genuine multi-camera source with no audio channel is not an edge case in real footage). Fixed by tracking the number of synthetic inputs added directly, instead of inferring it from the argv list’s length. No change to the single-no-audio-clip path, which was already correct.
0.10.0 — 2026-09-06 — FFmpeg 8+/Windows compatibility, per-tool doctor/contract usability, provenance and honesty fixes
doctor --json: per-toolusable.doctorreported capability-levelavailable/missing/unknown, but a caller had to cross-reference each tool’s own required capabilities by hand to answer “can I runcaption.pyon this machine today” – a plain Homebrewffmpegon macOS isokoverall (nothing required by every tool is missing) whilecaption.pyspecifically cannot run at all. The newtoolsfield folds the same per-capability state into{"<tool>": {"usable": "yes"|"no"|"unknown", "missing": [...], "fix": "one-line remedy", "unknown": [...]}}per tool, following the same “unknown is not missing” rule doctor already uses. Additive; every existingdoctorkey is unchanged.contract --json:reencodes_video/reencodes_audioper tool. Each of the 21 tools now declares, per stream type,"always"/"never"/"conditional"(with areencode_notefor the conditional ones), read from what each script’s own encode/copy args actually do. Surfaces a fact that wasn’t documented anywhere:fit,caption,overlay,graphics,color,join,multicamandsilencealways transcode audio to AAC alongside a video filter — there is no-c:a copypath in this codebase for a tool that also re-encodes video, so a caller cannot assume the original audio codec survives a picture-only edit.caption.py’s docstring now says plainly that burn-in is the only mode (no soft-subtitle mux) and always re-encodes both streams. Additive contract field; no tool’s behaviour changed.- SKILL.md: explicit “what this skill does and does not decide”. Added a section naming what belongs to a production agent (approval, which cut is right), another skill (thumbnail composition), or nobody in this skill (content understanding, judging a highlight’s interest beyond a measured proxy) – and an explicit rule against ever falling back to a raw
ffmpeg/filter_complexinvocation when a request needs something none of the 21 scripts expose. The trigger description (frontmatter) is intentionally left as-is: it stays broad on purpose (any video/audio file touch), since narrowing it risks under-triggering on requests that do belong here; what changed is what the skill does once triggered.evals/agent_prompts_24.jsongains one refusal case (r05-no-raw-ffmpeg) asking directly for a raw-filter_complexcommand. check.py: plain-languagereasonon the less obvious FAILs. Every row already hadfix(the command that resolves it); video codec, pixel format, HDR colour and loudness FAILs now also carryreason(“QuickTime and iOS commonly reject video that isn’t 8-bit 4:2:0”) for a caller reporting the result to someone who doesn’t already know why the spec value matters. Empty on PASS rows and every other check. Additive, no existing field changed.cut.py --json: full requested-vs-actual provenance.expected_duration/duration_error_ms/precision/reencodedalready existed (0.9.1); addedrequested_start/requested_end(orrequested_segmentsfor--segments),requested_duration,output_duration,duration_delta_seconds(seconds-unit alias ofduration_error_ms) andmode(copy/accurate/hybrid— “hybrid” means a lossless cut silently re-encoded because the keyframe snap exceeded--tolerance) andkeyframe_snapped. Additive only, no existing field changed. Intended for a downstream repo (an editing skill, an agent) that wants this in its own provenance/audit trail without re-deriving it fromreencoded+precision.render.py’s check stage now sets the exit code. Aproject.jsonwith a"check"stage always returned 0, even when the delivery-spec check failed orcheck.pyitself couldn’t run —render.pywas the one tool in the skill that could report a broken deliverable as a success. It now exits 1 in both cases, matchingcheck.py’s own exit code exactly;--json’scheckfield still carries the full row-by-row result either way, and the output file is still written (this changes the exit code, not what gets rendered).- Test: docstring examples can’t drift from the parser.
scenes.py’s docstring once claimed a ranking option (motion) that was never implemented; that was prose, not an example, so nothing caught it. A newtest_contract.pytest at least closes the more common version of this gap: every--flagused in a script’s ownExamples:lines must exist in that script’s real argparse parser, checked via the contract’sinput_schema. Verified to catch a deliberately introduced typo before writing this entry. Does not (and cannot) catch a false claim made only in prose. - SKILL.md: explicit language-matching instruction. The agent has always been graded on replying in the user’s language (see the 24-prompt eval), but SKILL.md never actually said to — it worked by the model’s own default, not by instruction. The Report format section now says explicitly: reply in whatever language the request was written in (Japanese, English, Chinese, or any other), keep only the field labels (
Done:,Steps:, …) in English, and follow a language switch mid-conversation. No code change; this only affects the model-facing instructions. multicam.pylow-confidence warning.sync.pywarns on stderr when its cross-correlation confidence is below 0.1 (“check that both files contain the same audio event”);multicam.pyused the same measurement per camera but never warned, even though it applies the offset to a rendered cut rather than just reporting it. It now warns per camera below the same threshold. SKILL.md’s existing “checkconfidencebefore trusting a sync” guidance now namesmulticam.py’s per-camera confidence explicitly.sync.py/multicam.pydocumentation: audio sync is not lip sync. Both align audio tracks to each other by cross-correlation; neither has ever done any face or mouth detection, and a highconfidenceonly means the audio matched well, not that the final picture looks in sync. This was previously undocumented;sync.py’s docstring,multicam.py’s docstring and SKILL.md now say so explicitly. No behaviour change.-
export.py --preset copy. Every existing preset re-encodes (evenprores/h265, which keep the source resolution). A caller with nothing to change — the deliverable already matches the source, no platform target — had no way to get a real, delivered file out ofexport.pywithout paying for and risking a needless re-encode.copyis a genuine stream copy (-c:v copy -c:a copy, no-anunless the source truly has no audio track): same codecs, same container (keeps the source’s own extension unless-onames one), same colour tags — it skips the CFR-conforming (-r/-fps_mode cfr) and BT.709-retagging steps every re-encoding preset applies, since neither is meaningful (or safe) without decoding the picture, and it never issues the HDR “outputs SDR BT.709 tags” warning other presets do, because it doesn’t touch colour at all.-movflags +faststartstill applies when the resolved output is.mp4(a real optimisation a copy can do for free). Verified codec/resolution/frame-count/HDR-tags stay byte-for-byte the source’s, not just nominally unchanged (frame count viaffprobe -count_frames, which a re-encode could silently drop or duplicate). scenes.py --rank-by.--highlightsranked candidate scenes by audio energy only, and the docstring falsely claimed motion was also used (it never was — dead documentation).--rank-by {audio,duration}(default audio, unchanged) makes the criterion explicit and adds a real second option (longest scene first); the result JSON now reportshighlights_rank_by.fit.py --crop-x/--crop-y.--fit cropalways cropped from the centre, so reframing a wide shot to 9:16 could cut off a subject held to one side (a product, a person off-centre).--crop-x/--crop-y(0=left/top, 0.5=centre default, 1=right/bottom) pick which edge to keep instead; range-checked before ffmpeg runs. No change to the default (centre) behaviour.- Filter file paths on Windows.
subtitles=,ass=,lut3d=file=,fontfile=andfontsdir=values are parsed twice by ffmpeg (graph, then filter options), so a drive letter needs two levels of colon escaping (D\\\\:/x.srt). 0.9.1 escaped once and every caption / LUT job on Windows failed withUnable to parse "original_size" option valueorError parsing a filter description.escape_filter_pathnow escapes for both passes;;is escaped too. Reproduced and tested on Linux with a directory literally namedD:plus spaces and non-ASCII in the path. overlay.py --imagelength on FFmpeg 7+.-shortestalone left up to 2 s of the looped still after the video ended (8.1 / 9.0); the command now also passes-t <video duration>.--helpon a legacy Windows console. stdout / stderr are reconfigured to UTF-8 (with replacement) by_common, so non-ASCII help text (Japanese example, arrows) no longer raisesUnicodeEncodeError; the test harness decodes script output as UTF-8.- macOS CI installs
ffmpeg-full: Homebrew’sffmpegformula no longer links libass / freetype / harfbuzz / zimg, so it has nosubtitles,ass,drawtextorzscalefilter. README, the installer hint and_common’s ownrequire_toolerror message (INSTALL_HINTS) all say so now. - Tests: the contract-test fixture used
-vsync vfr, an option FFmpeg 9 removed (-fps_mode vfrsince 5.1); filter paths with a drive colon, spaces and Unicode through caption (SRT, ASS, fonts dir), color (LUT full and blended) and overlay (fontfile), with PSNR proving the caption and LUT changed the picture; overlay still bounded by the video length;--helpunder a cp1252 console. provides:contract --jsongains a top-levelprovidesfield listing all 21 tools by a cross-repository Capability id (ffmpeg-skill.<tool>) forkajisho5/AI-video-production-OS’sCapabilityContract.provides— seedocs/contract.md.
0.9.2 — typed primary colour correction
- color.py:
--correct(exposure / contrast / saturation / white balance), a fourth colour mode alongside--to-sdr/--lut/--retag/--strip-dovi. Each flag is one option of one real, always-available libavfilter filter —--exposure(exposurefilter, -3..3 stops),--contrast/--saturation(eqfilter, 0..2, 1=unchanged),--temperature(colortemperature, 2000..12000 K, 6500=unchanged) and--tint(green -1 .. +1 magenta, mapped tocolorbalance’s three midtone channels:gm=-tint,rm=bm=tint/2) — range-checked against this script’s own safe subset of what each filter documents (ffmpeg -h filter=<name>) before ffmpeg runs; no filter string,filter_complexor raw argv is ever accepted from the caller. The four stages (exposure → white balance → contrast → saturation) are always chained in that fixed order, each one always present at its filter’s own documented no-op default, so the pipeline is one stable four-filter chain regardless of which flags were given.--json’smeasurementsfield carriesanalyze_levels()(signalstats luma/saturation) for the input and the output side by side — an OBSERVED technical measurement, never a “looks better” judgement. - Contract:
color’s optional capabilities gainfilter:exposure/filter:eq/filter:colorbalance/filter:colortemperature(declaredwhen: "--correct");X264now also lists--correctamong its callers. No change to any existing flag, contract field, MCP schema or tool semantics. - Tests: 4 real-media tests in
tests/test_all.py(defaults are near-identity, positive exposure raises measured luma,--saturation 0desaturates, temperature/tint run and preserve geometry, five out-of-range parameters are each refused with no partial output).
0.9.1 — FFmpeg 8 capability detection; audio extraction, audio join, sample-accurate trims, typed dynamics
- doctor / contract on FFmpeg 8.
ffmpeg -filtersprints two flag characters on FFmpeg 8 (T. acompressor A->A) where 6 and 7 print three (..C); 0.9.0 anchored on three and reported everyfilter:*capability missing on FFmpeg 8 (macOS / Windows CI of consumers). Rows are now recognised by theirA->Aio-spec token, encoders by the------separator, so the flag width no longer matters. A listing that cannot be read yields a third state,unknown, distinct frommissing(an installed filter is never reported absent) and fromavailable(a failed detection is never a pass);detectionanderrorssay which listing failed and why; exit 2 for “required but unknown”. Existing keys unchanged. Fixtures for the 6.1 capture and the 7 / 8 layouts intests/fixtures/, tests through a fake ffmpeg. - audio.py: an audio output extension on a video input drops the picture (
audio.py talk.mp4 -o talk.wavextracts;--voice -o talk.m4acleans on the way) instead of failing inside the WAV muxer;--audio-stream Npicks a track (probelistsaudio_streams). Typed dynamics:--compress(--comp-threshold/-ratio/-attack/-release/-makeup/-knee),--limit(--limit-ceiling/-attack/-release),--gate(--gate-threshold/-ratio/-attack/-release/-range/-knee), each flag one documented option of acompressor / alimiter / agate, range-checked before ffmpeg runs, dB converted to the linear value the filter takes; no filter string is accepted from the caller. - join.py: audio-only inputs (WAV, FLAC, MP3, M4A, …) are joined as audio at one sample rate (first clip’s,
--sample-rate) and channel layout (widest,--channels), withacrossfadeor a butt join; the output must have an audio extension; audio and video inputs cannot be mixed. Contract:joinisaudio_only: true,video_required: false. - cut.py: the codec follows the output extension on re-encode (
-o x.wavis PCM; a.wavnever receives AAC packets, which 0.9.0 wrote on--accurateand on the keyframe fallback for audio inputs); an audio extension on a video input extracts the audio; audio stream copies seek on the output side so-c copylands on the packet, not the previous video keyframe;--accurateon audio trims at the sample (atrim). The JSON reportsprecision(packet/sample/codec_frame/frame),duration_error_msandreencoded. Measured: WAV copy within 2 ms,--accurateWAV / FLAC / AAC-to-WAV exact to the sample at 44.1 and 48 kHz, AAC output +21 ms (encoder priming, reported ascodec_frame). probeaddsaudio_streams(index, codec, channels, layout, sample rate, language, title).- Tests: 5 doctor detection tests (FFmpeg 6 / 7 / 8 layouts, garbage, failure), 4 audio tests (extraction and stream selection, audio join, measured precision, typed dynamics); evals
evals/agent_prompts_audio2.json.
0.9.0 — machine-readable execution contract
ffmpeg-skill contract --json (python3 scripts/_contract.py --json) describes the skill for agent frameworks: contract_version 1.0 separate from the skill version; one ToolSpec per public script (ffmpeg-skill/<name>) with an input schema generated from its argparse parser, an output schema, role (analysis / analysis_and_execution / execution / verification), required and conditional ffmpeg capabilities, dry-run support, mutates_input: false, the verification tools to run afterwards, and whether a visual check is required. ffmpeg-skill doctor reports which capabilities the machine has. docs/contract.md explains the fields and how a planner consumes them.
--jsonresults now carry"status": "completed"; failures under--jsonalso print{"status": "failed", "error": {"kind", "message"}}on stdout (stderr message and exit codes unchanged).--dry-runno longer writes the generated SRT (caption.py --text) or the HTML (report.py).- MCP server lists
batch(the one script it was missing);tools/listnow equals the contract’s tool list, and a test keeps it that way. - MCP
tools/listis derived from the contract: no tool table or hand-writteninputSchemainmcp/server.pyany more. Names, order,inputSchema(translated fromToolSpec.input_schema: types, enums, defaults, descriptions, required fields, mutually exclusive groups, the non-canonicalargvbranch) and the structured-argument mapping all come fromscripts/_contract.py. Tests: schema equality for all 21 tools, byte-identicaltools/list, drift (add / remove / edit a script), and JSON-RPC round trips of probe, cut, silence, loudness, export and render built from the derived schema. - Installer copies
package.jsonso an installed skill knows its version, and answerscontract/doctor. tests/test_contract.py(unit + integration, including a fake-ffmpeg dry-run guard and the real-device corpus when present),evals/contract/,npm run release-checkextended.- No new editing features; no script changed its media behaviour.
0.8.5 — audio-only inputs, spelled out
- SKILL.md: “Audio-only files” section. WAV, FLAC, MP3, M4A/AAC, OGG and Opus go through
probe,cut,silence,loudness,audio,syncandcheck --platform podcastunchanged; the output extension picks the codec;Look: not needed; picture scripts refuse with “input has no video stream”. Six audio-only request→script rows. - Evals: six audio-only agent prompts (
evals/agent_prompts_audio.json), grader checks that audio-only runs use no picture script and mark the visual check not needed, routing tasks 25–29, two audio-only trigger cases. First run: 6/6 on every criterion. - Test for WAV/M4A/MP3 through the audio scripts (56 tests).
- GitHub Sponsors:
.github/FUNDING.ymland a Support section in the README.
0.8.4 — three repeats, independent grading
The 24-prompt evaluation was run three times (72 agent runs, evals/results/iteration-2-4.json) and every run was graded by a separate model (evals/results/iteration-2-4-independent-grades.json): routing 72/72, honest 72/72, user’s language 72/72, report format 71/72, visual check whenever the picture changed 24/24, mean quality 4.9 / 5. Script choices were identical across repeats; only defaults (crop vs pad, silence margin) varied.
overlay.py --fadewithout--start/--endnow fades in at 0 and out at the end of the video (it was silently ignored; found by an agent during the runs).--dry-runno longer prints “wrote" for a file that was not written (central fix in `_common.info`).
0.8.3 — internals and triggering
_common.py: the shared flag state is an explicitContextobject (STATE.dry_runetc., dict-style access kept),run()is split into recording, dry-run, captured and progress paths with one failure handler. Behaviour unchanged: 54/54 tests and the sync benchmark give identical numbers before and after.sync.py: the 35 % minimum-overlap and the square-root overlap weight are named constants with the benchmark rationale (what 0.2 / 0.5 and exponents 0.25 / 1.0 did) written next to them.- Trigger tests (
evals/trigger/): 10 should-trigger and 10 should-not requests judged by an independent model against a catalog with four decoy skills. 20/20 on this description.
0.8.2 — second agent-run evaluation
24 prompts (12 English edits, 8 Japanese edits, 4 that must be declined) run by independent agents against the 0.8.1 skill (evals/agent_prompts_24.json, evals/grade_runs_24.py, evals/results/iteration-2.json). Routing 24/24, honest refusals 4/4, Japanese reports 9/9, visual check whenever the picture changed 8/8, mean 6.5 commands per job.
caption.py --textnow writes the generated SRT beside the output file, not beside the source.probe.pyaccepts the common flags (--json,--fieldetc.) like every other script.audio.py:--music-fade-outfades only the bed;--fade-outfades the whole mix (previously both were applied at once).render.pyproject audio gainedmusic_fade_out.- SKILL.md: how to find a CJK font before burning Japanese captions.
- Tests for all of the above; render test no longer depends on a clean output directory.
0.8.1 — skill craft
The skill file itself, measured. Six realistic prompts were run by independent agents with the old and the restructured SKILL.md (evals/agent_prompts.json, evals/grade_runs.py, results in evals/results/).
- SKILL.md rewritten for the agent, not as a catalogue: a description that says when to trigger; body 402 → 169 lines with workflow, what to ask vs assume, request→script map, report format and “looks right but is wrong” pitfalls; per-script CLI reference moved to
references/scripts.md, real-device notes toreferences/devices.md(both installed and packaged). - Results: routing 100 % for both versions; mean commands per job 9.0 → 7.7 (the Reels job went from 16 commands with a forced redo to a single
render.pypass); report format followed 4/6 → 6/6. - Found and fixed from the transcripts:
check.pywarned on untagged 8-bit H.264 and agents added a pointless retag (now PASS);look.py --atstamped 00:00:00.000 on every frame (now the requested time); the “fix FAILs” instruction made an agent boost -44 LUFS park ambience by 30 dB (check step reworded, pitfall added).
0.8.0 — validation release
Measured instead of assumed. New tests/corpus.py pulls public real-device videos (GoPro HERO 4K 10-bit, DJI 4K60 no-audio, two iPhones incl. Dolby Vision 4K60, two Android screen recordings incl. 18 fps VFR and 120 fps, an HDR10 PQ test pattern, a 24p clip, and Blender’s Tears of Steel) and runs verify.py over them; tests/bench_*.py score algorithms against known ground truth.
sync.py: normalised cross-correlation over the overlap (prefix-sum energies) with a runner-up-aware confidence. Lags with under 35 % overlap are ignored and scores carry a sqrt(overlap) weight so partial coincidental matches cannot beat the true alignment. Benchmark on real dialogue/music (±30 s offsets, gain, noise, EQ): 120 s windows 40/40 within 10 ms (max 1.1 ms); 60 s stress windows went from 86 % to 95 %, with 4 of the 5 remaining misses flagged by confidence < 0.3.scenes.py: cuts are now one-frame spikes (score above threshold and > 3× the neighbouring median), not any frame over a threshold; motion, flashes and pans stop registering.--ratioadded. Benchmark on 53 hard cuts between single-take corpus clips: precision 0.95, recall 1.00 (F1 0.97) at the default threshold 8; 0.98 / 0.94 at 12.loudness.py: silent input is reported ("silent": true) instead of crashing; normalisation refuses with a clear message.verify.py: tone-maps the HDR-preserved cut instead of the whole file (a 10-minute 4K HDR source timed out).- Corpus results: 90/92 verify steps pass on first run; both failures fixed above. Silence benchmark: 0 missed gaps, ≤1 ms leftover silence over 20 cases.
- SKILL.md: sync guidance now cites the benchmark and the 4× window rule.
0.7.0
mcp/server.py(new): the whole toolkit as an MCP server over stdio (JSON-RPC 2.0, standard library only). Every script is a tool; named args or raw argv; results as structured JSON. Installed alongside scripts bynpx ffmpeg-skill.batch.py(new): apply a step recipe or a render project to every file in a folder, content-hash cache so re-runs only touch changed files,--watchpolling.caption.py --transcribe: optional local speech-to-text bridge (whisper.cppwhisper-cli, faster-whisper, or openai-whisper if present). Never required; a clear install hint otherwise.- Tests: 49 end-to-end cases.
0.6.0
graphics.py(new): motion-graphics templates with no image assets — lower-third (slide in/out), title card, chapter chip, progress bar, countdown, corner bug — coloured from brand.json.brand.jsonsupport: fonts, colours, logo (position/scale/opacity), safe margin, caption defaults.caption.py --brand,overlay.py --brand --logo,graphics.py --brand, and a"brand"key inrender.pyprojects (plus agraphicsstage and{"logo": true}overlays).report.py(new): single-file HTML delivery report with before/after contact sheets, media facts, loudness, compliance table and the commands run.- Tests: 46 end-to-end cases.
0.5.0
render.py(new): declarative edits from oneproject.json(clips → join → silence → fit → captions → overlays → audio → loudness → export → check).--initwrites a starter,--dry-runprints every command,--stop-afterfor iterating.scenes.py(new): scene changes (scdet) and audio peaks, highlight proposals sized to a target duration,--edlforcut.py --segments, per-scene contact sheet.check.py(new): pre-delivery compliance for youtube / shorts / reels / tiktok / x / linkedin / broadcast / podcast / custom: duration, aspect, resolution, fps, VFR, codec, pixel format, colour/HDR, file size, loudness, true peak, with the fix command per failure.evals/: 24 natural-language routing tasks with expected scripts, plus a transcript scorer..github/workflows/ci.yml: 3-OS matrix, manual trigger only until the Actions quota resets.join.py:--widthalone keeps the first clip’s aspect;--fastno longer breaksexport.pypresets.
0.4.1
Fixes found by running verify.py on a real iPhone clip (Dolby Vision 8.4 / HLG, 10-bit HEVC, 60 fps VFR, portrait rotation, extra metadata tracks):
- HDR sources now stay HDR through every re-encode (
cut,fit,caption,overlay,silence,join,multicam,sync): HEVC Main10 with the source’s HLG/PQ tags instead of an 8-bit H.264 file mislabelled BT.709. Usecolor.py --to-sdrwhen you want SDR. - Audio mapping uses the first audio stream only (
0:a:0?); iPhone.movfiles carry timecode/metadata tracks that broke-map 0:a?. probe.py --analyzenormalises 10-/12-bit levels to an 8-bit scale before the Log heuristic.look.pytone-maps HDR frames for display so the agent judges representative colours.verify.pyrunscolor --to-sdron the original file and adds an “hdr preserved” check on the accurate cut.
0.4.0
verify.py(new): real-footage verification kit — runs the toolchain over the user’s own files and reports PASS/FAIL per step, Markdown and JSON.multicam.py(new): align N cameras/recorders by audio (drift correction optional), switch between them from a time list or automatically, pick the audio source.probe.py: Dolby Vision detection (dolby_vision,hdr_format),--analyzesamples picture levels and flags Log-looking footage.color.py:--strip-doviremoves the Dolby Vision RPU losslessly; HLG and DV 8.4 sources verified through--to-sdr.caption.py: karaoke word timing now follows speech energy in the audio (--karaoke-timing energy|even).--progress(percent / ETA) and--fast(preview preset) on every script.- Tests: 38 end-to-end cases.
0.3.0
look.py(new): contact sheet with timecodes, single-frame extraction, side-by-side before/after PNGs — the agent can inspect its own output.silence.py(new): silence detection and frame-accurate removal with margins,--listand--edlcut-list export compatible withcut.py --segments.join.py(new): xfade/acrossfade transitions between clips with automatic normalisation of size, fps, pixel format and audio layout (silent track synthesised when missing).--dry-runand--jsonon every script: print the ffmpeg commands without running, or emit a structured result (output, probe, commands).- SKILL.md workflow now includes plan (
--dry-run) and visual verification (look.py) steps.
0.2.0
color.py(new): HDR10/HLG → SDR BT.709 tone mapping (zscale + tonemap), 3D.cubeLUTs with strength blending, metadata-only colour retagging.audio.py(new): voice clean-up chain, FFT denoise, music bed with sidechain ducking, fades, 5.1 → stereo downmix, mono/stereo layout, track replacement.sync.py: coarse-to-fine search (20 ms FFT → 1 ms direct) and--fix-driftclock-drift measurement and correction by resampling; roughly 5x faster on the default window.caption.py:--animate fade|pop|slideand--karaokeword-by-word highlight, generated as a styled ASS sized to the video; SRT input can be animated too.fit.py:--smooth blend|interpolatefor slow motion.- VFR sources are conformed to constant frame rate automatically by every re-encoding script;
cut.pyswitches to accurate mode on VFR. probe.py:hdr,hdr_format,bit_depthfields.export.py: warns when an HDR source is exported without tone mapping.- Tests now cover VFR, rotated, 5.1, 10-bit HDR10 HEVC and drifting sources.
0.1.0
- Initial release: probe, cut, caption, fit, sync, loudness, overlay, export; npx installer; demo and tests.