ffmpeg-skill 1.20.0 → 1.22.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/docs/contract.md +14 -13
- package/package.json +1 -1
- package/scripts/_contract.py +40 -0
- package/scripts/batch.py +43 -8
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 (`1.
|
|
24
|
+
| `skill.version` | the npm / package.json version (`1.22.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`
|
|
@@ -88,19 +88,19 @@ spelling keeps working until 2.0.
|
|
|
88
88
|
|
|
89
89
|
| What 2.0 removes | Since | Replacement | To be ready today |
|
|
90
90
|
|---|---|---|---|
|
|
91
|
-
| The per-tool v1 success keys next to `result_v2` (`output`, `probe`, `commands`, `verified`, `verification` and each tool's own keys at the top level) | 1.
|
|
92
|
-
| `--crf` as an alias of `--quality` on every re-encoding tool that takes `--quality` (`export.py` keeps `--crf`: its preset chooses the encoder) | 1.
|
|
93
|
-
| `json` and `progress` in the MCP `inputSchema` | 1.
|
|
94
|
-
| `hdr` meaning "BT.2020 primaries *or* a PQ/HLG transfer" in `probe` | 1.
|
|
95
|
-
| Overwriting an existing output with only a warning | 1.
|
|
91
|
+
| The per-tool v1 success keys next to `result_v2` (`output`, `probe`, `commands`, `verified`, `verification` and each tool's own keys at the top level) | 1.22.0 | `result_v2`, promoted to the top level in 2.0 | Run with `FFMPEG_SKILL_RESULT_V2=1` and read `result_v2` (`metrics`, `notes`, `details`) instead of the top-level keys |
|
|
92
|
+
| `--crf` as an alias of `--quality` on every re-encoding tool that takes `--quality` (`export.py` keeps `--crf`: its preset chooses the encoder) | 1.22.0 | `--quality N` (the same CRF scale, codec-neutral) | Pass `--quality`; `--crf` warns on stderr and is marked in `--help` |
|
|
93
|
+
| `json` and `progress` in the MCP `inputSchema` | 1.22.0 | nothing: the transport sets them itself | Stop sending them from an MCP client; run the server with `FFMPEG_SKILL_MCP_LEAN=1` to see the 2.0 schema |
|
|
94
|
+
| `hdr` meaning "BT.2020 primaries *or* a PQ/HLG transfer" in `probe` | 1.22.0 | `hdr_signal` (true only for PQ / HLG / Dolby Vision); in 2.0 `hdr` takes that meaning | Key on `hdr_signal` for "is this a real HDR signal" and on `hdr_format` for the `BT.2020 SDR` case |
|
|
95
|
+
| Overwriting an existing output with only a warning | 1.22.0 | `--overwrite` as explicit consent (refused without it from 2.0) | Set `FFMPEG_SKILL_NO_OVERWRITE=1` (the recommended agent setting) and pass `--overwrite` where a replacement is intended |
|
|
96
96
|
|
|
97
97
|
## Skill
|
|
98
98
|
|
|
99
99
|
```json
|
|
100
100
|
{
|
|
101
101
|
"contract_version": "1.0",
|
|
102
|
-
"deprecated": [{"what": "...", "since": "1.
|
|
103
|
-
"skill": {"id": "ffmpeg-skill", "version": "1.
|
|
102
|
+
"deprecated": [{"what": "...", "since": "1.22.0", "replacement": "...", "removed_in": "2.0.0", "where": "cli | json | mcp | behaviour"}],
|
|
103
|
+
"skill": {"id": "ffmpeg-skill", "version": "1.22.0", "execution_mode": "local", "kind": "execution",
|
|
104
104
|
"entrypoints": {"cli": "...", "mcp": "...", "contract": "...", "doctor": "..."},
|
|
105
105
|
"not_provided": ["AI reasoning", "decisions", "production plans", "project IR", "approvals", "network access", "transcription engine"]},
|
|
106
106
|
"requirements": {"python": ">=3.9 (standard library only)", "ffmpeg": ">=5.0", "ffprobe": ">=5.0"},
|
|
@@ -120,6 +120,7 @@ One entry per tool under `tools`, sorted by id. Tool ids are stable:
|
|
|
120
120
|
| Field | Meaning |
|
|
121
121
|
|---|---|
|
|
122
122
|
| `id`, `name`, `version`, `executable` | `ffmpeg-skill/cut`, `cut`, skill version, `scripts/cut.py` |
|
|
123
|
+
| `examples[]` | `{prompts[], command}` (1.22.0): SKILL.md's own "User says" / "Do" table rows for this tool, machine-readable — `prompts` is the row's phrasing(s), `command` the row's `Do` text with the markdown backticks stripped. Every tool has at least one; parsed from SKILL.md at read time, so it never drifts from the table a person reads |
|
|
123
124
|
| `role` | `analysis`, `analysis_and_execution`, `execution` or `verification` (see below) |
|
|
124
125
|
| `capabilities.required` | ffmpeg components the tool always needs |
|
|
125
126
|
| `capabilities.optional[]` | `{capability, when}`: needed only for that flag or input |
|
|
@@ -128,7 +129,7 @@ One entry per tool under `tools`, sorted by id. Tool ids are stable:
|
|
|
128
129
|
| `output_schema` | what `--json` prints on stdout |
|
|
129
130
|
| `supports_dry_run`, `dry_run` | whether `--dry-run` plans without running ffmpeg or writing files |
|
|
130
131
|
| `supports_json` | whether `--json` exists |
|
|
131
|
-
| `supports_json_brief` | whether `--json-brief` exists (1.
|
|
132
|
+
| `supports_json_brief` | whether `--json-brief` exists (1.22.0): the same success document with `probe` replaced by a compact `summary` (`duration_s`, `width`, `height`, `fps`, `vcodec`, `acodec`, `channels`, and `lufs` when the tool measured one), `commands` replaced by the number of commands run, and the per-step `verification` list dropped (its verdict stays in `verified`). Tool-specific keys are unchanged, `--json`'s own output is unchanged, and a failure prints the same failure document either way |
|
|
132
133
|
| `mutates_input` | always `false`: no tool overwrites its input |
|
|
133
134
|
| `produces_artifact` | writes a file (media, PNG, HTML, EDL) |
|
|
134
135
|
| `verification` | `{required, tools}`: which tools to run on the output afterwards |
|
|
@@ -434,7 +435,7 @@ given a different type):
|
|
|
434
435
|
|
|
435
436
|
| key | tool | what it holds |
|
|
436
437
|
|---|---|---|
|
|
437
|
-
| `caption` | `caption.py` | the cue-layout counts the run only printed before (`shifted`, `wrapped`, `rebalanced`, `split`, `extended`, `dropped`) plus `wrap` (`"phrase"` or `"measured"`) and `phrase_breaks`, the number of cues a phrase rule broke somewhere the 1.15 width rule would not. `broken_inside_word` (1.
|
|
438
|
+
| `caption` | `caption.py` | the cue-layout counts the run only printed before (`shifted`, `wrapped`, `rebalanced`, `split`, `extended`, `dropped`) plus `wrap` (`"phrase"` or `"measured"`) and `phrase_breaks`, the number of cues a phrase rule broke somewhere the 1.15 width rule would not. `broken_inside_word` (1.22.0) counts atoms hard-sliced at the live column's edge because they did not fit alone even at the size floor — never a rewrite, the sliced pieces are the exact original characters. `overlong` is now residual: it fires only when even a single character is wider than the column |
|
|
438
439
|
| `tracks`, `subtitle_tracks` | `caption.py --mode mux` | one entry per subtitle stream in the output — `{index, file, language, title, codec, default, cues, kept_from_input}`; a stream the input already carried has `file: null` and `kept_from_input: true`. `subtitle_tracks` is the total. Every field describes the file as written, not as asked for: an MPEG-4 output reports `title: null` (the muxer stores none) and `default: true` on its first track (the muxer always enables it), each with a `notes` line |
|
|
439
440
|
| `auto_chapters` | `metadata.py --auto-chapters` | `{source, min_chapter, max_chapters, proposed, kept, titles, chapters, description_block, files}`. `titles` is always `"placeholder"`: the machine-readable form of "the skill did not name these". Each chapter carries its `evidence` (`start`, `silence`, `scene`, or `silence+scene` with the span, its length and the cut time) |
|
|
440
441
|
| `audiogram` | `waveform.py` (every run) | `{style, background, image, position, vis_height, platform, captions, title, stages, verified}`. `background` is `"image"` or `"color"`; `verified` is true when the render probes at the asked-for frame size, frame rate and within 0.05 s of the source audio, and is `false` under `--dry-run`, where nothing was rendered to verify |
|
|
@@ -450,7 +451,7 @@ Per-tool keys added in 1.17, all additive:
|
|
|
450
451
|
| `jobs`, `jobs_requested`, `wall_seconds`, `item_seconds_total`, `timed_out` | `batch.py` | the parallelism actually applied and the number asked for, the batch's wall clock, the sum of the per-item times (so the speed-up can be quoted), and whether the shared timeout budget ran out. A timed-out item carries `"skipped": "timeout"` in its result row |
|
|
451
452
|
| `cache` | `render.py --cache` | `{dir, ffmpeg, hits, misses, saved_seconds, entries}`, plus `would_hit` under `--dry-run`. The ffmpeg build banner, the skill version, the contract version, the forwarded flags (`--fast`, `--codec`, …) and the output's extension are all part of every key, so a cache is never reused across any of them — a `--fast` draft is never served to a run that did not ask for one |
|
|
452
453
|
|
|
453
|
-
Per-tool keys added in 1.
|
|
454
|
+
Per-tool keys added in 1.22.0, all additive:
|
|
454
455
|
|
|
455
456
|
| key | tool | what it holds |
|
|
456
457
|
|---|---|---|
|
|
@@ -458,7 +459,7 @@ Per-tool keys added in 1.20.0, all additive:
|
|
|
458
459
|
| `text_unchanged` | `caption.py` | a sibling inside the `caption` block, **burn mode only** (`--mode mux` never touches the text and omits the key): `true` when the drawn text equals the cues that were handed in — nothing transcribed, no cue dropped, no cue **split** across two consecutive cues and no glyph stripped (`--emoji none`). Wrapping, line breaks and timing do not count: the words are the same. This tool never rewrites, shortens or translates a cue, so the key is a statement of what happened, not a judgement of the text |
|
|
459
460
|
|
|
460
461
|
|
|
461
|
-
Per-tool keys added in 1.
|
|
462
|
+
Per-tool keys added in 1.22.0, all additive:
|
|
462
463
|
|
|
463
464
|
| key | tool | what it holds |
|
|
464
465
|
|---|---|---|
|
|
@@ -588,7 +589,7 @@ most often across the corpus in `evals/agent_prompts*.json`. Every tool -- inclu
|
|
|
588
589
|
-- is still callable by name through `tools/call` regardless of what `tools/list` advertised; the
|
|
589
590
|
contract (`ffmpeg-skill contract --json`) still describes all 42 unconditionally. Set
|
|
590
591
|
`FFMPEG_SKILL_MCP_FULL=1` (anything but "" or `0`) in the server's environment to make `tools/list`
|
|
591
|
-
return all 42, as every version before 1.
|
|
592
|
+
return all 42, as every version before 1.22.0 did.
|
|
592
593
|
|
|
593
594
|
## Consuming the contract from an agent
|
|
594
595
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ffmpeg-skill",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.22.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/scripts/_contract.py
CHANGED
|
@@ -18,6 +18,7 @@ The contract has its own version (CONTRACT_VERSION) that only changes when the s
|
|
|
18
18
|
this document changes; the skill version comes from package.json.
|
|
19
19
|
"""
|
|
20
20
|
import argparse
|
|
21
|
+
import functools
|
|
21
22
|
import importlib.util
|
|
22
23
|
import json
|
|
23
24
|
import os
|
|
@@ -462,6 +463,43 @@ def output_schema(name: str, meta: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
462
463
|
required = ["status", "output", "dry_run", "commands"]
|
|
463
464
|
return {"type": "object", "properties": props, "required": required, "additionalProperties": True}
|
|
464
465
|
|
|
466
|
+
@functools.lru_cache(maxsize=1)
|
|
467
|
+
def skill_examples() -> Dict[str, List[Dict[str, Any]]]:
|
|
468
|
+
"""1.20.0: per-tool `examples`, parsed from SKILL.md's "User says" / "Do" table -- the
|
|
469
|
+
same facts that table already states, machine-readable instead of prose-only. A row
|
|
470
|
+
naming more than one tool (e.g. metadata.py's --auto-chapters row also names render.py)
|
|
471
|
+
attaches to each; a row naming none (prose only, no backtick command) attaches to none.
|
|
472
|
+
`prompts` is a list because a row often gives several phrasings ("cut from 1:20 to 2:05",
|
|
473
|
+
"trim the first 10 s") for the one `command`; `command` has the markdown backticks
|
|
474
|
+
stripped but otherwise keeps the row's own text (including "or"/"then" alternatives).
|
|
475
|
+
"""
|
|
476
|
+
examples: Dict[str, List[Dict[str, Any]]] = {}
|
|
477
|
+
try:
|
|
478
|
+
lines = (ROOT / "SKILL.md").read_text(encoding="utf-8").splitlines()
|
|
479
|
+
except OSError:
|
|
480
|
+
return examples
|
|
481
|
+
in_table = False
|
|
482
|
+
esc_pipe = "\x00" # a markdown-escaped "\|" (a literal pipe inside a cell, e.g. broll's --audio b\|mix) is not a column separator
|
|
483
|
+
for line in lines:
|
|
484
|
+
if line.startswith("| User says"):
|
|
485
|
+
in_table = True
|
|
486
|
+
continue
|
|
487
|
+
if not in_table:
|
|
488
|
+
continue
|
|
489
|
+
if not line.startswith("|"):
|
|
490
|
+
break
|
|
491
|
+
if line.startswith("|---") or line.startswith("|-----"):
|
|
492
|
+
continue
|
|
493
|
+
cells = line.strip().strip("|").replace("\\|", esc_pipe).split("|")
|
|
494
|
+
if len(cells) != 2:
|
|
495
|
+
continue
|
|
496
|
+
prompt_cell, do = (c.replace(esc_pipe, "|").strip() for c in cells)
|
|
497
|
+
prompts = re.findall(r'"([^"]+)"', prompt_cell) or [prompt_cell.strip('"')]
|
|
498
|
+
command = re.sub(r"`([^`]*)`", r"\1", do)
|
|
499
|
+
for tool in dict.fromkeys(re.findall(r"`([a-z][a-z0-9_]*)\.py", do)):
|
|
500
|
+
examples.setdefault(tool, []).append({"prompts": prompts, "command": command})
|
|
501
|
+
return examples
|
|
502
|
+
|
|
465
503
|
|
|
466
504
|
# ----------------------------------------------------------------------------- environment
|
|
467
505
|
def skill_version() -> str:
|
|
@@ -1012,6 +1050,8 @@ def tool_spec(name: str, version: str) -> Dict[str, Any]:
|
|
|
1012
1050
|
"version": version,
|
|
1013
1051
|
"description": (parser.description or "").strip().splitlines()[0] if parser.description else "",
|
|
1014
1052
|
"executable": f"scripts/{name}.py",
|
|
1053
|
+
# 1.20.0: SKILL.md's own request-table rows for this tool, machine-readable
|
|
1054
|
+
"examples": skill_examples().get(name, []),
|
|
1015
1055
|
"role": meta["role"],
|
|
1016
1056
|
"capabilities": {"required": list(meta["required"]), "optional": _merge_optional(meta["optional"], CODEC_CAPS if "codec" in schema["properties"] else [])},
|
|
1017
1057
|
"inputs": list(meta["inputs"]),
|
package/scripts/batch.py
CHANGED
|
@@ -88,21 +88,30 @@ def log(message: str) -> None:
|
|
|
88
88
|
buf.append(message)
|
|
89
89
|
|
|
90
90
|
|
|
91
|
-
def run_step(argv: List[str], per_call: "Optional[float]" = None) -> bool:
|
|
91
|
+
def run_step(argv: List[str], per_call: "Optional[float]" = None) -> "tuple[bool, Optional[dict]]":
|
|
92
|
+
"""Returns (ok, doc): doc is the step's own --json result document, or None if it couldn't
|
|
93
|
+
be parsed. {in}/{out} are already resolved by the caller before argv reaches here, so forcing
|
|
94
|
+
--json on every step (harmless: no step reads another step's stdout, only its output path)
|
|
95
|
+
costs nothing and lets a caller like process() read back tool-specific fields, e.g. cut.py's
|
|
96
|
+
`reencoded`, without adding a bespoke flag per field it might someday want."""
|
|
92
97
|
script = argv[0]
|
|
93
98
|
if script not in ALLOWED_STEP_SCRIPTS:
|
|
94
99
|
die(f"recipe step names a script that isn't one of this skill's own tools: {script!r} "
|
|
95
100
|
f"(must be a bare filename like 'silence.py', found in scripts/)")
|
|
96
|
-
cmd = [str(HERE / script)] + argv[1:] + child_args()
|
|
101
|
+
cmd = [str(HERE / script)] + argv[1:] + child_args() + (["--json"] if "--json" not in argv[1:] else [])
|
|
97
102
|
log(" → " + " ".join(os.path.basename(c) if i < 1 else c for i, c in enumerate(cmd)))
|
|
98
103
|
proc = run_tool(cmd, per_call=per_call)
|
|
104
|
+
try:
|
|
105
|
+
doc = json.loads(proc.stdout)
|
|
106
|
+
except ValueError:
|
|
107
|
+
doc = None
|
|
99
108
|
if proc.returncode != 0:
|
|
100
109
|
log(" " + "\n ".join(proc.stderr.strip().splitlines()[-4:]))
|
|
101
|
-
return False
|
|
110
|
+
return False, doc
|
|
102
111
|
for line in proc.stderr.splitlines():
|
|
103
112
|
if line.startswith("warning:"): # a step's deprecation notice is not swallowed by a success (review 9)
|
|
104
113
|
log(" " + line)
|
|
105
|
-
return True
|
|
114
|
+
return True, doc
|
|
106
115
|
|
|
107
116
|
|
|
108
117
|
def _run_buffered(fn, *a):
|
|
@@ -147,22 +156,34 @@ def process(src: Path, recipe: Dict[str, Any], outdir: Path, work: Path,
|
|
|
147
156
|
proj["output"] = str(final.resolve())
|
|
148
157
|
pj = work / f"{src.stem}_project.json"
|
|
149
158
|
pj.write_text(json.dumps(proj, indent=2), encoding="utf-8")
|
|
150
|
-
ok = run_step(["render.py", str(pj)], budget())
|
|
159
|
+
ok, _doc = run_step(["render.py", str(pj)], budget())
|
|
160
|
+
cut_reencoded: List[bool] = []
|
|
151
161
|
else:
|
|
152
162
|
steps = recipe.get("steps") or []
|
|
153
163
|
if not steps:
|
|
154
164
|
die("recipe needs steps or project")
|
|
155
165
|
cur = str(src)
|
|
156
166
|
ok = True
|
|
167
|
+
cut_reencoded = []
|
|
157
168
|
for i, step in enumerate(steps):
|
|
158
169
|
last = i == len(steps) - 1
|
|
159
170
|
out = str(final) if last else str(work / f"{src.stem}_step{i}.{'mp4' if src.suffix.lower() not in ('.wav', '.mp3', '.m4a', '.flac') else src.suffix.lstrip('.')}")
|
|
160
171
|
argv = [str(a).replace("{in}", cur).replace("{out}", out) for a in step]
|
|
161
|
-
|
|
172
|
+
step_ok, doc = run_step(argv, budget())
|
|
173
|
+
# only cut.py's own doc carries `reencoded` -- true if ANY range this call cut needed
|
|
174
|
+
# the tolerance-triggered hybrid re-encode fallback (cut.py ORs its per-segment results
|
|
175
|
+
# into one top-level field; it doesn't report which range, so this is per cut.py call,
|
|
176
|
+
# not per --segments range).
|
|
177
|
+
if step_ok and argv and argv[0] == "cut.py" and isinstance(doc, dict) and "reencoded" in doc:
|
|
178
|
+
cut_reencoded.append(bool(doc["reencoded"]))
|
|
179
|
+
if not step_ok:
|
|
162
180
|
ok = False
|
|
163
181
|
break
|
|
164
182
|
cur = out
|
|
165
|
-
|
|
183
|
+
result: Dict[str, Any] = {"file": str(src), "output": str(final), "ok": ok, "seconds": round(time.time() - t0, 1)}
|
|
184
|
+
if cut_reencoded:
|
|
185
|
+
result["cut_reencoded"] = cut_reencoded
|
|
186
|
+
return result
|
|
166
187
|
|
|
167
188
|
|
|
168
189
|
def main() -> int:
|
|
@@ -512,10 +533,24 @@ def main() -> int:
|
|
|
512
533
|
failed_files = [r["file"] for r in results if not r["ok"]]
|
|
513
534
|
die(f"{len(results) - done} of {len(results)} items failed: {', '.join(failed_files[:5])}" + (" ..." if len(failed_files) > 5 else ""),
|
|
514
535
|
kind="verification", output=None, dry_run=STATE.dry_run, results=results, processed=done, total=len(results))
|
|
536
|
+
# cut.py's own `reencoded`, rolled up across every cut.py step this batch ran: how much of the
|
|
537
|
+
# folder landed on the fast lossless path (mode "copy") vs fell back to the tolerance-triggered
|
|
538
|
+
# hybrid re-encode on at least one range -- purely a function of the sources' keyframe placement
|
|
539
|
+
# relative to each cut point, not something a single run can predict, so it's worth reporting
|
|
540
|
+
# after the fact rather than not at all. Per cut.py call, not per --segments range: cut.py ORs
|
|
541
|
+
# its own per-segment results into one top-level field and doesn't say which range needed it.
|
|
542
|
+
all_cuts = [r for res in results for r in (res.get("cut_reencoded") or [])]
|
|
543
|
+
cut_summary = None
|
|
544
|
+
if all_cuts:
|
|
545
|
+
copied = sum(1 for r in all_cuts if not r)
|
|
546
|
+
cut_summary = {"calls": len(all_cuts), "stream_copy": copied, "reencoded": len(all_cuts) - copied,
|
|
547
|
+
"stream_copy_rate": round(copied / len(all_cuts), 3)}
|
|
548
|
+
info(f"cut.py: {copied}/{len(all_cuts)} call(s) stayed fully lossless stream-copy "
|
|
549
|
+
f"({cut_summary['stream_copy_rate']:.0%}), {len(all_cuts) - copied} fell back to hybrid re-encode on at least one range")
|
|
515
550
|
emit(None, results=results, processed=done, total=len(results),
|
|
516
551
|
jobs=jobs, jobs_requested=requested, wall_seconds=round(time.time() - started, 1),
|
|
517
552
|
item_seconds_total=round(sum(float(r.get("seconds") or 0) for r in results), 1),
|
|
518
|
-
timed_out=timed_out["hit"])
|
|
553
|
+
timed_out=timed_out["hit"], cut_stream_copy=cut_summary)
|
|
519
554
|
return 0
|
|
520
555
|
|
|
521
556
|
|