ffmpeg-skill 0.8.5 → 0.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.
Files changed (44) hide show
  1. package/README.md +296 -118
  2. package/SKILL.md +39 -7
  3. package/bin/install.js +10 -2
  4. package/mcp/server.py +40 -44
  5. package/package.json +19 -5
  6. package/scripts/_common.py +55 -7
  7. package/scripts/_contract.py +757 -0
  8. package/scripts/audio.py +100 -7
  9. package/scripts/caption.py +10 -3
  10. package/scripts/check.py +21 -7
  11. package/scripts/color.py +68 -4
  12. package/scripts/cut.py +83 -9
  13. package/scripts/export.py +15 -6
  14. package/scripts/fit.py +17 -3
  15. package/scripts/join.py +74 -3
  16. package/scripts/multicam.py +10 -0
  17. package/scripts/overlay.py +5 -0
  18. package/scripts/render.py +13 -2
  19. package/scripts/report.py +6 -3
  20. package/scripts/scenes.py +15 -3
  21. package/scripts/sync.py +8 -0
  22. package/mcp/__pycache__/server.cpython-311.pyc +0 -0
  23. package/scripts/__pycache__/_common.cpython-311.pyc +0 -0
  24. package/scripts/__pycache__/audio.cpython-311.pyc +0 -0
  25. package/scripts/__pycache__/batch.cpython-311.pyc +0 -0
  26. package/scripts/__pycache__/caption.cpython-311.pyc +0 -0
  27. package/scripts/__pycache__/check.cpython-311.pyc +0 -0
  28. package/scripts/__pycache__/color.cpython-311.pyc +0 -0
  29. package/scripts/__pycache__/cut.cpython-311.pyc +0 -0
  30. package/scripts/__pycache__/export.cpython-311.pyc +0 -0
  31. package/scripts/__pycache__/fit.cpython-311.pyc +0 -0
  32. package/scripts/__pycache__/graphics.cpython-311.pyc +0 -0
  33. package/scripts/__pycache__/join.cpython-311.pyc +0 -0
  34. package/scripts/__pycache__/look.cpython-311.pyc +0 -0
  35. package/scripts/__pycache__/loudness.cpython-311.pyc +0 -0
  36. package/scripts/__pycache__/multicam.cpython-311.pyc +0 -0
  37. package/scripts/__pycache__/overlay.cpython-311.pyc +0 -0
  38. package/scripts/__pycache__/probe.cpython-311.pyc +0 -0
  39. package/scripts/__pycache__/render.cpython-311.pyc +0 -0
  40. package/scripts/__pycache__/report.cpython-311.pyc +0 -0
  41. package/scripts/__pycache__/scenes.cpython-311.pyc +0 -0
  42. package/scripts/__pycache__/silence.cpython-311.pyc +0 -0
  43. package/scripts/__pycache__/sync.cpython-311.pyc +0 -0
  44. package/scripts/__pycache__/verify.cpython-311.pyc +0 -0
@@ -0,0 +1,757 @@
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 platform
25
+ import re
26
+ import shutil
27
+ import subprocess
28
+ import sys
29
+ from pathlib import Path
30
+ from typing import Any, Dict, List, Optional
31
+
32
+ HERE = Path(__file__).resolve().parent
33
+ ROOT = HERE.parent
34
+ SKILL_ID = "ffmpeg-skill"
35
+ CONTRACT_VERSION = "1.0"
36
+
37
+ ROLES = {
38
+ "analysis": "reads media and reports measurements; writes no media",
39
+ "analysis_and_execution": "measures by default or with a flag, and can also write a transformed artifact",
40
+ "execution": "writes a new media artifact from the input(s); the input is never modified",
41
+ "verification": "checks or shows an artifact (probe numbers, compliance rows, contact sheets); writes no media",
42
+ }
43
+
44
+ # Facts that are not derivable from the argparse parsers. Capability names:
45
+ # ffmpeg / ffprobe the binaries on PATH
46
+ # encoder:<name> `ffmpeg -encoders`
47
+ # filter:<name> `ffmpeg -filters`
48
+ # bsf:<name> `ffmpeg -bsfs`
49
+ # external:whisper a local whisper engine (whisper.cpp / faster-whisper / openai-whisper)
50
+ # "optional" entries name the flag or condition under which the capability is needed.
51
+ FF = ["ffmpeg", "ffprobe"]
52
+ X264 = "encoder:libx264"
53
+ X265 = "encoder:libx265"
54
+ AAC = "encoder:aac"
55
+ HDR_X265 = {"capability": X265, "when": "the source is HDR (kept as HEVC Main10)"}
56
+ AUDIO_OUT = [
57
+ {"capability": "encoder:libmp3lame", "when": "output extension is .mp3"},
58
+ {"capability": "encoder:libopus", "when": "output extension is .opus"},
59
+ {"capability": "encoder:libvorbis", "when": "output extension is .ogg"},
60
+ {"capability": "encoder:flac", "when": "output extension is .flac"},
61
+ ]
62
+
63
+ TOOL_META: Dict[str, Dict[str, Any]] = {
64
+ "probe": dict(role="analysis", inputs=["media (video or audio, any container ffprobe reads)"], outputs=["measurement JSON on stdout (no file)"],
65
+ required=["ffprobe"], optional=[{"capability": "ffmpeg", "when": "--analyze"}, {"capability": "filter:signalstats", "when": "--analyze"}],
66
+ video_required=False, audio_only=True, visual=False, verify=[], produces_artifact=False, idempotency="bit_exact", deterministic=True),
67
+ "cut": dict(role="execution", inputs=["video or audio asset"], outputs=["cut video/audio artifact (same container family, or audio extracted when -o has an audio extension)"],
68
+ 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"}] + AUDIO_OUT,
69
+ video_required=False, audio_only=True, visual=False, verify=["probe"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
70
+ "fit": dict(role="execution", inputs=["video asset"], outputs=["video artifact at the requested duration / aspect / fps"],
71
+ required=FF + [X264, AAC], optional=[HDR_X265, {"capability": "filter:minterpolate", "when": "--smooth interpolate"}],
72
+ video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
73
+ "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"],
74
+ required=FF + [X264, AAC, "filter:subtitles"], optional=[{"capability": "filter:ass", "when": "--animate / --karaoke"}, HDR_X265, {"capability": "external:whisper", "when": "--transcribe"}],
75
+ video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
76
+ "overlay": dict(role="execution", inputs=["video asset", "image (--image / --logo) or text (--text)"], outputs=["video artifact with the overlay composited"],
77
+ required=FF + [X264, AAC], optional=[{"capability": "filter:drawtext", "when": "--text"}, HDR_X265],
78
+ video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
79
+ "graphics": dict(role="execution", inputs=["video asset"], outputs=["video artifact with the drawn template"],
80
+ required=FF + [X264, AAC, "filter:drawtext"], optional=[HDR_X265],
81
+ video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
82
+ "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"],
83
+ required=FF, optional=[{"capability": AAC, "when": "writing a video container"}] + AUDIO_OUT,
84
+ video_required=False, audio_only=True, visual=False, verify=["probe"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
85
+ "multicam": dict(role="execution", inputs=["reference camera", "other cameras / recorders"], outputs=["switched multicam video artifact"],
86
+ required=FF + [X264, AAC], optional=[HDR_X265],
87
+ video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
88
+ "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, or dropped when -o has an audio extension)"],
89
+ required=FF + [AAC], optional=[{"capability": "filter:afftdn", "when": "--denoise / --voice"}, {"capability": "filter:sidechaincompress", "when": "--duck"},
90
+ {"capability": "filter:acompressor", "when": "--compress / --voice"}, {"capability": "filter:alimiter", "when": "--limit"}, {"capability": "filter:agate", "when": "--gate"}] + AUDIO_OUT,
91
+ video_required=False, audio_only=True, visual=False, verify=["probe"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
92
+ "loudness": dict(role="analysis_and_execution", inputs=["video or audio asset"], outputs=["loudness measurement JSON (--measure-only)", "normalised artifact (video stream-copied)"],
93
+ required=FF + ["filter:loudnorm", AAC], optional=AUDIO_OUT,
94
+ video_required=False, audio_only=True, visual=False, verify=["probe", "check"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
95
+ "silence": dict(role="analysis_and_execution", inputs=["video or audio asset"], outputs=["silence list JSON (--list)", "artifact with silences removed", "EDL text (--edl)"],
96
+ 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,
97
+ video_required=False, audio_only=True, visual=False, verify=["probe"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
98
+ "join": dict(role="execution", inputs=["two or more video assets, or two or more audio-only assets"], outputs=["concatenated video artifact", "concatenated audio artifact (audio-only inputs, audio output extension)"],
99
+ required=FF + [X264, AAC, "filter:xfade", "filter:acrossfade"], optional=[HDR_X265] + AUDIO_OUT,
100
+ video_required=False, audio_only=True, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
101
+ "color": dict(role="execution", inputs=["video asset", ".cube LUT (--lut)"], outputs=["video artifact with converted colour"],
102
+ required=FF, optional=[{"capability": X264, "when": "--to-sdr / --lut / --correct"}, {"capability": "filter:zscale", "when": "--to-sdr"}, {"capability": "filter:tonemap", "when": "--to-sdr"},
103
+ {"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"},
104
+ {"capability": "filter:exposure", "when": "--correct"}, {"capability": "filter:eq", "when": "--correct"},
105
+ {"capability": "filter:colorbalance", "when": "--correct"}, {"capability": "filter:colortemperature", "when": "--correct"}],
106
+ video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
107
+ "export": dict(role="execution", inputs=["video asset"], outputs=["delivery artifact in the preset's format"],
108
+ required=FF, optional=[{"capability": X264, "when": "preset youtube / youtube4k / reels / x"}, {"capability": AAC, "when": "any preset except gif"},
109
+ {"capability": X265, "when": "preset h265"}, {"capability": "encoder:prores_ks", "when": "preset prores"},
110
+ {"capability": "filter:palettegen", "when": "preset gif"}, {"capability": "encoder:gif", "when": "preset gif"}],
111
+ video_required=True, audio_only=False, visual=False, verify=["probe", "check"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
112
+ "check": dict(role="verification", inputs=["media artifact"], outputs=["compliance rows JSON on stdout (no file)"],
113
+ required=["ffprobe"], optional=[{"capability": "ffmpeg", "when": "loudness rows (default)"}, {"capability": "filter:loudnorm", "when": "loudness rows (default)"}],
114
+ video_required=False, audio_only=True, visual=False, verify=[], produces_artifact=False, idempotency="bit_exact", deterministic=True),
115
+ "scenes": dict(role="analysis", inputs=["video asset"], outputs=["scene / audio-peak / highlight JSON on stdout", "EDL text (--edl)", "per-scene contact sheet PNG (--sheet)"],
116
+ required=FF + ["filter:scdet"], optional=[{"capability": "filter:drawtext", "when": "--sheet"}, {"capability": "filter:tile", "when": "--sheet"}],
117
+ video_required=True, audio_only=False, visual=False, verify=[], produces_artifact=True, idempotency="bit_exact", deterministic=True),
118
+ "look": dict(role="verification", inputs=["video artifact"], outputs=["PNG contact sheet / frames / side-by-side"],
119
+ 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"}],
120
+ video_required=True, audio_only=False, visual=False, verify=[], produces_artifact=True, idempotency="bit_exact", deterministic=True),
121
+ "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)"],
122
+ required=FF, optional=[{"capability": "delegated", "when": "each stage runs cut / join / fit / caption / overlay / audio / loudness / export / check with their capabilities"}],
123
+ video_required=True, audio_only=False, visual=True, verify=["probe", "check", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
124
+ "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"],
125
+ required=FF, optional=[{"capability": "delegated", "when": "each recipe step runs the named script with its capabilities"}],
126
+ video_required=False, audio_only=True, visual=False, verify=["probe"], produces_artifact=True, idempotency="cached", deterministic=True),
127
+ "verify": dict(role="verification", inputs=["media files and/or folders"], outputs=["PASS/FAIL JSON per step", "Markdown report (--report)", "step outputs (--out / --keep)"],
128
+ required=FF, optional=[{"capability": "delegated", "when": "runs cut / fit / caption / export / loudness / color on each file"}],
129
+ video_required=False, audio_only=True, visual=False, verify=[], produces_artifact=True, idempotency="environment_dependent", deterministic=False),
130
+ "report": dict(role="verification", inputs=["deliverable (--after)", "source (--before)", "commands / notes text"], outputs=["single-file HTML delivery report"],
131
+ required=FF + ["filter:loudnorm"], optional=[{"capability": "delegated", "when": "runs look (sheets) and check (--platform)"}],
132
+ video_required=False, audio_only=True, visual=False, verify=[], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
133
+ }
134
+
135
+ # Dry-run behaviour a parser cannot express (measured in tests/test_contract.py with a fake ffmpeg
136
+ # on PATH). "analysis_only": ffmpeg still decodes/measures the input under --dry-run, but nothing
137
+ # is encoded and no file is written. Every other tool with the flag runs no ffmpeg at all.
138
+ DRY_RUN_ANALYSIS = {
139
+ "sync": "audio is decoded to find the offset; the aligned output is not written",
140
+ "multicam": "audio is decoded to align the cameras; the switched output is not written",
141
+ "scenes": "scene and audio-peak measurement runs; --sheet and --edl are not written",
142
+ "report": "probe, loudness and contact-sheet measurements run; the HTML is not written",
143
+ }
144
+ DRY_RUN_NOTES = {
145
+ "probe": "read-only tool; --dry-run changes nothing (ffprobe still runs)",
146
+ "check": "read-only tool; --dry-run skips the ffmpeg loudness measurement, so loudness rows are absent",
147
+ "verify": "not supported: the flag is accepted but the steps run and outputs are written",
148
+ }
149
+
150
+ # Whether a tool re-encodes each stream *when that stream is present in the input* -- not whether
151
+ # the tool touches the file at all. "always"/"never" are unconditional given that stream exists;
152
+ # "conditional" means it depends on flags or on how far a lossless attempt misses (see "note").
153
+ # Read from each script's actual encode/copy args, not from role or intent, since several tools
154
+ # (fit, caption, overlay, graphics, color, join, multicam, silence) always transcode audio to AAC
155
+ # alongside a video filter even though the audio itself is untouched content -- there is no
156
+ # "-c:a copy while re-encoding video" path in this codebase, so a soft-subtitle-style passthrough
157
+ # of the original audio codec never happens on those tools.
158
+ REENCODE_META: Dict[str, Dict[str, str]] = {
159
+ "probe": dict(video="never", audio="never", note="analysis only, no artifact"),
160
+ "cut": dict(video="conditional", audio="conditional", note="lossless -c copy preferred; re-encodes on --accurate, a VFR source, or a keyframe snap past --tolerance (see cut.py --json: mode, keyframe_snapped)"),
161
+ "fit": dict(video="always", audio="always", note="always re-encodes to AAC when audio is present, even if only --fps or --aspect was asked for"),
162
+ "caption": dict(video="always", audio="always", note="burn-in only: no soft-subtitle mux path exists, so captions always cost a full re-encode of both streams"),
163
+ "overlay": dict(video="always", audio="always"),
164
+ "graphics": dict(video="always", audio="always"),
165
+ "sync": dict(video="never", audio="conditional", note="video is never touched; audio is copied or re-encoded depending on --trim-second / --replace-audio / --fix-drift"),
166
+ "multicam": dict(video="always", audio="always"),
167
+ "audio": dict(video="never", audio="always", note="video stream is always -c:v copy when present; this tool's job is the audio"),
168
+ "loudness": dict(video="never", audio="always"),
169
+ "silence": dict(video="always", audio="always", note="removing gaps requires cutting on non-keyframe boundaries"),
170
+ "join": dict(video="always", audio="always"),
171
+ "color": dict(video="always", audio="always"),
172
+ "export": dict(video="conditional", audio="conditional", note="--preset copy is -c:v copy -c:a copy (no re-encode); every other preset re-encodes both"),
173
+ "check": dict(video="never", audio="never", note="read-only, no artifact"),
174
+ "scenes": dict(video="never", audio="never", note="analysis only; --sheet renders a new contact-sheet PNG, not a re-encode of the source"),
175
+ "look": dict(video="never", audio="never", note="renders a new contact-sheet/frame PNG, not a re-encode of the source"),
176
+ "render": dict(video="conditional", audio="conditional", note="delegated: depends on which stages a project.json runs and how each one behaves"),
177
+ "batch": dict(video="conditional", audio="conditional", note="delegated: depends on which script each recipe step runs"),
178
+ "verify": dict(video="conditional", audio="conditional", note="delegated: runs cut/fit/caption/export/loudness/color internally as checks"),
179
+ "report": dict(video="never", audio="never", note="measures via look/check; produces an HTML report, not a re-encoded artifact"),
180
+ }
181
+
182
+ IDEMPOTENCY = {
183
+ "bit_exact": "same inputs and flags give byte-identical output",
184
+ "content_equivalent": "same inputs and flags give the same media content; bytes may differ between encoder builds",
185
+ "cached": "re-runs skip inputs whose content hash and recipe are unchanged",
186
+ "environment_dependent": "output includes timings or machine state and differs between runs",
187
+ }
188
+
189
+
190
+ # ----------------------------------------------------------------------------- parsers
191
+ class _Captured(Exception):
192
+ def __init__(self, parser: argparse.ArgumentParser) -> None:
193
+ self.parser = parser
194
+
195
+
196
+ def _capture_parser(script: Path) -> argparse.ArgumentParser:
197
+ """Import the script and run main() until parse_args() to get its live parser."""
198
+ original = argparse.ArgumentParser.parse_args
199
+
200
+ def fake_parse(self: argparse.ArgumentParser, *a: Any, **k: Any) -> Any:
201
+ raise _Captured(self)
202
+
203
+ argparse.ArgumentParser.parse_args = fake_parse # type: ignore[assignment]
204
+ sys_argv = sys.argv
205
+ try:
206
+ sys.argv = [str(script)]
207
+ spec = importlib.util.spec_from_file_location("ffskill_tool_" + script.stem, script)
208
+ module = importlib.util.module_from_spec(spec)
209
+ assert spec.loader is not None
210
+ spec.loader.exec_module(module)
211
+ module.main()
212
+ except _Captured as cap:
213
+ return cap.parser
214
+ finally:
215
+ argparse.ArgumentParser.parse_args = original # type: ignore[assignment]
216
+ sys.argv = sys_argv
217
+ raise RuntimeError(f"{script.name}: main() returned before parse_args()")
218
+
219
+
220
+ def _json_type(action: argparse.Action) -> Dict[str, Any]:
221
+ if isinstance(action, argparse._StoreTrueAction):
222
+ return {"type": "boolean"}
223
+ if isinstance(action, argparse._AppendAction) or action.nargs in ("+", "*"):
224
+ return {"type": "array", "items": {"type": "string"}}
225
+ if action.type is int:
226
+ return {"type": "integer"}
227
+ if action.type is float:
228
+ return {"type": "number"}
229
+ return {"type": "string"}
230
+
231
+
232
+ def input_schema(parser: argparse.ArgumentParser) -> Dict[str, Any]:
233
+ props: Dict[str, Any] = {}
234
+ required: List[str] = []
235
+ positional: List[str] = []
236
+ common = {"dry_run", "json", "progress", "fast"}
237
+ for action in parser._actions:
238
+ if isinstance(action, argparse._HelpAction):
239
+ continue
240
+ prop: Dict[str, Any] = _json_type(action)
241
+ if action.help and action.help != argparse.SUPPRESS:
242
+ prop["description"] = action.help % {"default": action.default} if "%(default)" in action.help else action.help
243
+ if action.choices:
244
+ prop["enum"] = list(action.choices)
245
+ if action.default not in (None, False, argparse.SUPPRESS):
246
+ prop["default"] = action.default
247
+ if action.option_strings:
248
+ prop["cli"] = list(action.option_strings)
249
+ if action.required:
250
+ required.append(action.dest)
251
+ else:
252
+ prop["cli"] = "positional"
253
+ positional.append(action.dest)
254
+ if action.nargs not in ("?", "*"):
255
+ required.append(action.dest)
256
+ if action.dest in common:
257
+ prop["common"] = True
258
+ props[action.dest] = prop
259
+ groups = [g for g in getattr(parser, "_mutually_exclusive_groups", []) if g._group_actions]
260
+ schema: Dict[str, Any] = {"type": "object", "properties": props, "required": required, "positional": positional, "additionalProperties": False}
261
+ if groups:
262
+ schema["mutually_exclusive"] = [[a.dest for a in g._group_actions] for g in groups]
263
+ schema["one_of_required"] = [[a.dest for a in g._group_actions] for g in groups if g.required]
264
+ return schema
265
+
266
+
267
+ def output_schema(name: str, meta: Dict[str, Any]) -> Dict[str, Any]:
268
+ """What the tool prints on stdout with --json (keys observed in the implementation)."""
269
+ if name == "probe":
270
+ return {"type": "object", "description": "one probe document, or an array of them for several inputs",
271
+ "properties": {"file": {"type": "string"}, "format": {"type": "string"}, "duration": {"type": "number"}, "size_bytes": {"type": "integer"},
272
+ "video": {"type": ["object", "null"]}, "audio": {"type": ["object", "null"]}}, "additionalProperties": True}
273
+ base = {"status": {"enum": ["completed"]}, "output": {"type": ["string", "null"], "description": "path written, or null"},
274
+ "dry_run": {"type": "boolean"}, "commands": {"type": "array", "items": {"type": "string"}, "description": "every ffmpeg command line planned or run"},
275
+ "probe": {"type": "object", "description": "probe of the output when a file was written"}}
276
+ extra: Dict[str, Any] = {}
277
+ if name == "check":
278
+ extra = {"platform": {"type": "string"}, "ok": {"type": "boolean"}, "failed": {"type": "integer"}, "warnings": {"type": "integer"},
279
+ "checks": {"type": "array", "items": {"type": "object", "properties": {"check": {"type": "string"}, "status": {"enum": ["PASS", "WARN", "FAIL"]}, "value": {}, "expected": {}, "fix": {"type": "string"}, "kind": {"enum": ["format", "judgement"]}}}}}
280
+ elif name == "scenes":
281
+ extra = {"file": {"type": "string"}, "duration": {"type": "number"}, "scene_count": {"type": "integer"}, "scenes": {"type": "array"}, "audio_peaks": {"type": "array"}}
282
+ elif name == "silence":
283
+ extra = {"silences": {"type": "array"}, "keep": {"type": "array"}, "input_duration": {"type": "number"}, "kept_duration": {"type": "number"}, "removed_seconds": {"type": "number"}}
284
+ elif name == "sync":
285
+ extra = {"reference": {"type": "string"}, "second": {"type": "string"}, "offset_seconds": {"type": "number"}, "confidence": {"type": "number"}, "meaning": {"type": "string"}, "drift": {"type": "object"}}
286
+ elif name == "look":
287
+ extra = {"outputs": {"type": "array", "items": {"type": "string"}}}
288
+ elif name == "render":
289
+ extra = {"stages": {"type": "array", "items": {"type": "string"}}, "check": {"type": ["object", "null"]}}
290
+ elif name == "verify":
291
+ extra = {"report": {"type": ["string", "null"]}, "files": {"type": "array"}, "failed": {"type": "integer"}, "total": {"type": "integer"}}
292
+ elif name == "batch":
293
+ extra = {"results": {"type": "array"}, "processed": {"type": "integer"}, "total": {"type": "integer"}}
294
+ elif name == "report":
295
+ extra = {"report": {"type": "string"}, "check": {"type": ["object", "null"]}}
296
+ elif name == "loudness":
297
+ extra = {"measured": {"type": "object", "description": "--measure-only prints the loudnorm measurement instead (input_i, input_tp, input_lra, input_thresh, target_offset)"}}
298
+ elif name == "cut":
299
+ extra = {"expected_duration": {"type": "number", "description": "seconds requested"},
300
+ "duration_error_ms": {"type": ["number", "null"], "description": "written minus requested, measured by ffprobe (null under --dry-run)"},
301
+ "precision": {"enum": ["packet", "sample", "codec_frame", "frame"],
302
+ "description": "packet: stream copy on a packet/keyframe boundary; sample: decoded audio trimmed to the sample, lossless output; codec_frame: sample-trimmed then framed by a lossy encoder (priming delay adds to the length); frame: re-encoded video"},
303
+ "reencoded": {"type": "boolean"}}
304
+ elif name == "join":
305
+ extra = {"mode": {"enum": ["video", "audio"]}, "clips": {"type": "integer"}, "transition": {"type": "string"}, "expected_duration": {"type": "number"},
306
+ "sample_rate": {"type": "integer", "description": "audio mode only"}, "channels": {"type": "integer", "description": "audio mode only"},
307
+ "video": {"type": "boolean", "description": "false in audio mode: the output has no video stream"}}
308
+ elif name == "audio":
309
+ extra = {"video": {"type": "boolean", "description": "true when the input's video stream was copied; false for an audio output extension (extraction)"},
310
+ "audio_stream": {"type": "integer", "description": "which input audio stream was processed (--audio-stream)"},
311
+ "dynamics": {"type": "array", "items": {"enum": ["agate", "acompressor", "alimiter"]}, "description": "typed dynamics filters applied, in graph order"}}
312
+ props = dict(base)
313
+ props.update(extra)
314
+ required = ["status", "output", "dry_run", "commands"]
315
+ return {"type": "object", "properties": props, "required": required, "additionalProperties": True}
316
+
317
+
318
+ # ----------------------------------------------------------------------------- environment
319
+ def skill_version() -> str:
320
+ for candidate in (ROOT / "package.json",):
321
+ try:
322
+ return str(json.loads(candidate.read_text(encoding="utf-8"))["version"])
323
+ except (OSError, ValueError, KeyError):
324
+ continue
325
+ return "unknown"
326
+
327
+
328
+ def skill_description() -> str:
329
+ try:
330
+ text = (ROOT / "SKILL.md").read_text(encoding="utf-8")
331
+ m = re.search(r"^description:\s*(.+)$", text, re.M)
332
+ return m.group(1).strip() if m else ""
333
+ except OSError:
334
+ return ""
335
+
336
+
337
+ def public_tools() -> List[str]:
338
+ return sorted(p.stem for p in HERE.glob("*.py") if not p.name.startswith("_"))
339
+
340
+
341
+ # `ffmpeg -filters` rows: FFmpeg <= 7 prints three flag characters (`..C acompressor A->A ...`),
342
+ # FFmpeg 8 prints two (`T. acompressor A->A ...`). The row is recognised by its io-spec token
343
+ # (`A->A`, `|->V`, `N->N`, ...) so the flag width does not matter; a legend line never carries `->`.
344
+ _FILTER_ROW = re.compile(r"^\s*(?:[A-Z.]{1,6}\s+)?([A-Za-z0-9_]+)\s+(\S*->\S*)(?:\s|$)")
345
+ # `ffmpeg -encoders` rows follow a ` ------` separator: flags (six characters today; any width of
346
+ # letters and dots is accepted) then the encoder name. A legend line has `=` where the name would be.
347
+ _ENCODER_ROW = re.compile(r"^\s*[A-Z.]{2,10}\s+([A-Za-z0-9_-]+)(?:\s|$)")
348
+ _LIST_SEPARATOR = re.compile(r"^\s*-{3,}\s*$")
349
+
350
+
351
+ def _parse_ff_list(flag: str, text: str) -> List[str]:
352
+ """Names in the stdout of `ffmpeg <flag>`; empty when no row was recognised."""
353
+ names: List[str] = []
354
+ if flag == "-filters":
355
+ for line in text.splitlines():
356
+ m = _FILTER_ROW.match(line)
357
+ if m:
358
+ names.append(m.group(1))
359
+ elif flag == "-encoders":
360
+ lines = text.splitlines()
361
+ sep = next((i for i, l in enumerate(lines) if _LIST_SEPARATOR.match(l)), None)
362
+ rows = lines[sep + 1:] if sep is not None else lines
363
+ for line in rows:
364
+ m = _ENCODER_ROW.match(line)
365
+ if m and m.group(1) != "=":
366
+ names.append(m.group(1))
367
+ elif flag == "-bsfs":
368
+ for line in text.splitlines():
369
+ parts = line.split()
370
+ if len(parts) == 1 and not parts[0].endswith(":"):
371
+ names.append(parts[0])
372
+ return names
373
+
374
+
375
+ def _ff_listing(binary: str, flag: str) -> Dict[str, Any]:
376
+ """`{"names": [...], "status": parsed | unparsed | failed | missing, "detail": str}` for `ffmpeg <flag>`.
377
+
378
+ `parsed`: rows recognised. `unparsed`: ffmpeg ran but no row matched, so the capabilities it
379
+ covers are unknown, not absent. `failed`: ffmpeg exited non-zero. `missing`: no binary on PATH.
380
+ """
381
+ exe = shutil.which(binary)
382
+ if not exe:
383
+ return {"names": [], "status": "missing", "detail": f"{binary} not on PATH"}
384
+ try:
385
+ proc = subprocess.run([exe, "-hide_banner", flag], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
386
+ except OSError as e:
387
+ return {"names": [], "status": "failed", "detail": f"{binary} {flag}: {e}"}
388
+ if proc.returncode != 0:
389
+ tail = " ".join(proc.stderr.strip().splitlines()[-2:])
390
+ return {"names": [], "status": "failed", "detail": f"{binary} {flag} exited {proc.returncode}: {tail}"}
391
+ names = _parse_ff_list(flag, proc.stdout)
392
+ if not names:
393
+ return {"names": [], "status": "unparsed", "detail": f"no row recognised in `{binary} {flag}` output ({len(proc.stdout.splitlines())} lines)"}
394
+ return {"names": names, "status": "parsed", "detail": f"{len(names)} entries"}
395
+
396
+
397
+ def _ff_list(binary: str, flag: str) -> List[str]:
398
+ """Names from `ffmpeg -encoders` / `-filters` / `-bsfs` (empty list when ffmpeg is missing or unparsed)."""
399
+ return _ff_listing(binary, flag)["names"]
400
+
401
+
402
+ def _version_line(binary: str) -> Optional[str]:
403
+ exe = shutil.which(binary)
404
+ if not exe:
405
+ return None
406
+ proc = subprocess.run([exe, "-version"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
407
+ first = (proc.stdout or proc.stderr).splitlines()[:1]
408
+ m = re.match(rf"{binary} version (\S+)", first[0]) if first else None
409
+ return m.group(1) if m else (first[0] if first else "unknown")
410
+
411
+
412
+ def _whisper_available() -> bool:
413
+ if shutil.which("whisper-cli") or shutil.which("whisper-cpp") or shutil.which("whisper"):
414
+ return True
415
+ return importlib.util.find_spec("faster_whisper") is not None or importlib.util.find_spec("whisper") is not None
416
+
417
+
418
+ def required_capabilities() -> Dict[str, List[str]]:
419
+ req: set = set()
420
+ opt: set = set()
421
+ for meta in TOOL_META.values():
422
+ req.update(meta["required"])
423
+ opt.update(o["capability"] for o in meta["optional"] if o["capability"] != "delegated")
424
+ opt -= req
425
+ return {"required": sorted(req), "optional": sorted(opt)}
426
+
427
+
428
+ def doctor() -> Dict[str, Any]:
429
+ """Detect which declared capabilities this machine has. No secrets, no environment variables.
430
+
431
+ Three states per capability: available, missing, unknown. `unknown` means the ffmpeg listing
432
+ that would prove it could not be read (unparsed output, ffmpeg failure); it is never folded into
433
+ `missing` (a filter that exists is not reported absent) nor into `available` (a failed detection
434
+ is not a pass). `ok` is true only when nothing required is missing or unknown.
435
+ """
436
+ listings = {
437
+ "encoders": _ff_listing("ffmpeg", "-encoders"),
438
+ "filters": _ff_listing("ffmpeg", "-filters"),
439
+ "bsfs": _ff_listing("ffmpeg", "-bsfs"),
440
+ }
441
+ sets = {k: set(v["names"]) for k, v in listings.items()}
442
+ state: Dict[str, str] = {} # capability -> available | missing | unknown
443
+ wanted = required_capabilities()
444
+
445
+ def _from(kind: str, name: str) -> str:
446
+ lst = listings[kind]
447
+ if lst["status"] == "parsed":
448
+ return "available" if name in sets[kind] else "missing"
449
+ if lst["status"] == "missing":
450
+ return "missing" # no ffmpeg at all: nothing it provides is available
451
+ return "unknown"
452
+
453
+ for cap in wanted["required"] + wanted["optional"]:
454
+ if cap == "ffmpeg":
455
+ state[cap] = "available" if shutil.which("ffmpeg") else "missing"
456
+ elif cap == "ffprobe":
457
+ state[cap] = "available" if shutil.which("ffprobe") else "missing"
458
+ elif cap.startswith("encoder:"):
459
+ state[cap] = _from("encoders", cap[8:])
460
+ elif cap.startswith("filter:"):
461
+ state[cap] = _from("filters", cap[7:])
462
+ elif cap.startswith("bsf:"):
463
+ state[cap] = _from("bsfs", cap[4:])
464
+ elif cap == "external:whisper":
465
+ state[cap] = "available" if _whisper_available() else "missing"
466
+ else:
467
+ state[cap] = "missing"
468
+ available = sorted(c for c, st in state.items() if st == "available")
469
+ missing_required = sorted(c for c in wanted["required"] if state[c] == "missing")
470
+ missing_optional = sorted(c for c in wanted["optional"] if state[c] == "missing")
471
+ unknown = sorted(c for c, st in state.items() if st == "unknown")
472
+ unknown_required = [c for c in unknown if c in wanted["required"]]
473
+ errors = [f"{k}: {v['detail']}" for k, v in listings.items() if v["status"] in ("unparsed", "failed")]
474
+ return {
475
+ "python": ".".join(str(x) for x in sys.version_info[:3]),
476
+ "ffmpeg": _version_line("ffmpeg"),
477
+ "ffprobe": _version_line("ffprobe"),
478
+ "available": available,
479
+ "missing": missing_required,
480
+ "missing_optional": missing_optional,
481
+ "unknown": unknown,
482
+ "detection": {k: {"status": v["status"], "count": len(v["names"]), "detail": v["detail"]} for k, v in listings.items()},
483
+ "errors": errors,
484
+ "ok": not missing_required and not unknown_required,
485
+ "tools": _tool_usability(state),
486
+ }
487
+
488
+
489
+ def _capability_fix_hint(cap: str) -> str:
490
+ """One-line, plain-language remedy for a single missing/unknown capability."""
491
+ if cap in ("ffmpeg", "ffprobe"):
492
+ from _common import INSTALL_HINTS
493
+ hint = INSTALL_HINTS.get(platform.system(), "see https://ffmpeg.org/download.html").strip().splitlines()[0].strip()
494
+ return f"install ffmpeg: {hint}"
495
+ full_hint = "on macOS, brew install ffmpeg-full (the plain formula lacks subtitles/drawtext/zscale)" if platform.system() == "Darwin" else "install/build ffmpeg with it enabled"
496
+ if cap.startswith("encoder:"):
497
+ return f"this ffmpeg build has no {cap[8:]} encoder; {full_hint}"
498
+ if cap.startswith("filter:"):
499
+ return f"this ffmpeg build has no {cap[7:]} filter; {full_hint}"
500
+ if cap.startswith("bsf:"):
501
+ return f"this ffmpeg build has no {cap[4:]} bitstream filter; {full_hint}"
502
+ if cap == "external:whisper":
503
+ return "install a local whisper (whisper-cli, whisper-cpp, faster-whisper or openai-whisper) for --transcribe"
504
+ return f"'{cap}' is not available; see docs/contract.md"
505
+
506
+
507
+ def _tool_usability(state: Dict[str, str]) -> Dict[str, Dict[str, Any]]:
508
+ """Per-tool usable/missing/unknown, folded from the same capability `state` doctor already
509
+ computed. Answers "can I run this tool on this machine today", not just "what capabilities
510
+ exist" -- a caller reading only `available`/`missing` still has to cross-reference each tool's
511
+ own required-capability list by hand to answer that."""
512
+ tools: Dict[str, Dict[str, Any]] = {}
513
+ for name, meta in TOOL_META.items():
514
+ required = list(meta["required"])
515
+ missing = [c for c in required if state.get(c) == "missing"]
516
+ unknown = [c for c in required if state.get(c) == "unknown"]
517
+ entry: Dict[str, Any] = {"usable": "no" if missing else ("unknown" if unknown else "yes")}
518
+ if missing:
519
+ entry["missing"] = missing
520
+ entry["fix"] = "; ".join(dict.fromkeys(_capability_fix_hint(c) for c in missing))
521
+ if unknown:
522
+ entry["unknown"] = unknown
523
+ tools[name] = entry
524
+ return tools
525
+
526
+
527
+ # Cross-repository Capability ids (kajisho5/AI-video-production-OS docs/SPEC.md
528
+ # `CapabilityContract.provides`), matching the ids already assigned to these 21 tools in
529
+ # that project's own docs/CAPABILITY_MATRIX.md section 9: "ffmpeg-skill's 21 raw tools ...
530
+ # are Capabilities in their own right, independent of the higher-level Skills that
531
+ # delegate to them". Each tool's own id is `ffmpeg-skill/<tool>` (a slash, matching every
532
+ # ToolSpec.id here); the Capability id uses a dot - `ffmpeg-skill.<tool>` - the same
533
+ # `<domain>.<verb>`-shaped convention every other Skill's Capability ids use elsewhere in
534
+ # that project (`video.trim`, `audio.gain`, ...), with "ffmpeg-skill" as the domain.
535
+ def capability_provides() -> List[Dict[str, str]]:
536
+ return [{"id": f"{SKILL_ID}.{name}", "lifecycle": "EXPERIMENTAL", "tool_id": f"{SKILL_ID}/{name}"} for name in public_tools()]
537
+
538
+
539
+ # ----------------------------------------------------------------------------- contract
540
+ def tool_spec(name: str, version: str) -> Dict[str, Any]:
541
+ if name not in TOOL_META:
542
+ # a public script without metadata is drift: fail loudly instead of guessing its role or capabilities
543
+ 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 '_')")
544
+ meta = TOOL_META[name]
545
+ parser = _capture_parser(HERE / f"{name}.py")
546
+ schema = input_schema(parser)
547
+ # structured key -> CLI flag where key.replace("_", "-") is not the long option (MCP uses the same table)
548
+ exceptions: Dict[str, str] = {}
549
+ for dest, prop in schema["properties"].items():
550
+ if prop["cli"] == "positional":
551
+ continue
552
+ longs = [f for f in prop["cli"] if f.startswith("--")]
553
+ if dest == "output":
554
+ exceptions[dest] = "-o"
555
+ elif name == "loudness" and dest == "lufs":
556
+ exceptions[dest] = "-I"
557
+ elif "--" + dest.replace("_", "-") not in longs:
558
+ exceptions[dest] = longs[0] if longs else prop["cli"][0]
559
+ supports_dry_run = "dry_run" in schema["properties"] and name != "verify"
560
+ return {
561
+ "id": f"{SKILL_ID}/{name}",
562
+ "name": name,
563
+ "version": version,
564
+ "description": (parser.description or "").strip().splitlines()[0] if parser.description else "",
565
+ "executable": f"scripts/{name}.py",
566
+ "role": meta["role"],
567
+ "capabilities": {"required": list(meta["required"]), "optional": list(meta["optional"])},
568
+ "inputs": list(meta["inputs"]),
569
+ "outputs": list(meta["outputs"]),
570
+ "input_schema": schema,
571
+ "output_schema": output_schema(name, meta),
572
+ "supports_dry_run": supports_dry_run,
573
+ "dry_run": {"supported": supports_dry_run,
574
+ "ffmpeg_execution": "full" if not supports_dry_run else "analysis_only" if name in DRY_RUN_ANALYSIS else "none",
575
+ "semantics": "prints the ffmpeg command lines that would run; no output file is written",
576
+ **({"note": DRY_RUN_ANALYSIS.get(name) or DRY_RUN_NOTES[name]} if name in DRY_RUN_ANALYSIS or name in DRY_RUN_NOTES else {})},
577
+ "supports_json": "json" in schema["properties"],
578
+ "mutates_input": False,
579
+ "produces_artifact": meta["produces_artifact"],
580
+ "verification": {"required": bool(meta["verify"]), "tools": [f"{SKILL_ID}/{t}" for t in meta["verify"]]},
581
+ # a tool with no declared entry reports "conditional" rather than guessing "always" or
582
+ # "never" -- same "unknown is not missing" principle as doctor's capability detection
583
+ "reencodes_video": REENCODE_META.get(name, {}).get("video", "conditional"),
584
+ "reencodes_audio": REENCODE_META.get(name, {}).get("audio", "conditional"),
585
+ **({"reencode_note": REENCODE_META[name]["note"]} if REENCODE_META.get(name, {}).get("note") else {}),
586
+ "requires_visual_verification": meta["visual"],
587
+ "audio_only": meta["audio_only"],
588
+ "video_required": meta["video_required"],
589
+ "deterministic_inputs": meta["deterministic"],
590
+ "idempotency_hint": meta["idempotency"],
591
+ "mcp": {"tool": name, "positional": schema["positional"], "argument_exceptions": exceptions},
592
+ }
593
+
594
+
595
+ # ----------------------------------------------------------------------------- MCP derivation
596
+ # tools that print JSON without --json (probe) or whose primary output is a file path (look): the transport
597
+ # does not append --json for them (stated in invocation.structured.argument_mapping.json)
598
+ MCP_JSON_EXEMPT = ("look", "probe")
599
+ MCP_STRUCTURED_NOTE = ("Structured arguments: keys are the input_schema property names (argparse dests), positionals "
600
+ "are passed by name, output -> -o. Or argv: the raw CLI list (non-canonical; all other keys are then ignored). "
601
+ "Media paths must be absolute.")
602
+
603
+
604
+ def mcp_input_schema(spec: Dict[str, Any]) -> Dict[str, Any]:
605
+ """Translate a ToolSpec.input_schema into the JSON Schema an MCP tools/list entry carries.
606
+
607
+ Deterministic and lossless where JSON Schema can express argparse semantics:
608
+ - properties keep type / enum / default / description / items; the ffmpeg-skill-only keys
609
+ (`cli`, `common`) are dropped, positionals get a "(positional N)" prefix in the description;
610
+ - required fields, mutually exclusive groups (`not required [a, b]` per pair) and required
611
+ groups (`anyOf required`) apply to the structured branch;
612
+ - the raw-argv compatibility branch (`argv` present) lifts those constraints, which JSON Schema
613
+ expresses as a top-level anyOf of the two branches.
614
+ Not expressible and therefore documented rather than encoded: which keys the tool ignores when
615
+ `argv` is given (all of them), and argparse's `%(default)s` help interpolation (already applied).
616
+ """
617
+ src = spec["input_schema"]
618
+ props: Dict[str, Any] = {}
619
+ positional = list(src.get("positional", []))
620
+ for dest in sorted(src["properties"]):
621
+ p = src["properties"][dest]
622
+ out: Dict[str, Any] = {"type": p["type"]}
623
+ if p["type"] == "array":
624
+ out["items"] = dict(p.get("items", {"type": "string"}))
625
+ desc = p.get("description", "")
626
+ if dest in positional:
627
+ desc = f"(positional {positional.index(dest) + 1}) {desc}".strip()
628
+ if desc:
629
+ out["description"] = desc
630
+ for key in ("enum", "default"):
631
+ if key in p:
632
+ out[key] = p[key]
633
+ props[dest] = out
634
+ props["argv"] = {"type": "array", "items": {"type": "string"}, "description": "raw CLI arguments (non-canonical compatibility path; when present every other key is ignored)"}
635
+ structured: Dict[str, Any] = {}
636
+ if src.get("required"):
637
+ structured["required"] = list(src["required"])
638
+ all_of: List[Dict[str, Any]] = []
639
+ for group in src.get("mutually_exclusive", []):
640
+ for i, a in enumerate(group):
641
+ for b in group[i + 1:]:
642
+ all_of.append({"not": {"required": [a, b]}})
643
+ if all_of:
644
+ structured["allOf"] = all_of
645
+ one_of = [[{"required": [d]} for d in group] for group in src.get("one_of_required", [])]
646
+ if one_of:
647
+ structured["anyOf"] = one_of[0] if len(one_of) == 1 else [{"allOf": [{"anyOf": g} for g in one_of]}]
648
+ schema: Dict[str, Any] = {"type": "object", "properties": props, "additionalProperties": False}
649
+ if structured:
650
+ schema["anyOf"] = [{"required": ["argv"]}, structured]
651
+ return schema
652
+
653
+
654
+ def mcp_tool(spec: Dict[str, Any]) -> Dict[str, Any]:
655
+ """The MCP tools/list entry for a ToolSpec: name, description and the derived inputSchema."""
656
+ return {"name": spec["name"], "description": f"{spec['description']} {MCP_STRUCTURED_NOTE}".strip(), "inputSchema": mcp_input_schema(spec)}
657
+
658
+
659
+ def mcp_tools(detect: bool = False) -> List[Dict[str, Any]]:
660
+ return [mcp_tool(spec) for spec in build(detect=detect)["tools"]]
661
+
662
+
663
+ def build(detect: bool = True) -> Dict[str, Any]:
664
+ version = skill_version()
665
+ tools = [tool_spec(n, version) for n in public_tools()]
666
+ wanted = required_capabilities()
667
+ caps: Dict[str, Any] = {"required": wanted["required"], "optional": wanted["optional"], "naming": "ffmpeg | ffprobe | encoder:<name> | filter:<name> | bsf:<name> | external:whisper"}
668
+ if detect:
669
+ d = doctor()
670
+ caps.update({"available": d["available"], "missing": d["missing"], "missing_optional": d["missing_optional"],
671
+ "unknown": d["unknown"], "detection": d["detection"], "detected_by": "doctor"})
672
+ return {
673
+ "contract_version": CONTRACT_VERSION,
674
+ "skill": {
675
+ "id": SKILL_ID,
676
+ "version": version,
677
+ "description": skill_description(),
678
+ "execution_mode": "local",
679
+ "kind": "execution",
680
+ "entrypoints": {
681
+ "cli": "python3 scripts/<tool>.py [args] [--json] [--dry-run]",
682
+ "mcp": "python3 mcp/server.py (stdio JSON-RPC; tools/list == this tool list)",
683
+ "contract": "python3 scripts/_contract.py --json | ffmpeg-skill contract --json",
684
+ "doctor": "python3 scripts/_contract.py doctor --json | ffmpeg-skill doctor --json",
685
+ },
686
+ "not_provided": ["AI reasoning", "decisions", "production plans", "project IR", "approvals", "network access", "transcription engine"],
687
+ },
688
+ "requirements": {"python": ">=3.9 (standard library only)", "ffmpeg": ">=5.0", "ffprobe": ">=5.0", "node": ">=16 (npx installer only)"},
689
+ "execution": {
690
+ "shell": False,
691
+ "arbitrary_executables": False,
692
+ "subprocess": "argv list only: [python3, scripts/<tool>.py, ...] and [ffmpeg|ffprobe, ...] resolved from PATH",
693
+ "network": False,
694
+ "input_mutation": False,
695
+ },
696
+ "invocation": {
697
+ "structured": {
698
+ "canonical": True,
699
+ "transports": ["cli", "mcp"],
700
+ "argument_mapping": {
701
+ "positional": "listed in input_schema.positional, in order; array values expand to several arguments",
702
+ "options": "key -> --key with '_' replaced by '-'; booleans are flags; arrays repeat the flag; input_schema.properties[key].cli lists the accepted spellings",
703
+ "exceptions": "per tool in mcp.argument_exceptions (key -> flag), e.g. output -> -o, loudness.lufs -> -I, graphics.count_from -> --from",
704
+ "json": "--json is appended for every tool except look and probe (probe prints JSON by default)",
705
+ },
706
+ },
707
+ "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"},
708
+ },
709
+ "roles": ROLES,
710
+ "idempotency_hints": IDEMPOTENCY,
711
+ "verification_policy": {
712
+ "probe_first": "run ffmpeg-skill/probe on every input before planning",
713
+ "verify_last": "run the tools named in each ToolSpec.verification after it wrote an artifact",
714
+ "visual": "when requires_visual_verification is true, run ffmpeg-skill/look on the output and inspect the PNG",
715
+ "audio_only": "audio-only inputs and audio-only tools never need ffmpeg-skill/look",
716
+ "check_rows": "ffmpeg-skill/check rows carry kind=format (fix) or kind=judgement (decide with the user)",
717
+ },
718
+ "json_output": {
719
+ "success": {"status": "completed", "exit_code": 0, "stdout": "one JSON document (output_schema)"},
720
+ "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"},
721
+ "error_kinds": {"input": "missing or unsuitable input, bad arguments", "ffmpeg": "ffmpeg/ffprobe returned an error", "missing_tool": "ffmpeg or ffprobe not on PATH"},
722
+ },
723
+ "capabilities": caps,
724
+ "tools": tools,
725
+ "provides": capability_provides(),
726
+ }
727
+
728
+
729
+ def main() -> int:
730
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
731
+ ap.add_argument("command", nargs="?", choices=["contract", "doctor"], default="contract")
732
+ ap.add_argument("--json", action="store_true", help="JSON on stdout (the contract is always JSON)")
733
+ ap.add_argument("--static", action="store_true", help="omit environment detection (available / missing capabilities)")
734
+ args = ap.parse_args()
735
+ if args.command == "doctor":
736
+ d = doctor()
737
+ if args.json:
738
+ print(json.dumps(d, indent=2, sort_keys=True))
739
+ else:
740
+ print(f"python {d['python']}; ffmpeg {d['ffmpeg'] or 'MISSING'}; ffprobe {d['ffprobe'] or 'MISSING'}")
741
+ print(f"available: {', '.join(d['available'])}")
742
+ print(f"missing required: {', '.join(d['missing']) or 'none'}")
743
+ print(f"missing optional: {', '.join(d['missing_optional']) or 'none'}")
744
+ if d["unknown"]:
745
+ print(f"unknown (detection failed, not proven missing): {', '.join(d['unknown'])}")
746
+ for err in d["errors"]:
747
+ print(f"detection error: {err}", file=sys.stderr)
748
+ if d["ok"]:
749
+ return 0
750
+ # 1: something required is missing; 2: nothing proven missing but a required capability is unknown
751
+ return 1 if d["missing"] else 2
752
+ print(json.dumps(build(detect=not args.static), indent=2, sort_keys=True, ensure_ascii=False))
753
+ return 0
754
+
755
+
756
+ if __name__ == "__main__":
757
+ sys.exit(main())