ffmpeg-skill 0.9.0 → 0.12.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 (57) hide show
  1. package/README.md +315 -122
  2. package/SKILL.md +115 -18
  3. package/bin/install.js +16 -2
  4. package/mcp/server.py +2 -0
  5. package/package.json +15 -3
  6. package/references/ci-platform-pitfalls.md +111 -0
  7. package/references/process-pitfalls.md +85 -0
  8. package/references/scripts.md +122 -11
  9. package/scripts/_common.py +247 -15
  10. package/scripts/_contract.py +420 -48
  11. package/scripts/audio.py +101 -8
  12. package/scripts/background.py +73 -0
  13. package/scripts/caption.py +97 -18
  14. package/scripts/check.py +21 -7
  15. package/scripts/color.py +104 -13
  16. package/scripts/crop.py +79 -0
  17. package/scripts/cut.py +85 -11
  18. package/scripts/export.py +16 -7
  19. package/scripts/fit.py +76 -12
  20. package/scripts/graphics.py +12 -3
  21. package/scripts/insert.py +128 -0
  22. package/scripts/join.py +88 -8
  23. package/scripts/loudness.py +3 -3
  24. package/scripts/multicam.py +11 -1
  25. package/scripts/overlay.py +64 -5
  26. package/scripts/proxy.py +82 -0
  27. package/scripts/render.py +13 -2
  28. package/scripts/reverse.py +56 -0
  29. package/scripts/scenes.py +15 -3
  30. package/scripts/sequence.py +124 -0
  31. package/scripts/silence.py +2 -2
  32. package/scripts/stabilize.py +83 -0
  33. package/scripts/sync.py +9 -1
  34. package/mcp/__pycache__/server.cpython-311.pyc +0 -0
  35. package/scripts/__pycache__/_common.cpython-311.pyc +0 -0
  36. package/scripts/__pycache__/_contract.cpython-311.pyc +0 -0
  37. package/scripts/__pycache__/audio.cpython-311.pyc +0 -0
  38. package/scripts/__pycache__/batch.cpython-311.pyc +0 -0
  39. package/scripts/__pycache__/caption.cpython-311.pyc +0 -0
  40. package/scripts/__pycache__/check.cpython-311.pyc +0 -0
  41. package/scripts/__pycache__/color.cpython-311.pyc +0 -0
  42. package/scripts/__pycache__/cut.cpython-311.pyc +0 -0
  43. package/scripts/__pycache__/export.cpython-311.pyc +0 -0
  44. package/scripts/__pycache__/fit.cpython-311.pyc +0 -0
  45. package/scripts/__pycache__/graphics.cpython-311.pyc +0 -0
  46. package/scripts/__pycache__/join.cpython-311.pyc +0 -0
  47. package/scripts/__pycache__/look.cpython-311.pyc +0 -0
  48. package/scripts/__pycache__/loudness.cpython-311.pyc +0 -0
  49. package/scripts/__pycache__/multicam.cpython-311.pyc +0 -0
  50. package/scripts/__pycache__/overlay.cpython-311.pyc +0 -0
  51. package/scripts/__pycache__/probe.cpython-311.pyc +0 -0
  52. package/scripts/__pycache__/render.cpython-311.pyc +0 -0
  53. package/scripts/__pycache__/report.cpython-311.pyc +0 -0
  54. package/scripts/__pycache__/scenes.cpython-311.pyc +0 -0
  55. package/scripts/__pycache__/silence.cpython-311.pyc +0 -0
  56. package/scripts/__pycache__/sync.cpython-311.pyc +0 -0
  57. package/scripts/__pycache__/verify.cpython-311.pyc +0 -0
@@ -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
@@ -32,6 +33,12 @@ HERE = Path(__file__).resolve().parent
32
33
  ROOT = HERE.parent
33
34
  SKILL_ID = "ffmpeg-skill"
34
35
  CONTRACT_VERSION = "1.0"
36
+ # `doctor`'s own introspection calls (-filters/-encoders/-bsfs/-version) are meant to be fast,
37
+ # bounded, non-media operations; a hang here would silently freeze the one tool meant to report
38
+ # whether the machine is broken. Media-processing scripts (cut, fit, ...) are NOT bounded this
39
+ # way -- a legitimate --accurate re-encode of a long file can take a long time, so no timeout is
40
+ # applied there (see README, "Development").
41
+ _DETECT_TIMEOUT = 10
35
42
 
36
43
  ROLES = {
37
44
  "analysis": "reads media and reports measurements; writes no media",
@@ -63,17 +70,36 @@ TOOL_META: Dict[str, Dict[str, Any]] = {
63
70
  "probe": dict(role="analysis", inputs=["media (video or audio, any container ffprobe reads)"], outputs=["measurement JSON on stdout (no file)"],
64
71
  required=["ffprobe"], optional=[{"capability": "ffmpeg", "when": "--analyze"}, {"capability": "filter:signalstats", "when": "--analyze"}],
65
72
  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"}],
73
+ "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)"],
74
+ 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
75
  video_required=False, audio_only=True, visual=False, verify=["probe"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
69
76
  "fit": dict(role="execution", inputs=["video asset"], outputs=["video artifact at the requested duration / aspect / fps"],
70
77
  required=FF + [X264, AAC], optional=[HDR_X265, {"capability": "filter:minterpolate", "when": "--smooth interpolate"}],
71
78
  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"}],
79
+ "crop": dict(role="execution", inputs=["video asset"], outputs=["video artifact cropped to the given pixel rectangle"],
80
+ required=FF + [X264, AAC], optional=[HDR_X265],
81
+ video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
82
+ "insert": dict(role="execution", inputs=["still image"], outputs=["silent video artifact of the requested duration / frame size / fps"],
83
+ required=FF + [X264], optional=[{"capability": "filter:zoompan", "when": "--zoom / --pan"}],
84
+ video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
85
+ "background": dict(role="execution", inputs=[], outputs=["generated solid-colour or gradient video artifact"],
86
+ required=FF + [X264], optional=[{"capability": "filter:gradients", "when": "--gradient"}],
87
+ video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="bit_exact", deterministic=True),
88
+ "reverse": dict(role="execution", inputs=["video asset"], outputs=["reversed video artifact"],
89
+ required=FF + [X264, "filter:reverse"], optional=[HDR_X265, {"capability": "filter:areverse", "when": "the input has audio and --no-audio is not given"}, {"capability": AAC, "when": "the input has audio and --no-audio is not given"}],
90
+ video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
91
+ "stabilize": dict(role="execution", inputs=["video asset"], outputs=["motion-stabilised video artifact"],
92
+ required=FF + [X264, "filter:vidstabdetect", "filter:vidstabtransform"], optional=[HDR_X265, {"capability": AAC, "when": "the input has audio"}],
93
+ video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
94
+ "sequence": dict(role="execution", inputs=["a directory of numbered/globbed still images"], outputs=["video artifact built from the frame sequence"],
95
+ required=FF + [X264], optional=[],
96
+ video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
97
+ "caption": dict(role="execution", inputs=["video asset", "SRT/ASS file or timed text (--text)"], outputs=["video artifact with burnt-in captions (--mode burn)", "video artifact with an added soft subtitle stream (--mode mux)", "generated .srt / .ass sidecar"],
98
+ required=FF + [X264, AAC, "filter:subtitles"], optional=[{"capability": "filter:ass", "when": "--animate / --karaoke"}, HDR_X265, {"capability": "external:whisper", "when": "--transcribe"},
99
+ {"capability": "encoder:mov_text", "when": "--mode mux with a .mp4/.m4v/.mov output"}, {"capability": "encoder:webvtt", "when": "--mode mux with a .webm output"}, {"capability": "encoder:srt", "when": "--mode mux with a .mkv output"}],
74
100
  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],
101
+ "overlay": dict(role="execution", inputs=["video asset", "image (--image / --logo), text (--text), or a second video (--video) to composite"], outputs=["video artifact with the overlay composited"],
102
+ required=FF + [X264, AAC], optional=[{"capability": "filter:drawtext", "when": "--text"}, {"capability": "filter:chromakey", "when": "--chromakey"}, HDR_X265],
77
103
  video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
78
104
  "graphics": dict(role="execution", inputs=["video asset"], outputs=["video artifact with the drawn template"],
79
105
  required=FF + [X264, AAC, "filter:drawtext"], optional=[HDR_X265],
@@ -84,8 +110,9 @@ TOOL_META: Dict[str, Dict[str, Any]] = {
84
110
  "multicam": dict(role="execution", inputs=["reference camera", "other cameras / recorders"], outputs=["switched multicam video artifact"],
85
111
  required=FF + [X264, AAC], optional=[HDR_X265],
86
112
  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,
113
+ "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)"],
114
+ required=FF + [AAC], optional=[{"capability": "filter:afftdn", "when": "--denoise / --voice"}, {"capability": "filter:sidechaincompress", "when": "--duck"},
115
+ {"capability": "filter:acompressor", "when": "--compress / --voice"}, {"capability": "filter:alimiter", "when": "--limit"}, {"capability": "filter:agate", "when": "--gate"}] + AUDIO_OUT,
89
116
  video_required=False, audio_only=True, visual=False, verify=["probe"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
90
117
  "loudness": dict(role="analysis_and_execution", inputs=["video or audio asset"], outputs=["loudness measurement JSON (--measure-only)", "normalised artifact (video stream-copied)"],
91
118
  required=FF + ["filter:loudnorm", AAC], optional=AUDIO_OUT,
@@ -93,12 +120,17 @@ TOOL_META: Dict[str, Dict[str, Any]] = {
93
120
  "silence": dict(role="analysis_and_execution", inputs=["video or audio asset"], outputs=["silence list JSON (--list)", "artifact with silences removed", "EDL text (--edl)"],
94
121
  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
122
  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),
123
+ "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)"],
124
+ required=FF + [X264, AAC, "filter:xfade", "filter:acrossfade"], optional=[HDR_X265] + AUDIO_OUT,
125
+ video_required=False, audio_only=True, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
99
126
  "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"}],
127
+ required=FF, optional=[{"capability": X264, "when": "--to-sdr / --lut / --correct"}, {"capability": "filter:zscale", "when": "--to-sdr"}, {"capability": "filter:tonemap", "when": "--to-sdr"},
128
+ {"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"},
129
+ {"capability": "filter:exposure", "when": "--correct"}, {"capability": "filter:eq", "when": "--correct"},
130
+ {"capability": "filter:colorbalance", "when": "--correct"}, {"capability": "filter:colortemperature", "when": "--correct"}],
131
+ video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
132
+ "proxy": dict(role="execution", inputs=["video asset"], outputs=["low-resolution, low-bitrate proxy artifact for downstream analysis, preview or editing decisions"],
133
+ required=FF + [X264, AAC], optional=[HDR_X265],
102
134
  video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
103
135
  "export": dict(role="execution", inputs=["video asset"], outputs=["delivery artifact in the preset's format"],
104
136
  required=FF, optional=[{"capability": X264, "when": "preset youtube / youtube4k / reels / x"}, {"capability": AAC, "when": "any preset except gif"},
@@ -143,6 +175,45 @@ DRY_RUN_NOTES = {
143
175
  "verify": "not supported: the flag is accepted but the steps run and outputs are written",
144
176
  }
145
177
 
178
+ # Whether a tool re-encodes each stream *when that stream is present in the input* -- not whether
179
+ # the tool touches the file at all. "always"/"never" are unconditional given that stream exists;
180
+ # "conditional" means it depends on flags or on how far a lossless attempt misses (see "note").
181
+ # Read from each script's actual encode/copy args, not from role or intent, since several tools
182
+ # (fit, caption, overlay, graphics, color, join, multicam, silence) always transcode audio to AAC
183
+ # alongside a video filter even though the audio itself is untouched content -- there is no
184
+ # "-c:a copy while re-encoding video" path in this codebase, so a soft-subtitle-style passthrough
185
+ # of the original audio codec never happens on those tools.
186
+ REENCODE_META: Dict[str, Dict[str, str]] = {
187
+ "probe": dict(video="never", audio="never", note="analysis only, no artifact"),
188
+ "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)"),
189
+ "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"),
190
+ "crop": dict(video="always", audio="always", note="the crop filter always forces a re-encode of both streams"),
191
+ "insert": dict(video="always", audio="never", note="always encodes a fresh silent clip from the still image; there is no audio stream to touch"),
192
+ "background": dict(video="always", audio="never", note="always encodes a fresh generated clip; there is no input to copy from"),
193
+ "reverse": dict(video="always", audio="conditional", note="video always re-encodes (reverse buffers and re-emits every frame); audio re-encodes to AAC when present and not dropped by --no-audio"),
194
+ "stabilize": dict(video="always", audio="conditional", note="video always re-encodes (two-pass vidstab); audio is re-encoded to AAC when present, never touched by the stabilization filters themselves"),
195
+ "sequence": dict(video="always", audio="never", note="always encodes a fresh clip from the frame sequence; there is no audio stream"),
196
+ "caption": dict(video="conditional", audio="conditional", note="--mode burn (default) always re-encodes both streams to render pixels; --mode mux copies video and audio untouched and only adds a subtitle stream"),
197
+ "overlay": dict(video="always", audio="always"),
198
+ "graphics": dict(video="always", audio="always"),
199
+ "sync": dict(video="conditional", audio="conditional", note="video is -c:v copy only for --trim-second when the second file started earlier (offset<0) and the copy succeeds; it is re-encoded whenever --replace-audio's stream copy fails, or in --trim-second when the second file started later (offset>=0, the common case) or --fix-drift is used"),
200
+ "multicam": dict(video="always", audio="always"),
201
+ "audio": dict(video="never", audio="always", note="video stream is always -c:v copy when present; this tool's job is the audio"),
202
+ "loudness": dict(video="never", audio="always"),
203
+ "silence": dict(video="always", audio="always", note="removing gaps requires cutting on non-keyframe boundaries"),
204
+ "join": dict(video="always", audio="always"),
205
+ "color": dict(video="conditional", audio="conditional", note="--strip-dovi and --retag (when the stream copy succeeds) are -c copy of both streams; --retag falls back to re-encoding only if the copy attempt fails; --to-sdr / --lut / --correct always re-encode both"),
206
+ "proxy": dict(video="always", audio="conditional", note="video is always re-encoded at proxy-grade quality; audio is re-encoded when present, dropped entirely with --no-audio or when the source has none"),
207
+ "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"),
208
+ "check": dict(video="never", audio="never", note="read-only, no artifact"),
209
+ "scenes": dict(video="never", audio="never", note="analysis only; --sheet renders a new contact-sheet PNG, not a re-encode of the source"),
210
+ "look": dict(video="never", audio="never", note="renders a new contact-sheet/frame PNG, not a re-encode of the source"),
211
+ "render": dict(video="conditional", audio="conditional", note="delegated: depends on which stages a project.json runs and how each one behaves"),
212
+ "batch": dict(video="conditional", audio="conditional", note="delegated: depends on which script each recipe step runs"),
213
+ "verify": dict(video="conditional", audio="conditional", note="delegated: runs cut/fit/caption/export/loudness/color internally as checks"),
214
+ "report": dict(video="never", audio="never", note="measures via look/check; produces an HTML report, not a re-encoded artifact"),
215
+ }
216
+
146
217
  IDEMPOTENCY = {
147
218
  "bit_exact": "same inputs and flags give byte-identical output",
148
219
  "content_equivalent": "same inputs and flags give the same media content; bytes may differ between encoder builds",
@@ -259,6 +330,20 @@ def output_schema(name: str, meta: Dict[str, Any]) -> Dict[str, Any]:
259
330
  extra = {"report": {"type": "string"}, "check": {"type": ["object", "null"]}}
260
331
  elif name == "loudness":
261
332
  extra = {"measured": {"type": "object", "description": "--measure-only prints the loudnorm measurement instead (input_i, input_tp, input_lra, input_thresh, target_offset)"}}
333
+ elif name == "cut":
334
+ extra = {"expected_duration": {"type": "number", "description": "seconds requested"},
335
+ "duration_error_ms": {"type": ["number", "null"], "description": "written minus requested, measured by ffprobe (null under --dry-run)"},
336
+ "precision": {"enum": ["packet", "sample", "codec_frame", "frame"],
337
+ "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"},
338
+ "reencoded": {"type": "boolean"}}
339
+ elif name == "join":
340
+ extra = {"mode": {"enum": ["video", "audio"]}, "clips": {"type": "integer"}, "transition": {"type": "string"}, "expected_duration": {"type": "number"},
341
+ "sample_rate": {"type": "integer", "description": "audio mode only"}, "channels": {"type": "integer", "description": "audio mode only"},
342
+ "video": {"type": "boolean", "description": "false in audio mode: the output has no video stream"}}
343
+ elif name == "audio":
344
+ extra = {"video": {"type": "boolean", "description": "true when the input's video stream was copied; false for an audio output extension (extraction)"},
345
+ "audio_stream": {"type": "integer", "description": "which input audio stream was processed (--audio-stream)"},
346
+ "dynamics": {"type": "array", "items": {"enum": ["agate", "acompressor", "alimiter"]}, "description": "typed dynamics filters applied, in graph order"}}
262
347
  props = dict(base)
263
348
  props.update(extra)
264
349
  required = ["status", "output", "dry_run", "commands"]
@@ -288,31 +373,99 @@ def public_tools() -> List[str]:
288
373
  return sorted(p.stem for p in HERE.glob("*.py") if not p.name.startswith("_"))
289
374
 
290
375
 
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)
376
+ # `ffmpeg -filters` rows: FFmpeg <= 7 prints three flag characters (`..C acompressor A->A ...`),
377
+ # FFmpeg 8 prints two (`T. acompressor A->A ...`). The row is recognised by its io-spec token
378
+ # (`A->A`, `|->V`, `N->N`, ...) so the flag width does not matter; a legend line never carries `->`.
379
+ _FILTER_ROW = re.compile(r"^\s*(?:[A-Z.]{1,6}\s+)?([A-Za-z0-9_]+)\s+(\S*->\S*)(?:\s|$)")
380
+ # `ffmpeg -encoders` rows follow a ` ------` separator: flags (six characters today; any width of
381
+ # letters and dots is accepted) then the encoder name. A legend line has `=` where the name would be.
382
+ _ENCODER_ROW = re.compile(r"^\s*[A-Z.]{2,10}\s+([A-Za-z0-9_-]+)(?:\s|$)")
383
+ _LIST_SEPARATOR = re.compile(r"^\s*-{3,}\s*$")
384
+
385
+
386
+ def _parse_ff_list(flag: str, text: str) -> List[str]:
387
+ """Names in the stdout of `ffmpeg <flag>`; empty when no row was recognised."""
297
388
  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":
389
+ if flag == "-filters":
390
+ for line in text.splitlines():
391
+ m = _FILTER_ROW.match(line)
392
+ if m:
393
+ names.append(m.group(1))
394
+ elif flag == "-encoders":
395
+ lines = text.splitlines()
396
+ sep = next((i for i, l in enumerate(lines) if _LIST_SEPARATOR.match(l)), None)
397
+ rows = lines[sep + 1:] if sep is not None else lines
398
+ for line in rows:
399
+ m = _ENCODER_ROW.match(line)
400
+ if m and m.group(1) != "=":
401
+ names.append(m.group(1))
402
+ elif flag == "-bsfs":
403
+ for line in text.splitlines():
404
+ parts = line.split()
304
405
  if len(parts) == 1 and not parts[0].endswith(":"):
305
406
  names.append(parts[0])
306
- elif flags and len(parts) >= 2 and re.fullmatch(flags, parts[0]):
307
- names.append(parts[1])
308
407
  return names
309
408
 
310
409
 
410
+ def _ff_listing(binary: str, flag: str) -> Dict[str, Any]:
411
+ """`{"names": [...], "status": parsed | unparsed | failed | missing, "detail": str}` for `ffmpeg <flag>`.
412
+
413
+ `parsed`: rows recognised. `unparsed`: ffmpeg ran but no row matched, so the capabilities it
414
+ covers are unknown, not absent. `failed`: ffmpeg exited non-zero. `missing`: no binary on PATH.
415
+ """
416
+ exe = shutil.which(binary)
417
+ if not exe:
418
+ return {"names": [], "status": "missing", "detail": f"{binary} not on PATH"}
419
+ try:
420
+ proc = subprocess.run([exe, "-hide_banner", flag], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=_DETECT_TIMEOUT)
421
+ except subprocess.TimeoutExpired:
422
+ return {"names": [], "status": "failed", "detail": f"{binary} {flag} did not exit within {_DETECT_TIMEOUT}s"}
423
+ except OSError as e:
424
+ return {"names": [], "status": "failed", "detail": f"{binary} {flag}: {e}"}
425
+ if proc.returncode != 0:
426
+ tail = " ".join(proc.stderr.strip().splitlines()[-2:])
427
+ return {"names": [], "status": "failed", "detail": f"{binary} {flag} exited {proc.returncode}: {tail}"}
428
+ names = _parse_ff_list(flag, proc.stdout)
429
+ if not names:
430
+ return {"names": [], "status": "unparsed", "detail": f"no row recognised in `{binary} {flag}` output ({len(proc.stdout.splitlines())} lines)"}
431
+ return {"names": names, "status": "parsed", "detail": f"{len(names)} entries"}
432
+
433
+
434
+ def _ff_list(binary: str, flag: str) -> List[str]:
435
+ """Names from `ffmpeg -encoders` / `-filters` / `-bsfs` (empty list when ffmpeg is missing or unparsed)."""
436
+ return _ff_listing(binary, flag)["names"]
437
+
438
+
439
+ # GPU-backed encoders are named `<codec>_<backend>` by every ffmpeg build (h264_nvenc,
440
+ # hevc_videotoolbox, av1_qsv, h264_vaapi, hevc_amf, ...); recognised by the backend suffix so a
441
+ # new codec in a future ffmpeg build needs no change here.
442
+ _GPU_ENCODER_SUFFIXES = ("_nvenc", "_videotoolbox", "_qsv", "_vaapi", "_amf")
443
+
444
+
445
+ def _gpu_encoders(encoders_listing: Dict[str, Any]) -> Dict[str, Any]:
446
+ """GPU-backed encoders this ffmpeg BUILD was compiled with, read from `-encoders` alone.
447
+
448
+ This proves the build carries e.g. h264_nvenc; it does NOT prove the GPU/driver on this
449
+ machine will accept a job -- that would require actually running an encode, which doctor's
450
+ introspection deliberately never does beyond listing/-version (see doctor()'s own docstring).
451
+ A caller that needs to know "will a GPU encode actually work here" has to try one; this only
452
+ answers "did this ffmpeg build even ship the capability."
453
+ """
454
+ status = encoders_listing["status"]
455
+ if status != "parsed":
456
+ return {"status": status, "detail": encoders_listing["detail"], "present": []}
457
+ present = sorted(n for n in encoders_listing["names"] if n.endswith(_GPU_ENCODER_SUFFIXES))
458
+ return {"status": "parsed", "present": present}
459
+
460
+
311
461
  def _version_line(binary: str) -> Optional[str]:
312
462
  exe = shutil.which(binary)
313
463
  if not exe:
314
464
  return None
315
- proc = subprocess.run([exe, "-version"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
465
+ try:
466
+ proc = subprocess.run([exe, "-version"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=_DETECT_TIMEOUT)
467
+ except subprocess.TimeoutExpired:
468
+ return None
316
469
  first = (proc.stdout or proc.stderr).splitlines()[:1]
317
470
  m = re.match(rf"{binary} version (\S+)", first[0]) if first else None
318
471
  return m.group(1) if m else (first[0] if first else "unknown")
@@ -324,6 +477,44 @@ def _whisper_available() -> bool:
324
477
  return importlib.util.find_spec("faster_whisper") is not None or importlib.util.find_spec("whisper") is not None
325
478
 
326
479
 
480
+ def _default_font() -> str:
481
+ from _common import BRAND_DEFAULTS
482
+ return str(BRAND_DEFAULTS["font"])
483
+
484
+
485
+ def _font_available(font_name: str) -> Dict[str, Any]:
486
+ """Whether `font_name` (a fontconfig family name, as passed to drawtext's `font=`) is actually
487
+ installed, distinct from silently resolving to a substitute.
488
+
489
+ This cannot be answered by running drawtext and checking its exit code: fontconfig substitutes
490
+ the closest match for ANY name, known or not, so `ffmpeg -vf drawtext=font='<garbage>'` still
491
+ exits 0 (verified against this repo's own sandbox ffmpeg -- a deliberately bogus family name
492
+ produces the same success exit code as "DejaVu Sans"). That is exactly the "false success" this
493
+ capability exists to catch (see issue #66): a missing font never fails the encode, it just
494
+ silently renders with a different typeface. `fc-match` is queried instead, since it reports the
495
+ family fontconfig actually resolved to; that only equals the request when the font is installed.
496
+ """
497
+ exe = shutil.which("fc-match")
498
+ if not exe:
499
+ return {"status": "unknown", "detail": "fc-match not on PATH; drawtext succeeding proves nothing (fontconfig substitutes silently), so availability cannot be verified"}
500
+ try:
501
+ proc = subprocess.run([exe, "--format=%{family}\n", font_name], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=_DETECT_TIMEOUT)
502
+ except subprocess.TimeoutExpired:
503
+ return {"status": "unknown", "detail": f"fc-match did not exit within {_DETECT_TIMEOUT}s"}
504
+ except OSError as e:
505
+ return {"status": "unknown", "detail": f"fc-match: {e}"}
506
+ if proc.returncode != 0:
507
+ tail = " ".join(proc.stderr.strip().splitlines()[-2:])
508
+ return {"status": "unknown", "detail": f"fc-match exited {proc.returncode}: {tail}"}
509
+ lines = [l for l in proc.stdout.splitlines() if l.strip()]
510
+ resolved = lines[0].strip() if lines else ""
511
+ if not resolved:
512
+ return {"status": "unknown", "detail": "fc-match produced no output"}
513
+ if resolved.lower() == font_name.lower():
514
+ return {"status": "available", "detail": f"fc-match resolves '{font_name}' to itself"}
515
+ return {"status": "missing", "detail": f"fc-match substitutes '{resolved}' for '{font_name}' -- '{font_name}' is not installed"}
516
+
517
+
327
518
  def required_capabilities() -> Dict[str, List[str]]:
328
519
  req: set = set()
329
520
  opt: set = set()
@@ -335,41 +526,197 @@ def required_capabilities() -> Dict[str, List[str]]:
335
526
 
336
527
 
337
528
  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] = {}
529
+ """Detect which declared capabilities this machine has. No secrets, no environment variables.
530
+
531
+ `version` is this INSTALLED COPY's own version (read from its local package.json, same value
532
+ `contract --json`'s `skill.version` reports) -- never fetched from the network or compared
533
+ against the latest published release. A copy installed with `npx ffmpeg-skill` is not updated
534
+ automatically; re-run the installer to refresh it, then `doctor` again to confirm the version
535
+ changed. This exists so a stale installed copy is visible locally, not to check for updates.
536
+
537
+ Three states per capability: available, missing, unknown. `unknown` means the ffmpeg listing
538
+ that would prove it could not be read (unparsed output, ffmpeg failure); it is never folded into
539
+ `missing` (a filter that exists is not reported absent) nor into `available` (a failed detection
540
+ is not a pass). `ok` is true only when nothing required is missing or unknown.
541
+
542
+ `gpu_encoders` is a separate, honest answer to a question none of the required/optional
543
+ capabilities above ask: which GPU-backed encoders (nvenc, videotoolbox, qsv, vaapi, amf) this
544
+ ffmpeg BUILD carries, from `-encoders` alone. No tool here requires or uses one -- every tool
545
+ still assumes CPU x264/x265 -- so `gpu_encoders` never affects `ok` or any tool's `usable`. It
546
+ only proves the build shipped the capability, never that the GPU/driver on this machine will
547
+ actually accept a job (that needs a real encode, which this introspection never runs).
548
+
549
+ `fonts` is the same kind of informational answer for the default drawtext font (caption.py's
550
+ --animate/--karaoke, graphics.py's templates -- see issue #66): available/missing/unknown for
551
+ whether BRAND_DEFAULTS["font"] ("DejaVu Sans") is actually installed, not silently substituted
552
+ by fontconfig. It never affects `ok` or a tool's `usable` -- a missing font is not a broken
553
+ tool, drawtext still runs and still writes an artifact, it may just render with a different
554
+ typeface than requested (which is why this exists: that substitution is otherwise invisible).
555
+ """
556
+ listings = {
557
+ "encoders": _ff_listing("ffmpeg", "-encoders"),
558
+ "filters": _ff_listing("ffmpeg", "-filters"),
559
+ "bsfs": _ff_listing("ffmpeg", "-bsfs"),
560
+ }
561
+ sets = {k: set(v["names"]) for k, v in listings.items()}
562
+ state: Dict[str, str] = {} # capability -> available | missing | unknown
343
563
  wanted = required_capabilities()
564
+
565
+ def _from(kind: str, name: str) -> str:
566
+ lst = listings[kind]
567
+ if lst["status"] == "parsed":
568
+ return "available" if name in sets[kind] else "missing"
569
+ if lst["status"] == "missing":
570
+ return "missing" # no ffmpeg at all: nothing it provides is available
571
+ return "unknown"
572
+
344
573
  for cap in wanted["required"] + wanted["optional"]:
345
574
  if cap == "ffmpeg":
346
- have[cap] = shutil.which("ffmpeg") is not None
575
+ state[cap] = "available" if shutil.which("ffmpeg") else "missing"
347
576
  elif cap == "ffprobe":
348
- have[cap] = shutil.which("ffprobe") is not None
577
+ state[cap] = "available" if shutil.which("ffprobe") else "missing"
349
578
  elif cap.startswith("encoder:"):
350
- have[cap] = cap[8:] in encoders
579
+ state[cap] = _from("encoders", cap[8:])
351
580
  elif cap.startswith("filter:"):
352
- have[cap] = cap[7:] in filters
581
+ state[cap] = _from("filters", cap[7:])
353
582
  elif cap.startswith("bsf:"):
354
- have[cap] = cap[4:] in bsfs
583
+ state[cap] = _from("bsfs", cap[4:])
355
584
  elif cap == "external:whisper":
356
- have[cap] = _whisper_available()
585
+ state[cap] = "available" if _whisper_available() else "missing"
357
586
  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))
587
+ state[cap] = "missing"
588
+ available = sorted(c for c, st in state.items() if st == "available")
589
+ missing_required = sorted(c for c in wanted["required"] if state[c] == "missing")
590
+ missing_optional = sorted(c for c in wanted["optional"] if state[c] == "missing")
591
+ unknown = sorted(c for c, st in state.items() if st == "unknown")
592
+ unknown_required = [c for c in unknown if c in wanted["required"]]
593
+ errors = [f"{k}: {v['detail']}" for k, v in listings.items() if v["status"] in ("unparsed", "failed")]
362
594
  return {
595
+ "version": skill_version(),
363
596
  "python": ".".join(str(x) for x in sys.version_info[:3]),
364
597
  "ffmpeg": _version_line("ffmpeg"),
365
598
  "ffprobe": _version_line("ffprobe"),
366
599
  "available": available,
367
600
  "missing": missing_required,
368
601
  "missing_optional": missing_optional,
369
- "ok": not missing_required,
602
+ "unknown": unknown,
603
+ "detection": {k: {"status": v["status"], "count": len(v["names"]), "detail": v["detail"]} for k, v in listings.items()},
604
+ "errors": errors,
605
+ "ok": not missing_required and not unknown_required,
606
+ "tools": _tool_usability(state),
607
+ "gpu_encoders": _gpu_encoders(listings["encoders"]),
608
+ "fonts": _fonts_capability(),
370
609
  }
371
610
 
372
611
 
612
+ def _fonts_capability() -> Dict[str, Any]:
613
+ font = _default_font()
614
+ result = _font_available(font)
615
+ return {"default_font": font, "status": result["status"], "detail": result["detail"]}
616
+
617
+
618
+ def _capability_fix_hint(cap: str) -> str:
619
+ """One-line, plain-language remedy for a single missing/unknown capability."""
620
+ if cap in ("ffmpeg", "ffprobe"):
621
+ from _common import INSTALL_HINTS
622
+ hint = INSTALL_HINTS.get(platform.system(), "see https://ffmpeg.org/download.html").strip().splitlines()[0].strip()
623
+ return f"install ffmpeg: {hint}"
624
+ system = platform.system()
625
+ if system == "Darwin":
626
+ full_hint = "on macOS, brew install ffmpeg-full (the plain formula lacks subtitles/drawtext/zscale)"
627
+ elif system == "Windows":
628
+ full_hint = "on Windows, winget install Gyan.FFmpeg (the gyan.dev full build carries subtitles/drawtext/zscale; a plain choco ffmpeg package can lack them)"
629
+ else:
630
+ full_hint = "install/build ffmpeg with it enabled"
631
+ if cap.startswith("encoder:"):
632
+ return f"this ffmpeg build has no {cap[8:]} encoder; {full_hint}"
633
+ if cap.startswith("filter:"):
634
+ return f"this ffmpeg build has no {cap[7:]} filter; {full_hint}"
635
+ if cap.startswith("bsf:"):
636
+ return f"this ffmpeg build has no {cap[4:]} bitstream filter; {full_hint}"
637
+ if cap == "external:whisper":
638
+ return "install a local whisper (whisper-cli, whisper-cpp, faster-whisper or openai-whisper) for --transcribe"
639
+ return f"'{cap}' is not available; see docs/contract.md"
640
+
641
+
642
+ def _tool_usability(state: Dict[str, str]) -> Dict[str, Dict[str, Any]]:
643
+ """Per-tool usable/missing/unknown, folded from the same capability `state` doctor already
644
+ computed. Answers "can I run this tool on this machine today", not just "what capabilities
645
+ exist" -- a caller reading only `available`/`missing` still has to cross-reference each tool's
646
+ own required-capability list by hand to answer that."""
647
+ tools: Dict[str, Dict[str, Any]] = {}
648
+ for name, meta in TOOL_META.items():
649
+ required = list(meta["required"])
650
+ missing = [c for c in required if state.get(c) == "missing"]
651
+ unknown = [c for c in required if state.get(c) == "unknown"]
652
+ entry: Dict[str, Any] = {"usable": "no" if missing else ("unknown" if unknown else "yes")}
653
+ if missing:
654
+ entry["missing"] = missing
655
+ entry["fix"] = "; ".join(dict.fromkeys(_capability_fix_hint(c) for c in missing))
656
+ if unknown:
657
+ entry["unknown"] = unknown
658
+ tools[name] = entry
659
+ return tools
660
+
661
+
662
+ # Cross-repository Capability ids (kajisho5/AI-video-production-OS docs/SPEC.md
663
+ # `CapabilityContract.provides`), matching the ids already assigned to these 21 tools in
664
+ # that project's own docs/CAPABILITY_MATRIX.md section 9: "ffmpeg-skill's 21 raw tools ...
665
+ # are Capabilities in their own right, independent of the higher-level Skills that
666
+ # delegate to them". Each tool's own id is `ffmpeg-skill/<tool>` (a slash, matching every
667
+ # ToolSpec.id here); the Capability id uses a dot - `ffmpeg-skill.<tool>` - the same
668
+ # `<domain>.<verb>`-shaped convention every other Skill's Capability ids use elsewhere in
669
+ # that project (`video.trim`, `audio.gain`, ...), with "ffmpeg-skill" as the domain.
670
+ def capability_provides() -> List[Dict[str, str]]:
671
+ return [{"id": f"{SKILL_ID}.{name}", "lifecycle": "EXPERIMENTAL", "tool_id": f"{SKILL_ID}/{name}"} for name in public_tools()]
672
+
673
+
674
+ # Abstract, domain-shaped capability ids (`<domain>.<verb>`, matching the convention `provides`
675
+ # already documents for cross-repo Capability ids) that a planning agent can resolve without
676
+ # already knowing this skill's tool names. Unlike `provides` (one entry per tool, id derived
677
+ # from the tool name), this is a hand-authored, many-to-one table: several of these ids name a
678
+ # tool PLUS the fixed parameters that pin it to that specific behaviour (`video.reframe` is
679
+ # `fit.py` with `fit=crop` fixed, not bare `fit.py`, which also speed-ramps and pads). This is
680
+ # still purely descriptive -- a caller still builds and runs the named tool's own CLI/MCP call
681
+ # from its `input_schema`; nothing here executes or chooses on the caller's behalf. Deliberately
682
+ # excludes any capability that would require judgment to resolve (e.g. no `video.highlight`:
683
+ # `scenes.py --highlights` ranks by a measured proxy, never by understood content -- see
684
+ # SKILL.md "What this skill does and does not decide" -- so it is not offered as a capability
685
+ # a planner can blindly delegate to). `media.proxy` (a low-bitrate, fast-decode proxy, distinct
686
+ # from `export.py`'s delivery presets, which target visual quality over size/speed) resolves to
687
+ # `proxy.py` -- itself a purely mechanical resize+re-encode with no opinion on which asset should
688
+ # be proxied or what for.
689
+ CAPABILITY_MAP: List[Dict[str, Any]] = [
690
+ {"capability": "video.trim", "tool_id": f"{SKILL_ID}/cut", "params": {}},
691
+ {"capability": "video.reframe", "tool_id": f"{SKILL_ID}/fit", "params": {"fit": "crop"}},
692
+ {"capability": "audio.loudness", "tool_id": f"{SKILL_ID}/loudness", "params": {}},
693
+ {"capability": "subtitle.burn", "tool_id": f"{SKILL_ID}/caption", "params": {}},
694
+ {"capability": "media.stream.inspect", "tool_id": f"{SKILL_ID}/probe", "params": {}},
695
+ {"capability": "media.frames.extract", "tool_id": f"{SKILL_ID}/look", "params": {}},
696
+ {"capability": "media.proxy", "tool_id": f"{SKILL_ID}/proxy", "params": {}},
697
+ ]
698
+
699
+
700
+ def capability_map(tools: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
701
+ """Validate CAPABILITY_MAP against the live tool specs before returning it. A fixed param
702
+ that isn't a real input_schema property is drift, not a typo to ship silently (same "fail
703
+ loudly" posture as tool_spec()'s missing-TOOL_META check). A tool_id that no longer exists
704
+ is not drift in that sense -- a tool can legitimately be removed -- so that capability is
705
+ dropped from the map rather than crashing the whole contract build."""
706
+ by_id = {t["id"]: t for t in tools}
707
+ result = []
708
+ for entry in CAPABILITY_MAP:
709
+ spec = by_id.get(entry["tool_id"])
710
+ if spec is None:
711
+ continue
712
+ props = spec["input_schema"]["properties"]
713
+ for key in entry["params"]:
714
+ if key not in props:
715
+ raise RuntimeError(f"capability {entry['capability']!r} sets param {key!r} which is not in {entry['tool_id']}'s input_schema")
716
+ result.append(dict(entry))
717
+ return result
718
+
719
+
373
720
  # ----------------------------------------------------------------------------- contract
374
721
  def tool_spec(name: str, version: str) -> Dict[str, Any]:
375
722
  if name not in TOOL_META:
@@ -412,6 +759,11 @@ def tool_spec(name: str, version: str) -> Dict[str, Any]:
412
759
  "mutates_input": False,
413
760
  "produces_artifact": meta["produces_artifact"],
414
761
  "verification": {"required": bool(meta["verify"]), "tools": [f"{SKILL_ID}/{t}" for t in meta["verify"]]},
762
+ # a tool with no declared entry reports "conditional" rather than guessing "always" or
763
+ # "never" -- same "unknown is not missing" principle as doctor's capability detection
764
+ "reencodes_video": REENCODE_META.get(name, {}).get("video", "conditional"),
765
+ "reencodes_audio": REENCODE_META.get(name, {}).get("audio", "conditional"),
766
+ **({"reencode_note": REENCODE_META[name]["note"]} if REENCODE_META.get(name, {}).get("note") else {}),
415
767
  "requires_visual_verification": meta["visual"],
416
768
  "audio_only": meta["audio_only"],
417
769
  "video_required": meta["video_required"],
@@ -496,7 +848,8 @@ def build(detect: bool = True) -> Dict[str, Any]:
496
848
  caps: Dict[str, Any] = {"required": wanted["required"], "optional": wanted["optional"], "naming": "ffmpeg | ffprobe | encoder:<name> | filter:<name> | bsf:<name> | external:whisper"}
497
849
  if detect:
498
850
  d = doctor()
499
- caps.update({"available": d["available"], "missing": d["missing"], "missing_optional": d["missing_optional"], "detected_by": "doctor"})
851
+ caps.update({"available": d["available"], "missing": d["missing"], "missing_optional": d["missing_optional"],
852
+ "unknown": d["unknown"], "detection": d["detection"], "detected_by": "doctor"})
500
853
  return {
501
854
  "contract_version": CONTRACT_VERSION,
502
855
  "skill": {
@@ -545,11 +898,14 @@ def build(detect: bool = True) -> Dict[str, Any]:
545
898
  },
546
899
  "json_output": {
547
900
  "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"},
901
+ "failure": {"status": "failed", "exit_code": "non-zero (127 when ffmpeg/ffprobe is missing)", "stdout": "{\"status\": \"failed\", \"exit_code\": N, \"error\": {\"kind\": ..., \"message\": ...}, \"commands\": [...]} when --json was given", "stderr": "human-readable message"},
902
+ "error_kinds": {"input": "missing or unsuitable input, bad arguments", "ffmpeg": "ffmpeg/ffprobe returned an error (message carries the last stderr lines)", "output": "ffmpeg exited 0 but the artifact is missing, empty or unreadable (an empty file is removed)", "missing_tool": "ffmpeg or ffprobe not on PATH"},
903
+ "success_criterion": "exit 0 AND the output exists AND is non-empty AND ffprobe reads a stream from it; only then is status completed printed and the output probe attached",
550
904
  },
551
905
  "capabilities": caps,
552
906
  "tools": tools,
907
+ "provides": capability_provides(),
908
+ "capability_map": capability_map(tools),
553
909
  }
554
910
 
555
911
 
@@ -564,11 +920,27 @@ def main() -> int:
564
920
  if args.json:
565
921
  print(json.dumps(d, indent=2, sort_keys=True))
566
922
  else:
923
+ print(f"ffmpeg-skill {d['version']} (this installed copy; re-run `npx ffmpeg-skill` to refresh it -- copies are not updated automatically)")
567
924
  print(f"python {d['python']}; ffmpeg {d['ffmpeg'] or 'MISSING'}; ffprobe {d['ffprobe'] or 'MISSING'}")
568
925
  print(f"available: {', '.join(d['available'])}")
569
926
  print(f"missing required: {', '.join(d['missing']) or 'none'}")
570
927
  print(f"missing optional: {', '.join(d['missing_optional']) or 'none'}")
571
- return 0 if d["ok"] else 1
928
+ if d["unknown"]:
929
+ print(f"unknown (detection failed, not proven missing): {', '.join(d['unknown'])}")
930
+ not_usable = sorted(name for name, t in d["tools"].items() if t["usable"] != "yes")
931
+ if not_usable and d["ok"]:
932
+ print(f"note: overall 'ok' means nothing REQUIRED BY EVERY TOOL is missing -- {len(not_usable)} tool(s) still can't run today: {', '.join(not_usable)} (see doctor --json .tools for why)")
933
+ gpu = d["gpu_encoders"]
934
+ if gpu["status"] == "parsed":
935
+ print(f"GPU-backed encoders in this build: {', '.join(gpu['present']) or 'none'} (no tool here uses one yet; this build-presence check does not prove the GPU/driver will accept a job)")
936
+ fonts = d["fonts"]
937
+ print(f"default drawtext font '{fonts['default_font']}': {fonts['status']} ({fonts['detail']})")
938
+ for err in d["errors"]:
939
+ print(f"detection error: {err}", file=sys.stderr)
940
+ if d["ok"]:
941
+ return 0
942
+ # 1: something required is missing; 2: nothing proven missing but a required capability is unknown
943
+ return 1 if d["missing"] else 2
572
944
  print(json.dumps(build(detect=not args.static), indent=2, sort_keys=True, ensure_ascii=False))
573
945
  return 0
574
946