ffmpeg-skill 0.8.4 → 0.9.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
@@ -18,7 +18,7 @@ npx ffmpeg-skill
18
18
  - **Lossless when possible** — cuts and joins use stream copy by default; re-encoding only happens when it must (frame-accurate cuts, filters, format changes).
19
19
  - **Cut & join** segments with `mm:ss` / `hh:mm:ss.ms` times.
20
20
  - **Declarative edits** — describe the whole edit in a `project.json` (clips, transitions, captions, overlays, music, loudness, export, check) and re-render after every tweak.
21
- - **MCP server** — `mcp/server.py` exposes every script as an MCP tool over stdio (stdlib only) for Claude Desktop, Cursor or any MCP client.
21
+ - **MCP server** — `mcp/server.py` exposes every script as an MCP tool over stdio (stdlib only) for Claude Desktop, Cursor or any MCP client; tool names, order and `inputSchema` are derived from the contract, so the MCP surface follows the scripts.
22
22
  - **Batch / watch folder** — one recipe over a whole shoot with a content-hash cache; re-runs only touch what changed.
23
23
  - **Optional local transcription** — `caption.py --transcribe` uses whisper.cpp / faster-whisper / openai-whisper when present; never required.
24
24
  - **Brand kit** — one `brand.json` (fonts, colours, logo, safe margins, caption style) applied by captions, overlays, graphics and projects.
@@ -140,13 +140,22 @@ python3 tests/corpus.py --fetch --verify # ~1.4 GB download, then verify (sl
140
140
  python3 tests/bench_sync.py --cases 100
141
141
  ```
142
142
 
143
+ ## Machine-readable contract
144
+
145
+ ```bash
146
+ npx ffmpeg-skill contract --json # every tool as a ToolSpec: schema from argparse, role, capabilities, verification, dry-run
147
+ npx ffmpeg-skill doctor # which required ffmpeg components this machine has
148
+ ```
149
+
150
+ For agent frameworks that treat ffmpeg-skill as an execution skill: `contract --json` lists the 21 tools as `ffmpeg-skill/<name>` with input/output schemas, `analysis` / `execution` / `verification` roles, the ffmpeg encoders and filters each one needs, whether the result must be probed, checked or looked at, and that no tool modifies its input or runs a shell. `contract_version` (1.0) is separate from the skill version. Details in [docs/contract.md](docs/contract.md).
151
+
143
152
  ## MCP
144
153
 
145
154
  ```json
146
155
  {"mcpServers": {"ffmpeg-skill": {"command": "python3", "args": ["/Users/you/.claude/skills/ffmpeg-skill/mcp/server.py"]}}}
147
156
  ```
148
157
 
149
- `python3 mcp/server.py --list` prints the tools; `--call probe '{"inputs": ["a.mp4"]}'` runs one from the shell.
158
+ `python3 mcp/server.py --list` prints the tools (the same set, order and schemas as `contract --json`: the contract is the source of truth, MCP is the transport); `--call probe '{"inputs": ["a.mp4"]}'` runs one from the shell.
150
159
 
151
160
  ## Requirements
152
161
 
@@ -163,6 +172,10 @@ python3 evals/run.py --list # routing eval prompts (see evals/)
163
172
  node bin/install.js --dir /tmp/skills # try the installer without touching ~/.claude
164
173
  ```
165
174
 
175
+ ## Support
176
+
177
+ If this skill saves you time, you can help keep it maintained through [GitHub Sponsors](https://github.com/sponsors/kajisho5). Issues and pull requests are just as welcome.
178
+
166
179
  ## License
167
180
 
168
181
  [MIT](LICENSE)
package/SKILL.md CHANGED
@@ -49,7 +49,8 @@ Scripts live in `scripts/` next to this file; run them with `python3 <skill-dir>
49
49
  crops keeping the subject, colours not washed out, transitions landing
50
50
  where intended. The job is not finished until the report's `Look:` line
51
51
  names that PNG; a probe alone cannot see a caption sitting on someone's
52
- face. Audio-only jobs (sync, loudness, silence) write `Look: not needed`.
52
+ face. Audio-only jobs (sync, loudness, silence, or any job whose input is
53
+ an audio file) write `Look: not needed`; there is no picture to inspect.
53
54
 
54
55
 
55
56
  ## Before you run anything: what to ask, what to assume
@@ -112,6 +113,34 @@ Do not ask for things `probe.py` can tell you.
112
113
  | "it's a phone video with variable frame rate" | nothing extra: every re-encoding script conforms VFR to constant fps automatically; `fit.py --fps 30` to pick the rate |
113
114
 
114
115
 
116
+ ## Audio-only files
117
+
118
+ Audio files are a first-class input, not a special case. `probe.py`, `cut.py`,
119
+ `silence.py`, `loudness.py`, `audio.py`, `sync.py` and `check.py --platform
120
+ podcast` all accept WAV, FLAC, MP3, M4A/AAC, OGG and Opus (any container ffmpeg
121
+ can read) and write the codec that fits the output extension, so the same
122
+ commands work with `talk.wav` in place of `talk.mp4`. What changes:
123
+
124
+ - The output extension picks the format: `-o out.mp3` converts, `-o out.wav`
125
+ keeps PCM, `-o out.m4a` writes AAC. `audio.py in.wav -o out.mp3` with no
126
+ other flag is a plain conversion.
127
+ - `cut.py` stream-copies audio too, so trims are lossless unless the format
128
+ cannot be cut on a packet boundary.
129
+ - `Look: not needed` in the report; `Check:` still applies for loudness
130
+ (`check.py file.wav --platform podcast` measures LUFS and true peak).
131
+ - Scripts that need a picture (`fit`, `caption`, `overlay`, `graphics`,
132
+ `color`, `export`, `join`, `scenes`, `look`) refuse an audio file with
133
+ "input has no video stream". Say so instead of forcing a video wrapper.
134
+
135
+ | User says (audio file) | Do |
136
+ |-----------|----|
137
+ | "normalise this WAV to -14 LUFS", "podcast levels" | `loudness.py talk.wav -I -14 --tp -1 -o talk_norm.wav` (`-I -16 --tp -1.5` for podcasts) |
138
+ | "remove the silence from this recording" | `silence.py talk.wav -o talk_tight.wav` |
139
+ | "clean up the noise in this M4A" | `audio.py talk.m4a --voice -o talk_clean.m4a` (speech) or `--denoise` |
140
+ | "convert this WAV to MP3" | `audio.py talk.wav -o talk.mp3` |
141
+ | "trim this audio from 00:30 to 02:00" | `cut.py talk.wav --start 0:30 --end 2:00 -o talk_cut.wav` |
142
+ | "is this loud enough for Apple Podcasts?" | `check.py talk.m4a --platform podcast` |
143
+
115
144
  ## Report format
116
145
 
117
146
  Finish every job with this shape (numbers from `probe.py`/`check.py`, not memory):
package/bin/install.js CHANGED
@@ -12,6 +12,8 @@
12
12
  * npx ffmpeg-skill --dir ./skills # custom parent directory
13
13
  * npx ffmpeg-skill --project # ./.claude/skills/ffmpeg-skill in the current project
14
14
  * npx ffmpeg-skill --uninstall # remove from the selected targets
15
+ * npx ffmpeg-skill contract --json # machine-readable execution contract (see docs/contract.md)
16
+ * npx ffmpeg-skill doctor [--json] # which required ffmpeg capabilities this machine has
15
17
  */
16
18
  'use strict';
17
19
 
@@ -22,7 +24,7 @@ const { spawnSync } = require('child_process');
22
24
 
23
25
  const SKILL_NAME = 'ffmpeg-skill';
24
26
  const ROOT = path.resolve(__dirname, '..');
25
- const PAYLOAD = ['SKILL.md', 'scripts', 'references', 'mcp'];
27
+ const PAYLOAD = ['SKILL.md', 'scripts', 'references', 'mcp', 'package.json'];
26
28
 
27
29
  const args = process.argv.slice(2);
28
30
  const has = (flag) => args.includes(flag);
@@ -36,6 +38,12 @@ if (has('--help') || has('-h')) {
36
38
  process.exit(0);
37
39
  }
38
40
 
41
+ // `contract` / `doctor` are answered by scripts/_contract.py; everything else installs.
42
+ if (args[0] === 'contract' || args[0] === 'doctor') {
43
+ const py = spawnSync('python3', [path.join(ROOT, 'scripts', '_contract.py'), ...args], { stdio: 'inherit' });
44
+ process.exit(py.error ? 127 : py.status);
45
+ }
46
+
39
47
  const home = os.homedir();
40
48
  const targets = [];
41
49
  const want = { claude: has('--claude'), cursor: has('--cursor'), codex: has('--codex') };
package/mcp/server.py CHANGED
@@ -1,8 +1,10 @@
1
1
  #!/usr/bin/env python3
2
2
  """ffmpeg-skill as an MCP server (stdio, JSON-RPC 2.0) — standard library only.
3
3
 
4
- Every script in ../scripts becomes a tool; arguments are passed as an argv list
5
- or as a flat object of flags. Results are the script's --json output.
4
+ Every public script in ../scripts becomes a tool. Tool names, order, inputSchema and the
5
+ structured-argument mapping all come from the contract (scripts/_contract.py); this file
6
+ is only the transport. Arguments are passed as a flat object (keys = argparse dests) or,
7
+ for CLI compatibility, as an argv list. Results are the script's --json output.
6
8
 
7
9
  Run:
8
10
  python3 mcp/server.py # stdio transport
@@ -20,41 +22,45 @@ HERE = Path(__file__).resolve().parent
20
22
  SCRIPTS = HERE.parent / "scripts"
21
23
  PROTOCOL_VERSION = "2024-11-05"
22
24
 
23
- TOOLS: Dict[str, str] = {
24
- "probe": "Inspect a media file: duration, fps/VFR, resolution, codecs, HDR, rotation, audio. Args: inputs (list), analyze (bool), compact (bool).",
25
- "cut": "Cut a range or several segments, lossless when possible. Args: input, start, end, duration, segments, accurate, output.",
26
- "fit": "Fit to a duration (speed/trim) and/or aspect (pad/crop). Args: input, duration, method, aspect, fit, width, fps, smooth, output.",
27
- "caption": "Burn SRT/ASS or timed text; animated/karaoke styles; brand. Args: input, srt, ass, text, animate, karaoke, font, size, position, brand, output.",
28
- "overlay": "Composite a logo/image/text with timing and fade. Args: input, image, text, logo, position, start, end, fade, opacity, scale, brand, output.",
29
- "graphics": "Motion-graphics templates: lower-third, title, chapter, progress, countdown, bug. Args: input, template, name, title, subtitle, start, end, brand, output.",
30
- "sync": "Detect offset between two recordings by audio, optional drift fix. Args: reference, second, fix_drift, replace_audio, trim_second, output.",
31
- "multicam": "Align N cameras/recorders and switch between them. Args: inputs (list), switch, auto, audio, fix_drift, output.",
32
- "audio": "Denoise/voice chain, music bed with ducking, fades, downmix, replace. Args: input, voice, denoise, music, duck, music_volume, fade_in, fade_out, downmix, replace, output.",
33
- "loudness": "Two-pass EBU R128 normalisation. Args: input, lufs, tp, measure_only, output.",
34
- "silence": "Remove dead air / list silences. Args: input, threshold, min_silence, margin, list, edl, output.",
35
- "join": "Concatenate clips with transitions, normalising size/fps/audio. Args: inputs (list), transition, duration, width, height, fps, output.",
36
- "color": "HDR→SDR tone mapping, LUTs, retag, strip Dolby Vision. Args: input, to_sdr, lut, retag, strip_dovi, tonemap, output.",
37
- "export": "Platform presets: youtube, youtube4k, reels, x, prores, h265, gif. Args: input, preset, fit, output.",
38
- "check": "Pre-delivery compliance per platform. Args: input, platform, no_loudness.",
39
- "scenes": "Scene changes, audio peaks, highlight proposals. Args: input, highlights, target, edl, sheet.",
40
- "look": "Contact sheet / frames / before-after PNG for visual checks. Args: input, at (list), tiles, compare, output.",
41
- "render": "Render a whole edit from project.json. Args: project, fast, stop_after, init.",
42
- "verify": "Run the toolchain on real files and report PASS/FAIL. Args: paths (list), quick, report.",
43
- "report": "HTML delivery report. Args: after, before, platform, commands, notes, title, output.",
44
- }
45
- POSITIONAL = {"probe": ["inputs"], "sync": ["reference", "second"], "multicam": ["inputs"], "join": ["inputs"], "verify": ["paths"], "render": ["project"]}
25
+ sys.path.insert(0, str(SCRIPTS))
26
+ import _contract # noqa: E402 (the contract is the only source of tool names, schemas and argument mapping)
27
+
28
+ _SPECS: Dict[str, Dict[str, Any]] = {}
29
+
30
+
31
+ def specs() -> Dict[str, Dict[str, Any]]:
32
+ """ToolSpecs from the contract, generated once per process (argparse -> ToolSpec -> here)."""
33
+ if not _SPECS:
34
+ for spec in _contract.build(detect=False)["tools"]:
35
+ _SPECS[spec["name"]] = spec
36
+ return _SPECS
37
+
38
+
39
+ class _Positional(dict):
40
+ """Positional argument names per tool, read from the contract (kept as a mapping for compatibility)."""
41
+
42
+ def __missing__(self, name: str) -> List[str]:
43
+ return list(specs()[name]["mcp"]["positional"]) if name in specs() else ["input"]
44
+
45
+ def get(self, name: str, default: Any = None) -> Any: # type: ignore[override]
46
+ return self[name] if name in specs() else default
47
+
48
+
49
+ POSITIONAL: Dict[str, List[str]] = _Positional()
46
50
 
47
51
 
48
52
  def build_argv(name: str, args: Dict[str, Any]) -> List[str]:
53
+ """Structured arguments -> argv, using the mapping the contract states (invocation.structured)."""
49
54
  if isinstance(args.get("argv"), list):
50
55
  argv = [str(a) for a in args["argv"]]
51
- if name not in ("look", "probe") and "--json" not in argv and "--help" not in argv:
56
+ if name not in _contract.MCP_JSON_EXEMPT and "--json" not in argv and "--help" not in argv:
52
57
  argv.append("--json")
53
58
  return argv
59
+ spec = specs().get(name)
60
+ exceptions = spec["mcp"]["argument_exceptions"] if spec else {}
54
61
  argv: List[str] = []
55
62
  args = dict(args)
56
- pos = POSITIONAL.get(name, ["input"])
57
- for key in pos:
63
+ for key in POSITIONAL[name]:
58
64
  val = args.pop(key, None)
59
65
  if val is None:
60
66
  continue
@@ -65,11 +71,7 @@ def build_argv(name: str, args: Dict[str, Any]) -> List[str]:
65
71
  for key, val in args.items():
66
72
  if val is None or val is False:
67
73
  continue
68
- flag = "--" + key.replace("_", "-")
69
- if key == "output":
70
- flag = "-o"
71
- if key == "lufs" and name == "loudness":
72
- flag = "-I"
74
+ flag = exceptions.get(key, "--" + key.replace("_", "-"))
73
75
  if val is True:
74
76
  argv.append(flag)
75
77
  elif isinstance(val, list):
@@ -77,14 +79,14 @@ def build_argv(name: str, args: Dict[str, Any]) -> List[str]:
77
79
  argv += [flag, str(v)]
78
80
  else:
79
81
  argv += [flag, str(val)]
80
- if name not in ("look", "probe") and "--json" not in argv and "--help" not in argv:
82
+ if name not in _contract.MCP_JSON_EXEMPT and "--json" not in argv and "--help" not in argv:
81
83
  argv.append("--json")
82
84
  return argv
83
85
 
84
86
 
85
87
  def call_tool(name: str, args: Dict[str, Any]) -> Dict[str, Any]:
86
88
  script = SCRIPTS / f"{name}.py"
87
- if not script.exists():
89
+ if name not in specs() or not script.exists():
88
90
  return {"isError": True, "content": [{"type": "text", "text": f"unknown tool {name}"}]}
89
91
  argv = build_argv(name, args or {})
90
92
  proc = subprocess.run([sys.executable, str(script)] + argv, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
@@ -108,14 +110,8 @@ def call_tool(name: str, args: Dict[str, Any]) -> Dict[str, Any]:
108
110
 
109
111
 
110
112
  def tool_list() -> List[Dict[str, Any]]:
111
- out = []
112
- for name, desc in TOOLS.items():
113
- out.append({
114
- "name": name,
115
- "description": desc + " Pass either named args (flags without dashes, underscores for hyphens) or argv (raw CLI list). Media paths must be absolute.",
116
- "inputSchema": {"type": "object", "properties": {"argv": {"type": "array", "items": {"type": "string"}, "description": "raw CLI arguments"}}, "additionalProperties": True},
117
- })
118
- return out
113
+ """tools/list: one entry per ToolSpec, in the contract's order, inputSchema derived from ToolSpec.input_schema."""
114
+ return [_contract.mcp_tool(spec) for spec in specs().values()]
119
115
 
120
116
 
121
117
  def handle(req: Dict[str, Any]) -> Dict[str, Any]:
@@ -142,7 +138,7 @@ def version() -> str:
142
138
  def main() -> int:
143
139
  if "--list" in sys.argv:
144
140
  for t in tool_list():
145
- print(f"{t['name']:10s} {t['description'].split(' Pass either')[0]}")
141
+ print(f"{t['name']:10s} {t['description'].split(' Structured arguments:')[0]}")
146
142
  return 0
147
143
  if "--call" in sys.argv: # debugging helper: --call NAME '{"input": "..."}'
148
144
  i = sys.argv.index("--call")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ffmpeg-skill",
3
- "version": "0.8.4",
3
+ "version": "0.9.0",
4
4
  "description": "Agent Skill that lets coding agents (Claude Code, Cursor, Codex) do professional video editing with local FFmpeg: MCP server, batch processing, declarative project rendering, brand kits, motion-graphics templates, HTML delivery reports, scene detection, delivery checks, cut, silence removal, transitions, multicam, captions, sync with drift correction, HDR to SDR, LUTs, audio clean-up and ducking, loudness, platform exports. No API keys, no cloud, no dependencies.",
5
5
  "keywords": ["ffmpeg", "video", "agent-skill", "claude-code", "cursor", "codex", "skill", "video-editing"],
6
6
  "license": "MIT",
@@ -24,9 +24,11 @@
24
24
  "LICENSE"
25
25
  ],
26
26
  "scripts": {
27
- "test": "python3 tests/test_all.py",
27
+ "test": "python3 tests/test_all.py && python3 tests/test_contract.py",
28
28
  "release-check": "bash tests/release_check.sh",
29
- "demo": "bash examples/make_demo.sh"
29
+ "demo": "bash examples/make_demo.sh",
30
+ "contract": "python3 scripts/_contract.py --json",
31
+ "doctor": "python3 scripts/_contract.py doctor"
30
32
  },
31
33
  "engines": {
32
34
  "node": ">=16"
@@ -31,8 +31,12 @@ INSTALL_HINTS = {
31
31
  }
32
32
 
33
33
 
34
- def die(msg: str, code: int = 1) -> "None":
34
+ def die(msg: str, code: int = 1, kind: str = "input") -> "None":
35
+ """Exit with a message. Under --json also print a machine-readable failure document
36
+ (status: failed) on stdout so callers get the same shape as a success; exit codes are unchanged."""
35
37
  sys.stderr.write(f"error: {msg}\n")
38
+ if STATE.json:
39
+ print_json({"status": "failed", "error": {"kind": kind, "message": msg}})
36
40
  sys.exit(code)
37
41
 
38
42
 
@@ -53,7 +57,7 @@ def require_tool(name: str) -> str:
53
57
  die(
54
58
  f"'{name}' was not found on PATH.\n"
55
59
  f"Install FFmpeg (which includes ffprobe) for {system}:\n{hint}",
56
- code=127,
60
+ code=127, kind="missing_tool",
57
61
  )
58
62
  return "" # unreachable
59
63
 
@@ -122,7 +126,7 @@ def apply_common(args: "argparse.Namespace") -> None:
122
126
  def emit(output: Optional[str], **extra: Any) -> None:
123
127
  """Final stdout line: the output path, or a JSON document with --json."""
124
128
  if STATE.json:
125
- doc: Dict[str, Any] = {"output": output, "dry_run": STATE.dry_run, "commands": list(STATE.commands)}
129
+ doc: Dict[str, Any] = {"status": "completed", "output": output, "dry_run": STATE.dry_run, "commands": list(STATE.commands)}
126
130
  if output and not STATE.dry_run and os.path.exists(output):
127
131
  doc["probe"] = probe(output)
128
132
  doc.update(extra)
@@ -141,7 +145,7 @@ def _is_ffmpeg(cmd: Sequence[str]) -> bool:
141
145
 
142
146
  def _fail(cmd: Sequence[str], returncode: int, stderr: str) -> None:
143
147
  tail = "\n".join(stderr.strip().splitlines()[-15:])
144
- die(f"command failed ({returncode}): {cmd[0]}\n{tail}", code=returncode or 1)
148
+ die(f"command failed ({returncode}): {cmd[0]}\n{tail}", code=returncode or 1, kind="ffmpeg")
145
149
 
146
150
 
147
151
  def run(cmd: Sequence[str], *, quiet: bool = False, check: bool = True) -> subprocess.CompletedProcess:
@@ -0,0 +1,577 @@
1
+ #!/usr/bin/env python3
2
+ """Machine-readable execution contract for ffmpeg-skill (internal module, not a tool).
3
+
4
+ python3 scripts/_contract.py --json # the contract, with detected capabilities
5
+ python3 scripts/_contract.py --json --static # same, without environment detection
6
+ python3 scripts/_contract.py doctor [--json] # which required capabilities this machine has
7
+ ffmpeg-skill contract --json # the same through the npm entry point
8
+
9
+ The contract describes every public tool in scripts/ (one ToolSpec per script that does
10
+ not start with "_"): what it needs, what it takes, what it writes, how to verify the
11
+ result, and whether an agent can plan it with --dry-run. Input schemas are generated
12
+ from each script's argparse parser, so the CLI stays the single source of truth; the
13
+ per-tool facts that cannot be read from a parser (role, verification policy, required
14
+ ffmpeg components) live in TOOL_META below and are checked against the scripts by
15
+ tests/test_contract.py.
16
+
17
+ The contract has its own version (CONTRACT_VERSION) that only changes when the shape of
18
+ this document changes; the skill version comes from package.json.
19
+ """
20
+ import argparse
21
+ import importlib.util
22
+ import json
23
+ import os
24
+ import re
25
+ import shutil
26
+ import subprocess
27
+ import sys
28
+ from pathlib import Path
29
+ from typing import Any, Dict, List, Optional
30
+
31
+ HERE = Path(__file__).resolve().parent
32
+ ROOT = HERE.parent
33
+ SKILL_ID = "ffmpeg-skill"
34
+ CONTRACT_VERSION = "1.0"
35
+
36
+ ROLES = {
37
+ "analysis": "reads media and reports measurements; writes no media",
38
+ "analysis_and_execution": "measures by default or with a flag, and can also write a transformed artifact",
39
+ "execution": "writes a new media artifact from the input(s); the input is never modified",
40
+ "verification": "checks or shows an artifact (probe numbers, compliance rows, contact sheets); writes no media",
41
+ }
42
+
43
+ # Facts that are not derivable from the argparse parsers. Capability names:
44
+ # ffmpeg / ffprobe the binaries on PATH
45
+ # encoder:<name> `ffmpeg -encoders`
46
+ # filter:<name> `ffmpeg -filters`
47
+ # bsf:<name> `ffmpeg -bsfs`
48
+ # external:whisper a local whisper engine (whisper.cpp / faster-whisper / openai-whisper)
49
+ # "optional" entries name the flag or condition under which the capability is needed.
50
+ FF = ["ffmpeg", "ffprobe"]
51
+ X264 = "encoder:libx264"
52
+ X265 = "encoder:libx265"
53
+ AAC = "encoder:aac"
54
+ HDR_X265 = {"capability": X265, "when": "the source is HDR (kept as HEVC Main10)"}
55
+ AUDIO_OUT = [
56
+ {"capability": "encoder:libmp3lame", "when": "output extension is .mp3"},
57
+ {"capability": "encoder:libopus", "when": "output extension is .opus"},
58
+ {"capability": "encoder:libvorbis", "when": "output extension is .ogg"},
59
+ {"capability": "encoder:flac", "when": "output extension is .flac"},
60
+ ]
61
+
62
+ TOOL_META: Dict[str, Dict[str, Any]] = {
63
+ "probe": dict(role="analysis", inputs=["media (video or audio, any container ffprobe reads)"], outputs=["measurement JSON on stdout (no file)"],
64
+ required=["ffprobe"], optional=[{"capability": "ffmpeg", "when": "--analyze"}, {"capability": "filter:signalstats", "when": "--analyze"}],
65
+ video_required=False, audio_only=True, visual=False, verify=[], produces_artifact=False, idempotency="bit_exact", deterministic=True),
66
+ "cut": dict(role="execution", inputs=["video or audio asset"], outputs=["cut video/audio artifact (same container family)"],
67
+ required=FF, optional=[{"capability": X264, "when": "re-encode: --accurate, VFR source, or a keyframe farther than --tolerance"}, HDR_X265, {"capability": AAC, "when": "re-encode of a video container"}],
68
+ video_required=False, audio_only=True, visual=False, verify=["probe"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
69
+ "fit": dict(role="execution", inputs=["video asset"], outputs=["video artifact at the requested duration / aspect / fps"],
70
+ required=FF + [X264, AAC], optional=[HDR_X265, {"capability": "filter:minterpolate", "when": "--smooth interpolate"}],
71
+ video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
72
+ "caption": dict(role="execution", inputs=["video asset", "SRT/ASS file or timed text (--text)"], outputs=["video artifact with burnt-in captions", "generated .srt / .ass sidecar"],
73
+ required=FF + [X264, AAC, "filter:subtitles"], optional=[{"capability": "filter:ass", "when": "--animate / --karaoke"}, HDR_X265, {"capability": "external:whisper", "when": "--transcribe"}],
74
+ video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
75
+ "overlay": dict(role="execution", inputs=["video asset", "image (--image / --logo) or text (--text)"], outputs=["video artifact with the overlay composited"],
76
+ required=FF + [X264, AAC], optional=[{"capability": "filter:drawtext", "when": "--text"}, HDR_X265],
77
+ video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
78
+ "graphics": dict(role="execution", inputs=["video asset"], outputs=["video artifact with the drawn template"],
79
+ required=FF + [X264, AAC, "filter:drawtext"], optional=[HDR_X265],
80
+ video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
81
+ "sync": dict(role="analysis_and_execution", inputs=["reference recording (video or audio)", "second recording (video or audio)"], outputs=["offset / drift JSON on stdout", "aligned artifact with --replace-audio / --trim-second / --fix-drift -o"],
82
+ required=FF, optional=[{"capability": AAC, "when": "writing a video container"}] + AUDIO_OUT,
83
+ video_required=False, audio_only=True, visual=False, verify=["probe"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
84
+ "multicam": dict(role="execution", inputs=["reference camera", "other cameras / recorders"], outputs=["switched multicam video artifact"],
85
+ required=FF + [X264, AAC], optional=[HDR_X265],
86
+ video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
87
+ "audio": dict(role="execution", inputs=["video or audio asset", "music bed (--music) or replacement track (--replace)"], outputs=["artifact with the processed audio (video stream-copied)"],
88
+ required=FF + [AAC], optional=[{"capability": "filter:afftdn", "when": "--denoise / --voice"}, {"capability": "filter:sidechaincompress", "when": "--duck"}] + AUDIO_OUT,
89
+ video_required=False, audio_only=True, visual=False, verify=["probe"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
90
+ "loudness": dict(role="analysis_and_execution", inputs=["video or audio asset"], outputs=["loudness measurement JSON (--measure-only)", "normalised artifact (video stream-copied)"],
91
+ required=FF + ["filter:loudnorm", AAC], optional=AUDIO_OUT,
92
+ video_required=False, audio_only=True, visual=False, verify=["probe", "check"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
93
+ "silence": dict(role="analysis_and_execution", inputs=["video or audio asset"], outputs=["silence list JSON (--list)", "artifact with silences removed", "EDL text (--edl)"],
94
+ required=FF + ["filter:silencedetect"], optional=[{"capability": X264, "when": "removing silences from a video"}, HDR_X265, {"capability": AAC, "when": "removing silences from a video"}] + AUDIO_OUT,
95
+ video_required=False, audio_only=True, visual=False, verify=["probe"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
96
+ "join": dict(role="execution", inputs=["two or more video assets"], outputs=["concatenated video artifact"],
97
+ required=FF + [X264, AAC, "filter:xfade", "filter:acrossfade"], optional=[HDR_X265],
98
+ video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
99
+ "color": dict(role="execution", inputs=["video asset", ".cube LUT (--lut)"], outputs=["video artifact with converted colour"],
100
+ required=FF, optional=[{"capability": X264, "when": "--to-sdr / --lut"}, {"capability": "filter:zscale", "when": "--to-sdr"}, {"capability": "filter:tonemap", "when": "--to-sdr"},
101
+ {"capability": "filter:lut3d", "when": "--lut"}, {"capability": "bsf:filter_units", "when": "--strip-dovi"}, {"capability": X265, "when": "--lut on an HDR source"}, {"capability": AAC, "when": "re-encode"}],
102
+ video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
103
+ "export": dict(role="execution", inputs=["video asset"], outputs=["delivery artifact in the preset's format"],
104
+ required=FF, optional=[{"capability": X264, "when": "preset youtube / youtube4k / reels / x"}, {"capability": AAC, "when": "any preset except gif"},
105
+ {"capability": X265, "when": "preset h265"}, {"capability": "encoder:prores_ks", "when": "preset prores"},
106
+ {"capability": "filter:palettegen", "when": "preset gif"}, {"capability": "encoder:gif", "when": "preset gif"}],
107
+ video_required=True, audio_only=False, visual=False, verify=["probe", "check"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
108
+ "check": dict(role="verification", inputs=["media artifact"], outputs=["compliance rows JSON on stdout (no file)"],
109
+ required=["ffprobe"], optional=[{"capability": "ffmpeg", "when": "loudness rows (default)"}, {"capability": "filter:loudnorm", "when": "loudness rows (default)"}],
110
+ video_required=False, audio_only=True, visual=False, verify=[], produces_artifact=False, idempotency="bit_exact", deterministic=True),
111
+ "scenes": dict(role="analysis", inputs=["video asset"], outputs=["scene / audio-peak / highlight JSON on stdout", "EDL text (--edl)", "per-scene contact sheet PNG (--sheet)"],
112
+ required=FF + ["filter:scdet"], optional=[{"capability": "filter:drawtext", "when": "--sheet"}, {"capability": "filter:tile", "when": "--sheet"}],
113
+ video_required=True, audio_only=False, visual=False, verify=[], produces_artifact=True, idempotency="bit_exact", deterministic=True),
114
+ "look": dict(role="verification", inputs=["video artifact"], outputs=["PNG contact sheet / frames / side-by-side"],
115
+ required=FF + ["filter:tile"], optional=[{"capability": "filter:drawtext", "when": "timecode stamps (default; --no-timecode to skip)"}, {"capability": "filter:zscale", "when": "HDR source"}, {"capability": "filter:tonemap", "when": "HDR source"}],
116
+ video_required=True, audio_only=False, visual=False, verify=[], produces_artifact=True, idempotency="bit_exact", deterministic=True),
117
+ "render": dict(role="execution", inputs=["project.json (clips, transitions, captions, overlays, audio, loudness, export, check)"], outputs=["final video artifact", "work directory of stage outputs (--keep / --work)"],
118
+ required=FF, optional=[{"capability": "delegated", "when": "each stage runs cut / join / fit / caption / overlay / audio / loudness / export / check with their capabilities"}],
119
+ video_required=True, audio_only=False, visual=True, verify=["probe", "check", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
120
+ "batch": dict(role="execution", inputs=["folder of media", "batch.json recipe (steps or a render project)"], outputs=["one artifact per input file in the recipe's output_dir", "content-hash cache"],
121
+ required=FF, optional=[{"capability": "delegated", "when": "each recipe step runs the named script with its capabilities"}],
122
+ video_required=False, audio_only=True, visual=False, verify=["probe"], produces_artifact=True, idempotency="cached", deterministic=True),
123
+ "verify": dict(role="verification", inputs=["media files and/or folders"], outputs=["PASS/FAIL JSON per step", "Markdown report (--report)", "step outputs (--out / --keep)"],
124
+ required=FF, optional=[{"capability": "delegated", "when": "runs cut / fit / caption / export / loudness / color on each file"}],
125
+ video_required=False, audio_only=True, visual=False, verify=[], produces_artifact=True, idempotency="environment_dependent", deterministic=False),
126
+ "report": dict(role="verification", inputs=["deliverable (--after)", "source (--before)", "commands / notes text"], outputs=["single-file HTML delivery report"],
127
+ required=FF + ["filter:loudnorm"], optional=[{"capability": "delegated", "when": "runs look (sheets) and check (--platform)"}],
128
+ video_required=False, audio_only=True, visual=False, verify=[], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
129
+ }
130
+
131
+ # Dry-run behaviour a parser cannot express (measured in tests/test_contract.py with a fake ffmpeg
132
+ # on PATH). "analysis_only": ffmpeg still decodes/measures the input under --dry-run, but nothing
133
+ # is encoded and no file is written. Every other tool with the flag runs no ffmpeg at all.
134
+ DRY_RUN_ANALYSIS = {
135
+ "sync": "audio is decoded to find the offset; the aligned output is not written",
136
+ "multicam": "audio is decoded to align the cameras; the switched output is not written",
137
+ "scenes": "scene and audio-peak measurement runs; --sheet and --edl are not written",
138
+ "report": "probe, loudness and contact-sheet measurements run; the HTML is not written",
139
+ }
140
+ DRY_RUN_NOTES = {
141
+ "probe": "read-only tool; --dry-run changes nothing (ffprobe still runs)",
142
+ "check": "read-only tool; --dry-run skips the ffmpeg loudness measurement, so loudness rows are absent",
143
+ "verify": "not supported: the flag is accepted but the steps run and outputs are written",
144
+ }
145
+
146
+ IDEMPOTENCY = {
147
+ "bit_exact": "same inputs and flags give byte-identical output",
148
+ "content_equivalent": "same inputs and flags give the same media content; bytes may differ between encoder builds",
149
+ "cached": "re-runs skip inputs whose content hash and recipe are unchanged",
150
+ "environment_dependent": "output includes timings or machine state and differs between runs",
151
+ }
152
+
153
+
154
+ # ----------------------------------------------------------------------------- parsers
155
+ class _Captured(Exception):
156
+ def __init__(self, parser: argparse.ArgumentParser) -> None:
157
+ self.parser = parser
158
+
159
+
160
+ def _capture_parser(script: Path) -> argparse.ArgumentParser:
161
+ """Import the script and run main() until parse_args() to get its live parser."""
162
+ original = argparse.ArgumentParser.parse_args
163
+
164
+ def fake_parse(self: argparse.ArgumentParser, *a: Any, **k: Any) -> Any:
165
+ raise _Captured(self)
166
+
167
+ argparse.ArgumentParser.parse_args = fake_parse # type: ignore[assignment]
168
+ sys_argv = sys.argv
169
+ try:
170
+ sys.argv = [str(script)]
171
+ spec = importlib.util.spec_from_file_location("ffskill_tool_" + script.stem, script)
172
+ module = importlib.util.module_from_spec(spec)
173
+ assert spec.loader is not None
174
+ spec.loader.exec_module(module)
175
+ module.main()
176
+ except _Captured as cap:
177
+ return cap.parser
178
+ finally:
179
+ argparse.ArgumentParser.parse_args = original # type: ignore[assignment]
180
+ sys.argv = sys_argv
181
+ raise RuntimeError(f"{script.name}: main() returned before parse_args()")
182
+
183
+
184
+ def _json_type(action: argparse.Action) -> Dict[str, Any]:
185
+ if isinstance(action, argparse._StoreTrueAction):
186
+ return {"type": "boolean"}
187
+ if isinstance(action, argparse._AppendAction) or action.nargs in ("+", "*"):
188
+ return {"type": "array", "items": {"type": "string"}}
189
+ if action.type is int:
190
+ return {"type": "integer"}
191
+ if action.type is float:
192
+ return {"type": "number"}
193
+ return {"type": "string"}
194
+
195
+
196
+ def input_schema(parser: argparse.ArgumentParser) -> Dict[str, Any]:
197
+ props: Dict[str, Any] = {}
198
+ required: List[str] = []
199
+ positional: List[str] = []
200
+ common = {"dry_run", "json", "progress", "fast"}
201
+ for action in parser._actions:
202
+ if isinstance(action, argparse._HelpAction):
203
+ continue
204
+ prop: Dict[str, Any] = _json_type(action)
205
+ if action.help and action.help != argparse.SUPPRESS:
206
+ prop["description"] = action.help % {"default": action.default} if "%(default)" in action.help else action.help
207
+ if action.choices:
208
+ prop["enum"] = list(action.choices)
209
+ if action.default not in (None, False, argparse.SUPPRESS):
210
+ prop["default"] = action.default
211
+ if action.option_strings:
212
+ prop["cli"] = list(action.option_strings)
213
+ if action.required:
214
+ required.append(action.dest)
215
+ else:
216
+ prop["cli"] = "positional"
217
+ positional.append(action.dest)
218
+ if action.nargs not in ("?", "*"):
219
+ required.append(action.dest)
220
+ if action.dest in common:
221
+ prop["common"] = True
222
+ props[action.dest] = prop
223
+ groups = [g for g in getattr(parser, "_mutually_exclusive_groups", []) if g._group_actions]
224
+ schema: Dict[str, Any] = {"type": "object", "properties": props, "required": required, "positional": positional, "additionalProperties": False}
225
+ if groups:
226
+ schema["mutually_exclusive"] = [[a.dest for a in g._group_actions] for g in groups]
227
+ schema["one_of_required"] = [[a.dest for a in g._group_actions] for g in groups if g.required]
228
+ return schema
229
+
230
+
231
+ def output_schema(name: str, meta: Dict[str, Any]) -> Dict[str, Any]:
232
+ """What the tool prints on stdout with --json (keys observed in the implementation)."""
233
+ if name == "probe":
234
+ return {"type": "object", "description": "one probe document, or an array of them for several inputs",
235
+ "properties": {"file": {"type": "string"}, "format": {"type": "string"}, "duration": {"type": "number"}, "size_bytes": {"type": "integer"},
236
+ "video": {"type": ["object", "null"]}, "audio": {"type": ["object", "null"]}}, "additionalProperties": True}
237
+ base = {"status": {"enum": ["completed"]}, "output": {"type": ["string", "null"], "description": "path written, or null"},
238
+ "dry_run": {"type": "boolean"}, "commands": {"type": "array", "items": {"type": "string"}, "description": "every ffmpeg command line planned or run"},
239
+ "probe": {"type": "object", "description": "probe of the output when a file was written"}}
240
+ extra: Dict[str, Any] = {}
241
+ if name == "check":
242
+ extra = {"platform": {"type": "string"}, "ok": {"type": "boolean"}, "failed": {"type": "integer"}, "warnings": {"type": "integer"},
243
+ "checks": {"type": "array", "items": {"type": "object", "properties": {"check": {"type": "string"}, "status": {"enum": ["PASS", "WARN", "FAIL"]}, "value": {}, "expected": {}, "fix": {"type": "string"}, "kind": {"enum": ["format", "judgement"]}}}}}
244
+ elif name == "scenes":
245
+ extra = {"file": {"type": "string"}, "duration": {"type": "number"}, "scene_count": {"type": "integer"}, "scenes": {"type": "array"}, "audio_peaks": {"type": "array"}}
246
+ elif name == "silence":
247
+ extra = {"silences": {"type": "array"}, "keep": {"type": "array"}, "input_duration": {"type": "number"}, "kept_duration": {"type": "number"}, "removed_seconds": {"type": "number"}}
248
+ elif name == "sync":
249
+ extra = {"reference": {"type": "string"}, "second": {"type": "string"}, "offset_seconds": {"type": "number"}, "confidence": {"type": "number"}, "meaning": {"type": "string"}, "drift": {"type": "object"}}
250
+ elif name == "look":
251
+ extra = {"outputs": {"type": "array", "items": {"type": "string"}}}
252
+ elif name == "render":
253
+ extra = {"stages": {"type": "array", "items": {"type": "string"}}, "check": {"type": ["object", "null"]}}
254
+ elif name == "verify":
255
+ extra = {"report": {"type": ["string", "null"]}, "files": {"type": "array"}, "failed": {"type": "integer"}, "total": {"type": "integer"}}
256
+ elif name == "batch":
257
+ extra = {"results": {"type": "array"}, "processed": {"type": "integer"}, "total": {"type": "integer"}}
258
+ elif name == "report":
259
+ extra = {"report": {"type": "string"}, "check": {"type": ["object", "null"]}}
260
+ elif name == "loudness":
261
+ extra = {"measured": {"type": "object", "description": "--measure-only prints the loudnorm measurement instead (input_i, input_tp, input_lra, input_thresh, target_offset)"}}
262
+ props = dict(base)
263
+ props.update(extra)
264
+ required = ["status", "output", "dry_run", "commands"]
265
+ return {"type": "object", "properties": props, "required": required, "additionalProperties": True}
266
+
267
+
268
+ # ----------------------------------------------------------------------------- environment
269
+ def skill_version() -> str:
270
+ for candidate in (ROOT / "package.json",):
271
+ try:
272
+ return str(json.loads(candidate.read_text(encoding="utf-8"))["version"])
273
+ except (OSError, ValueError, KeyError):
274
+ continue
275
+ return "unknown"
276
+
277
+
278
+ def skill_description() -> str:
279
+ try:
280
+ text = (ROOT / "SKILL.md").read_text(encoding="utf-8")
281
+ m = re.search(r"^description:\s*(.+)$", text, re.M)
282
+ return m.group(1).strip() if m else ""
283
+ except OSError:
284
+ return ""
285
+
286
+
287
+ def public_tools() -> List[str]:
288
+ return sorted(p.stem for p in HERE.glob("*.py") if not p.name.startswith("_"))
289
+
290
+
291
+ def _ff_list(binary: str, flag: str) -> List[str]:
292
+ """Names from `ffmpeg -encoders` / `-filters` / `-bsfs` (empty list when ffmpeg is missing)."""
293
+ exe = shutil.which(binary)
294
+ if not exe:
295
+ return []
296
+ proc = subprocess.run([exe, "-hide_banner", flag], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
297
+ names: List[str] = []
298
+ flags = {"-encoders": r"[VASFXBD.]{6}", "-filters": r"[TSC.]{3}"}.get(flag)
299
+ for line in proc.stdout.splitlines():
300
+ parts = line.split()
301
+ if not parts:
302
+ continue
303
+ if flag == "-bsfs":
304
+ if len(parts) == 1 and not parts[0].endswith(":"):
305
+ names.append(parts[0])
306
+ elif flags and len(parts) >= 2 and re.fullmatch(flags, parts[0]):
307
+ names.append(parts[1])
308
+ return names
309
+
310
+
311
+ def _version_line(binary: str) -> Optional[str]:
312
+ exe = shutil.which(binary)
313
+ if not exe:
314
+ return None
315
+ proc = subprocess.run([exe, "-version"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
316
+ first = (proc.stdout or proc.stderr).splitlines()[:1]
317
+ m = re.match(rf"{binary} version (\S+)", first[0]) if first else None
318
+ return m.group(1) if m else (first[0] if first else "unknown")
319
+
320
+
321
+ def _whisper_available() -> bool:
322
+ if shutil.which("whisper-cli") or shutil.which("whisper-cpp") or shutil.which("whisper"):
323
+ return True
324
+ return importlib.util.find_spec("faster_whisper") is not None or importlib.util.find_spec("whisper") is not None
325
+
326
+
327
+ def required_capabilities() -> Dict[str, List[str]]:
328
+ req: set = set()
329
+ opt: set = set()
330
+ for meta in TOOL_META.values():
331
+ req.update(meta["required"])
332
+ opt.update(o["capability"] for o in meta["optional"] if o["capability"] != "delegated")
333
+ opt -= req
334
+ return {"required": sorted(req), "optional": sorted(opt)}
335
+
336
+
337
+ def doctor() -> Dict[str, Any]:
338
+ """Detect which declared capabilities this machine has. No secrets, no environment variables."""
339
+ encoders = set(_ff_list("ffmpeg", "-encoders"))
340
+ filters = set(_ff_list("ffmpeg", "-filters"))
341
+ bsfs = set(_ff_list("ffmpeg", "-bsfs"))
342
+ have: Dict[str, bool] = {}
343
+ wanted = required_capabilities()
344
+ for cap in wanted["required"] + wanted["optional"]:
345
+ if cap == "ffmpeg":
346
+ have[cap] = shutil.which("ffmpeg") is not None
347
+ elif cap == "ffprobe":
348
+ have[cap] = shutil.which("ffprobe") is not None
349
+ elif cap.startswith("encoder:"):
350
+ have[cap] = cap[8:] in encoders
351
+ elif cap.startswith("filter:"):
352
+ have[cap] = cap[7:] in filters
353
+ elif cap.startswith("bsf:"):
354
+ have[cap] = cap[4:] in bsfs
355
+ elif cap == "external:whisper":
356
+ have[cap] = _whisper_available()
357
+ else:
358
+ have[cap] = False
359
+ available = sorted(c for c, ok in have.items() if ok)
360
+ missing_required = sorted(c for c in wanted["required"] if not have.get(c))
361
+ missing_optional = sorted(c for c in wanted["optional"] if not have.get(c))
362
+ return {
363
+ "python": ".".join(str(x) for x in sys.version_info[:3]),
364
+ "ffmpeg": _version_line("ffmpeg"),
365
+ "ffprobe": _version_line("ffprobe"),
366
+ "available": available,
367
+ "missing": missing_required,
368
+ "missing_optional": missing_optional,
369
+ "ok": not missing_required,
370
+ }
371
+
372
+
373
+ # ----------------------------------------------------------------------------- contract
374
+ def tool_spec(name: str, version: str) -> Dict[str, Any]:
375
+ if name not in TOOL_META:
376
+ # a public script without metadata is drift: fail loudly instead of guessing its role or capabilities
377
+ raise RuntimeError(f"scripts/{name}.py is public but has no TOOL_META entry in scripts/_contract.py; add one (or prefix the file with '_')")
378
+ meta = TOOL_META[name]
379
+ parser = _capture_parser(HERE / f"{name}.py")
380
+ schema = input_schema(parser)
381
+ # structured key -> CLI flag where key.replace("_", "-") is not the long option (MCP uses the same table)
382
+ exceptions: Dict[str, str] = {}
383
+ for dest, prop in schema["properties"].items():
384
+ if prop["cli"] == "positional":
385
+ continue
386
+ longs = [f for f in prop["cli"] if f.startswith("--")]
387
+ if dest == "output":
388
+ exceptions[dest] = "-o"
389
+ elif name == "loudness" and dest == "lufs":
390
+ exceptions[dest] = "-I"
391
+ elif "--" + dest.replace("_", "-") not in longs:
392
+ exceptions[dest] = longs[0] if longs else prop["cli"][0]
393
+ supports_dry_run = "dry_run" in schema["properties"] and name != "verify"
394
+ return {
395
+ "id": f"{SKILL_ID}/{name}",
396
+ "name": name,
397
+ "version": version,
398
+ "description": (parser.description or "").strip().splitlines()[0] if parser.description else "",
399
+ "executable": f"scripts/{name}.py",
400
+ "role": meta["role"],
401
+ "capabilities": {"required": list(meta["required"]), "optional": list(meta["optional"])},
402
+ "inputs": list(meta["inputs"]),
403
+ "outputs": list(meta["outputs"]),
404
+ "input_schema": schema,
405
+ "output_schema": output_schema(name, meta),
406
+ "supports_dry_run": supports_dry_run,
407
+ "dry_run": {"supported": supports_dry_run,
408
+ "ffmpeg_execution": "full" if not supports_dry_run else "analysis_only" if name in DRY_RUN_ANALYSIS else "none",
409
+ "semantics": "prints the ffmpeg command lines that would run; no output file is written",
410
+ **({"note": DRY_RUN_ANALYSIS.get(name) or DRY_RUN_NOTES[name]} if name in DRY_RUN_ANALYSIS or name in DRY_RUN_NOTES else {})},
411
+ "supports_json": "json" in schema["properties"],
412
+ "mutates_input": False,
413
+ "produces_artifact": meta["produces_artifact"],
414
+ "verification": {"required": bool(meta["verify"]), "tools": [f"{SKILL_ID}/{t}" for t in meta["verify"]]},
415
+ "requires_visual_verification": meta["visual"],
416
+ "audio_only": meta["audio_only"],
417
+ "video_required": meta["video_required"],
418
+ "deterministic_inputs": meta["deterministic"],
419
+ "idempotency_hint": meta["idempotency"],
420
+ "mcp": {"tool": name, "positional": schema["positional"], "argument_exceptions": exceptions},
421
+ }
422
+
423
+
424
+ # ----------------------------------------------------------------------------- MCP derivation
425
+ # tools that print JSON without --json (probe) or whose primary output is a file path (look): the transport
426
+ # does not append --json for them (stated in invocation.structured.argument_mapping.json)
427
+ MCP_JSON_EXEMPT = ("look", "probe")
428
+ MCP_STRUCTURED_NOTE = ("Structured arguments: keys are the input_schema property names (argparse dests), positionals "
429
+ "are passed by name, output -> -o. Or argv: the raw CLI list (non-canonical; all other keys are then ignored). "
430
+ "Media paths must be absolute.")
431
+
432
+
433
+ def mcp_input_schema(spec: Dict[str, Any]) -> Dict[str, Any]:
434
+ """Translate a ToolSpec.input_schema into the JSON Schema an MCP tools/list entry carries.
435
+
436
+ Deterministic and lossless where JSON Schema can express argparse semantics:
437
+ - properties keep type / enum / default / description / items; the ffmpeg-skill-only keys
438
+ (`cli`, `common`) are dropped, positionals get a "(positional N)" prefix in the description;
439
+ - required fields, mutually exclusive groups (`not required [a, b]` per pair) and required
440
+ groups (`anyOf required`) apply to the structured branch;
441
+ - the raw-argv compatibility branch (`argv` present) lifts those constraints, which JSON Schema
442
+ expresses as a top-level anyOf of the two branches.
443
+ Not expressible and therefore documented rather than encoded: which keys the tool ignores when
444
+ `argv` is given (all of them), and argparse's `%(default)s` help interpolation (already applied).
445
+ """
446
+ src = spec["input_schema"]
447
+ props: Dict[str, Any] = {}
448
+ positional = list(src.get("positional", []))
449
+ for dest in sorted(src["properties"]):
450
+ p = src["properties"][dest]
451
+ out: Dict[str, Any] = {"type": p["type"]}
452
+ if p["type"] == "array":
453
+ out["items"] = dict(p.get("items", {"type": "string"}))
454
+ desc = p.get("description", "")
455
+ if dest in positional:
456
+ desc = f"(positional {positional.index(dest) + 1}) {desc}".strip()
457
+ if desc:
458
+ out["description"] = desc
459
+ for key in ("enum", "default"):
460
+ if key in p:
461
+ out[key] = p[key]
462
+ props[dest] = out
463
+ props["argv"] = {"type": "array", "items": {"type": "string"}, "description": "raw CLI arguments (non-canonical compatibility path; when present every other key is ignored)"}
464
+ structured: Dict[str, Any] = {}
465
+ if src.get("required"):
466
+ structured["required"] = list(src["required"])
467
+ all_of: List[Dict[str, Any]] = []
468
+ for group in src.get("mutually_exclusive", []):
469
+ for i, a in enumerate(group):
470
+ for b in group[i + 1:]:
471
+ all_of.append({"not": {"required": [a, b]}})
472
+ if all_of:
473
+ structured["allOf"] = all_of
474
+ one_of = [[{"required": [d]} for d in group] for group in src.get("one_of_required", [])]
475
+ if one_of:
476
+ structured["anyOf"] = one_of[0] if len(one_of) == 1 else [{"allOf": [{"anyOf": g} for g in one_of]}]
477
+ schema: Dict[str, Any] = {"type": "object", "properties": props, "additionalProperties": False}
478
+ if structured:
479
+ schema["anyOf"] = [{"required": ["argv"]}, structured]
480
+ return schema
481
+
482
+
483
+ def mcp_tool(spec: Dict[str, Any]) -> Dict[str, Any]:
484
+ """The MCP tools/list entry for a ToolSpec: name, description and the derived inputSchema."""
485
+ return {"name": spec["name"], "description": f"{spec['description']} {MCP_STRUCTURED_NOTE}".strip(), "inputSchema": mcp_input_schema(spec)}
486
+
487
+
488
+ def mcp_tools(detect: bool = False) -> List[Dict[str, Any]]:
489
+ return [mcp_tool(spec) for spec in build(detect=detect)["tools"]]
490
+
491
+
492
+ def build(detect: bool = True) -> Dict[str, Any]:
493
+ version = skill_version()
494
+ tools = [tool_spec(n, version) for n in public_tools()]
495
+ wanted = required_capabilities()
496
+ caps: Dict[str, Any] = {"required": wanted["required"], "optional": wanted["optional"], "naming": "ffmpeg | ffprobe | encoder:<name> | filter:<name> | bsf:<name> | external:whisper"}
497
+ if detect:
498
+ d = doctor()
499
+ caps.update({"available": d["available"], "missing": d["missing"], "missing_optional": d["missing_optional"], "detected_by": "doctor"})
500
+ return {
501
+ "contract_version": CONTRACT_VERSION,
502
+ "skill": {
503
+ "id": SKILL_ID,
504
+ "version": version,
505
+ "description": skill_description(),
506
+ "execution_mode": "local",
507
+ "kind": "execution",
508
+ "entrypoints": {
509
+ "cli": "python3 scripts/<tool>.py [args] [--json] [--dry-run]",
510
+ "mcp": "python3 mcp/server.py (stdio JSON-RPC; tools/list == this tool list)",
511
+ "contract": "python3 scripts/_contract.py --json | ffmpeg-skill contract --json",
512
+ "doctor": "python3 scripts/_contract.py doctor --json | ffmpeg-skill doctor --json",
513
+ },
514
+ "not_provided": ["AI reasoning", "decisions", "production plans", "project IR", "approvals", "network access", "transcription engine"],
515
+ },
516
+ "requirements": {"python": ">=3.9 (standard library only)", "ffmpeg": ">=5.0", "ffprobe": ">=5.0", "node": ">=16 (npx installer only)"},
517
+ "execution": {
518
+ "shell": False,
519
+ "arbitrary_executables": False,
520
+ "subprocess": "argv list only: [python3, scripts/<tool>.py, ...] and [ffmpeg|ffprobe, ...] resolved from PATH",
521
+ "network": False,
522
+ "input_mutation": False,
523
+ },
524
+ "invocation": {
525
+ "structured": {
526
+ "canonical": True,
527
+ "transports": ["cli", "mcp"],
528
+ "argument_mapping": {
529
+ "positional": "listed in input_schema.positional, in order; array values expand to several arguments",
530
+ "options": "key -> --key with '_' replaced by '-'; booleans are flags; arrays repeat the flag; input_schema.properties[key].cli lists the accepted spellings",
531
+ "exceptions": "per tool in mcp.argument_exceptions (key -> flag), e.g. output -> -o, loudness.lufs -> -I, graphics.count_from -> --from",
532
+ "json": "--json is appended for every tool except look and probe (probe prints JSON by default)",
533
+ },
534
+ },
535
+ "raw_argv": {"canonical": False, "transports": ["mcp"], "note": "MCP tools also accept {\"argv\": [...]} for CLI compatibility; it is still bound to the named script, never a shell"},
536
+ },
537
+ "roles": ROLES,
538
+ "idempotency_hints": IDEMPOTENCY,
539
+ "verification_policy": {
540
+ "probe_first": "run ffmpeg-skill/probe on every input before planning",
541
+ "verify_last": "run the tools named in each ToolSpec.verification after it wrote an artifact",
542
+ "visual": "when requires_visual_verification is true, run ffmpeg-skill/look on the output and inspect the PNG",
543
+ "audio_only": "audio-only inputs and audio-only tools never need ffmpeg-skill/look",
544
+ "check_rows": "ffmpeg-skill/check rows carry kind=format (fix) or kind=judgement (decide with the user)",
545
+ },
546
+ "json_output": {
547
+ "success": {"status": "completed", "exit_code": 0, "stdout": "one JSON document (output_schema)"},
548
+ "failure": {"status": "failed", "exit_code": "non-zero (127 when ffmpeg/ffprobe is missing)", "stdout": "{\"status\": \"failed\", \"error\": {\"kind\": ..., \"message\": ...}} when --json was given", "stderr": "human-readable message"},
549
+ "error_kinds": {"input": "missing or unsuitable input, bad arguments", "ffmpeg": "ffmpeg/ffprobe returned an error", "missing_tool": "ffmpeg or ffprobe not on PATH"},
550
+ },
551
+ "capabilities": caps,
552
+ "tools": tools,
553
+ }
554
+
555
+
556
+ def main() -> int:
557
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
558
+ ap.add_argument("command", nargs="?", choices=["contract", "doctor"], default="contract")
559
+ ap.add_argument("--json", action="store_true", help="JSON on stdout (the contract is always JSON)")
560
+ ap.add_argument("--static", action="store_true", help="omit environment detection (available / missing capabilities)")
561
+ args = ap.parse_args()
562
+ if args.command == "doctor":
563
+ d = doctor()
564
+ if args.json:
565
+ print(json.dumps(d, indent=2, sort_keys=True))
566
+ else:
567
+ print(f"python {d['python']}; ffmpeg {d['ffmpeg'] or 'MISSING'}; ffprobe {d['ffprobe'] or 'MISSING'}")
568
+ print(f"available: {', '.join(d['available'])}")
569
+ print(f"missing required: {', '.join(d['missing']) or 'none'}")
570
+ print(f"missing optional: {', '.join(d['missing_optional']) or 'none'}")
571
+ return 0 if d["ok"] else 1
572
+ print(json.dumps(build(detect=not args.static), indent=2, sort_keys=True, ensure_ascii=False))
573
+ return 0
574
+
575
+
576
+ if __name__ == "__main__":
577
+ sys.exit(main())
@@ -23,7 +23,7 @@ import sys
23
23
  from pathlib import Path
24
24
  from typing import List, Optional, Tuple
25
25
 
26
- from _common import color_hex, load_brand, video_args, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, escape_filter_path, ffmpeg_base, fmt_srt_time, info, parse_time, probe, run, x264_args
26
+ from _common import STATE, color_hex, load_brand, video_args, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, escape_filter_path, ffmpeg_base, fmt_srt_time, info, parse_time, probe, run, x264_args
27
27
 
28
28
  ALIGN = {"bottom": 2, "top": 8, "center": 5, "bottom-left": 1, "bottom-right": 3, "top-left": 7, "top-right": 9}
29
29
 
@@ -352,7 +352,8 @@ def main() -> int:
352
352
  srt_path = os.path.splitext(out_guess)[0] + ".srt"
353
353
  else:
354
354
  srt_path = os.path.splitext(args.text)[0] + ".srt"
355
- write_srt(cues, srt_path)
355
+ if not STATE.dry_run:
356
+ write_srt(cues, srt_path)
356
357
  info(f"wrote {srt_path} ({len(cues)} cues)")
357
358
  if not args.input:
358
359
  print(srt_path)
@@ -382,7 +383,7 @@ def main() -> int:
382
383
  if args.fonts_dir:
383
384
  vf += f":fontsdir={escape_filter_path(args.fonts_dir)}"
384
385
  else:
385
- if not srt_path or not os.path.exists(srt_path):
386
+ if not srt_path or (not os.path.exists(srt_path) and not (STATE.dry_run and args.text)):
386
387
  die(f"SRT file not found: {srt_path}")
387
388
  style = [
388
389
  f"FontName={args.font}",
package/scripts/report.py CHANGED
@@ -19,7 +19,7 @@ import tempfile
19
19
  from pathlib import Path
20
20
  from typing import Any, Dict, List, Optional
21
21
 
22
- from _common import add_common, apply_common, die, emit, info, probe
22
+ from _common import STATE, add_common, apply_common, die, emit, info, probe
23
23
 
24
24
  HERE = Path(__file__).resolve().parent
25
25
 
@@ -150,8 +150,11 @@ def main() -> int:
150
150
  .foot{color:var(--ink2);font-size:12px;margin-top:36px;border-top:1px solid var(--line);padding-top:10px}
151
151
  """
152
152
  doc = f"<!doctype html><html><head><meta charset='utf-8'><meta name='viewport' content='width=device-width,initial-scale=1'><title>{html.escape(title)}</title><style>{css}</style></head><body><div class='wrap'>{''.join(parts)}</div></body></html>"
153
- Path(output).write_text(doc, encoding="utf-8")
154
- info(f"wrote {output} ({os.path.getsize(output) / 1024:.0f} KB)")
153
+ if STATE.dry_run:
154
+ info(f"wrote {output}") # printed as "[dry-run] would write"; nothing is written
155
+ else:
156
+ Path(output).write_text(doc, encoding="utf-8")
157
+ info(f"wrote {output} ({os.path.getsize(output) / 1024:.0f} KB)")
155
158
  emit(None, report=output, check=chk)
156
159
  if not args.json:
157
160
  print(output)