ffmpeg-skill 2.3.0 → 2.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/SKILL.md CHANGED
@@ -5,13 +5,13 @@ description: 'Edit video and audio with local FFmpeg from natural-language reque
5
5
 
6
6
  # ffmpeg-skill
7
7
 
8
- Scripts live in `scripts/` next to this file; run them with `python3 <skill-dir>/scripts/<name>.py`, and delivery templates in `templates/`. This file is enough to do a job: the table below routes the request, and `--help` on the script about to run is the cheapest full flag list. A reference file costs as much as this one; open one only for a question you have: `references/scripts.md` (every flag of all 42 scripts), `references/devices.md` (iPhone HDR, GoPro, DJI, screen recordings, Zoom), `references/gotchas.md` (the long form of the one-line rules at the end). MCP: `tools/list` shows only the core 12 by default; the other 42 (per this table) stay callable by name via `tools/call`, described by `contract --json`; set `FFMPEG_SKILL_MCP_FULL=1` for all 42.
8
+ Scripts live in `scripts/` next to this file; run them with `python3 <skill-dir>/scripts/<name>.py`, and delivery templates in `templates/`. This file is enough to do a job: the table below routes the request, and `--help` on the script about to run is the cheapest full flag list. A reference file costs as much as this one; open one only for a question you have: `references/scripts.md` (every flag of all 42 scripts), `references/devices.md` (iPhone HDR, GoPro, DJI, screen recordings, Zoom), `references/gotchas.md` (the long form of the one-line rules at the end). MCP: `tools/list` shows only the core 12 by default; the other 30 (per this table) stay callable by name via `tools/call`, described by `contract --json`; set `FFMPEG_SKILL_MCP_FULL=1` for all 42.
9
9
 
10
10
  Shared flags, on every script: `--dry-run`; `--json` (output path, a probe of the output, the commands run); `--json-brief` (status/output/verified plus a `summary`; prefer on writing steps); `--fast` (preview quality); `--progress`; `--timeout SECONDS` (`kind: timeout`, default 1800); `--overwrite` (step 7); `--plan FILE` (the dry run as a plan `render.py FILE` runs later; refuses if an input changed). Re-encoding tools also take `--codec h264|hevc|av1|prores` and `--quality N`: unset, SDR is x264, HDR is x265 Main10; `prores` needs `-o NAME.mov`, `h264` refuses HDR (`color.py --to-sdr` first).
11
11
 
12
12
  Writing tools run nothing under `--dry-run`; the measuring tools (`probe`, `check`, `sync`, `multicam`, `scenes`, `cropdetect`, `report`, `silence`, `loudness`, `stabilize`) may still run ffmpeg/ffprobe, skipping artifacts/side files (`--edl`, `--sheet`, a generated `.ass`); `verify` ignores the flag. Per tool: `contract --json` `dry_run`.
13
13
 
14
- ## Workflow (always follow this order)
14
+ ## Workflow (in this order)
15
15
 
16
16
  0. **Environment, only on failure.** Never start a job with `doctor`: a broken machine fails with `kind: missing_tool` or an ffmpeg error naming the filter/encoder. Run `python3 <skill-dir>/scripts/_contract.py doctor` (or `npx ffmpeg-skill doctor`) after such a failure, or when asked what the machine can do: read `ok`/`usable`, report the missing capability. `contract --json`'s tool schema is for a *planning* agent, not this workflow.
17
17
  1. **Probe what you must plan from.** Run `probe.py` on each input you plan from — duration, fps, resolution, codecs, channels, `variable_frame_rate_suspected` — and whenever the user asks about a file. No separate probe before every edit: every writing tool's `--json` already carries its input and a probe of the output. Plan from real numbers, never assumptions.
@@ -96,7 +96,7 @@ Timestamp flags (`--start`, `--end`, `--at`, `--from`, `--duration`, `--offset`,
96
96
  | "add subtitles from this SRT", "burn in captions" | `caption.py input.mp4 --srt subs.srt` |
97
97
  | "caption it with these lines" (text with times) | `caption.py input.mp4 --text cues.txt` |
98
98
  | "keep the subtitles toggleable", "mux in an SRT" | `caption.py input.mp4 --srt subs.srt --mode mux`; repeat `--srt file:lang` for several languages, `.mkv` for more than two |
99
- | "the captions are tiny / three lines on a Short", "don't chop the sentence" | `caption.py` shrinks the size until the cue fits `--max-lines` before splitting it (`--fit-size off` = 1.16 behaviour, `--min-size` the floor). Keep the template's `--max-lines` (2 on a vertical) and let the size drop; raising it to dodge a shrink stacks two words per line. A word still wider than the column at the floor is sliced at the edge (hyphen preferred), never rewritten, automatic; `broken_inside_word` in the JSON counts it |
99
+ | "the captions are tiny / three lines on a Short", "don't chop the sentence" | `caption.py` shrinks the size until the cue fits `--max-lines` before splitting it (`--fit-size off` keeps `--size` and splits instead, `--min-size` the floor). Keep the template's `--max-lines` (2 on a vertical) and let the size drop; raising it to dodge a shrink stacks two words per line. A word still wider than the column at the floor is sliced at the edge (hyphen preferred), never rewritten, automatic; `broken_inside_word` in the JSON counts it |
100
100
  | "transcribe it and caption it" | `caption.py input.mp4 --transcribe --animate pop --karaoke` (needs a local whisper; else `--text`) |
101
101
  | "TikTok-style captions with the words popping" | `caption.py input.mp4 --text cues.txt --animate pop --karaoke` |
102
102
  | "our logo top-right", "a watermark" | `overlay.py input.mp4 --image logo.png --position top-right --scale 200` |
package/docs/contract.md CHANGED
@@ -21,7 +21,7 @@ The contract is derived from the code that runs, not maintained beside it:
21
21
  | Field | Meaning | Changes when |
22
22
  |---|---|---|
23
23
  | `contract_version` | shape of this document (`1.0`) | a key is renamed, removed or changes meaning |
24
- | `skill.version` | the npm / package.json version (`2.3.0`) | any release |
24
+ | `skill.version` | the npm / package.json version (`2.3.2`) | any release |
25
25
 
26
26
  A release that adds a tool or a flag keeps `contract_version`; a breaking change to the
27
27
  ToolSpec shape bumps it. Consumers pin on `contract_version` and read `skill.version`
@@ -119,7 +119,7 @@ JSON would have been rewritten for that. The flat keys, typed per tool in `outpu
119
119
  "contract_version": "1.0",
120
120
  "deprecated": [],
121
121
  "removed": [{"what": "...", "since": "1.10.0", "replacement": "...", "removed_in": "2.0.0", "where": "cli | json | mcp | behaviour"}],
122
- "skill": {"id": "ffmpeg-skill", "version": "2.3.0", "execution_mode": "local", "kind": "execution",
122
+ "skill": {"id": "ffmpeg-skill", "version": "2.3.2", "execution_mode": "local", "kind": "execution",
123
123
  "entrypoints": {"cli": "...", "mcp": "...", "contract": "...", "doctor": "..."},
124
124
  "not_provided": ["AI reasoning", "decisions", "production plans", "project IR", "approvals", "network access", "transcription engine"]},
125
125
  "requirements": {"python": ">=3.9 (standard library only)", "ffmpeg": ">=5.0", "ffprobe": ">=5.0"},
@@ -225,7 +225,8 @@ subtitle/LUT/font files a filter reads. Tools that print their document without
225
225
  (`probe`, the analysis tools) still write the plan at exit; `verify.py` and `render.py`
226
226
  refuse `--plan` (their steps run for real; a project file is already a plan). `render.py FILE` executes a plan:
227
227
  it refuses (`kind: input`) when an input's size or head/tail hash differs from the plan,
228
- runs the tool with the planned `argv`, then the verify steps, and reports `plan`, `tool`,
228
+ runs the tool with the planned `argv`, then the verify steps (a `check` step may also carry
229
+ `"content": true`, added by hand, for `check.py --content`), and reports `plan`, `tool`,
229
230
  `tool_result` and `check`. `plan_version` is bumped when the document's shape changes.
230
231
 
231
232
  ### Repeatability
@@ -352,7 +353,10 @@ read (`ffmpeg -filters` in a layout the parser does not recognise, or ffmpeg exi
352
353
  non-zero), and it is never folded into `missing`, so an installed filter is not reported
353
354
  absent, nor into `available`, so a failed detection is not a pass. `detection` gives the
354
355
  status (`parsed`, `unparsed`, `failed`, `missing`), row count and detail of each listing;
355
- `errors` lists the unreadable ones. The filter parser recognises the FFmpeg 6/7 layout
356
+ `errors` lists the unreadable ones. `filter:drawtext` is also rendered once, with the
357
+ `fontfile=` the tools pass by default: a crash there makes it `missing`; a crash only on a
358
+ bare fontconfig lookup leaves it `available` and adds a `notes` entry (`--font-file`, not a
359
+ bare `--font <family>`, on that machine). The filter parser recognises the FFmpeg 6/7 layout
356
360
  (three flag characters, `..C acompressor A->A`) and the FFmpeg 8 layout (two, `T.
357
361
  acompressor A->A`) by the io-spec token, so the flag width does not matter; fixtures for
358
362
  both live in `tests/fixtures/`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ffmpeg-skill",
3
- "version": "2.3.0",
3
+ "version": "2.3.2",
4
4
  "description": "Agent Skill that gives coding agents (Claude Code, Cursor, Codex) a local video editor: 42 FFmpeg tools with a machine-readable contract, contract-derived MCP server, FFmpeg capability detection, probe-first / verify-last workflow. Cut, join, silence removal, fit, captions and karaoke, overlays, motion graphics, HDR to SDR, LUTs, audio clean-up and typed dynamics, sync with drift correction, multicam, loudness, delivery checks, project rendering, batch. No API keys, no cloud, no dependencies.",
5
5
  "keywords": [
6
6
  "ffmpeg",
@@ -631,7 +631,8 @@ A plan is a single tool's dry run as an artifact: `cut.py in.mp4 --start 2 --end
631
631
  head+tail), commands, output, verify}` and runs nothing. `render.py cut.json`
632
632
  re-fingerprints the inputs (refusing, `kind: input`, if any changed since the
633
633
  plan), runs the tool with the planned argv, then the verify steps (probe; `check`
634
- for a `--platform` or a platform export preset), and reports `plan`, `tool`,
634
+ for a `--platform` or a platform export preset; add `"content": true` to a check
635
+ step for the black/frozen/silence rows), and reports `plan`, `tool`,
635
636
  `tool_result` and `check`. Show the plan to the user, get the yes, execute:
636
637
  one round trip instead of re-deriving the command.
637
638
  `"export": {"preset": "reels", "normalize": true}` forwards `export.py --normalize`
@@ -29,7 +29,7 @@ EMOJI_SENTINEL = "\ue000"
29
29
 
30
30
 
31
31
  def emoji_placeholder(box_px: float) -> str:
32
- """The ASS override that reserves exactly `box_px` of advance and draws nothing.
32
+ r"""The ASS override that reserves exactly `box_px` of advance and draws nothing.
33
33
 
34
34
  Measured, not assumed (1.15 spec, open question 2). U+2588 FULL BLOCK is NOT 1.0 em: its
35
35
  advance measured 0.83 em in FreeSans, 0.79 in WenQuanYi Zen Hei and 0.66 in DejaVu Sans,
@@ -98,7 +98,7 @@ def escape_drawtext(text: str) -> str:
98
98
 
99
99
 
100
100
  def drawtext_text_opts(text: str, tmpdir: "Optional[str]" = None) -> str:
101
- """`textfile=<path>:expansion=none` for drawtext -- the one route that is provably safe for
101
+ r"""`textfile=<path>:expansion=none` for drawtext -- the one route that is provably safe for
102
102
  every character on every build shape this repo uses.
103
103
 
104
104
  The filter-graph parser never sees the text at all: only the PATH is parsed, and
@@ -668,22 +668,42 @@ def _drawtext_probe() -> Dict[str, Any]:
668
668
  present and doctor used to report the capability `available` anyway; every tool that actually
669
669
  used it (look, scenes --sheet, overlay --text, graphics) then crashed on first real use (#100).
670
670
 
671
- This runs the cheapest real drawtext render there is: a one-frame synthetic clip, no font=
672
- given at all (ffmpeg's own default resolution -- the same path that crashed). A clean exit
673
- means drawtext genuinely works here. Anything that could not prove either way (no ffmpeg,
674
- timeout, an ordinary nonzero exit with a real ffmpeg error) is `unknown`, same "unknown is not
675
- missing" principle as every other capability here. A crash specifically -- killed by signal on
676
- POSIX, or an unhandled access violation surfacing as a huge unsigned exit code on Windows -- is
677
- the one case this function exists to catch, and folds into `missing`: the filter is present in
678
- the build but cannot actually be used as ffmpeg's own default would use it.
671
+ This runs the cheapest real drawtext render there is: a one-frame synthetic clip, with the
672
+ fontfile= the tools themselves pass by default (_common.fonts.default_font_file), or no font
673
+ at all where none resolves. A clean exit means drawtext genuinely works for the tools here.
674
+ Anything that could not prove either way (no ffmpeg, timeout, an ordinary nonzero exit with a
675
+ real ffmpeg error) is `unknown`, same "unknown is not missing" principle as every other
676
+ capability here. A crash specifically -- killed by signal on POSIX, or an unhandled access
677
+ violation surfacing as a huge unsigned exit code on Windows -- folds into `missing`. When the
678
+ fontfile= render works but a bare one crashes, drawtext stays available and the result
679
+ carries a `note`: only a fontconfig lookup (`--font <family>` with no file) would crash.
679
680
  """
680
681
  exe = shutil.which("ffmpeg")
681
682
  if not exe:
682
683
  return {"status": "unknown", "detail": "ffmpeg not on PATH"}
684
+ # Probe the way the tools call drawtext: with the fontfile= they pick by default (#298). On a
685
+ # build whose fontconfig lookup crashes (#100) the tools still render, because they never ask
686
+ # fontconfig; probing only the bare `drawtext=text=x` reported such a machine as missing
687
+ # drawtext altogether. The bare render still runs, and its crash is reported as a note: it is
688
+ # the path `--font <family>` without a file would take.
689
+ from _common.fonts import default_font_file
690
+ from _common.decision import escape_filter_path
691
+ font_file = default_font_file(_default_font())
692
+ result = _drawtext_render(exe, f"drawtext=fontfile={escape_filter_path(font_file)}:text=x" if font_file else "drawtext=text=x")
693
+ if font_file and result["status"] == "available":
694
+ bare = _drawtext_render(exe, "drawtext=text=x")
695
+ if bare["status"] == "missing":
696
+ result["note"] = ("drawtext renders with fontfile= (what every drawtext tool passes by default) but crashes "
697
+ "when it has to resolve a font through fontconfig: pass --font-file, not a bare --font "
698
+ "<family>, on this machine (https://github.com/kajisho5/ffmpeg-skill/issues/100)")
699
+ return result
700
+
701
+
702
+ def _drawtext_render(exe: str, vf: str) -> Dict[str, Any]:
683
703
  try:
684
704
  proc = subprocess.run(
685
705
  [exe, "-hide_banner", "-loglevel", "error", "-f", "lavfi", "-i", "color=c=black:s=64x64:d=1",
686
- "-vf", "drawtext=text=x", "-frames:v", "1", "-f", "null", "-"],
706
+ "-vf", vf, "-frames:v", "1", "-f", "null", "-"],
687
707
  stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, encoding="utf-8", errors="replace", timeout=_DETECT_TIMEOUT,
688
708
  )
689
709
  except subprocess.TimeoutExpired:
@@ -695,9 +715,8 @@ def _drawtext_probe() -> Dict[str, Any]:
695
715
  if proc.returncode < 0 or proc.returncode >= 0x80000000:
696
716
  return {"status": "missing",
697
717
  "detail": f"drawtext render crashed (exit {proc.returncode}) instead of failing cleanly -- "
698
- "the filter is present in this build but cannot be used as-is, likely a fontconfig "
699
- "resolution crash (see https://github.com/kajisho5/ffmpeg-skill/issues/100); "
700
- "pass an explicit --font-file to every drawtext tool as a workaround"}
718
+ "the filter is present in this build but cannot render even with the fontfile= the "
719
+ "tools pass (see https://github.com/kajisho5/ffmpeg-skill/issues/100)"}
701
720
  tail = " ".join(proc.stderr.strip().splitlines()[-2:])
702
721
  return {"status": "unknown", "detail": f"drawtext probe exited {proc.returncode}: {tail}"}
703
722
 
@@ -809,6 +828,8 @@ def doctor() -> Dict[str, Any]:
809
828
  # than downgrading it; see _drawtext_probe()'s own docstring for why a crash alone
810
829
  # is the one case this exists to catch.
811
830
  probe = _drawtext_probe()
831
+ if probe.get("note"):
832
+ drawtext_probe = probe
812
833
  if probe["status"] == "missing":
813
834
  drawtext_probe = probe
814
835
  state[cap] = "missing"
@@ -832,6 +853,7 @@ def doctor() -> Dict[str, Any]:
832
853
  errors = [f"{k}: {v['detail']}" for k, v in listings.items() if v["status"] in ("unparsed", "failed")]
833
854
  if drawtext_probe is not None and drawtext_probe["status"] != "available":
834
855
  errors.append(f"filter:drawtext: {drawtext_probe['detail']}")
856
+ notes = [f"filter:drawtext: {drawtext_probe['note']}"] if drawtext_probe and drawtext_probe.get("note") else []
835
857
  return {
836
858
  "version": skill_version(),
837
859
  "python": ".".join(str(x) for x in sys.version_info[:3]),
@@ -843,6 +865,7 @@ def doctor() -> Dict[str, Any]:
843
865
  "unknown": unknown,
844
866
  "detection": {k: {"status": v["status"], "count": len(v["names"]), "detail": v["detail"]} for k, v in listings.items()},
845
867
  "errors": errors,
868
+ **({"notes": notes} if notes else {}),
846
869
  "ok": not missing_required and not unknown_required,
847
870
  "tools": _tool_usability(state),
848
871
  "gpu_encoders": _gpu_encoders(listings["encoders"]),
@@ -954,7 +954,7 @@ def main() -> int:
954
954
  sty.add_argument("--max-lines", type=int, default=2, help="most lines one cue may occupy; a longer cue is split into consecutive cues (default 2)")
955
955
  sty.add_argument("--fit-size", choices=["auto", "on", "off"], default="auto",
956
956
  help="shrink the caption size until the cue fits --max-lines, BEFORE splitting it: "
957
- "'auto' (default) only when no --size was given, 'on' always, 'off' for 1.16 behaviour")
957
+ "'auto' (default) only when no --size was given, 'on' always, 'off' keeps --size and splits the cue instead")
958
958
  sty.add_argument("--min-size", type=int, default=None,
959
959
  help="smallest size --fit-size may use, in ASS points (default 13 = 4.5%% of the frame height, "
960
960
  "the legibility floor)")
package/scripts/render.py CHANGED
@@ -729,8 +729,11 @@ def execute_plan(plan: Dict[str, Any], path: str) -> int:
729
729
  check_result = None
730
730
  exit_code = 0
731
731
  for step in plan.get("verify") or []:
732
- if step.get("tool") == "check" and step.get("platform") and output:
733
- cp = run_tool([str(HERE / "check.py"), output, "--platform", step["platform"], "--json"] + child_args())
732
+ # a check step names a platform, asks for check.py --content's black/frozen/silence rows
733
+ # ("content": true), or both -- the same keys a project's "check" stage takes
734
+ if step.get("tool") == "check" and (step.get("platform") or step.get("content") is True) and output:
735
+ cp = run_tool([str(HERE / "check.py"), output] + (["--platform", str(step["platform"])] if step.get("platform") else [])
736
+ + (["--content"] if step.get("content") is True else []) + ["--json"] + child_args())
734
737
  try:
735
738
  check_result = json.loads(cp.stdout)
736
739
  except ValueError:
@@ -742,7 +745,7 @@ def execute_plan(plan: Dict[str, Any], path: str) -> int:
742
745
  # same command run by hand)
743
746
  if exit_code:
744
747
  failed_rows = [r["check"] for r in (check_result or {}).get("checks", []) if r.get("status") == "FAIL"]
745
- info(f"plan done: {output}, but the {check_result.get('platform')} check failed" + (f": {', '.join(failed_rows)}" if failed_rows else ""))
748
+ info(f"plan done: {output}, but the {check_result.get('platform') or 'content'} check failed" + (f": {', '.join(failed_rows)}" if failed_rows else ""))
746
749
  else:
747
750
  info(f"plan done: {output}")
748
751
  emit(output, plan=path, tool=tool, stages=[tool] + (["check"] if check_result else []), check=check_result, tool_result=doc,