Skip to content

Releases: kajisho5/ffmpeg-skill

v0.12.5

Choose a tag to compare

@kajisho5 kajisho5 released this 08 Sep 21:35
87c84f2

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-match on Linux/macOS) and emit
    fontfile= instead of font= 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.
  • doctor now actually renders one frame through drawtext instead of trusting the -filters
    listing alone. A confirmed crash (killed by signal on POSIX, an access-violation-style exit on
    Windows) downgrades filter:drawtext from "listed" to missing, with the crash detail in
    errors[]; an ordinary nonzero exit proves nothing either way and leaves the listing-based
    result standing (same "unknown is not missing" principle used everywhere else in capability
    detection).
  • scenes.py --sheet gained --no-timecode, matching look.py, as a way out if drawtext is
    ever genuinely unusable on a machine.
  • Fixed a SyntaxWarning: invalid escape sequence '\;' in _common.py's shell_quote() (a stray
    backslash before an already-unescaped character; harmless today, an error in a future Python).
  • README's Quick Start script examples now note that Windows/Git Bash needs python, not
    python3 (bin/install.js and doctor/contract already handled this; the raw examples
    didn't say so).
  • SKILL.md's Gotchas section documents the crash and the fixes above.

Thanks to @willy92wins for the detailed repro in #100.

What's Changed

  • fit/color/graphics/overlay: stop silently dropping subtitle/data streams by @kajisho5 in #92
  • caption.py --mode mux: keep the input's existing subtitle track(s) by @kajisho5 in #94
  • color.py --correct: add gamma, lift/gain, levels, and curves by @kajisho5 in #95
  • stabilize.py: add --tripod and --crop (keep/black) by @kajisho5 in #97
  • README: name the contract-derivation pattern SPEC by @kajisho5 in #98
  • README: surface SPEC near the top by @kajisho5 in #99
  • fix drawtext crash on Windows fontconfig resolution by @kajisho5 in #101

Full Changelog: v0.12.0...v0.12.5

0.12.0

Choose a tag to compare

@kajisho5 kajisho5 released this 07 Sep 13:55
81fd69e

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.mp4 against an input opened as same.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 — -y then silently clobbered the source
    mid-encode. Reproduced on crop.py before the fix. _check_no_overwrite_input() in
    _common.py's run() compares os.path.realpath() of every -i argument 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=True or check=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 copy alongside the required video/audio re-encode, and --json now
    reports reencoded/dropped_non_av_streams honestly instead of a bare "completed".

  • sync.py's REENCODE_META claimed video="never"; --trim-second actually re-encodes
    video
    whenever the second recording starts later than the reference (the common case,
    offset>=0) or --fix-drift is used — only the offset<0 stream-copy path leaves video
    untouched. Fixed to "conditional"/"conditional" with a note.

  • Error taxonomy (additive): error.code and error.retryable now sit alongside every
    failure's existing error.kind/error.messagecode is a static relabelling of the same 4
    kinds this codebase has always used (INPUT_INVALID/DEPENDENCY_MISSING/
    FFMPEG_EXECUTION_FAILED/OUTPUT_INVALID, INTERNAL_ERROR fallback), not a new taxonomy the
    code can't back up; retryable is currently always false (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 --json now includes the second-pass (post-normalization) measurement as a
    result field — it was computed but only ever printed to stderr, so a caller had to make a
    separate --measure-only call to learn what loudness was actually achieved.

  • --audio-stream N extended to overlay.py, graphics.py, color.py; fit.py gained
    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, for fit.py, to ffmpeg's own
    implicit "best stream" heuristic, which for audio favours channel count over track order).
    join.py/multicam.py are out of scope — they combine separate input files, a different
    problem shape. Closes #62 (the same gap
    caption.py/audio.py already closed in #55).

  • doctor --json gains a fonts field, informational like gpu_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), so fc-match is queried directly.
    Never gates ok/usable; a substituted font doesn't make the tool unusable, just possibly
    styled differently than intended.

  • probe.py gains subtitle_stream_details, a detailed per-subtitle-stream array
    (index/codec/language/title) mirroring audio_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-copied skill.version example had drifted to a stale 0.9.1
      while package.json had moved to 0.11.0.
    • docs/contract.md's failure-JSON example was missing the code/retryable fields above.
    • SKILL.md/references/scripts.md claimed unconditionally that every script's --dry-run
      runs nothing; sync/multicam/scenes/report genuinely run ffmpeg/ffprobe to measure or
      analyse under --dry-run (they just don't write the final artifact), and verify accepts
      the flag but ignores it — all three doc locations now name the real exception set.
    • SKILL.md's Workflow section never mentioned doctor/contract at 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 flagging fit.py --fit pad's letterboxing as a defect, since that's that mode's correct
      output. Also formalized Look: PATH (pixels not inspected; agent has no image view) for an
      execution environment that can't actually view images.
    • bin/install.js's contract/doctor subcommand hardcoded python3; Windows Python
      installers commonly expose python/py instead (only the Microsoft Store package ships
      python3) — now falls back through python3pythonpy on Windows. The same
      hardcoding was also present, unfixed by that change, in mcp/server.py's and README's MCP
      client config examples — both now note the Windows alternative.
    • evals/agent_prompts_exec.json's f05-unsupported claimed "no reverse tool in the skill"
      and scored refusing a reverse request as correct — reverse.py has existed the whole time
      and SKILL.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 normal reverse.py
      success case; evals/results/exec-1.json's historical record is left unedited with a note
      explaining the old grading was wrong.
  • Cross-platform: end-to-end non-ASCII filename coverage. Filter-graph string escaping for
    Unicode paths was already tested; nothing exercised a non-ASCII filename as the actual -i/
    output argument through subprocess argv. Added a test copying a fixture to a CJK/accented
    filename and running probe/cut against it both directions — passes on all 3 CI platforms.

  • Docs: CONTRIBUTING.md, a .github/workflows/release.yml that 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, which gpu_encoders already limits
    itself to, is not proof a job succeeds).

  • --dry-run's probe stub no longer fabricates plausible-looking 1920x1080/30fps dimensions
    for a not-yet-written output.
    A first attempt at this (reporting the honest 0/0.0 "not
    measured" value instead, matching duration/size_bytes's existing convention in the same
    stub) had to be reverted mid-pass: join.py and fit.py both divide by a probed source
    width/height when computing the other dimension from an aspect ratio, and dry-run probes chain
    across multi-stage pipelines (a prior stage's still-unwritten dry-run output gets probed as the
    next stage's input), so a zero source dimension reached those divisions and crashed with
    ZeroDivisionError. Root-cause fixed instead: both division sites now treat a zero/unknown
    source dimension as "can't compute a ratio" and fall back to the requested dimension rather than
    dividing by it; every other tool touching probed width/height for aspect-ratio math was audited
    and either doesn't divide by it or hands it straight to an ffmpeg filter (moot under --dry-run,
    since ffmpeg never runs). --json was 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_streams was a plain integer count while audio_streams was 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_details adds 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), mirroring audio_streams' shape minus the audio-only fields (channels, layout, sample rate) ffprobe doesn't expose for subtitle str...

Read more

0.11.0

Choose a tag to compare

@kajisho5 kajisho5 released this 07 Sep 06:33
b388035

Closes the video-editing-skill ADR-002/ADR-003 gap investigation (11 confirmed gaps): crop.py, insert.py (incl. Ken Burns), fit.py --rotate/--flip, overlay.py --video/--chromakey (video-on-video PiP, chroma key), reverse.py, stabilize.py, sequence.py, background.py, proxy.py, contract --json's capability_map, and a repo-wide "fail loudly" pass making verify_output() the single success criterion for every writing tool. 28 tools total (was 21 at 0.10.0); README/package.json's stale "21"/"22" tool-count strings are also corrected to the real count here — see #50 for making that count self-maintaining going forward.
• SKILL.md: two more worked examples of the mechanical-vs-judgement line. "What this skill does and does not decide" already named categories (which cut is right, highlight ranking, thumbnails, content understanding) but not the line itself. Adds "apply this LUT" (mechanical, in scope) vs. "grade this scene to look cinematic" (judgement, belongs to a colour-grading skill) and "crop to this exact box" vs. "crop to keep the speaker in frame" (needs a subject decision this skill doesn't make), plus one sentence stating the general rule: same input + same explicit parameters -> same verifiable output stays here; anything depending on taste or understanding goes to whichever skill or agent makes that call. Docs-only; no behaviour changed. Closes #53.
• references/ci-platform-pitfalls.md: known per-OS ffmpeg/CI behaviour differences. Several fixes in this repo exist only because of platform-specific, empirically observed behaviour (Windows Chocolatey ffmpeg lacking -pattern_type glob support, the concat demuxer's end-of-list duration trick over-counting by a frame, #!/bin/sh PATH shims not being portable to Windows, Windows reporting a crashed ffmpeg subprocess's exit code differently from what this repo captured, and macOS's libvidstab build disagreeing with Linux's on whether a shake fixture got better or worse) — each was independently diagnosed once, at the cost of a full CI cycle and log-reading. Writing them down means the next platform-only test failure gets checked against this list before spending another cycle re-diagnosing it. Docs-only; no behaviour changed.
• proxy.py: low-bitrate proxy for downstream AI analysis, preview and editing decisions. No tool here served a "cheap for a machine to decode" output distinct from export.py's delivery presets, which all target near-visually-lossless platform delivery (CRF 18-24) rather than size/speed. proxy.py resizes to --width (default 640) or by --scale factor, re-encodes at a proxy-grade --crf (default 30) with the fastest x264/x265 preset, supports --fps and --no-audio, and keeps the source's own dynamic range (an HDR source proxies to HEVC10, same as every other re-encoding tool here — run color.py --to-sdr first if SDR is wanted). Pure mechanical resize+re-encode, same primitives fit.py/export.py already use: no new dependency, no GPU requirement, works identically regardless of source resolution (1080p/4K/6K/8K) or codec (H.264/H.265/ProRes, since ffmpeg's own decoders are already codec-agnostic everywhere in this repo). This tool only executes the spec it is given — it does not decide which asset should be proxied or what the proxy will be used for; that stays with the calling agent. capability_map gains media.proxy -> proxy (see below). Tests cover default width/CRF, --scale/--no-audio, forcing CFR on a VFR source, HDR passthrough, and dry-run.
• contract --json: capability_map. provides re-indexes each tool by an id shaped like its own name (ffmpeg-skill.cut); it doesn't let a planner that only knows an abstract goal ("I need to trim a video") find the right tool. capability_map is a new, small, hand-authored table: [{"capability": ".", "tool_id": "ffmpeg-skill/", "params": {...}}, ...], covering video.trim, video.reframe (pins fit to fit=crop, since fit.py also duration-fits and pads), audio.loudness, subtitle.burn, media.stream.inspect, media.frames.extract and media.proxy. It is purely descriptive — a caller still builds and runs the named tool's own CLI/MCP call from its input_schema; this skill never picks a capability or executes on the caller's behalf. Deliberately excludes anything that would require judgment to resolve (no video.highlight, since scenes.py --highlights ranks by a measured proxy, not understood content). docs/contract.md documents the table and why it stays short; tests/test_contract.py verifies every entry resolves to a real tool and real params. Additive; no existing field changed.
• doctor: this installed copy's own version. doctor --json and the human-readable doctor now report the version of the copy answering, read locally from its own package.json (same value contract --json's skill.version reports) — never fetched from the network, never compared against the latest published release. A copy installed with npx ffmpeg-skill is not updated automatically; the human-readable output and the installer --help/README Quick start now say to re-run the installer to refresh it. Additive; every existing doctor key is unchanged.
• doctor: ok vs. per-tool usable clarified. The human-readable doctor output now adds one line when ok is true but at least one tool's usable isn't "yes" (e.g. a plain Homebrew ffmpeg on macOS: overall ok since nothing required by every tool is missing, while caption.py specifically can't run). README Quick start says the same. No field changed, no behaviour changed — a caller reading doctor no longer has to already know to check tools separately from ok.
• doctor's Windows fix hint for a missing subtitles/drawtext/zscale filter now names the same remedy README documents for that platform (winget install Gyan.FFmpeg, since the gyan.dev full build carries them and a plain choco package can lack them) instead of falling through to a generic "install/build it" message — matching the existing macOS brew install ffmpeg-full hint. No change on any other platform.
• doctor's own introspection calls get a 10s timeout. ffmpeg -filters/-encoders/-bsfs/-version are meant to be fast, bounded, non-media operations; a hang here would silently freeze the one tool meant to report whether the machine is broken. They now time out and report failed rather than blocking forever. Deliberately NOT applied to any tool's actual media-processing ffmpeg invocation (cut, fit, caption, ...): a legitimate long re-encode must not be killed by an arbitrary ceiling. -nostdin was already passed everywhere (0.9.0), so a hang waiting on stdin was not possible; this closes the other silent-hang path. See README, "Requirements", for what remains the caller's own responsibility.
• tests/test_contract.py now runs on Windows CI. Only two spots (test_dry_run_never_runs_ffmpeg_and_writes_nothing and the whole DoctorDetectionTests class) actually depend on a POSIX #!/bin/sh PATH shim to force specific FFmpeg fixture layouts; everything else in the file — contract schema, reencodes_*, doctor.tools, MCP derivation, and every tool exercised through the contract including cut.py's provenance fields — already ran against the real ffmpeg on whichever OS the test ran on, but CI skipped the entire file on Windows regardless. The two shim-dependent spots are now skipIf'd individually (visible as skipped in the Windows job's log, not silently absent) and CI runs the rest of the file on all three OSes. Doing this surfaced a real, previously-invisible bug: test_contract_from_installed_copy redirects the installer's target directory by overriding HOME, which Node's os.homedir() ignores on Windows (it reads USERPROFILE), so the test silently installed into the runner's real home directory instead of its temp one and then failed to find the file it expected — fixed by setting both. See README, "Development".
• Test: cut.py's copy-mode keyframe snap can genuinely change the output's duration, not just its precision label. A non-keyframe-aligned --start/--end within --tolerance stays in fast stream-copy mode (mode: "copy"), but the underlying -ss seek still snaps to an earlier keyframe and pulls in extra content — output_duration and requested_duration can diverge by more than a rounding error while keyframe_snapped stays true. This was already reported in cut.py --json (0.10.0); a new test in tests/test_all.py pins a real, measured, non-trivial divergence so a regression that silently reports duration_delta_seconds: 0.0 in this scenario would be caught. No field or behaviour changed.
• Fail loudly: verify_output() is now the single success criterion for every writing tool.
Audit of the execution chain (natural language → script → real ffmpeg → exit status → output
verification → report) against 17 input/ffmpeg failure scenarios and a fake ffmpeg that exits 0
with an empty output. Every scenario already failed with a non-zero exit; the fixes below make
the failures precise and leave nothing misleading behind.
• verify_output() in _common.py: exists, non-empty, ffprobe reads a stream. emit() runs it
before printing any success, with or without --json.
• Output problems are reported as kind: "output" ("output verification failed: : not
written | 0 bytes | ffprobe cannot read it"), no longer as an input error; a 0-byte artifact
is removed.
• Failure JSON carries exit_code and commands (what was planned or run) next to
error.kind / error.message. ffmpeg failures raised by cut, loudness, silence and sync
carry kind: "ffmpeg".
• fit.py --fps 0 was silently treated as "no fps requested"; it is now an error.
• SKILL.md: what "done" means (exit 0 and a probe that matches the request), and a Failed:
report shape.
• Tests: input failures (missing, corrupt, empty, wrong stream, beyond duration, bad fps),
ffmpeg failures (invalid LUT, unwritable directory, unknown container), output verification
with a fake ffmpeg across nine tools, no partial files left behind.
• Evals: evals/agent_prompts_exec.jso...

Read more

0.10.0

Choose a tag to compare

@kajisho5 kajisho5 released this 06 Sep 19:58
e40c282

Standalone FFmpeg engine for agents. Additive contract for the kajisho5 ecosystem.

  • README: role as Engine / Hands; other repos decide
  • cut --json: requested_*, mode, keyframe_snapped, duration delta
  • contract: reencodes_video / reencodes_audio
  • doctor --json: per-tool usable / missing / fix
  • check.py: plain-language FAIL reasons
  • SKILL.md: this skill does not decide cuts or delivery
  • render.py: non-zero if check stage fails

Breaking: none
Requires: Python 3.9+, FFmpeg 5+