ffmpeg-skill 2.9.0 → 2.10.0

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
@@ -257,7 +257,7 @@ These are the rules the skill file gives the agent and the code enforces.
257
257
 
258
258
  | Tool | What it does |
259
259
  |---|---|
260
- | `render.py` | Render a whole edit from a declarative `project.json` (clips, transitions, captions, overlays including the social sticker/hook/meme graphics, music and stem levels, loudness, export, chapter markers, check); `--init`, `--dry-run`, `--stop-after`, `--cache DIR`/`--from STAGE` (reuse the stages that did not change); the captions block takes the fit-size policy (`fit_size`, `min_size`, `fit_size_scope`) and the run reports the caption stage's counts as `caption`; `--template NAME INPUT` renders a shipped delivery template (`--template all` writes the whole social pack plus its table); `--export-timeline edit.fcpxml\|.edl\|.otio` (2.1) writes the cut as an editor timeline for Final Cut, DaVinci Resolve or Premiere instead of rendering it — clips, speed, dissolves, the music bed and chapter markers carry over, and `not_exported` names what stays behind |
260
+ | `render.py` | Render a whole edit from a declarative `project.json` (clips, transitions, captions, overlays including the social sticker/hook/meme graphics and a picture-in-picture or chroma-keyed `video` layer sized for the final frame, music and stem levels, loudness, export, chapter markers, check); `--init`, `--dry-run`, `--stop-after`, `--cache DIR`/`--from STAGE` (reuse the stages that did not change); the captions block takes the fit-size policy (`fit_size`, `min_size`, `fit_size_scope`) and the run reports the caption stage's counts as `caption`; `--template NAME INPUT` renders a shipped delivery template (`--template all` writes the whole social pack plus its table); `--export-timeline edit.fcpxml\|.edl\|.otio` (2.1) writes the cut as an editor timeline for Final Cut, DaVinci Resolve or Premiere instead of rendering it — clips, speed, dissolves, the music bed and chapter markers carry over, and `not_exported` names what stays behind |
261
261
  | `batch.py` | Apply a step recipe or a project to a folder with a content-hash cache; `--watch`; `--jobs N` processes several files at once under one shared `--timeout` |
262
262
  | `multicam.py` | Align any number of cameras and recorders by audio (with drift correction) and cut between them from a switch list; `--switch energy` auto-cuts to whichever camera is loudest/talking instead of a hand-built list, with `--min-shot` (minimum shot length) and `--edl` (write a plain START-END cut list, not a CMX EDL; for Premiere/Resolve use `--write-project` then `render.py --export-timeline`) |
263
263
  | `verify.py` | Run the toolchain on real device files and report PASS / FAIL per step |
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.9.0`) | any release |
24
+ | `skill.version` | the npm / package.json version (`2.10.0`) | 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.9.0", "execution_mode": "local", "kind": "execution",
122
+ "skill": {"id": "ffmpeg-skill", "version": "2.10.0", "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"},
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ffmpeg-skill",
3
- "version": "2.9.0",
3
+ "version": "2.10.0",
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",
@@ -735,6 +735,23 @@ stem wins, and a stems level with no file to apply it to (`effects` without
735
735
  `"audio": {"voice": "light"|"medium"|"strong"}` picks the voice strength
736
736
  (`true` is `medium`).
737
737
 
738
+ An `overlays[]` entry draws one of `"logo": true`, `"image"`, `"text"` or
739
+ `"video"` (#326: a picture-in-picture or green-screen layer, `overlay.py
740
+ --video`), with `position`, `scale`, `opacity`, `start`, `end`, `margin`,
741
+ `platform` (and `fade`, `box`, `font*` for the still/text kinds) mapping to
742
+ overlay.py's flags. `"chromakey": "0x00ff00"`, `"chromakey_similarity"` and
743
+ `"chromakey_blend"` key a colour out of the video layer. The stage runs after
744
+ `fit`, so `"scale": 480` is 480 px of the delivered frame, and a template's
745
+ safe margins apply as they do to a logo. Refused (`kind: input`) before the
746
+ first stage: `video` together with `logo`/`image`/`text` in one entry (a
747
+ logo, image and text together still draw the first of them, as before), a
748
+ `chromakey*` key without `video`, `chromakey_similarity`/`_blend` without
749
+ `chromakey`, and a layer file that does not exist (under `--dry-run` too: no
750
+ earlier stage writes it). The layer's content is in the stage's `--cache`
751
+ key, so replacing the file re-runs `overlays` and what follows it; paths are
752
+ relative to the project file; `--export-timeline` lists the layer under
753
+ `not_exported`.
754
+
738
755
  `"chapters"` is a chapters file path, or an inline list of `{"at": TIME,
739
756
  "title": STR}`; it runs `metadata.py` on the delivered file as the last stage
740
757
  before `check`, so the markers are in the file that ships (streams copied). The
@@ -39,7 +39,7 @@ _NOT_EXPORTED = {
39
39
  "silence": "the silence cut (render.py measures it at render time; the timeline keeps the clips whole)",
40
40
  "captions": "captions (burn them with render.py, or import the .srt into the editor)",
41
41
  "graphics": "graphics (titles, lower thirds, social templates)",
42
- "overlays": "overlays (logos and text)",
42
+ "overlays": "overlays (logos, images, text and video layers)",
43
43
  "fit": "fit / reframe to the delivery aspect (set the sequence frame in the editor)",
44
44
  "audiogram": "the audiogram picture",
45
45
  "loudness": "loudness normalisation",
package/scripts/render.py CHANGED
@@ -22,7 +22,8 @@ Project format (all keys optional except clips):
22
22
  ],
23
23
  "overlays": [
24
24
  {"logo": true},
25
- {"text": "Episode 12", "position": "bottom", "start": 1, "end": 5, "fade": 0.3, "box": true}
25
+ {"text": "Episode 12", "position": "bottom", "start": 1, "end": 5, "fade": 0.3, "box": true},
26
+ {"video": "webcam.mp4", "position": "bottom-right", "scale": 480, "chromakey": "0x00ff00"}
26
27
  ],
27
28
  "audio": {"voice": "medium", "music": "bed.mp3", "music_volume": -16, "duck": true, "music_fade_out": 2,
28
29
  "effects": "sfx.wav", "stems": {"dialogue": 0, "music": -18, "effects": -22}},
@@ -37,6 +38,9 @@ Stages run in this order: clips (cut) → join → silence → fit → captions
37
38
  graphics → overlays → audio → loudness → export → chapters → check. Missing stages are
38
39
  skipped. "brand" points caption/graphics/overlay at a brand.json (fonts,
39
40
  colours, logo, safe margin); {"logo": true} in overlays places the brand logo.
41
+ An overlay entry draws one of logo, image, text or video (a picture-in-picture
42
+ layer, overlay.py --video; "chromakey", "chromakey_similarity" and
43
+ "chromakey_blend" key a colour out of that video and need "video").
40
44
 
41
45
  "check" mirrors check.py's own exit code: a delivery-spec FAIL (or check.py
42
46
  itself failing to run) exits 1, same as running check.py directly would --
@@ -140,8 +144,10 @@ OBJECT_KEYS: Dict[str, frozenset] = {
140
144
  "graphics[]": frozenset({"template", "name", "title", "subtitle", "start", "end", "position",
141
145
  "from", "scale", "primary", "text_color", "lang",
142
146
  "text", "top", "bottom", "duration", "margin", "platform"}),
147
+ # #326: "video" is a fourth layer kind (overlay.py --video), keyed with the chromakey* keys
143
148
  "overlays[]": frozenset({"logo", "image", "text", "position", "start", "end", "fade", "opacity",
144
- "scale", "font_size", "font", "font_file", "margin", "box", "platform"}),
149
+ "scale", "font_size", "font", "font_file", "margin", "box", "platform",
150
+ "video", "chromakey", "chromakey_similarity", "chromakey_blend"}),
145
151
  "audio": frozenset({"music", "replace", "music_volume", "fade_in", "fade_out", "music_fade_out",
146
152
  "gain", "duck_amount", "duck_threshold", "duck_attack", "duck_release",
147
153
  "voice", "denoise", "duck", "music_loop", "stereo", "mono", "downmix",
@@ -410,6 +416,28 @@ def check_keys(obj: Any, schema: str, label: str) -> None:
410
416
  else f" (valid keys: {', '.join(sorted(valid))})"))
411
417
 
412
418
 
419
+ def check_overlay(ov: Any, label: str) -> None:
420
+ """#326: a video layer is one kind of overlay, as overlay.py's --image/--text/--logo/--video
421
+ are one mutually exclusive choice, and the chromakey* keys only act on it. A logo, image and
422
+ text in one entry still draw the first of them, as before; only a combination with the new
423
+ "video" key is refused, so no project 2.8.6 rendered is refused now."""
424
+ if not isinstance(ov, dict):
425
+ return
426
+ if ov.get("video"):
427
+ others = [k for k in ("logo", "image", "text") if ov.get(k)]
428
+ if others:
429
+ die(f"{label}: give one of logo, image, text or video per entry, not video and {others[0]} "
430
+ "(add a second overlays[] entry for the other layer)")
431
+ else:
432
+ keyed = [k for k in ("chromakey", "chromakey_similarity", "chromakey_blend") if ov.get(k) is not None]
433
+ if keyed:
434
+ die(f"{label}: {keyed[0]} keys a colour out of a video layer and needs \"video\" (overlay.py --chromakey needs --video)")
435
+ if ov.get("chromakey") is None:
436
+ tuned = [k for k in ("chromakey_similarity", "chromakey_blend") if ov.get(k) is not None]
437
+ if tuned:
438
+ die(f"{label}: {tuned[0]} tunes \"chromakey\", which this entry does not set")
439
+
440
+
413
441
  def require_object(obj: Any, label: str) -> None:
414
442
  """Refuse a value that is not an object where the run reads it with .get(): "export": "reels"
415
443
  in a render, a string frame, a clip written as a bare path. 2.2.1 died there with a traceback
@@ -470,6 +498,8 @@ def validate_project(proj: Dict[str, Any], timeline: bool = False, stop_after: O
470
498
  die(f"{name}[{i}]: must be an object {{...}}, got {type(item).__name__} {item!r:.60}")
471
499
  for i, item in enumerate(items if isinstance(items, list) else []):
472
500
  check_keys(item, f"{name}[]", f"{name}[{i}]")
501
+ if name == "overlays":
502
+ check_overlay(item, f"overlays[{i}]")
473
503
  if name != "clips":
474
504
  continue
475
505
  # every clip stage starts from rel(c["src"]): without one it was a KeyError traceback
@@ -954,6 +984,15 @@ def main() -> int:
954
984
  output = rel(proj.get("output") or "final.mp4")
955
985
  # the final stage is a copy from the work dir, so run()'s own guard never sees the sources (review 5)
956
986
  refuse_output_is_input(output, *[rel(c.get("src")) for c in clips if c.get("src")])
987
+ if _reaches("overlays", args.stop_after):
988
+ # #326: an overlay video is the caller's file, never an earlier stage's output, so a
989
+ # missing one is refused here, under --dry-run too, not after every stage before it ran
990
+ for i, ov in enumerate(proj.get("overlays") or []):
991
+ if ov.get("video"):
992
+ layer = rel(ov["video"])
993
+ refuse_output_is_input(output, layer)
994
+ if not os.path.isfile(layer):
995
+ die(f"overlays[{i}].video: file not found: {layer}", kind="input")
957
996
  # 2.0 refuses an existing output without --overwrite; the final place_output() would, but
958
997
  # only after every stage had run -- say so before the first one instead
959
998
  _check_existing_output(["ffmpeg", output])
@@ -1248,9 +1287,13 @@ def main() -> int:
1248
1287
  argv += ["--image", rel(ov["image"])]
1249
1288
  elif ov.get("text"):
1250
1289
  argv += ["--text", ov["text"]]
1290
+ elif ov.get("video"):
1291
+ # #326: sized and placed here, after fit, against the frame that ships
1292
+ argv += ["--video", rel(ov["video"])]
1251
1293
  else:
1252
- die(f"overlays[{i}] needs image or text")
1253
- for k, flag in (("position", "--position"), ("start", "--start"), ("end", "--end"), ("fade", "--fade"), ("opacity", "--opacity"), ("scale", "--scale"), ("font_size", "--font-size"), ("font", "--font"), ("font_file", "--font-file"), ("margin", "--margin"), ("platform", "--platform")):
1294
+ die(f"overlays[{i}] needs logo, image, text or video")
1295
+ for k, flag in (("position", "--position"), ("start", "--start"), ("end", "--end"), ("fade", "--fade"), ("opacity", "--opacity"), ("scale", "--scale"), ("font_size", "--font-size"), ("font", "--font"), ("font_file", "--font-file"), ("margin", "--margin"), ("platform", "--platform"),
1296
+ ("chromakey", "--chromakey"), ("chromakey_similarity", "--chromakey-similarity"), ("chromakey_blend", "--chromakey-blend")):
1254
1297
  if ov.get(k) is not None:
1255
1298
  argv += [flag, str(ov[k])]
1256
1299
  if ov.get("box"):