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 +1 -1
- package/docs/contract.md +2 -2
- package/package.json +1 -1
- package/references/scripts.md +17 -0
- package/scripts/_common/timeline.py +1 -1
- package/scripts/render.py +47 -4
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.
|
|
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.
|
|
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.
|
|
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",
|
package/references/scripts.md
CHANGED
|
@@ -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
|
|
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
|
|
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"):
|