ffmpeg-skill 0.10.0 → 0.12.5

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 (56) hide show
  1. package/README.md +70 -10
  2. package/SKILL.md +89 -12
  3. package/bin/install.js +15 -1
  4. package/mcp/__pycache__/server.cpython-311.pyc +0 -0
  5. package/mcp/server.py +2 -0
  6. package/package.json +2 -2
  7. package/references/ci-platform-pitfalls.md +111 -0
  8. package/references/process-pitfalls.md +85 -0
  9. package/references/scripts.md +139 -12
  10. package/scripts/__pycache__/_common.cpython-311.pyc +0 -0
  11. package/scripts/__pycache__/_contract.cpython-311.pyc +0 -0
  12. package/scripts/__pycache__/audio.cpython-311.pyc +0 -0
  13. package/scripts/__pycache__/batch.cpython-311.pyc +0 -0
  14. package/scripts/__pycache__/caption.cpython-311.pyc +0 -0
  15. package/scripts/__pycache__/check.cpython-311.pyc +0 -0
  16. package/scripts/__pycache__/color.cpython-311.pyc +0 -0
  17. package/scripts/__pycache__/cut.cpython-311.pyc +0 -0
  18. package/scripts/__pycache__/export.cpython-311.pyc +0 -0
  19. package/scripts/__pycache__/fit.cpython-311.pyc +0 -0
  20. package/scripts/__pycache__/graphics.cpython-311.pyc +0 -0
  21. package/scripts/__pycache__/join.cpython-311.pyc +0 -0
  22. package/scripts/__pycache__/look.cpython-311.pyc +0 -0
  23. package/scripts/__pycache__/loudness.cpython-311.pyc +0 -0
  24. package/scripts/__pycache__/multicam.cpython-311.pyc +0 -0
  25. package/scripts/__pycache__/overlay.cpython-311.pyc +0 -0
  26. package/scripts/__pycache__/probe.cpython-311.pyc +0 -0
  27. package/scripts/__pycache__/render.cpython-311.pyc +0 -0
  28. package/scripts/__pycache__/report.cpython-311.pyc +0 -0
  29. package/scripts/__pycache__/scenes.cpython-311.pyc +0 -0
  30. package/scripts/__pycache__/silence.cpython-311.pyc +0 -0
  31. package/scripts/__pycache__/sync.cpython-311.pyc +0 -0
  32. package/scripts/__pycache__/verify.cpython-311.pyc +0 -0
  33. package/scripts/_common.py +255 -14
  34. package/scripts/_contract.py +276 -13
  35. package/scripts/audio.py +1 -1
  36. package/scripts/background.py +73 -0
  37. package/scripts/caption.py +106 -24
  38. package/scripts/color.py +129 -30
  39. package/scripts/crop.py +79 -0
  40. package/scripts/cut.py +2 -2
  41. package/scripts/export.py +1 -1
  42. package/scripts/fit.py +73 -14
  43. package/scripts/graphics.py +18 -7
  44. package/scripts/insert.py +128 -0
  45. package/scripts/join.py +14 -5
  46. package/scripts/look.py +13 -8
  47. package/scripts/loudness.py +3 -3
  48. package/scripts/multicam.py +1 -1
  49. package/scripts/overlay.py +78 -12
  50. package/scripts/proxy.py +82 -0
  51. package/scripts/reverse.py +56 -0
  52. package/scripts/scenes.py +8 -2
  53. package/scripts/sequence.py +124 -0
  54. package/scripts/silence.py +2 -2
  55. package/scripts/stabilize.py +101 -0
  56. package/scripts/sync.py +1 -1
@@ -33,6 +33,12 @@ HERE = Path(__file__).resolve().parent
33
33
  ROOT = HERE.parent
34
34
  SKILL_ID = "ffmpeg-skill"
35
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
36
42
 
37
43
  ROLES = {
38
44
  "analysis": "reads media and reports measurements; writes no media",
@@ -70,11 +76,30 @@ TOOL_META: Dict[str, Dict[str, Any]] = {
70
76
  "fit": dict(role="execution", inputs=["video asset"], outputs=["video artifact at the requested duration / aspect / fps"],
71
77
  required=FF + [X264, AAC], optional=[HDR_X265, {"capability": "filter:minterpolate", "when": "--smooth interpolate"}],
72
78
  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"}],
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"}],
75
90
  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],
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"}],
100
+ video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
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],
78
103
  video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
79
104
  "graphics": dict(role="execution", inputs=["video asset"], outputs=["video artifact with the drawn template"],
80
105
  required=FF + [X264, AAC, "filter:drawtext"], optional=[HDR_X265],
@@ -102,7 +127,11 @@ TOOL_META: Dict[str, Dict[str, Any]] = {
102
127
  required=FF, optional=[{"capability": X264, "when": "--to-sdr / --lut / --correct"}, {"capability": "filter:zscale", "when": "--to-sdr"}, {"capability": "filter:tonemap", "when": "--to-sdr"},
103
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"},
104
129
  {"capability": "filter:exposure", "when": "--correct"}, {"capability": "filter:eq", "when": "--correct"},
105
- {"capability": "filter:colorbalance", "when": "--correct"}, {"capability": "filter:colortemperature", "when": "--correct"}],
130
+ {"capability": "filter:colorbalance", "when": "--correct"}, {"capability": "filter:colortemperature", "when": "--correct"},
131
+ {"capability": "filter:colorlevels", "when": "--correct with any --levels-*"}, {"capability": "filter:curves", "when": "--correct --curves"}],
132
+ video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
133
+ "proxy": dict(role="execution", inputs=["video asset"], outputs=["low-resolution, low-bitrate proxy artifact for downstream analysis, preview or editing decisions"],
134
+ required=FF + [X264, AAC], optional=[HDR_X265],
106
135
  video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
107
136
  "export": dict(role="execution", inputs=["video asset"], outputs=["delivery artifact in the preset's format"],
108
137
  required=FF, optional=[{"capability": X264, "when": "preset youtube / youtube4k / reels / x"}, {"capability": AAC, "when": "any preset except gif"},
@@ -159,16 +188,23 @@ REENCODE_META: Dict[str, Dict[str, str]] = {
159
188
  "probe": dict(video="never", audio="never", note="analysis only, no artifact"),
160
189
  "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
190
  "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"),
191
+ "crop": dict(video="always", audio="always", note="the crop filter always forces a re-encode of both streams"),
192
+ "insert": dict(video="always", audio="never", note="always encodes a fresh silent clip from the still image; there is no audio stream to touch"),
193
+ "background": dict(video="always", audio="never", note="always encodes a fresh generated clip; there is no input to copy from"),
194
+ "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"),
195
+ "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"),
196
+ "sequence": dict(video="always", audio="never", note="always encodes a fresh clip from the frame sequence; there is no audio stream"),
197
+ "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"),
163
198
  "overlay": dict(video="always", audio="always"),
164
199
  "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"),
200
+ "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"),
166
201
  "multicam": dict(video="always", audio="always"),
167
202
  "audio": dict(video="never", audio="always", note="video stream is always -c:v copy when present; this tool's job is the audio"),
168
203
  "loudness": dict(video="never", audio="always"),
169
204
  "silence": dict(video="always", audio="always", note="removing gaps requires cutting on non-keyframe boundaries"),
170
205
  "join": dict(video="always", audio="always"),
171
- "color": dict(video="always", audio="always"),
206
+ "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"),
207
+ "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"),
172
208
  "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
209
  "check": dict(video="never", audio="never", note="read-only, no artifact"),
174
210
  "scenes": dict(video="never", audio="never", note="analysis only; --sheet renders a new contact-sheet PNG, not a re-encode of the source"),
@@ -382,7 +418,9 @@ def _ff_listing(binary: str, flag: str) -> Dict[str, Any]:
382
418
  if not exe:
383
419
  return {"names": [], "status": "missing", "detail": f"{binary} not on PATH"}
384
420
  try:
385
- proc = subprocess.run([exe, "-hide_banner", flag], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
421
+ proc = subprocess.run([exe, "-hide_banner", flag], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=_DETECT_TIMEOUT)
422
+ except subprocess.TimeoutExpired:
423
+ return {"names": [], "status": "failed", "detail": f"{binary} {flag} did not exit within {_DETECT_TIMEOUT}s"}
386
424
  except OSError as e:
387
425
  return {"names": [], "status": "failed", "detail": f"{binary} {flag}: {e}"}
388
426
  if proc.returncode != 0:
@@ -399,11 +437,36 @@ def _ff_list(binary: str, flag: str) -> List[str]:
399
437
  return _ff_listing(binary, flag)["names"]
400
438
 
401
439
 
440
+ # GPU-backed encoders are named `<codec>_<backend>` by every ffmpeg build (h264_nvenc,
441
+ # hevc_videotoolbox, av1_qsv, h264_vaapi, hevc_amf, ...); recognised by the backend suffix so a
442
+ # new codec in a future ffmpeg build needs no change here.
443
+ _GPU_ENCODER_SUFFIXES = ("_nvenc", "_videotoolbox", "_qsv", "_vaapi", "_amf")
444
+
445
+
446
+ def _gpu_encoders(encoders_listing: Dict[str, Any]) -> Dict[str, Any]:
447
+ """GPU-backed encoders this ffmpeg BUILD was compiled with, read from `-encoders` alone.
448
+
449
+ This proves the build carries e.g. h264_nvenc; it does NOT prove the GPU/driver on this
450
+ machine will accept a job -- that would require actually running an encode, which doctor's
451
+ introspection deliberately never does beyond listing/-version (see doctor()'s own docstring).
452
+ A caller that needs to know "will a GPU encode actually work here" has to try one; this only
453
+ answers "did this ffmpeg build even ship the capability."
454
+ """
455
+ status = encoders_listing["status"]
456
+ if status != "parsed":
457
+ return {"status": status, "detail": encoders_listing["detail"], "present": []}
458
+ present = sorted(n for n in encoders_listing["names"] if n.endswith(_GPU_ENCODER_SUFFIXES))
459
+ return {"status": "parsed", "present": present}
460
+
461
+
402
462
  def _version_line(binary: str) -> Optional[str]:
403
463
  exe = shutil.which(binary)
404
464
  if not exe:
405
465
  return None
406
- proc = subprocess.run([exe, "-version"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
466
+ try:
467
+ proc = subprocess.run([exe, "-version"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=_DETECT_TIMEOUT)
468
+ except subprocess.TimeoutExpired:
469
+ return None
407
470
  first = (proc.stdout or proc.stderr).splitlines()[:1]
408
471
  m = re.match(rf"{binary} version (\S+)", first[0]) if first else None
409
472
  return m.group(1) if m else (first[0] if first else "unknown")
@@ -415,6 +478,88 @@ def _whisper_available() -> bool:
415
478
  return importlib.util.find_spec("faster_whisper") is not None or importlib.util.find_spec("whisper") is not None
416
479
 
417
480
 
481
+ def _default_font() -> str:
482
+ from _common import BRAND_DEFAULTS
483
+ return str(BRAND_DEFAULTS["font"])
484
+
485
+
486
+ def _drawtext_probe() -> Dict[str, Any]:
487
+ """Actually render one frame through drawtext, rather than trusting `-filters` alone.
488
+
489
+ `-filters` only reports whether this ffmpeg build was compiled with the filter; it never
490
+ proves drawtext can actually execute. On some real Windows ffmpeg builds (winget's gyan.dev
491
+ 9.x), drawtext crashes with an access violation whenever it has to resolve a font through
492
+ fontconfig -- with or without a valid fonts.conf -- so `-filters` correctly reports drawtext
493
+ present and doctor used to report the capability `available` anyway; every tool that actually
494
+ used it (look, scenes --sheet, overlay --text, graphics) then crashed on first real use (#100).
495
+
496
+ This runs the cheapest real drawtext render there is: a one-frame synthetic clip, no font=
497
+ given at all (ffmpeg's own default resolution -- the same path that crashed). A clean exit
498
+ means drawtext genuinely works here. Anything that could not prove either way (no ffmpeg,
499
+ timeout, an ordinary nonzero exit with a real ffmpeg error) is `unknown`, same "unknown is not
500
+ missing" principle as every other capability here. A crash specifically -- killed by signal on
501
+ POSIX, or an unhandled access violation surfacing as a huge unsigned exit code on Windows -- is
502
+ the one case this function exists to catch, and folds into `missing`: the filter is present in
503
+ the build but cannot actually be used as ffmpeg's own default would use it.
504
+ """
505
+ exe = shutil.which("ffmpeg")
506
+ if not exe:
507
+ return {"status": "unknown", "detail": "ffmpeg not on PATH"}
508
+ try:
509
+ proc = subprocess.run(
510
+ [exe, "-hide_banner", "-loglevel", "error", "-f", "lavfi", "-i", "color=c=black:s=64x64:d=1",
511
+ "-vf", "drawtext=text=x", "-frames:v", "1", "-f", "null", "-"],
512
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=_DETECT_TIMEOUT,
513
+ )
514
+ except subprocess.TimeoutExpired:
515
+ return {"status": "unknown", "detail": f"drawtext probe did not exit within {_DETECT_TIMEOUT}s"}
516
+ except OSError as e:
517
+ return {"status": "unknown", "detail": f"drawtext probe: {e}"}
518
+ if proc.returncode == 0:
519
+ return {"status": "available", "detail": "one-frame drawtext render succeeded"}
520
+ if proc.returncode < 0 or proc.returncode >= 0x80000000:
521
+ return {"status": "missing",
522
+ "detail": f"drawtext render crashed (exit {proc.returncode}) instead of failing cleanly -- "
523
+ "the filter is present in this build but cannot be used as-is, likely a fontconfig "
524
+ "resolution crash (see https://github.com/kajisho5/ffmpeg-skill/issues/100); "
525
+ "pass an explicit --font-file to every drawtext tool as a workaround"}
526
+ tail = " ".join(proc.stderr.strip().splitlines()[-2:])
527
+ return {"status": "unknown", "detail": f"drawtext probe exited {proc.returncode}: {tail}"}
528
+
529
+
530
+ def _font_available(font_name: str) -> Dict[str, Any]:
531
+ """Whether `font_name` (a fontconfig family name, as passed to drawtext's `font=`) is actually
532
+ installed, distinct from silently resolving to a substitute.
533
+
534
+ This cannot be answered by running drawtext and checking its exit code: fontconfig substitutes
535
+ the closest match for ANY name, known or not, so `ffmpeg -vf drawtext=font='<garbage>'` still
536
+ exits 0 (verified against this repo's own sandbox ffmpeg -- a deliberately bogus family name
537
+ produces the same success exit code as "DejaVu Sans"). That is exactly the "false success" this
538
+ capability exists to catch (see issue #66): a missing font never fails the encode, it just
539
+ silently renders with a different typeface. `fc-match` is queried instead, since it reports the
540
+ family fontconfig actually resolved to; that only equals the request when the font is installed.
541
+ """
542
+ exe = shutil.which("fc-match")
543
+ if not exe:
544
+ return {"status": "unknown", "detail": "fc-match not on PATH; drawtext succeeding proves nothing (fontconfig substitutes silently), so availability cannot be verified"}
545
+ try:
546
+ proc = subprocess.run([exe, "--format=%{family}\n", font_name], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=_DETECT_TIMEOUT)
547
+ except subprocess.TimeoutExpired:
548
+ return {"status": "unknown", "detail": f"fc-match did not exit within {_DETECT_TIMEOUT}s"}
549
+ except OSError as e:
550
+ return {"status": "unknown", "detail": f"fc-match: {e}"}
551
+ if proc.returncode != 0:
552
+ tail = " ".join(proc.stderr.strip().splitlines()[-2:])
553
+ return {"status": "unknown", "detail": f"fc-match exited {proc.returncode}: {tail}"}
554
+ lines = [l for l in proc.stdout.splitlines() if l.strip()]
555
+ resolved = lines[0].strip() if lines else ""
556
+ if not resolved:
557
+ return {"status": "unknown", "detail": "fc-match produced no output"}
558
+ if resolved.lower() == font_name.lower():
559
+ return {"status": "available", "detail": f"fc-match resolves '{font_name}' to itself"}
560
+ return {"status": "missing", "detail": f"fc-match substitutes '{resolved}' for '{font_name}' -- '{font_name}' is not installed"}
561
+
562
+
418
563
  def required_capabilities() -> Dict[str, List[str]]:
419
564
  req: set = set()
420
565
  opt: set = set()
@@ -428,10 +573,30 @@ def required_capabilities() -> Dict[str, List[str]]:
428
573
  def doctor() -> Dict[str, Any]:
429
574
  """Detect which declared capabilities this machine has. No secrets, no environment variables.
430
575
 
576
+ `version` is this INSTALLED COPY's own version (read from its local package.json, same value
577
+ `contract --json`'s `skill.version` reports) -- never fetched from the network or compared
578
+ against the latest published release. A copy installed with `npx ffmpeg-skill` is not updated
579
+ automatically; re-run the installer to refresh it, then `doctor` again to confirm the version
580
+ changed. This exists so a stale installed copy is visible locally, not to check for updates.
581
+
431
582
  Three states per capability: available, missing, unknown. `unknown` means the ffmpeg listing
432
583
  that would prove it could not be read (unparsed output, ffmpeg failure); it is never folded into
433
584
  `missing` (a filter that exists is not reported absent) nor into `available` (a failed detection
434
585
  is not a pass). `ok` is true only when nothing required is missing or unknown.
586
+
587
+ `gpu_encoders` is a separate, honest answer to a question none of the required/optional
588
+ capabilities above ask: which GPU-backed encoders (nvenc, videotoolbox, qsv, vaapi, amf) this
589
+ ffmpeg BUILD carries, from `-encoders` alone. No tool here requires or uses one -- every tool
590
+ still assumes CPU x264/x265 -- so `gpu_encoders` never affects `ok` or any tool's `usable`. It
591
+ only proves the build shipped the capability, never that the GPU/driver on this machine will
592
+ actually accept a job (that needs a real encode, which this introspection never runs).
593
+
594
+ `fonts` is the same kind of informational answer for the default drawtext font (caption.py's
595
+ --animate/--karaoke, graphics.py's templates -- see issue #66): available/missing/unknown for
596
+ whether BRAND_DEFAULTS["font"] ("DejaVu Sans") is actually installed, not silently substituted
597
+ by fontconfig. It never affects `ok` or a tool's `usable` -- a missing font is not a broken
598
+ tool, drawtext still runs and still writes an artifact, it may just render with a different
599
+ typeface than requested (which is why this exists: that substitution is otherwise invisible).
435
600
  """
436
601
  listings = {
437
602
  "encoders": _ff_listing("ffmpeg", "-encoders"),
@@ -441,6 +606,7 @@ def doctor() -> Dict[str, Any]:
441
606
  sets = {k: set(v["names"]) for k, v in listings.items()}
442
607
  state: Dict[str, str] = {} # capability -> available | missing | unknown
443
608
  wanted = required_capabilities()
609
+ drawtext_probe: Optional[Dict[str, Any]] = None
444
610
 
445
611
  def _from(kind: str, name: str) -> str:
446
612
  lst = listings[kind]
@@ -457,6 +623,23 @@ def doctor() -> Dict[str, Any]:
457
623
  state[cap] = "available" if shutil.which("ffprobe") else "missing"
458
624
  elif cap.startswith("encoder:"):
459
625
  state[cap] = _from("encoders", cap[8:])
626
+ elif cap == "filter:drawtext":
627
+ listing_state = _from("filters", "drawtext")
628
+ if listing_state == "available":
629
+ # Only escalate an "available" listing to "missing" on an unambiguous crash --
630
+ # an ordinary nonzero exit (a real ffmpeg's own -h/-filters-only build variance, or
631
+ # in tests a fake ffmpeg shim that only implements -filters/-encoders/-bsfs/-version)
632
+ # proves nothing either way, so it leaves the listing-based result standing rather
633
+ # than downgrading it; see _drawtext_probe()'s own docstring for why a crash alone
634
+ # is the one case this exists to catch.
635
+ probe = _drawtext_probe()
636
+ if probe["status"] == "missing":
637
+ drawtext_probe = probe
638
+ state[cap] = "missing"
639
+ else:
640
+ state[cap] = "available"
641
+ else:
642
+ state[cap] = listing_state
460
643
  elif cap.startswith("filter:"):
461
644
  state[cap] = _from("filters", cap[7:])
462
645
  elif cap.startswith("bsf:"):
@@ -471,7 +654,10 @@ def doctor() -> Dict[str, Any]:
471
654
  unknown = sorted(c for c, st in state.items() if st == "unknown")
472
655
  unknown_required = [c for c in unknown if c in wanted["required"]]
473
656
  errors = [f"{k}: {v['detail']}" for k, v in listings.items() if v["status"] in ("unparsed", "failed")]
657
+ if drawtext_probe is not None and drawtext_probe["status"] != "available":
658
+ errors.append(f"filter:drawtext: {drawtext_probe['detail']}")
474
659
  return {
660
+ "version": skill_version(),
475
661
  "python": ".".join(str(x) for x in sys.version_info[:3]),
476
662
  "ffmpeg": _version_line("ffmpeg"),
477
663
  "ffprobe": _version_line("ffprobe"),
@@ -483,18 +669,38 @@ def doctor() -> Dict[str, Any]:
483
669
  "errors": errors,
484
670
  "ok": not missing_required and not unknown_required,
485
671
  "tools": _tool_usability(state),
672
+ "gpu_encoders": _gpu_encoders(listings["encoders"]),
673
+ "fonts": _fonts_capability(),
486
674
  }
487
675
 
488
676
 
677
+ def _fonts_capability() -> Dict[str, Any]:
678
+ font = _default_font()
679
+ result = _font_available(font)
680
+ return {"default_font": font, "status": result["status"], "detail": result["detail"]}
681
+
682
+
489
683
  def _capability_fix_hint(cap: str) -> str:
490
684
  """One-line, plain-language remedy for a single missing/unknown capability."""
491
685
  if cap in ("ffmpeg", "ffprobe"):
492
686
  from _common import INSTALL_HINTS
493
687
  hint = INSTALL_HINTS.get(platform.system(), "see https://ffmpeg.org/download.html").strip().splitlines()[0].strip()
494
688
  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"
689
+ system = platform.system()
690
+ if system == "Darwin":
691
+ full_hint = "on macOS, brew install ffmpeg-full (the plain formula lacks subtitles/drawtext/zscale)"
692
+ elif system == "Windows":
693
+ 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)"
694
+ else:
695
+ full_hint = "install/build ffmpeg with it enabled"
496
696
  if cap.startswith("encoder:"):
497
697
  return f"this ffmpeg build has no {cap[8:]} encoder; {full_hint}"
698
+ if cap == "filter:drawtext":
699
+ return ("drawtext crashed instead of rendering a frame (see errors[] for the exit detail) -- "
700
+ "every drawtext tool already resolves a concrete font file automatically when one can "
701
+ "be found (#100); if it still crashes, use --no-timecode with look.py or scenes.py "
702
+ "--sheet to skip drawtext entirely, or pass --font-file explicitly to overlay.py/"
703
+ "graphics.py (the two that accept it) rather than relying on font= resolution")
498
704
  if cap.startswith("filter:"):
499
705
  return f"this ffmpeg build has no {cap[7:]} filter; {full_hint}"
500
706
  if cap.startswith("bsf:"):
@@ -536,6 +742,52 @@ def capability_provides() -> List[Dict[str, str]]:
536
742
  return [{"id": f"{SKILL_ID}.{name}", "lifecycle": "EXPERIMENTAL", "tool_id": f"{SKILL_ID}/{name}"} for name in public_tools()]
537
743
 
538
744
 
745
+ # Abstract, domain-shaped capability ids (`<domain>.<verb>`, matching the convention `provides`
746
+ # already documents for cross-repo Capability ids) that a planning agent can resolve without
747
+ # already knowing this skill's tool names. Unlike `provides` (one entry per tool, id derived
748
+ # from the tool name), this is a hand-authored, many-to-one table: several of these ids name a
749
+ # tool PLUS the fixed parameters that pin it to that specific behaviour (`video.reframe` is
750
+ # `fit.py` with `fit=crop` fixed, not bare `fit.py`, which also speed-ramps and pads). This is
751
+ # still purely descriptive -- a caller still builds and runs the named tool's own CLI/MCP call
752
+ # from its `input_schema`; nothing here executes or chooses on the caller's behalf. Deliberately
753
+ # excludes any capability that would require judgment to resolve (e.g. no `video.highlight`:
754
+ # `scenes.py --highlights` ranks by a measured proxy, never by understood content -- see
755
+ # SKILL.md "What this skill does and does not decide" -- so it is not offered as a capability
756
+ # a planner can blindly delegate to). `media.proxy` (a low-bitrate, fast-decode proxy, distinct
757
+ # from `export.py`'s delivery presets, which target visual quality over size/speed) resolves to
758
+ # `proxy.py` -- itself a purely mechanical resize+re-encode with no opinion on which asset should
759
+ # be proxied or what for.
760
+ CAPABILITY_MAP: List[Dict[str, Any]] = [
761
+ {"capability": "video.trim", "tool_id": f"{SKILL_ID}/cut", "params": {}},
762
+ {"capability": "video.reframe", "tool_id": f"{SKILL_ID}/fit", "params": {"fit": "crop"}},
763
+ {"capability": "audio.loudness", "tool_id": f"{SKILL_ID}/loudness", "params": {}},
764
+ {"capability": "subtitle.burn", "tool_id": f"{SKILL_ID}/caption", "params": {}},
765
+ {"capability": "media.stream.inspect", "tool_id": f"{SKILL_ID}/probe", "params": {}},
766
+ {"capability": "media.frames.extract", "tool_id": f"{SKILL_ID}/look", "params": {}},
767
+ {"capability": "media.proxy", "tool_id": f"{SKILL_ID}/proxy", "params": {}},
768
+ ]
769
+
770
+
771
+ def capability_map(tools: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
772
+ """Validate CAPABILITY_MAP against the live tool specs before returning it. A fixed param
773
+ that isn't a real input_schema property is drift, not a typo to ship silently (same "fail
774
+ loudly" posture as tool_spec()'s missing-TOOL_META check). A tool_id that no longer exists
775
+ is not drift in that sense -- a tool can legitimately be removed -- so that capability is
776
+ dropped from the map rather than crashing the whole contract build."""
777
+ by_id = {t["id"]: t for t in tools}
778
+ result = []
779
+ for entry in CAPABILITY_MAP:
780
+ spec = by_id.get(entry["tool_id"])
781
+ if spec is None:
782
+ continue
783
+ props = spec["input_schema"]["properties"]
784
+ for key in entry["params"]:
785
+ if key not in props:
786
+ raise RuntimeError(f"capability {entry['capability']!r} sets param {key!r} which is not in {entry['tool_id']}'s input_schema")
787
+ result.append(dict(entry))
788
+ return result
789
+
790
+
539
791
  # ----------------------------------------------------------------------------- contract
540
792
  def tool_spec(name: str, version: str) -> Dict[str, Any]:
541
793
  if name not in TOOL_META:
@@ -717,12 +969,14 @@ def build(detect: bool = True) -> Dict[str, Any]:
717
969
  },
718
970
  "json_output": {
719
971
  "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"},
972
+ "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"},
973
+ "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"},
974
+ "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",
722
975
  },
723
976
  "capabilities": caps,
724
977
  "tools": tools,
725
978
  "provides": capability_provides(),
979
+ "capability_map": capability_map(tools),
726
980
  }
727
981
 
728
982
 
@@ -737,12 +991,21 @@ def main() -> int:
737
991
  if args.json:
738
992
  print(json.dumps(d, indent=2, sort_keys=True))
739
993
  else:
994
+ print(f"ffmpeg-skill {d['version']} (this installed copy; re-run `npx ffmpeg-skill` to refresh it -- copies are not updated automatically)")
740
995
  print(f"python {d['python']}; ffmpeg {d['ffmpeg'] or 'MISSING'}; ffprobe {d['ffprobe'] or 'MISSING'}")
741
996
  print(f"available: {', '.join(d['available'])}")
742
997
  print(f"missing required: {', '.join(d['missing']) or 'none'}")
743
998
  print(f"missing optional: {', '.join(d['missing_optional']) or 'none'}")
744
999
  if d["unknown"]:
745
1000
  print(f"unknown (detection failed, not proven missing): {', '.join(d['unknown'])}")
1001
+ not_usable = sorted(name for name, t in d["tools"].items() if t["usable"] != "yes")
1002
+ if not_usable and d["ok"]:
1003
+ 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)")
1004
+ gpu = d["gpu_encoders"]
1005
+ if gpu["status"] == "parsed":
1006
+ 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)")
1007
+ fonts = d["fonts"]
1008
+ print(f"default drawtext font '{fonts['default_font']}': {fonts['status']} ({fonts['detail']})")
746
1009
  for err in d["errors"]:
747
1010
  print(f"detection error: {err}", file=sys.stderr)
748
1011
  if d["ok"]:
package/scripts/audio.py CHANGED
@@ -209,7 +209,7 @@ def main() -> int:
209
209
  cmd += ["-vn"] # audio extension: the picture is dropped, not copied into a container that cannot hold it
210
210
  cmd += audio_codec_for(output, args.bitrate) + ["-shortest", output]
211
211
  run(cmd)
212
- r = probe(output)
212
+ r = probe(output, role="output")
213
213
  a = r["audio"]
214
214
  if r.get("video") and audio_out and not STATE["dry_run"]:
215
215
  die(f"{output} unexpectedly contains a video stream")
@@ -0,0 +1,73 @@
1
+ #!/usr/bin/env python3
2
+ """Generate a solid-colour or two-colour gradient background clip.
3
+
4
+ No input file: a silent, fixed-duration, exact-size clip generated entirely
5
+ by ffmpeg's own source filters (`color` for solid, `gradients` for a
6
+ two-colour gradient) -- for a title card background, a placeholder behind a
7
+ logo, or a base layer for overlay.py to composite onto.
8
+
9
+ Examples:
10
+ python3 background.py --duration 3 --width 1920 --height 1080 --color 0x101010
11
+ python3 background.py --duration 5 --width 1080 --height 1920 --gradient 0xff6a00:0x0057ff --angle 45
12
+ """
13
+ import argparse
14
+ import math
15
+ import sys
16
+
17
+ from _common import add_common, apply_common, die, emit, ffmpeg_base, info, parse_time, probe, run, video_args
18
+
19
+
20
+ def main() -> int:
21
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
22
+ ap.add_argument("-o", "--output", required=True, help="output file")
23
+ ap.add_argument("--duration", required=True, help="clip duration (seconds or mm:ss)")
24
+ ap.add_argument("--width", type=int, required=True, help="output width in px (must be even)")
25
+ ap.add_argument("--height", type=int, required=True, help="output height in px (must be even)")
26
+ ap.add_argument("--fps", type=float, default=30.0, help="output frame rate (default 30)")
27
+ src = ap.add_mutually_exclusive_group()
28
+ src.add_argument("--color", default="black", help="solid background colour, e.g. black, 0x101010 (default black)")
29
+ src.add_argument("--gradient", help="two colours as C1:C2 for a linear gradient, e.g. 0xff6a00:0x0057ff")
30
+ ap.add_argument("--angle", type=float, default=0.0, help="gradient angle in degrees (with --gradient, default 0 = left to right)")
31
+ ap.add_argument("--crf", type=int, default=18, help="x264 CRF (default 18)")
32
+ ap.add_argument("--preset", default="medium", help="x264 preset")
33
+ add_common(ap)
34
+ args = ap.parse_args()
35
+ apply_common(args)
36
+
37
+ target = parse_time(args.duration)
38
+ if target <= 0:
39
+ die("--duration must be > 0")
40
+ if args.fps <= 0:
41
+ die("--fps must be > 0")
42
+ if args.width <= 0 or args.height <= 0:
43
+ die(f"--width/--height must be > 0, got width={args.width} height={args.height}")
44
+ if args.width % 2 or args.height % 2:
45
+ die(f"--width/--height must be even (4:2:0 chroma), got width={args.width} height={args.height}")
46
+
47
+ if args.gradient:
48
+ try:
49
+ c0, c1 = args.gradient.split(":")
50
+ except ValueError:
51
+ die(f"--gradient needs two colours as C1:C2, got '{args.gradient}'")
52
+ rad = math.radians(args.angle)
53
+ x1 = round(args.width * math.cos(rad))
54
+ y1 = round(args.width * math.sin(rad))
55
+ src_filter = f"gradients=size={args.width}x{args.height}:rate={args.fps:g}:c0={c0}:c1={c1}:x0=0:y0=0:x1={x1}:y1={y1}"
56
+ else:
57
+ src_filter = f"color=c={args.color}:size={args.width}x{args.height}:rate={args.fps:g}"
58
+
59
+ output = args.output
60
+ cmd = ffmpeg_base() + ["-f", "lavfi", "-i", src_filter, "-t", f"{target:.3f}"]
61
+ cmd += video_args(None, args.crf, args.preset)
62
+ cmd += ["-an", output]
63
+ run(cmd)
64
+
65
+ result = probe(output, role="output")
66
+ v = result["video"]
67
+ info(f"wrote {output} ({result['duration']:.3f}s, {v['width']}x{v['height']}, {v['fps']:g}fps)")
68
+ emit(output)
69
+ return 0
70
+
71
+
72
+ if __name__ == "__main__":
73
+ sys.exit(main())