ffmpeg-skill 0.10.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.
@@ -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],
@@ -104,6 +129,9 @@ TOOL_META: Dict[str, Dict[str, Any]] = {
104
129
  {"capability": "filter:exposure", "when": "--correct"}, {"capability": "filter:eq", "when": "--correct"},
105
130
  {"capability": "filter:colorbalance", "when": "--correct"}, {"capability": "filter:colortemperature", "when": "--correct"}],
106
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],
134
+ video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
107
135
  "export": dict(role="execution", inputs=["video asset"], outputs=["delivery artifact in the preset's format"],
108
136
  required=FF, optional=[{"capability": X264, "when": "preset youtube / youtube4k / reels / x"}, {"capability": AAC, "when": "any preset except gif"},
109
137
  {"capability": X265, "when": "preset h265"}, {"capability": "encoder:prores_ks", "when": "preset prores"},
@@ -159,16 +187,23 @@ REENCODE_META: Dict[str, Dict[str, str]] = {
159
187
  "probe": dict(video="never", audio="never", note="analysis only, no artifact"),
160
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)"),
161
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"),
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"),
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"),
163
197
  "overlay": dict(video="always", audio="always"),
164
198
  "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"),
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"),
166
200
  "multicam": dict(video="always", audio="always"),
167
201
  "audio": dict(video="never", audio="always", note="video stream is always -c:v copy when present; this tool's job is the audio"),
168
202
  "loudness": dict(video="never", audio="always"),
169
203
  "silence": dict(video="always", audio="always", note="removing gaps requires cutting on non-keyframe boundaries"),
170
204
  "join": dict(video="always", audio="always"),
171
- "color": 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"),
172
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"),
173
208
  "check": dict(video="never", audio="never", note="read-only, no artifact"),
174
209
  "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 +417,9 @@ def _ff_listing(binary: str, flag: str) -> Dict[str, Any]:
382
417
  if not exe:
383
418
  return {"names": [], "status": "missing", "detail": f"{binary} not on PATH"}
384
419
  try:
385
- proc = subprocess.run([exe, "-hide_banner", flag], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
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"}
386
423
  except OSError as e:
387
424
  return {"names": [], "status": "failed", "detail": f"{binary} {flag}: {e}"}
388
425
  if proc.returncode != 0:
@@ -399,11 +436,36 @@ def _ff_list(binary: str, flag: str) -> List[str]:
399
436
  return _ff_listing(binary, flag)["names"]
400
437
 
401
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
+
402
461
  def _version_line(binary: str) -> Optional[str]:
403
462
  exe = shutil.which(binary)
404
463
  if not exe:
405
464
  return None
406
- 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
407
469
  first = (proc.stdout or proc.stderr).splitlines()[:1]
408
470
  m = re.match(rf"{binary} version (\S+)", first[0]) if first else None
409
471
  return m.group(1) if m else (first[0] if first else "unknown")
@@ -415,6 +477,44 @@ def _whisper_available() -> bool:
415
477
  return importlib.util.find_spec("faster_whisper") is not None or importlib.util.find_spec("whisper") is not None
416
478
 
417
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
+
418
518
  def required_capabilities() -> Dict[str, List[str]]:
419
519
  req: set = set()
420
520
  opt: set = set()
@@ -428,10 +528,30 @@ def required_capabilities() -> Dict[str, List[str]]:
428
528
  def doctor() -> Dict[str, Any]:
429
529
  """Detect which declared capabilities this machine has. No secrets, no environment variables.
430
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
+
431
537
  Three states per capability: available, missing, unknown. `unknown` means the ffmpeg listing
432
538
  that would prove it could not be read (unparsed output, ffmpeg failure); it is never folded into
433
539
  `missing` (a filter that exists is not reported absent) nor into `available` (a failed detection
434
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).
435
555
  """
436
556
  listings = {
437
557
  "encoders": _ff_listing("ffmpeg", "-encoders"),
@@ -472,6 +592,7 @@ def doctor() -> Dict[str, Any]:
472
592
  unknown_required = [c for c in unknown if c in wanted["required"]]
473
593
  errors = [f"{k}: {v['detail']}" for k, v in listings.items() if v["status"] in ("unparsed", "failed")]
474
594
  return {
595
+ "version": skill_version(),
475
596
  "python": ".".join(str(x) for x in sys.version_info[:3]),
476
597
  "ffmpeg": _version_line("ffmpeg"),
477
598
  "ffprobe": _version_line("ffprobe"),
@@ -483,16 +604,30 @@ def doctor() -> Dict[str, Any]:
483
604
  "errors": errors,
484
605
  "ok": not missing_required and not unknown_required,
485
606
  "tools": _tool_usability(state),
607
+ "gpu_encoders": _gpu_encoders(listings["encoders"]),
608
+ "fonts": _fonts_capability(),
486
609
  }
487
610
 
488
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
+
489
618
  def _capability_fix_hint(cap: str) -> str:
490
619
  """One-line, plain-language remedy for a single missing/unknown capability."""
491
620
  if cap in ("ffmpeg", "ffprobe"):
492
621
  from _common import INSTALL_HINTS
493
622
  hint = INSTALL_HINTS.get(platform.system(), "see https://ffmpeg.org/download.html").strip().splitlines()[0].strip()
494
623
  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"
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"
496
631
  if cap.startswith("encoder:"):
497
632
  return f"this ffmpeg build has no {cap[8:]} encoder; {full_hint}"
498
633
  if cap.startswith("filter:"):
@@ -536,6 +671,52 @@ def capability_provides() -> List[Dict[str, str]]:
536
671
  return [{"id": f"{SKILL_ID}.{name}", "lifecycle": "EXPERIMENTAL", "tool_id": f"{SKILL_ID}/{name}"} for name in public_tools()]
537
672
 
538
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
+
539
720
  # ----------------------------------------------------------------------------- contract
540
721
  def tool_spec(name: str, version: str) -> Dict[str, Any]:
541
722
  if name not in TOOL_META:
@@ -717,12 +898,14 @@ def build(detect: bool = True) -> Dict[str, Any]:
717
898
  },
718
899
  "json_output": {
719
900
  "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"},
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",
722
904
  },
723
905
  "capabilities": caps,
724
906
  "tools": tools,
725
907
  "provides": capability_provides(),
908
+ "capability_map": capability_map(tools),
726
909
  }
727
910
 
728
911
 
@@ -737,12 +920,21 @@ def main() -> int:
737
920
  if args.json:
738
921
  print(json.dumps(d, indent=2, sort_keys=True))
739
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)")
740
924
  print(f"python {d['python']}; ffmpeg {d['ffmpeg'] or 'MISSING'}; ffprobe {d['ffprobe'] or 'MISSING'}")
741
925
  print(f"available: {', '.join(d['available'])}")
742
926
  print(f"missing required: {', '.join(d['missing']) or 'none'}")
743
927
  print(f"missing optional: {', '.join(d['missing_optional']) or 'none'}")
744
928
  if d["unknown"]:
745
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']})")
746
938
  for err in d["errors"]:
747
939
  print(f"detection error: {err}", file=sys.stderr)
748
940
  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())