ffmpeg-skill 2.10.6 → 2.10.8

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/README.md CHANGED
@@ -395,9 +395,13 @@ The short list for humans. The agent-facing version, with the reasoning, is the
395
395
 
396
396
  Each operation is a script whose flags are its argparse parser, and that parser is what `contract --json` and the MCP `inputSchema` are built from. CI fails the build when those drift from the scripts. A caller-supplied filter graph, `-vf` string, or other ffmpeg option is not an argument any script accepts. `doctor` reads `ffmpeg -filters` (and `-encoders`, `-bsfs`) and reports a filter a tool needs before a job runs into it. MCP does accept an `argv` array, and that array is the named script's own CLI flags, still bound to that script: the script's flags, never an ffmpeg command line and never a shell.
397
397
 
398
- ### How does this differ from Remotion or HyperFrames? Can they be used together?
398
+ ### How does ffmpeg-skill compare with Remotion?
399
399
 
400
- Remotion renders a video from React components. HyperFrames renders one from HTML. This repository does neither, and it does not call either of them. It edits a media file you already have, on the machine, with ffmpeg and ffprobe, then probes the file it wrote. A render from either tool is an ordinary input: `probe.py`, `cut.py`, `fit.py`, `export.py` and `check.py` take the file the same way they take any other clip.
400
+ ffmpeg-skill edits footage you already have, on this machine, with FFmpeg tools whose flags are a machine-readable contract (`contract --json`). Remotion renders a video from React components. They complement each other: this repository does not call Remotion, and a file Remotion writes is an ordinary input (`probe.py`, `cut.py`, `fit.py`, `export.py`, `check.py`). HyperFrames renders a video from HTML and is the same kind of neighbour: a file it writes is an ordinary input too.
401
+
402
+ ### Does it validate the filter graph before running?
403
+
404
+ No. `--dry-run` prints the ffmpeg command a writing tool would run and does not start ffmpeg, so ffmpeg never parses that filter graph. ffprobe still runs when the tool probes an input that exists. Unless `--overwrite` is passed, an existing output is refused (`kind: input`) before ffmpeg. An output that is the same file as an input is refused either way. No media file is written, and `verified` is false; `--plan FILE` does write the plan JSON. A path that is not on disk yet is not measured: the probe is a stub (0×0, duration 0), so a number taken from it is not a measurement. The measuring tools (`probe`, `check`, `sync`, `multicam`, `scenes`, `cropdetect`, `report`, `silence`, `loudness`, `stabilize`) still run their measurements; only the artifact is skipped, and `silence` with `--filler --transcribe` also runs the speech engine, which may download a model. `verify` accepts `--dry-run` and ignores it: its steps run. `--plan FILE` is that dry run written down — the planned argv, the command lines, and a fingerprint of each existing input (size, plus a sha256 of the first and last 8 MiB). `render.py` and `verify.py` refuse `--plan`. `render.py FILE` runs a plan only when those fingerprints still match, by running the tool again with the planned argv, not by executing the recorded ffmpeg lines; `--dry-run` on that run only prints them. `doctor` reports whether the filters, encoders and bitstream filters the contract names are in this build (`ffmpeg -filters`, `-encoders`, `-bsfs`) and which fonts are installed. A name in that listing is not a check of the graph this command would build, and a listed GPU encoder is not a successful encode.
401
405
 
402
406
  ### Can it remove burned-in subtitles or logos?
403
407
 
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.10.6`) | any release |
24
+ | `skill.version` | the npm / package.json version (`2.10.8`) | 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.10.6", "execution_mode": "local", "kind": "execution",
122
+ "skill": {"id": "ffmpeg-skill", "version": "2.10.8", "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"},
@@ -613,7 +613,7 @@ Per-tool keys added in 1.18.0, all additive:
613
613
  | `shots` | `scenes.py --shots` | `[{start, end, label, flow_magnitude}]` per detected scene, `label` one of `static`/`pan`/`motion` from a lightweight block-matching optical-flow proxy (frames decoded at 4 fps, 48x27, no external dependency). A shot too short to sample two frames is `static` with `flow_magnitude: 0` |
614
614
  | `audio_peaks_db` | `scenes.py --audio-peaks` | `[{time, level}]`, measured dBFS loudness peaks. A **new** key: the pre-existing `audio_peaks` (always reported, unrelated unitless RMS figures used for `--highlights` scoring) keeps its 1.0 meaning unchanged |
615
615
  | `speech` | `scenes.py --speech` | `[{time, speech_music_ratio}]`, a per-second zero-crossing-rate ratio against the file's own median — a measured proxy, not a speech/music classification |
616
- | `motion_centre` | `cropdetect.py --motion-centre` | `[{time, x, y, x_frac, y_frac, motion}]` per second, sampled over the same windows as the crop-bar detection. `x`/`y` are source pixels, `x_frac`/`y_frac` a 0..1 fraction of `source_width`/`source_height`; a window with no measured motion reports `x`/`y`/`x_frac`/`y_frac: null`. Report only — this tool never picks a reframe |
616
+ | `motion_centre` | `cropdetect.py --motion-centre` | `[{time, x, y, x_frac, y_frac, motion}]` per second, sampled over the same windows as the crop-bar detection. `x`/`y` are source pixels, `x_frac`/`y_frac` a 0..1 fraction of `source_width`/`source_height`. Those two sizes are the displayed frame (a 90° or 270° rotation swaps the stored size), the same frame the crop rectangle and `crop.py` use. A window with no measured motion reports `x`/`y`/`x_frac`/`y_frac: null`. Report only — this tool never picks a reframe |
617
617
  | `speech_aware`, `speech_aware.breaths` | `silence.py --speech-aware` | `{min_silence, floor, breaths_kept, breaths_kept_seconds, breaths}`. `breaths` are the sub-`--min-silence` gaps kept because they sit inside a sentence; the removal list (`silences`, `keep`, `removed_seconds`) already reflects the speech-aware classification. Composes with `--filler` through the same `keep_ranges()`/`merge_spans()` pipeline, so `--speech-aware --filler` produces one removal list |
618
618
  | `sources` | `sync.py` | `[{path, offset_s, confidence, drift_ppm}]`, one entry per SOURCE. Present for every run, including the original single-SOURCE shape (where it mirrors the top-level `second`/`offset_seconds`/`confidence` additively). With 2+ SOURCEs it is the *only* per-source shape: there is no top-level `second`/`offset_seconds` because there is no single pair to put there |
619
619
  | `switch_mode`, `min_shot` | `multicam.py --switch energy` | `"energy"` and the `--min-shot` value used (default 1.5s), alongside the existing `cuts` (`[[start, end, camera], ...]`) which already carries the camera index for `--edl`'s companion cut list |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ffmpeg-skill",
3
- "version": "2.10.6",
3
+ "version": "2.10.8",
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",
@@ -281,8 +281,12 @@ see what the app covers. An explicit `--margin` always wins.
281
281
 
282
282
  ### Dimensions and rotation
283
283
  `yuv420p` needs even width/height; `fit.py` and `export.py` round to even values
284
- automatically. Phone footage often carries a `rotation` tag; `probe.py` reports
285
- it and `fit.py` accounts for it when computing the output frame.
284
+ automatically. Phone footage often carries a `rotation` tag. `probe.py` reports
285
+ the stored width and height plus that rotation. A tool that places or measures
286
+ a rectangle (`fit.py`, `crop.py`, `cropdetect.py`, `broll.py`, `overlay.py
287
+ --scale-percent` / `--platform`, `look.py --safe`) uses the displayed size,
288
+ the two swapped for 90° and 270°, because ffmpeg applies the tag before the
289
+ filter graph.
286
290
 
287
291
  ## Planning
288
292
 
@@ -242,7 +242,10 @@ cropdetect.py INPUT [--seconds N] [--samples N] [--limit F] [--round N] [--motio
242
242
  Measurement only -- writes no file. Samples `--samples` windows spread
243
243
  across the file (default 5, totalling `--seconds` 10s of footage) and
244
244
  reports the crop rectangle FFmpeg's `cropdetect` filter found most often, as
245
- `{x, y, width, height}` ready to hand to `crop.py`. Distinct from
245
+ `{x, y, width, height}` ready to hand to `crop.py`. `source_width` /
246
+ `source_height` are that same displayed frame: a 90° or 270° rotation swaps
247
+ the stored size, because the filter measures the picture after ffmpeg applies
248
+ the tag. Distinct from
246
249
  `fit.py --fit crop`, which crops to a target aspect ratio it computes
247
250
  itself with no black-bar measurement involved. A rectangle that matches the
248
251
  full source frame means no bars were found. Does not decide whether
@@ -501,7 +504,8 @@ broll.py A.mp4 --insert B.mp4 --at T [--duration D | --end T2] [--from T3]
501
504
  [--insert ... --at ...] [--audio a|b|mix] [--pad-color black] [-o OUT]
502
505
  ```
503
506
  A plays as it is; during each window B's picture is shown instead (scaled and
504
- padded to A's frame, A's fps), and A resumes at its own time when the window
507
+ padded to A's displayed frame, A's fps; a 90° or 270° rotation swaps the
508
+ stored size), and A resumes at its own time when the window
505
509
  ends -- a cutaway, not a splice, so the output is exactly as long as A. One
506
510
  `--insert`/`--at` pair per cutaway (`--duration`, `--end`, `--from` are per
507
511
  cutaway too, or given once for all; defaults 4 s and 0); windows may not
@@ -1220,7 +1224,8 @@ Outputs PNG. View it with the Read tool (or any image viewer) and judge the
1220
1224
  frame like an editor would. Use `--compare` to show before/after to the user. `--safe NAME` (1.14) shades the zones that
1221
1225
  destination's own UI covers -- TikTok's description block and like column, the Reels/Shorts
1222
1226
  chrome -- on the sheet or the frame, so "is the caption readable" can be answered about the app
1223
- rather than about the file.
1227
+ rather than about the file. The "wrong aspect" note compares the displayed frame, so a phone
1228
+ file stored sideways is not flagged when the picture already matches the destination.
1224
1229
 
1225
1230
  ### caption.py — subtitles (static, animated, karaoke)
1226
1231
  ```
@@ -1466,6 +1471,9 @@ overlay.py INPUT --image PNG [--scale W | --scale-percent P] | --text "..." [--f
1466
1471
  `--platform NAME` (1.14) takes each edge's margin from that destination's safe zone
1467
1472
  (`scripts/_platforms.py`), so a template's top-left logo clears TikTok's status bar instead of
1468
1473
  sitting 24 px into it; an explicit `--margin` (or a brand `safe_margin`) wins.
1474
+ `--scale-percent` and those margins use the displayed frame width and height:
1475
+ a 90° or 270° rotation swaps the stored size, which is the frame the overlay
1476
+ is composited on.
1469
1477
  Alpha in PNGs is respected. Fades apply to the overlay only; the video keeps
1470
1478
  playing. `--scale` / `--scale-percent` set the width of an image, logo or
1471
1479
  `--video` layer. They are refused with `--text`: text size is `--font-size`.
@@ -244,7 +244,7 @@ def fmt_smpte_time(seconds: float, fps: float) -> str:
244
244
  return f"{h:02d}:{m:02d}:{s:02d}:{frame:02d}"
245
245
 
246
246
 
247
- def escape_filter_path(path: str) -> str:
247
+ def escape_filter_path(path: str, *, bind: bool = True) -> str:
248
248
  """Escape a file path for use as a filter option value (subtitles=, ass=, lut3d=file=, fontfile=, fontsdir=).
249
249
 
250
250
  A filter option value is parsed twice: the graph parser splits filters on `,` / `;` and options
@@ -258,8 +258,11 @@ def escape_filter_path(path: str) -> str:
258
258
  token, so a single `\\'` is consumed by the first pass and "Ryo's Mac/cues.srt" reaches the
259
259
  filter as "Ryos Mac/cues.srt" (Unable to open ...). Three backslashes survive both passes
260
260
  (measured on 6.1 and 7.1 with subtitles=, ass= and lut3d=file=).
261
+
262
+ `bind=False` escapes without recording the path as a plan input. The colour-emoji probe
263
+ uses that: its SRT lives in a temp directory that is gone before any plan is written.
261
264
  """
262
- if os.path.isfile(path) and path not in STATE.plan_inputs:
265
+ if bind and os.path.isfile(path) and path not in STATE.plan_inputs:
263
266
  STATE.plan_inputs.append(path) # a plan binds subtitle/LUT/font files too (review 6)
264
267
  p = str(Path(path))
265
268
  p = p.replace("\\", "/")
@@ -8,6 +8,7 @@ import os
8
8
  import shutil
9
9
  import subprocess
10
10
  from typing import Any, Dict, List, Tuple, Optional
11
+ from _common.decision import escape_filter_path
11
12
  from _common.emit import die
12
13
  from _common.runner import ffmpeg_exe
13
14
 
@@ -165,6 +166,18 @@ EMOJI_ASSET_HINT = (
165
166
  _EMOJI_COLOR_FAMILIES = ("Noto Color Emoji", "Apple Color Emoji", "Segoe UI Emoji")
166
167
 
167
168
 
169
+ def _fc_list_path(line: str) -> str:
170
+ """The file from one `fc-list ... file` line.
171
+
172
+ fontconfig prints `<file>: <families>`. The separator is a colon followed by a space.
173
+ A Windows drive letter is `C:` followed by a backslash, so splitting on the first colon
174
+ yields `C` and the font is reported missing.
175
+ """
176
+ if ": " in line:
177
+ return line.partition(": ")[0].strip()
178
+ return line.strip().rstrip(":")
179
+
180
+
168
181
  _EMOJI_SUPPORT_CACHE: "Dict[Tuple[Optional[str], bool], Dict[str, Any]]" = {}
169
182
 
170
183
 
@@ -182,7 +195,7 @@ def _emoji_color_font() -> "Tuple[Optional[str], Optional[str], bool]":
182
195
  if proc.returncode != 0:
183
196
  return None, None, False
184
197
  for line in proc.stdout.splitlines():
185
- path = line.split(":", 1)[0].strip()
198
+ path = _fc_list_path(line)
186
199
  if path and os.path.exists(path):
187
200
  return family, path, True
188
201
  return None, None, True
@@ -205,7 +218,7 @@ def _libass_color_probe() -> "Optional[bool]":
205
218
  proc = subprocess.run(
206
219
  [exe, "-hide_banner", "-loglevel", "error", "-f", "lavfi",
207
220
  "-i", "color=c=black:s=64x64:d=0.04",
208
- "-vf", "subtitles=" + srt.replace("\\", "/"), "-frames:v", "1",
221
+ "-vf", "subtitles=" + escape_filter_path(srt, bind=False), "-frames:v", "1",
209
222
  "-f", "rawvideo", "-pix_fmt", "rgb24", "-"],
210
223
  stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, timeout=20)
211
224
  except (subprocess.TimeoutExpired, OSError):
@@ -1465,7 +1465,20 @@ def mcp_tool(spec: Dict[str, Any]) -> Dict[str, Any]:
1465
1465
  descriptions verbatim (the same ~250 characters repeated 42 times in every tools/list dump);
1466
1466
  it now goes once into the server's `initialize` response (`instructions`) instead, so a tool's
1467
1467
  own description stays its own one-line sentence."""
1468
- return {"name": spec["name"], "description": spec["description"], "inputSchema": mcp_input_schema(spec)}
1468
+ return {"name": spec["name"], "description": spec["description"], "inputSchema": mcp_input_schema(spec),
1469
+ "annotations": mcp_annotations(spec)}
1470
+
1471
+
1472
+ def mcp_annotations(spec: Dict[str, Any]) -> Dict[str, Any]:
1473
+ """MCP ToolAnnotations (spec 2025-06-18) derived from the ToolSpec flags. Hints only: clients
1474
+ must not treat them as a security boundary. Every tool runs locally (no open world); none edits
1475
+ its input in place; a tool that writes nothing is read-only."""
1476
+ writes = bool(spec.get("produces_artifact")) or bool(spec.get("mutates_input"))
1477
+ return {"title": spec["name"],
1478
+ "readOnlyHint": not writes,
1479
+ "destructiveHint": bool(spec.get("mutates_input")),
1480
+ "idempotentHint": spec.get("idempotency_hint") in ("bit_exact", "content_equivalent", "cached"),
1481
+ "openWorldHint": False}
1469
1482
 
1470
1483
 
1471
1484
  def mcp_tools(detect: bool = False) -> List[Dict[str, Any]]:
package/scripts/broll.py CHANGED
@@ -56,6 +56,9 @@ def main() -> int:
56
56
  die("A-roll has no video stream")
57
57
  dur_a = meta_a.get("duration") or 0.0
58
58
  w, h = meta_a["video"]["width"], meta_a["video"]["height"]
59
+ # the cutaway is scaled onto the frame ffmpeg has already rotated, not the stored one
60
+ if meta_a["video"].get("rotation") in (90, -90, 270, -270):
61
+ w, h = h, w
59
62
  fps = meta_a["video"].get("fps") or 30.0
60
63
  has_audio_a = bool(meta_a.get("audio"))
61
64
 
@@ -138,6 +138,11 @@ def main() -> int:
138
138
  if not meta.get("video"):
139
139
  die("input has no video stream")
140
140
  sw, sh = meta["video"]["width"], meta["video"]["height"]
141
+ # cropdetect and the grey decode both see the frame after ffmpeg applies rotation, so the
142
+ # rectangle and the motion points are in that frame. Reporting the stored size made a
143
+ # 176x320 crop look taller than a 320x180 source.
144
+ if meta["video"].get("rotation") in (90, -90, 270, -270):
145
+ sw, sh = sh, sw
141
146
  duration = meta.get("duration") or 0.0
142
147
 
143
148
  rects = detect(args.input, args.seconds, args.samples, args.limit, args.round_to, duration)
package/scripts/look.py CHANGED
@@ -170,8 +170,11 @@ def main() -> int:
170
170
  z = PLATFORMS[args.safe]["safe"]
171
171
  frame = PLATFORMS[args.safe]["frame"]
172
172
  src = meta["video"]
173
- if src.get("width") and src.get("height") and abs(src["width"] / src["height"] - frame["w"] / frame["h"]) > 0.02:
174
- info(f"--safe {args.safe}: this source is {src['width']}x{src['height']}, not {args.safe}'s "
173
+ dw, dh = src.get("width") or 0, src.get("height") or 0
174
+ if src.get("rotation") in (90, -90, 270, -270):
175
+ dw, dh = dh, dw
176
+ if dw and dh and abs(dw / dh - frame["w"] / frame["h"]) > 0.02:
177
+ info(f"--safe {args.safe}: this source is {dw}x{dh}, not {args.safe}'s "
175
178
  f"{frame['w']}x{frame['h']} -- the zones are drawn as fractions of the frame you gave, "
176
179
  f"so reframe first (fit.py --aspect) to see what the app really covers")
177
180
  boxes = []
@@ -117,7 +117,9 @@ def main() -> int:
117
117
  ap.add_argument("--opacity", type=float, default=1.0, help="0..1 (default 1)")
118
118
  img = ap.add_argument_group("image options")
119
119
  img.add_argument("--scale", type=int, help="width in px for an image, logo or video layer (keeps aspect); text size is --font-size")
120
- img.add_argument("--scale-percent", type=float, help="width as a %% of the video for an image, logo or video layer (keeps aspect); text size is --font-size")
120
+ img.add_argument("--scale-percent", type=float,
121
+ help="width as a %% of the displayed frame for an image, logo or video layer (keeps aspect); "
122
+ "a 90 or 270 degree rotation uses the stored height; text size is --font-size")
121
123
  txt = ap.add_argument_group("text options")
122
124
  txt.add_argument("--font", default="DejaVu Sans", help="fontconfig font name")
123
125
  txt.add_argument("--font-file", help="explicit .ttf/.otf/.ttc path (use this for CJK fonts)")
@@ -209,7 +211,12 @@ def main() -> int:
209
211
  die(f"--audio-stream {args.audio_stream}: input has {len(audio_streams)} audio stream(s), 0..{len(audio_streams) - 1}")
210
212
  if args.audio_stream and not audio_streams:
211
213
  die("--audio-stream needs an input with audio streams")
212
- vw = meta["video"]["width"]
214
+ vw = meta["video"]["width"] or 0
215
+ vh = meta["video"].get("height") or 0
216
+ # ffmpeg applies display rotation before the filter graph, so the frame this overlay is
217
+ # composited on is the displayed one. A 320x180 phone file tagged 90 degrees is 180 wide here.
218
+ if meta["video"].get("rotation") in (90, -90, 270, -270):
219
+ vw, vh = vh, vw
213
220
  # --platform: the edges this destination's own UI covers, in pixels of this frame. An
214
221
  # explicit --margin (or a brand safe_margin, applied above) is the more specific statement
215
222
  # and wins; without either, the historical 24 px default is unchanged.
@@ -220,7 +227,7 @@ def main() -> int:
220
227
  # fall back to the destination's own delivery frame -- which is what the fitted
221
228
  # intermediate this stage runs on will be anyway
222
229
  frame = PLATFORMS[args.platform]["frame"]
223
- pw, ph = vw or frame["w"], meta["video"].get("height") or frame["h"]
230
+ pw, ph = vw or frame["w"], vh or frame["h"]
224
231
  safe_margins = safe_margins_px(args.platform, pw, ph)
225
232
  info(f"--platform {args.platform}: safe margins top {safe_margins['top']} / bottom {safe_margins['bottom']} / "
226
233
  f"left {safe_margins['left']} / right {safe_margins['right']} px (clear of the app's own UI)")