ffmpeg-skill 0.9.0 → 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 (43) hide show
  1. package/README.md +291 -122
  2. package/SKILL.md +39 -7
  3. package/bin/install.js +1 -1
  4. package/package.json +15 -3
  5. package/scripts/_common.py +47 -3
  6. package/scripts/_contract.py +221 -41
  7. package/scripts/audio.py +100 -7
  8. package/scripts/caption.py +6 -0
  9. package/scripts/check.py +21 -7
  10. package/scripts/color.py +68 -4
  11. package/scripts/cut.py +83 -9
  12. package/scripts/export.py +15 -6
  13. package/scripts/fit.py +17 -3
  14. package/scripts/join.py +74 -3
  15. package/scripts/multicam.py +10 -0
  16. package/scripts/overlay.py +5 -0
  17. package/scripts/render.py +13 -2
  18. package/scripts/scenes.py +15 -3
  19. package/scripts/sync.py +8 -0
  20. package/mcp/__pycache__/server.cpython-311.pyc +0 -0
  21. package/scripts/__pycache__/_common.cpython-311.pyc +0 -0
  22. package/scripts/__pycache__/_contract.cpython-311.pyc +0 -0
  23. package/scripts/__pycache__/audio.cpython-311.pyc +0 -0
  24. package/scripts/__pycache__/batch.cpython-311.pyc +0 -0
  25. package/scripts/__pycache__/caption.cpython-311.pyc +0 -0
  26. package/scripts/__pycache__/check.cpython-311.pyc +0 -0
  27. package/scripts/__pycache__/color.cpython-311.pyc +0 -0
  28. package/scripts/__pycache__/cut.cpython-311.pyc +0 -0
  29. package/scripts/__pycache__/export.cpython-311.pyc +0 -0
  30. package/scripts/__pycache__/fit.cpython-311.pyc +0 -0
  31. package/scripts/__pycache__/graphics.cpython-311.pyc +0 -0
  32. package/scripts/__pycache__/join.cpython-311.pyc +0 -0
  33. package/scripts/__pycache__/look.cpython-311.pyc +0 -0
  34. package/scripts/__pycache__/loudness.cpython-311.pyc +0 -0
  35. package/scripts/__pycache__/multicam.cpython-311.pyc +0 -0
  36. package/scripts/__pycache__/overlay.cpython-311.pyc +0 -0
  37. package/scripts/__pycache__/probe.cpython-311.pyc +0 -0
  38. package/scripts/__pycache__/render.cpython-311.pyc +0 -0
  39. package/scripts/__pycache__/report.cpython-311.pyc +0 -0
  40. package/scripts/__pycache__/scenes.cpython-311.pyc +0 -0
  41. package/scripts/__pycache__/silence.cpython-311.pyc +0 -0
  42. package/scripts/__pycache__/sync.cpython-311.pyc +0 -0
  43. package/scripts/__pycache__/verify.cpython-311.pyc +0 -0
package/package.json CHANGED
@@ -1,8 +1,20 @@
1
1
  {
2
2
  "name": "ffmpeg-skill",
3
- "version": "0.9.0",
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
- "keywords": ["ffmpeg", "video", "agent-skill", "claude-code", "cursor", "codex", "skill", "video-editing"],
3
+ "version": "0.10.0",
4
+ "description": "Agent Skill that gives coding agents (Claude Code, Cursor, Codex) a local video editor: 21 FFmpeg tools with a machine-readable contract, contract-derived MCP server, FFmpeg capability detection, probe-first / verify-last workflow. Cut, join, silence removal, fit, captions and karaoke, overlays, motion graphics, HDR to SDR, LUTs, audio clean-up and typed dynamics, sync with drift correction, multicam, loudness, delivery checks, project rendering, batch. No API keys, no cloud, no dependencies.",
5
+ "keywords": [
6
+ "ffmpeg",
7
+ "video",
8
+ "video-editing",
9
+ "video-processing",
10
+ "audio",
11
+ "agent-skill",
12
+ "claude-code",
13
+ "cursor",
14
+ "codex",
15
+ "mcp",
16
+ "skill"
17
+ ],
6
18
  "license": "MIT",
7
19
  "author": "kajisho5",
8
20
  "repository": {
@@ -16,8 +16,18 @@ from fractions import Fraction
16
16
  from pathlib import Path
17
17
  from typing import Any, Dict, List, Optional, Sequence
18
18
 
19
+ # Every script prints paths, help text and reports that may contain non-ASCII (Japanese examples,
20
+ # arrows). On Windows the console streams default to a legacy code page and raise
21
+ # UnicodeEncodeError; make them UTF-8 with replacement so a --help never crashes on encoding.
22
+ for _stream in (sys.stdout, sys.stderr):
23
+ try:
24
+ if getattr(_stream, "encoding", "").lower().replace("-", "") != "utf8":
25
+ _stream.reconfigure(encoding="utf-8", errors="replace")
26
+ except (AttributeError, ValueError):
27
+ pass
28
+
19
29
  INSTALL_HINTS = {
20
- "Darwin": " brew install ffmpeg",
30
+ "Darwin": " brew install ffmpeg-full (the plain ffmpeg formula lacks subtitles/drawtext/zscale)",
21
31
  "Linux": (
22
32
  " Debian/Ubuntu: sudo apt install ffmpeg\n"
23
33
  " Fedora: sudo dnf install ffmpeg\n"
@@ -322,6 +332,16 @@ def probe(path: str) -> Dict[str, Any]:
322
332
  "sample_rate": _to_int(audio.get("sample_rate")),
323
333
  "bitrate": _to_int(audio.get("bit_rate")),
324
334
  }
335
+ # every audio stream in file order: index n here is `-map 0:a:n` (audio.py --audio-stream n)
336
+ out["audio_streams"] = [{
337
+ "index": n,
338
+ "codec": a.get("codec_name"),
339
+ "channels": _to_int(a.get("channels")),
340
+ "channel_layout": a.get("channel_layout"),
341
+ "sample_rate": _to_int(a.get("sample_rate")),
342
+ "language": (a.get("tags") or {}).get("language"),
343
+ "title": (a.get("tags") or {}).get("title"),
344
+ } for n, a in enumerate(s for s in streams if s.get("codec_type") == "audio")]
325
345
  return out
326
346
 
327
347
 
@@ -356,10 +376,21 @@ def fmt_srt_time(seconds: float) -> str:
356
376
 
357
377
 
358
378
  def escape_filter_path(path: str) -> str:
359
- """Escape a path for use inside an ffmpeg filter graph option value."""
379
+ """Escape a file path for use as a filter option value (subtitles=, ass=, lut3d=file=, fontfile=, fontsdir=).
380
+
381
+ A filter option value is parsed twice: the graph parser splits filters on `,` / `;` and options
382
+ on `:`, then the filter's own option parser splits key=value pairs on `:` again. A character that
383
+ must survive both passes needs two levels of escaping, so a Windows drive letter `D:/x.srt` is
384
+ written `D\\\\:/x.srt`; with a single backslash the second pass still splits at the colon and
385
+ ffmpeg reads `/x.srt` as the next option (`Unable to parse "original_size" option value`).
386
+ Backslashes are turned into forward slashes first (ffmpeg accepts them on Windows), so a backslash
387
+ never has to be escaped itself; `'`, `,`, `;`, `[` and `]` are graph-level characters.
388
+ """
360
389
  p = str(Path(path))
361
390
  p = p.replace("\\", "/")
362
- p = p.replace(":", "\\:").replace("'", "\\'").replace(",", "\\,").replace("[", "\\[").replace("]", "\\]")
391
+ p = p.replace(":", "\\\\:")
392
+ for ch in ("'", ",", ";", "[", "]"):
393
+ p = p.replace(ch, "\\" + ch)
363
394
  return p
364
395
 
365
396
 
@@ -436,6 +467,19 @@ def audio_codec_for(output_path: str, default_bitrate: str = "192k") -> List[str
436
467
  return list(AUDIO_CODECS.get(ext, ["-c:a", "aac", "-b:a", default_bitrate]))
437
468
 
438
469
 
470
+ def is_audio_output(output_path: str) -> bool:
471
+ """True when the output extension is an audio-only container (.wav, .flac, .mp3, .m4a, .aac, .ogg, .opus).
472
+
473
+ Such a file cannot hold a video stream and, for .wav, cannot hold compressed audio: scripts use
474
+ this to drop the picture (-vn) and to pick the codec from the extension instead of AAC.
475
+ """
476
+ return os.path.splitext(output_path)[1].lower() in AUDIO_CODECS
477
+
478
+
479
+ def db_to_linear(db: float) -> float:
480
+ return 10 ** (db / 20.0)
481
+
482
+
439
483
  def analyze_levels(path: str, seconds: float = 20.0) -> Dict[str, Any]:
440
484
  """Sample luma/saturation statistics (signalstats) and guess whether the picture is Log-encoded.
441
485
 
@@ -21,6 +21,7 @@ import argparse
21
21
  import importlib.util
22
22
  import json
23
23
  import os
24
+ import platform
24
25
  import re
25
26
  import shutil
26
27
  import subprocess
@@ -63,8 +64,8 @@ TOOL_META: Dict[str, Dict[str, Any]] = {
63
64
  "probe": dict(role="analysis", inputs=["media (video or audio, any container ffprobe reads)"], outputs=["measurement JSON on stdout (no file)"],
64
65
  required=["ffprobe"], optional=[{"capability": "ffmpeg", "when": "--analyze"}, {"capability": "filter:signalstats", "when": "--analyze"}],
65
66
  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"}],
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,
68
69
  video_required=False, audio_only=True, visual=False, verify=["probe"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
69
70
  "fit": dict(role="execution", inputs=["video asset"], outputs=["video artifact at the requested duration / aspect / fps"],
70
71
  required=FF + [X264, AAC], optional=[HDR_X265, {"capability": "filter:minterpolate", "when": "--smooth interpolate"}],
@@ -84,8 +85,9 @@ TOOL_META: Dict[str, Dict[str, Any]] = {
84
85
  "multicam": dict(role="execution", inputs=["reference camera", "other cameras / recorders"], outputs=["switched multicam video artifact"],
85
86
  required=FF + [X264, AAC], optional=[HDR_X265],
86
87
  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,
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,
89
91
  video_required=False, audio_only=True, visual=False, verify=["probe"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
90
92
  "loudness": dict(role="analysis_and_execution", inputs=["video or audio asset"], outputs=["loudness measurement JSON (--measure-only)", "normalised artifact (video stream-copied)"],
91
93
  required=FF + ["filter:loudnorm", AAC], optional=AUDIO_OUT,
@@ -93,12 +95,14 @@ TOOL_META: Dict[str, Dict[str, Any]] = {
93
95
  "silence": dict(role="analysis_and_execution", inputs=["video or audio asset"], outputs=["silence list JSON (--list)", "artifact with silences removed", "EDL text (--edl)"],
94
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,
95
97
  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),
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),
99
101
  "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
+ 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"}],
102
106
  video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
103
107
  "export": dict(role="execution", inputs=["video asset"], outputs=["delivery artifact in the preset's format"],
104
108
  required=FF, optional=[{"capability": X264, "when": "preset youtube / youtube4k / reels / x"}, {"capability": AAC, "when": "any preset except gif"},
@@ -143,6 +147,38 @@ DRY_RUN_NOTES = {
143
147
  "verify": "not supported: the flag is accepted but the steps run and outputs are written",
144
148
  }
145
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
+
146
182
  IDEMPOTENCY = {
147
183
  "bit_exact": "same inputs and flags give byte-identical output",
148
184
  "content_equivalent": "same inputs and flags give the same media content; bytes may differ between encoder builds",
@@ -259,6 +295,20 @@ def output_schema(name: str, meta: Dict[str, Any]) -> Dict[str, Any]:
259
295
  extra = {"report": {"type": "string"}, "check": {"type": ["object", "null"]}}
260
296
  elif name == "loudness":
261
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"}}
262
312
  props = dict(base)
263
313
  props.update(extra)
264
314
  required = ["status", "output", "dry_run", "commands"]
@@ -288,26 +338,67 @@ def public_tools() -> List[str]:
288
338
  return sorted(p.stem for p in HERE.glob("*.py") if not p.name.startswith("_"))
289
339
 
290
340
 
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)
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."""
297
353
  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":
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()
304
370
  if len(parts) == 1 and not parts[0].endswith(":"):
305
371
  names.append(parts[0])
306
- elif flags and len(parts) >= 2 and re.fullmatch(flags, parts[0]):
307
- names.append(parts[1])
308
372
  return names
309
373
 
310
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
+
311
402
  def _version_line(binary: str) -> Optional[str]:
312
403
  exe = shutil.which(binary)
313
404
  if not exe:
@@ -335,30 +426,51 @@ def required_capabilities() -> Dict[str, List[str]]:
335
426
 
336
427
 
337
428
  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] = {}
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
343
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
+
344
453
  for cap in wanted["required"] + wanted["optional"]:
345
454
  if cap == "ffmpeg":
346
- have[cap] = shutil.which("ffmpeg") is not None
455
+ state[cap] = "available" if shutil.which("ffmpeg") else "missing"
347
456
  elif cap == "ffprobe":
348
- have[cap] = shutil.which("ffprobe") is not None
457
+ state[cap] = "available" if shutil.which("ffprobe") else "missing"
349
458
  elif cap.startswith("encoder:"):
350
- have[cap] = cap[8:] in encoders
459
+ state[cap] = _from("encoders", cap[8:])
351
460
  elif cap.startswith("filter:"):
352
- have[cap] = cap[7:] in filters
461
+ state[cap] = _from("filters", cap[7:])
353
462
  elif cap.startswith("bsf:"):
354
- have[cap] = cap[4:] in bsfs
463
+ state[cap] = _from("bsfs", cap[4:])
355
464
  elif cap == "external:whisper":
356
- have[cap] = _whisper_available()
465
+ state[cap] = "available" if _whisper_available() else "missing"
357
466
  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))
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")]
362
474
  return {
363
475
  "python": ".".join(str(x) for x in sys.version_info[:3]),
364
476
  "ffmpeg": _version_line("ffmpeg"),
@@ -366,10 +478,64 @@ def doctor() -> Dict[str, Any]:
366
478
  "available": available,
367
479
  "missing": missing_required,
368
480
  "missing_optional": missing_optional,
369
- "ok": not missing_required,
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),
370
486
  }
371
487
 
372
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
+
373
539
  # ----------------------------------------------------------------------------- contract
374
540
  def tool_spec(name: str, version: str) -> Dict[str, Any]:
375
541
  if name not in TOOL_META:
@@ -412,6 +578,11 @@ def tool_spec(name: str, version: str) -> Dict[str, Any]:
412
578
  "mutates_input": False,
413
579
  "produces_artifact": meta["produces_artifact"],
414
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 {}),
415
586
  "requires_visual_verification": meta["visual"],
416
587
  "audio_only": meta["audio_only"],
417
588
  "video_required": meta["video_required"],
@@ -496,7 +667,8 @@ def build(detect: bool = True) -> Dict[str, Any]:
496
667
  caps: Dict[str, Any] = {"required": wanted["required"], "optional": wanted["optional"], "naming": "ffmpeg | ffprobe | encoder:<name> | filter:<name> | bsf:<name> | external:whisper"}
497
668
  if detect:
498
669
  d = doctor()
499
- caps.update({"available": d["available"], "missing": d["missing"], "missing_optional": d["missing_optional"], "detected_by": "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"})
500
672
  return {
501
673
  "contract_version": CONTRACT_VERSION,
502
674
  "skill": {
@@ -550,6 +722,7 @@ def build(detect: bool = True) -> Dict[str, Any]:
550
722
  },
551
723
  "capabilities": caps,
552
724
  "tools": tools,
725
+ "provides": capability_provides(),
553
726
  }
554
727
 
555
728
 
@@ -568,7 +741,14 @@ def main() -> int:
568
741
  print(f"available: {', '.join(d['available'])}")
569
742
  print(f"missing required: {', '.join(d['missing']) or 'none'}")
570
743
  print(f"missing optional: {', '.join(d['missing_optional']) or 'none'}")
571
- return 0 if d["ok"] else 1
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
572
752
  print(json.dumps(build(detect=not args.static), indent=2, sort_keys=True, ensure_ascii=False))
573
753
  return 0
574
754