ffmpeg-skill 3.2.2 → 3.2.3

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.
@@ -478,25 +478,35 @@ def _bit_depth(pix_fmt: Optional[str]) -> int:
478
478
  return n if 8 <= n <= 16 else 8
479
479
 
480
480
 
481
- def keyframes_near(path: str, t: float, window: float = 5.0) -> List[float]:
482
- """Video keyframe timestamps within +-window seconds of t, ascending. Read with
483
- -read_intervals so a long file is not scanned end to end; empty when ffprobe cannot say."""
484
- ffprobe = require_tool("ffprobe")
485
- lo = max(0.0, t - window)
486
- proc = run([ffprobe, "-v", "error", "-select_streams", "v:0", "-skip_frame", "nokey",
487
- "-read_intervals", f"{lo:.3f}%{t + window:.3f}", "-show_entries", "frame=pts_time",
481
+ def keyframe_times(path: str, lo: float, hi: float, stream: str = "v:0") -> Optional[List[float]]:
482
+ """Keyframe timestamps of `stream` read from lo to hi seconds (file time), ascending and as
483
+ ffprobe prints them (microseconds). Read with -read_intervals so a long file is not scanned end
484
+ to end; the read starts at the keyframe at or before lo, so the first may lie before it. None
485
+ when ffprobe fails (unmeasured, not "no keyframes")."""
486
+ # an interval's end is exclusive (6.1: a keyframe at exactly hi is not read), so read a
487
+ # little past it and drop what lies beyond hi
488
+ proc = run([require_tool("ffprobe"), "-v", "error", "-select_streams", stream, "-skip_frame", "nokey",
489
+ "-read_intervals", f"{max(0.0, lo):.6f}%{hi + 0.01:.6f}", "-show_entries", "frame=pts_time",
488
490
  "-of", "csv=p=0", path], quiet=True, check=False)
489
491
  if proc.returncode != 0:
490
- return []
492
+ return None
491
493
  out: List[float] = []
492
494
  for line in proc.stdout.splitlines():
493
495
  try:
494
- out.append(round(float(line.strip().rstrip(",")), 3))
496
+ k = float(line.strip().rstrip(","))
495
497
  except ValueError:
496
498
  continue
499
+ if k <= hi + 1e-6:
500
+ out.append(k)
497
501
  return sorted(set(out))
498
502
 
499
503
 
504
+ def keyframes_near(path: str, t: float, window: float = 5.0) -> List[float]:
505
+ """Video keyframe timestamps within +-window seconds of t (to the ms), ascending; empty when
506
+ ffprobe cannot say (cut.py's best-effort hints; probe.py --keyframes-near uses keyframe_times)."""
507
+ return sorted(set(round(k, 3) for k in keyframe_times(path, t - window, t + window) or []))
508
+
509
+
500
510
  # frame timing: is a stream's frame rate really variable? (cut.py's VFR guard)
501
511
  TIMING_WINDOWS = 5 # sampled windows per file, fewer on a short one
502
512
  TIMING_WINDOW_SECONDS = 6
@@ -609,6 +619,162 @@ def measured_level_dbfs(path: str, seconds: Optional[float] = 120.0,
609
619
  return {"mean_dbfs": float(m_mean.group(1)), "peak_dbfs": float(m_max.group(1))}
610
620
 
611
621
 
622
+ # Legal signal range of limited-range Y'CbCr (check.py --levels, color.py --legalize). Each standard
623
+ # is stated in percent of the nominal swing, so the code limits follow from the bit depth: 0 % luma is
624
+ # code 16 << (depth - 8), 100 % is 235 << (depth - 8); chroma's nominal swing is 16..240 (scaled the
625
+ # same way) around the midpoint. A sample is legal when its code lies inside [lo, hi] inclusive.
626
+ # bt709 ITU-R BT.709 nominal range: luma 0..100 %, chroma 16..240 (8-bit) -- no excursion.
627
+ # ebu-r103 EBU R 103 tolerance: luma -1 %..103 %, RGB components -5 %..105 %. Chroma is held to the
628
+ # widest Cb/Cr an RGB triple inside -5 %..105 % can produce (|C| <= 0.55 of the RGB span,
629
+ # i.e. 8-bit 5..251); RGB gamut itself is not measured here (a per-plane Y'CbCr count).
630
+ LEGAL_RANGES: Dict[str, Dict[str, Any]] = {
631
+ "bt709": {"luma_pct": (0.0, 100.0), "chroma_half_swing": 0.5,
632
+ "reference": "ITU-R BT.709 nominal range: luma 16-235, chroma 16-240 (8-bit; 10-bit 64-940 / 64-960)"},
633
+ "ebu-r103": {"luma_pct": (-1.0, 103.0), "chroma_half_swing": 0.55,
634
+ "reference": "EBU R 103 tolerance: luma -1 %..103 % (8-bit 14-241), chroma as far as RGB -5 %..105 % "
635
+ "reaches (8-bit 5-251)"},
636
+ }
637
+ LEGAL_RANGE_CHOICES = sorted(LEGAL_RANGES)
638
+
639
+
640
+ def legal_limits(standard: str, bit_depth: int = 8) -> Dict[str, Tuple[int, int]]:
641
+ """{"y": (lo, hi), "c": (lo, hi)}: the inclusive legal code range of a limited-range sample at
642
+ `bit_depth`. A limit that falls between two codes is rounded inward (the code past it is out)."""
643
+ spec = LEGAL_RANGES[standard]
644
+ scale = 1 << (max(8, int(bit_depth)) - 8)
645
+ black, white = 16 * scale, 235 * scale
646
+ span = white - black
647
+ p_lo, p_hi = spec["luma_pct"]
648
+ y = (math.ceil(black + span * p_lo / 100.0 - 1e-9), math.floor(white + span * (p_hi - 100.0) / 100.0 + 1e-9))
649
+ mid, cspan = 128 * scale, 224 * scale
650
+ half = spec["chroma_half_swing"] * cspan
651
+ c = (max(0, math.ceil(mid - half - 1e-9)), min((256 * scale) - 1, math.floor(mid + half + 1e-9)))
652
+ return {"y": y, "c": c}
653
+
654
+
655
+ _COMPONENT_DEPTHS: Dict[str, int] = {}
656
+
657
+
658
+ def component_depth(pix_fmt: Optional[str]) -> int:
659
+ """Bits per luma sample of `pix_fmt`, from ffmpeg's own pixel-format table (`-pix_fmts`,
660
+ BIT_DEPTHS column): the depth the planes really hold, which the name does not always say --
661
+ nv12 is 8-bit 4:2:0 (the 12 is bits per pixel), p010le is 10-bit. Falls back to _bit_depth()
662
+ when ffmpeg does not list the format."""
663
+ name = str(pix_fmt or "")
664
+ if not _COMPONENT_DEPTHS:
665
+ proc = run_analysis([require_tool("ffmpeg"), "-hide_banner", "-pix_fmts"], check=False)
666
+ for line in (proc.stdout or "").splitlines():
667
+ cols = line.split()
668
+ if len(cols) == 5 and re.fullmatch(r"\d+(?:-\d+)*", cols[4]):
669
+ _COMPONENT_DEPTHS[cols[1]] = int(cols[4].split("-")[0])
670
+ return _COMPONENT_DEPTHS.get(name) or _bit_depth(name)
671
+
672
+
673
+ def _metadata_frames(text: str, keys: Optional[frozenset] = None) -> List[Tuple[float, Dict[str, float]]]:
674
+ """metadata=print's output: one (pts_time, {key: value}) per `frame:` block, signalstats keys only
675
+ (only those in `keys` when given: signalstats prints ~25 per frame and an hour is 100k frames)."""
676
+ frames: List[Tuple[float, Dict[str, float]]] = []
677
+ for line in text.splitlines():
678
+ if line.startswith("frame:"):
679
+ m = re.search(r"pts_time:\s*(-?[0-9.]+|nan)", line)
680
+ t = float(m.group(1)) if m and m.group(1) != "nan" else float("nan")
681
+ frames.append((t, {}))
682
+ elif "lavfi.signalstats." in line and "=" in line and frames:
683
+ key, val = line.split("lavfi.signalstats.", 1)[1].split("=", 1)
684
+ key = key.strip()
685
+ if keys is not None and key not in keys:
686
+ continue
687
+ try:
688
+ frames[-1][1][key] = float(val)
689
+ except ValueError:
690
+ pass
691
+ return frames
692
+
693
+
694
+ # the signalstats keys measure_signal_range reads: the frame's own extremes and BRNG, and the mask's means
695
+ _OWN_KEYS = frozenset({"BRNG", "YMIN", "YMAX", "UMIN", "UMAX", "VMIN", "VMAX"})
696
+ _MASK_KEYS = frozenset({"YAVG", "UAVG", "VAVG"})
697
+
698
+
699
+ def measure_signal_range(path: str, standard: str, window: float = 10.0,
700
+ bit_depth: Optional[int] = None, pix_fmt: Optional[str] = None) -> Optional[Dict[str, Any]]:
701
+ """Share of luma and chroma samples outside `standard`'s legal range, per clip and per `window`
702
+ seconds, plus min/max Y/U/V, from one decode of every frame of the first video stream.
703
+
704
+ The count is exact, not sampled from a histogram: each plane goes through lutyuv, which maps an
705
+ out-of-range code to the depth's top code (255 at 8 bits) and every other code to 0, and
706
+ signalstats' mean of that mask is the top code x the out-of-range share of that plane in that frame. Chroma planes are
707
+ counted at their own (subsampled) resolution, so "chroma" is the share of Cb and Cr samples. A
708
+ second signalstats ahead of the mask reads the frame's own MIN/MAX and BRNG (the share of pixels
709
+ with any component outside the BT.601/709 nominal range, signalstats' fixed limits). Every frame
710
+ holds the same number of samples, so a clip's share is the mean of its frames' shares.
711
+
712
+ The limits and the mask use `bit_depth`, or else the depth of `pix_fmt`'s planes
713
+ (component_depth(): nv12 is 8-bit, not 12). None when the measurement is not whole: nothing
714
+ decoded, ffmpeg failed part-way (a share from the frames read before the failure would grade a
715
+ file nobody measured to the end), or the two passes counted different frames."""
716
+ if standard not in LEGAL_RANGES:
717
+ die(f"unknown legal range {standard!r}; choose from {', '.join(LEGAL_RANGE_CHOICES)}")
718
+ import tempfile
719
+ from _common.decision import escape_filter_path
720
+ ffmpeg = require_tool("ffmpeg")
721
+ depth = int(bit_depth or component_depth(pix_fmt))
722
+ lim = legal_limits(standard, depth)
723
+ (ylo, yhi), (clo, chi) = lim["y"], lim["c"]
724
+ top = (1 << depth) - 1 # a literal: lutyuv's own maxval is 235 for limited-range luma, 240 for chroma
725
+ out = str(top) + "*(lt(val\\,{lo})+gt(val\\,{hi}))"
726
+ mask = (f"lutyuv=y='{out.format(lo=ylo, hi=yhi)}':u='{out.format(lo=clo, hi=chi)}'"
727
+ f":v='{out.format(lo=clo, hi=chi)}'")
728
+ with tempfile.TemporaryDirectory(prefix="ffskill_levels_") as tmp:
729
+ stats_file = os.path.join(tmp, "stats.txt")
730
+ vf = (f"signalstats=stat=brng,metadata=print:file={escape_filter_path(stats_file)},"
731
+ f"{mask},signalstats,metadata=print:file=-")
732
+ cmd = [ffmpeg, "-hide_banner", "-nostdin", "-i", path, "-map", "0:v:0", "-an", "-sn", "-dn", "-vf", vf, "-f", "null", "-"]
733
+ proc = run_analysis(cmd, check=False, record=True)
734
+ try:
735
+ with open(stats_file, encoding="utf-8", errors="replace") as f:
736
+ own = _metadata_frames(f.read(), _OWN_KEYS)
737
+ except OSError:
738
+ own = []
739
+ masks = _metadata_frames(proc.stdout or "", _MASK_KEYS)
740
+ if proc.returncode != 0 or not own or len(own) != len(masks):
741
+ return None
742
+ t0 = next((t for t, _ in own if t == t), 0.0)
743
+ rows: List[Dict[str, Any]] = []
744
+ for (t, s), (_, m) in zip(own, masks, strict=True):
745
+ y, u, v = (m.get(k, 0.0) / top * 100.0 for k in ("YAVG", "UAVG", "VAVG"))
746
+ rows.append({"t": (t - t0) if t == t else None, "y": y, "u": u, "v": v, "brng": s.get("BRNG", 0.0) * 100.0,
747
+ "min": {k: s.get(k.upper() + "MIN") for k in "yuv"}, "max": {k: s.get(k.upper() + "MAX") for k in "yuv"}})
748
+
749
+ def summary(frames: List[Dict[str, Any]]) -> Dict[str, Any]:
750
+ k = len(frames)
751
+ share = {p: sum(f[p] for f in frames) / k for p in ("y", "u", "v", "brng")}
752
+ worst = max(frames, key=lambda f: max(f["y"], (f["u"] + f["v"]) / 2))
753
+ def ext(which: str, fn: Any) -> Dict[str, Any]:
754
+ vals = {p: [f[which][p] for f in frames if f[which][p] is not None] for p in "yuv"}
755
+ return {p: (int(fn(v)) if v else None) for p, v in vals.items()}
756
+ return {"frames": k,
757
+ "out_of_range_pct": {"y": round(share["y"], 4), "u": round(share["u"], 4), "v": round(share["v"], 4),
758
+ "chroma": round((share["u"] + share["v"]) / 2, 4)},
759
+ "brng_pct": round(share["brng"], 4),
760
+ "worst_frame": {"time": None if worst["t"] is None else round(worst["t"], 3),
761
+ "y_pct": round(worst["y"], 4), "chroma_pct": round((worst["u"] + worst["v"]) / 2, 4)},
762
+ "min": ext("min", min), "max": ext("max", max)}
763
+
764
+ doc = {"standard": standard, "reference": LEGAL_RANGES[standard]["reference"], "bit_depth": depth,
765
+ "legal": {"y": list(lim["y"]), "c": list(lim["c"])}, "window_seconds": window, **summary(rows)}
766
+ windows: List[Dict[str, Any]] = []
767
+ if window and window > 0:
768
+ buckets: Dict[int, List[Dict[str, Any]]] = {}
769
+ for i, f in enumerate(rows):
770
+ buckets.setdefault(int((f["t"] if f["t"] is not None else i) // window), []).append(f)
771
+ for idx in sorted(buckets):
772
+ w = summary(buckets[idx])
773
+ windows.append({"start": round(idx * window, 3), "end": round((idx + 1) * window, 3), **w})
774
+ doc["windows"] = windows
775
+ return doc
776
+
777
+
612
778
  def analyze_levels(path: str, seconds: float = 20.0) -> Dict[str, Any]:
613
779
  """Sample luma/saturation statistics (signalstats) and guess whether the picture is Log-encoded.
614
780
 
@@ -329,7 +329,7 @@ class Context:
329
329
  makes it obvious what run()/emit() depend on and lets tests reset it with ``STATE.reset()``.
330
330
  """
331
331
 
332
- __slots__ = ("dry_run", "json", "json_brief", "progress", "fast", "duration_hint", "commands", "timeout", "overwrite", "written", "preexisting", "plan", "plan_written", "plan_inputs", "codec",
332
+ __slots__ = ("dry_run", "json", "json_brief", "progress", "fast", "duration_hint", "commands", "timeout", "overwrite", "written", "preexisting", "plan", "plan_written", "plan_inputs", "codec", "alpha_line",
333
333
  "hw", "hw_source", "hw_notes", "hw_swaps", "hw_fallback", "hw_env_ignored", "hw_stages", "hw_auto",
334
334
  "notes", "warnings", "fallbacks", "skipped", "audit_log", "stage_verification", "first_input", "audio_inputs", "video_inputs",
335
335
  "source_timecode", "smpte_hint", "delivered")
@@ -359,6 +359,7 @@ class Context:
359
359
  self.plan_written = False # write_plan() ran (emit or the exit hook), so the hook does not write twice
360
360
  self.plan_inputs: List[str] = [] # side inputs (srt/ass/lut/font files) a tool named through escape_filter_path
361
361
  self.codec: Optional[str] = None # --codec: encoder for the re-encode (None = x264 for SDR, x265 for HDR)
362
+ self.alpha_line: Optional[str] = None # --keep-alpha / --transparent: the alpha line written (_common.alpha.ALPHA_CODECS)
362
363
  self.hw = False # --hw / $FFMPEG_SKILL_HW: VideoToolbox encoders where they apply
363
364
  self.hw_source: Optional[str] = None # "flag" (--hw/--no-hw given) or "env" (FFMPEG_SKILL_HW), None = off by default
364
365
  self.hw_notes: List[str] = [] # why an encode stayed on (or fell back to) the CPU, for the result document
@@ -2025,7 +2026,7 @@ def _read_ff_list(flag: str, pattern: str) -> set:
2025
2026
  why = f"{tool} {flag} could not run: {exc}"
2026
2027
  if why:
2027
2028
  _LIST_ERRORS[flag] = why
2028
- what = {"-encoders": "encoder", "-bsfs": "bitstream filter"}.get(flag, "filter")
2029
+ what = {"-encoders": "encoder", "-decoders": "decoder", "-bsfs": "bitstream filter"}.get(flag, "filter")
2029
2030
  warn(f"could not read this ffmpeg build's {what} list ({why}); nothing that needs it can be confirmed, which is not the same as the build lacking it",
2030
2031
  "ffmpeg_list_unreadable", list=flag.lstrip("-"), tool=tool, error=why)
2031
2032
  return names
@@ -51,6 +51,7 @@ ROLES = {
51
51
  # Facts that are not derivable from the argparse parsers. Capability names:
52
52
  # ffmpeg / ffprobe the binaries on PATH
53
53
  # encoder:<name> `ffmpeg -encoders`
54
+ # decoder:<name> `ffmpeg -decoders`
54
55
  # filter:<name> `ffmpeg -filters`
55
56
  # bsf:<name> `ffmpeg -bsfs`
56
57
  # external:whisper a local whisper engine (whisper.cpp / faster-whisper / openai-whisper)
@@ -78,7 +79,8 @@ TOOL_META: Dict[str, Dict[str, Any]] = {
78
79
  required=["ffprobe"], optional=[{"capability": "ffmpeg", "when": "--analyze"}, {"capability": "filter:signalstats", "when": "--analyze"}],
79
80
  video_required=False, audio_only=True, visual=False, verify=[], produces_artifact=False, idempotency="bit_exact", deterministic=True),
80
81
  "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)"],
81
- required=FF, optional=[{"capability": X264, "when": "re-encode: --accurate, VFR source, or a keyframe farther than --tolerance"},
82
+ required=FF, optional=[{"capability": X264, "when": "re-encode: --accurate, VFR source, or a keyframe farther than --tolerance; --smart (the partial GOPs at the cut points)"},
83
+ {"capability": "bsf:filter_units", "when": "--smart"}, {"capability": "bsf:h264_mp4toannexb", "when": "--smart"},
82
84
  dict(HDR_X265, when=HDR_X265["when"] + "; on an SDR HEVC source (the default since 3.0.0; --no-keep-hevc uses x264)"), {"capability": AAC, "when": "re-encode of a video container"}] + AUDIO_OUT,
83
85
  video_required=False, audio_only=True, visual=False, verify=["probe"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
84
86
  "fit": dict(role="execution", inputs=["video asset"], outputs=["video artifact at the requested duration / aspect / fps"],
@@ -136,7 +138,7 @@ TOOL_META: Dict[str, Dict[str, Any]] = {
136
138
  "insert": dict(role="execution", inputs=["still image"], outputs=["silent video artifact of the requested duration / frame size / fps"],
137
139
  required=FF + [X264], optional=[{"capability": "filter:zoompan", "when": "--zoom / --pan"}],
138
140
  video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
139
- "background": dict(role="execution", inputs=[], outputs=["generated solid-colour or gradient video artifact"],
141
+ "background": dict(role="execution", inputs=[], outputs=["generated solid-colour or gradient video artifact", "transparent canvas in an alpha line (--transparent)"],
140
142
  required=FF + [X264], optional=[{"capability": "filter:gradients", "when": "--gradient"}],
141
143
  video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="bit_exact", deterministic=True),
142
144
  "reverse": dict(role="execution", inputs=["video asset"], outputs=["reversed video artifact"],
@@ -185,7 +187,7 @@ TOOL_META: Dict[str, Dict[str, Any]] = {
185
187
  video_required=False, audio_only=True, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
186
188
  "color": dict(role="execution", inputs=["video asset", ".cube LUT (--lut)"], outputs=["video artifact with converted colour"],
187
189
  required=FF, optional=[{"capability": X264, "when": "--to-sdr / --lut / --correct"}, {"capability": "filter:zscale", "when": "--to-sdr"}, {"capability": "filter:tonemap", "when": "--to-sdr"},
188
- {"capability": "filter:lut3d", "when": "--lut"}, {"capability": "bsf:filter_units", "when": "--strip-dovi"}, {"capability": "bsf:h264_metadata", "when": "--retag on H.264 (without it the video is re-encoded)"},
190
+ {"capability": "filter:lut3d", "when": "--lut"}, {"capability": "filter:lutyuv", "when": "--legalize"}, {"capability": "filter:signalstats", "when": "--legalize (the measurement of the written file)"}, {"capability": "bsf:filter_units", "when": "--strip-dovi"}, {"capability": "bsf:h264_metadata", "when": "--retag on H.264 (without it the video is re-encoded)"},
189
191
  {"capability": "bsf:hevc_metadata", "when": "--retag on HEVC (without it the video is re-encoded)"},
190
192
  {"capability": "bsf:mpeg2_metadata", "when": "--retag on MPEG-2 (without it the video is re-encoded)"}, {"capability": "bsf:av1_metadata", "when": "--retag on AV1 (without it the video is re-encoded)"},
191
193
  {"capability": "bsf:vp9_metadata", "when": "--retag on VP9 (without it the video is re-encoded)"}, {"capability": X265, "when": "--lut on an HDR source"}, {"capability": AAC, "when": "re-encode"},
@@ -206,7 +208,8 @@ TOOL_META: Dict[str, Dict[str, Any]] = {
206
208
  "check": dict(role="verification", inputs=["media artifact"], outputs=["compliance rows JSON on stdout (no file)"],
207
209
  required=["ffprobe"], optional=[{"capability": "ffmpeg", "when": "loudness rows (default); the audio row's volumedetect silence test when loudness is skipped"}, {"capability": "filter:loudnorm", "when": "loudness rows (default)"},
208
210
  {"capability": "filter:blackdetect", "when": "--content (black row)"}, {"capability": "filter:freezedetect", "when": "--content (frozen row)"},
209
- {"capability": "filter:silencedetect", "when": "--content (silence row)"}],
211
+ {"capability": "filter:silencedetect", "when": "--content (silence row)"},
212
+ {"capability": "filter:signalstats", "when": "--levels (legal levels row)"}, {"capability": "filter:lutyuv", "when": "--levels (legal levels row)"}],
210
213
  video_required=False, audio_only=True, visual=False, verify=[], produces_artifact=False, idempotency="bit_exact", deterministic=True),
211
214
  "scenes": dict(role="analysis", inputs=["video asset", "audio asset (after 3.2.0: one scene; --beats, --audio-peaks, --speech, --highlights; --sheet and --shots need a picture)"], outputs=["scene / audio-peak / highlight JSON on stdout", "EDL text (--edl)", "per-scene contact sheet PNG (--sheet)"],
212
215
  required=FF + ["filter:scdet"], optional=[{"capability": "filter:drawtext", "when": "--sheet"}, {"capability": "filter:tile", "when": "--sheet"},
@@ -260,7 +263,7 @@ DRY_RUN_NOTES = {
260
263
  # of the original audio codec never happens on those tools.
261
264
  REENCODE_META: Dict[str, Dict[str, str]] = {
262
265
  "probe": dict(video="never", audio="never", note="analysis only, no artifact"),
263
- "cut": dict(video="conditional", audio="conditional", note="lossless -c copy preferred; re-encodes on --accurate, --codec, a VFR source (--vfr-guard), a keyframe snap past --tolerance, or a --segments join the parts cannot stream-copy (see cut.py --json: mode, reencode_reason, keyframe_snapped)"),
266
+ "cut": dict(video="conditional", audio="conditional", note="lossless -c copy preferred; --smart copies the whole GOPs inside a cut and re-encodes the partial ones at its ends; re-encodes on --accurate, --codec, a VFR source (--vfr-guard), a keyframe snap past --tolerance, or a --segments join the parts cannot stream-copy (see cut.py --json: mode, reencode_reason, keyframe_snapped)"),
264
267
  "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"),
265
268
  "crop": dict(video="always", audio="always", note="the crop filter always forces a re-encode of both streams"),
266
269
  "deinterlace": dict(video="always", audio="always", note="the yadif filter always forces a re-encode of the video stream; audio is re-encoded to AAC when present"),
@@ -437,6 +440,7 @@ ERROR_REASONS = {
437
440
  "plan_unknown_tool": "render.py PLAN: the plan names no tool render.py can run; details: {plan, plan_sha256, checked_at, tool, audit_log}",
438
441
  "plan_cwd_missing": "render.py PLAN: the directory the plan was made in is gone; details: {plan, plan_sha256, checked_at, cwd, audit_log}",
439
442
  "plan_argv_invalid": "render.py PLAN: argv is not a list; details: {plan, plan_sha256, checked_at, argv_type, audit_log}",
443
+ "decoder_missing": "color.py / graphics.py / overlay.py: a VP9 source or --video layer stored with an alpha layer, on a build with no libvpx-vp9 decoder (FFmpeg's own VP9 decoder drops the alpha), kind missing_tool; details: {name}",
440
444
  "encoder_missing": "--codec av1/prores: the build's encoder list was read and has no encoder for the codec (kind missing_tool; details: {codec, encoders}); or ffmpeg itself reported \"Unknown encoder\" for the encoder the command named (kind ffmpeg; details: {name}; the hint points at doctor)",
441
445
  "asr_engine_missing": "kind missing_tool: --transcribe (caption.py, silence.py --filler) found no local speech-to-text engine; the message lists the install lines; details: {}",
442
446
  "encoder_list_unreadable": "--codec av1/prores: the build's encoder list could not be read, so whether it has the encoder is unknown (kind ffmpeg); details: {codec, encoders, error}",
@@ -513,8 +517,10 @@ DIAGNOSTIC_CODES = {
513
517
  "output_audio_silent": ("warnings", "export.py: the written audio is silent (-inf LUFS), so no gain meets the platform's loudness; {platform}"),
514
518
  "flash_pass_failed": ("warnings", "check.py --content: the flash pass failed, so the flashes row is not measured (WARN, could not measure); the other rows stand"),
515
519
  "repair_incomplete": ("warnings", "export.py --repair: decode errors remain after the remux (a damaged frame needs a re-encode); {timestamp_warnings, decode_errors}"),
520
+ "legalize_residual": ("warnings", "color.py --legalize: samples of the encoded file are still outside the legal range after the clamp (the encoder's error around clamped areas); the shares are in legalize.output"),
516
521
  "size_over_limit": ("warnings", "export.py --max-mb: the written file is over the size asked for (the encoder overshot its bitrate); {size, limit}"),
517
522
  "loudness_outside_spec": ("warnings", "export.py: the written file's loudness is outside the platform's spec (normalized: after --normalize too); {platform, lufs, tp, normalized}"),
523
+ "smart_fallback": ("fallbacks", "cut.py --smart could not make or verify the smart cut, so the whole cut was re-encoded as --accurate would (smart.reason says why); {reason_code}"),
518
524
  "camera_not_started": ("fallbacks", "multicam.py: a range asked for a camera that had not started; camera 0 played it ({start, end, requested_camera, camera, starts_at}); `cuts` names the camera rendered"),
519
525
  "loudness_not_measured": ("warnings", "export.py: the written file's loudness could not be read, so the platform's loudness was not verified (loudness.ok null, a failing loudness verification step)"),
520
526
  "loudness_no_audio": ("skipped", "export.py: a platform preset's loudness check, on a source with no audio"),
@@ -533,9 +539,39 @@ DIAGNOSTIC_CODES = {
533
539
  "batch_cache_reset": ("warnings", "batch.py: .ffskill_cache.json did not parse and was started empty (also an audit line)"),
534
540
  "batch_cache_output_changed": ("warnings", "batch.py: a cached item's output was replaced or edited since the batch wrote it; the item runs again"),
535
541
  "batch_cache_entry_unverifiable": ("warnings", "batch.py: a cache entry from before output identities; the item runs again"),
542
+ "alpha_dropped": ("warnings", "color.py / graphics.py / overlay.py: the source's alpha channel is used and the output does not keep it (no --keep-alpha); the `alpha` verification step fails with reason alpha_dropped, and --drop-alpha says an opaque output is wanted"),
536
543
  }
537
544
 
538
545
 
546
+ # The alpha path beyond export.py (_common.alpha): --keep-alpha on the tools that grade or
547
+ # composite, --transparent on background.py. One description, so the four cannot drift.
548
+ ALPHA_LINE_TOOLS = ("color", "graphics", "overlay", "background")
549
+ ALPHA_LINE_OPTIONAL = [{"capability": "encoder:prores_ks", "when": "--keep-alpha / --transparent with --alpha-codec prores4444 (the default)"},
550
+ {"capability": "encoder:qtrle", "when": "--alpha-codec qtrle"}, {"capability": "encoder:png", "when": "--alpha-codec png"},
551
+ {"capability": "encoder:libvpx-vp9", "when": "--alpha-codec vp9"},
552
+ {"capability": "encoder:libopus", "when": "--alpha-codec vp9 on an input with audio (color, graphics, overlay)"},
553
+ {"capability": "decoder:libvpx-vp9", "when": "an alpha source or overlay --video layer stored as VP9 with alpha (refused without it, error.reason decoder_missing)"},
554
+ {"capability": "filter:alphaextract", "when": "an alpha source or --keep-alpha / --transparent (the alpha is read back)"}]
555
+
556
+
557
+ def alpha_result_schema(name: str) -> Dict[str, Any]:
558
+ flag = "--transparent" if name == "background" else "--keep-alpha"
559
+ out: Dict[str, Any] = {
560
+ "alpha_codec": {"enum": ["png", "prores4444", "qtrle", "vp9"], "description": f"{flag} only: the alpha line written (--alpha-codec, default prores4444); the source's colour tags are kept and no matrix is converted, as --codec prores does"},
561
+ "alpha_mode": {"enum": ["straight", "premultiplied"], "description": f"{flag} only: how the colour was written beside the alpha (--alpha-mode, default straight)"},
562
+ }
563
+ if name == "background":
564
+ out["alpha_codec"]["description"] += ". The `alpha` verification step reads every written frame back and expects alpha 0 (provenance specification)"
565
+ return out
566
+ out["alpha_dropped"] = {"type": "boolean", "description": "true when the source's alpha is used (a frame below opaque, or a stored alpha plane that could not be read) and the output does not keep it (color.py --retag too, when its copy fell back to an H.264 re-encode): without --keep-alpha the `alpha` verification step fails (reason alpha_dropped, attribution artifact, with an alpha_dropped warning); with --drop-alpha there is no such step and a note says the alpha was dropped on purpose. Absent on a source without alpha"}
567
+ if name == "color":
568
+ out["alpha_codec"]["description"] += ". --correct / --lut only: the grade works on the colour (at 4:4:4) and the source's alpha is merged back unchanged; the `alpha` step compares the written alpha with the source's frame by frame, tolerance 2 of 255 (comparison {ok, paired, worst_delta, worst_frame}; frames {expected, measured}; written {codec, pix_fmt, alpha_plane})"
569
+ else:
570
+ out["alpha_codec"]["description"] += ". Drawn on the input's alpha at 4:4:4 (layers composited with their alpha, text drawn into it); the `alpha` step (property alpha_at_least_source) passes when the written stream stores an alpha plane, every frame's alpha is at or above the source's (tolerance 2 of 255) and the output did not turn opaque in every frame the source was transparent in (reason: opaque_everywhere, alpha_below_source or no_alpha_plane when it fails)"
571
+ out["alpha_codec"]["description"] += ". A source that stores an alpha plane whose alpha could not be read is never taken as opaque: its step fails with reason source_alpha_unmeasured"
572
+ return out
573
+
574
+
539
575
  # `font_used` on the tools that draw text: which font the text was drawn with, so a run that
540
576
  # drew with a substitute says so (with a font_substituted fallback)
541
577
  FONT_USED_SCHEMA = {
@@ -565,7 +601,8 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
565
601
  "video": {"type": ["object", "null"]}, "audio": {"type": ["object", "null"]},
566
602
  "timecode": {"type": "object", "description": "3.0.0: the source's start timecode {value, drop_frame, source}: the first HH:MM:SS[:;.]FF timecode tag from the video stream, then a tmcd data stream, then the container; all three null when none carries one"},
567
603
  "chapters": {"type": "array", "description": "the container's chapter markers [{index, start, end, title}]"},
568
- "subtitle_stream_details": {"type": "array", "description": "every subtitle stream in file order [{index (the n of -map 0:s:n), codec, language, title}]"}},
604
+ "subtitle_stream_details": {"type": "array", "description": "every subtitle stream in file order [{index (the n of -map 0:s:n), codec, language, title}]"},
605
+ "keyframes": {"type": "array", "description": "--keyframes-near only: one entry per time [{at, window, keyframes (the video keyframes within window seconds of it), before (the nearest of those at or before it, or null when none is within the window), after (at or after it, likewise)}], seconds from the file's start; a failed keyframe read fails the run (kind ffmpeg)"}},
569
606
  "additionalProperties": True}
570
607
  base = {"status": {"enum": ["completed"]}, "output": {"type": ["string", "null"], "description": "path written, or null"},
571
608
  "dry_run": {"type": "boolean"}, "commands": {"type": "array", "items": {"type": "string"}, "description": "every ffmpeg command line planned or run"},
@@ -582,8 +619,9 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
582
619
  if name == "check":
583
620
  extra = {"platform": {"type": "string"}, "ok": {"type": "boolean"}, "failed": {"type": "integer"}, "warnings": {"type": "integer"},
584
621
  "input_identity": {"type": "object", "description": "2.24: {size, mtime_ns} of the file graded, the same shape as a writing tool's output_identity"},
622
+ "levels": {"type": "object", "description": "--levels only: {standard, reference, bit_depth, legal {y, c}: inclusive legal code ranges at the file's depth, window_seconds, frames, out_of_range_pct {y, u, v, chroma}: share of samples outside them (chroma: Cb and Cr samples together), brng_pct (signalstats BRNG: pixels with any component outside the fixed BT.601/709 nominal range), worst_frame {time, y_pct, chroma_pct}, min {y, u, v}, max {y, u, v}, windows: the same per --levels-window seconds ({start, end, ...})}"},
585
623
  "notes": {"type": "array", "items": {"type": "string"}, "description": "present when no --platform was named (youtube was assumed and judgement rows are WARN), or when --compare paired a different number of frames than the reference has"},
586
- "checks": {"type": "array", "items": {"type": "object", "properties": {"check": {"type": "string", "description": "row name; --content adds black, frozen and silence; --compare adds similarity; --expect-identity adds identity; audio FAILs when the track is present but silent (peak <= -50 dBFS)"}, "status": {"enum": ["PASS", "WARN", "FAIL"]}, "value": {}, "expected": {}, "fix": {"type": "string"}, "kind": {"enum": ["format", "judgement"]}}}}}
624
+ "checks": {"type": "array", "items": {"type": "object", "properties": {"check": {"type": "string", "description": "row name; --content adds black, frozen and silence; --compare adds similarity; --levels adds legal levels; --expect-identity adds identity; audio FAILs when the track is present but silent (peak <= -50 dBFS)"}, "status": {"enum": ["PASS", "WARN", "FAIL"]}, "value": {}, "expected": {}, "fix": {"type": "string"}, "kind": {"enum": ["format", "judgement"]}}}}}
587
625
  elif name == "caption":
588
626
  extra = {"transcription": {"type": "object", "description": "with --transcribe: {engine, model, language, routing (why auto chose it), detected_language when auto detected it}; when auto ran the English-only Parakeet model on English assumed, notes says so"},
589
627
  "caption": {"type": "object", "description": "cue layout: shifted / wrapped / rebalanced / split / extended / dropped counts, plus wrap ('phrase' or 'measured'), phrase_breaks (1.16), broken_inside_word -- atoms hard-sliced at the column edge because they did not fit alone even at the size floor (1.18.4) -- and overlong -- now residual: a single character alone wider than the column. Burn mode: cues_burned -- non-blank cues that overlap [0, duration] and are drawn -- and cues_outside -- non-blank cues wholly outside it (2.2.6); a burn with no visible cue is refused, kind input. --ass: ass_malformed -- Dialogue lines that could not be read and are not counted -- and with --offset ass_shifted / ass_unshifted -- lines moved, and lines whose timestamps could not be read, copied as they were (each count above 0 is also a diagnostics.warnings entry)"},
@@ -674,7 +712,8 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
674
712
  "sdr_path": {"enum": ["tonemap", "gamut"], "description": "--to-sdr only: tonemap for PQ / HLG / Dolby Vision (and --force on an untagged file); gamut for BT.2020 primaries on an SDR transfer, converted to BT.709 without a tone map"},
675
713
  "notes": {"type": "array", "items": {"type": "string"}, "description": "--to-sdr: which path was taken and why; --retag: why the video was re-encoded (the stream copy failed, or ffprobe still read the old tags on it)"},
676
714
  "reencoded": {"type": "boolean", "description": "--retag: true when the stream copy could not carry the tags and the video was re-encoded with them on its frames"},
677
- "measurements": {"type": "object", "description": "--correct only: signalstats levels of input and output"}}
715
+ "measurements": {"type": "object", "description": "--correct only: signalstats levels of input and output"},
716
+ "legalize": {"type": "object", "description": "--legalize only: {standard, reference, legal {y, c}, output}: output is check.py --levels' levels document for the written file (window 0); its verification step legal_levels fails when one frame keeps more than 1 % of its luma or chroma samples out of range after the encode"}}
678
717
  elif name == "loop":
679
718
  extra = {"boomerang": {"type": "boolean", "description": "--boomerang: frames 0..N-1 then N-2..1, repeated, so neither turnaround frame is shown twice"},
680
719
  "crossfade": {"type": "number", "description": "--crossfade only: the fade used, in seconds, rounded to whole frames; one repeat is the clip minus this"},
@@ -683,7 +722,8 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
683
722
  "notes": {"type": "array", "items": {"type": "string"}, "description": "--boomerang on a source with audio: the audio was dropped"}}
684
723
  elif name == "cut":
685
724
  extra = {"expected_duration": {"type": "number", "description": "seconds requested"},
686
- "mode": {"enum": ["copy", "hybrid", "accurate"], "description": "copy: nothing re-encoded at any stage, the join included; accurate: --accurate was asked for or forced; hybrid: asked for lossless but a segment or the join re-encoded anyway (reencode_reason says why)"},
725
+ "mode": {"enum": ["copy", "hybrid", "accurate", "smart"], "description": "copy: nothing re-encoded at any stage, the join included; accurate: --accurate was asked for or forced (or --smart fell back, reason in smart.reason); hybrid: asked for lossless but a segment or the join re-encoded anyway (reencode_reason says why); smart: --smart re-encoded only the partial GOPs at the cut points and copied the rest, verified"},
726
+ "smart": {"type": "object", "description": "--smart only: {applied: true, parts [{segment, kind: reencode|copy, start, end, frames}], reencoded_frames, copied_frames, matched (the source parameters the re-encoded parts reproduce: codec_name, profile, level, pix_fmt, width, height, sample_aspect_ratio, has_b_frames, colour tags), encoder (the libx264 arguments), checks {frames, grid_worst_seconds, copied_packets_equal, frames_decoded_equal, time_base}, time_base}; or {applied: false, reason, reason_code} when the cut could not be made or did not verify and the whole cut was re-encoded instead (reason_code: no_video, pending_input, vfr, codec, hold, params_unreadable, container, pix_fmt, profile, interlaced, reorder_depth, frame_rate, hdr, packets, no_frames, segments, av_offset, open_gop, seek, encode, copy_count, encode_count, params_mismatch, frames, decode, time_base)"},
687
727
  "requested_start": {"type": ["number", "null"], "description": "one segment: the requested start, clamped to the media's duration (null for --segments)"},
688
728
  "requested_end": {"type": ["number", "null"], "description": "one segment: the requested end, clamped to the media's duration (null for --segments)"},
689
729
  "requested_segments": {"type": ["array", "null"], "description": "--segments: [[start, end], ...] requested, clamped to the media's duration (null for one segment)"},
@@ -701,8 +741,8 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
701
741
  "keyframe_snapped": {"type": ["boolean", "null"], "description": "since 3.0.0 equal to start_snapped: a copied start really moved (2.x: precision was packet, the run a stream copy end to end)"},
702
742
  "start_snapped": {"type": ["boolean", "null"], "description": "measured: true when a copied segment's picture starts more than one frame from its requested start (the keyframe the copy's seek lands on, measured with the seek ffmpeg makes); false when every start is where asked (an MP4 edit list hides the pre-roll, the start is a keyframe, an audio-only copy, or everything re-encoded); null when a copied start was not measured (--dry-run)"},
703
743
  "reencoded": {"type": "boolean"},
704
- "reencode_reason": {"type": "array", "items": {"enum": ["requested", "codec", "vfr", "vfr_inconclusive", "pcm_container", "copy_failed", "stream_lost", "tolerance", "concat_fallback"]},
705
- "description": "why anything was re-encoded, distinct values in first-seen order ([] when nothing was): requested (--accurate), codec (--codec), vfr (a variable-frame-rate source forced --accurate), vfr_inconclusive (its frame timing could not be measured), pcm_container (compressed audio into a .wav), copy_failed (the stream copy errored), stream_lost (the copy wrote no video or no audio, as an MPEG-TS copy with no keyframe in its window does), tolerance (a keyframe snap past --tolerance), concat_fallback (--segments parts could not be joined by stream copy)"},
744
+ "reencode_reason": {"type": "array", "items": {"enum": ["requested", "codec", "vfr", "vfr_inconclusive", "pcm_container", "copy_failed", "stream_lost", "tolerance", "concat_fallback", "smart", "smart_fallback"]},
745
+ "description": "why anything was re-encoded, distinct values in first-seen order ([] when nothing was): smart (--smart re-encoded the partial GOPs at the cut points), smart_fallback (--smart could not be made or verified: the whole cut was re-encoded, smart.reason says why), requested (--accurate), codec (--codec), vfr (a variable-frame-rate source forced --accurate), vfr_inconclusive (its frame timing could not be measured), pcm_container (compressed audio into a .wav), copy_failed (the stream copy errored), stream_lost (the copy wrote no video or no audio, as an MPEG-TS copy with no keyframe in its window does), tolerance (a keyframe snap past --tolerance), concat_fallback (--segments parts could not be joined by stream copy)"},
706
746
  "segment_precision": {"type": ["array", "null"], "items": {"enum": ["packet", "sample", "codec_frame", "frame"]},
707
747
  "description": "--segments only: each segment's precision in order; least_exact_precision is the least exact of them (null for a single segment)"},
708
748
  "edit_list": {"type": "boolean", "description": "a single MP4/MOV stream copy kept the edit list it wrote instead of -avoid_negative_ts make_zero, so the keyframe's pre-roll is stored but hidden and the picture and the sound start at the requested time (the default since 3.0.0; false under --no-edit-list and for every other path)"},
@@ -737,6 +777,8 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
737
777
  "audio": {"type": "object", "description": "what the mix was built from: voice (null | light | medium | strong), stereo_widen, effects/effects_volume, and with --music the music_volume plus duck (null when --duck was not given, else the threshold in dB and linear, ratio, attack_ms, release_ms, amount_db actually used)"}}
738
778
  if name in ("overlay", "graphics", "caption", "grid"):
739
779
  extra["font_used"] = FONT_USED_SCHEMA
780
+ if name in ALPHA_LINE_TOOLS:
781
+ extra.update(alpha_result_schema(name))
740
782
  if name in NON_AV_STREAM_TOOLS and "dropped_non_av_streams" not in extra:
741
783
  extra["dropped_non_av_streams"] = {"type": "boolean", "description": "true when the source's subtitle or data streams are not in the output: either the run leaves them out by design (a path whose output they would no longer match, such as speedramp.py's, join.py's or freeze.py's), or they could not be copied and the encode ran again without them (then a non_av_streams_dropped fallback says why); export.py's non_av_streams lists them"}
742
784
  if name == "cropdetect":
@@ -850,7 +892,7 @@ def _parse_ff_list(flag: str, text: str) -> List[str]:
850
892
  m = _FILTER_ROW.match(line)
851
893
  if m:
852
894
  names.append(m.group(1))
853
- elif flag == "-encoders":
895
+ elif flag in ("-encoders", "-decoders"):
854
896
  lines = text.splitlines()
855
897
  sep = next((i for i, l in enumerate(lines) if _LIST_SEPARATOR.match(l)), None)
856
898
  rows = lines[sep + 1:] if sep is not None else lines
@@ -1233,6 +1275,7 @@ def required_capabilities() -> Dict[str, List[str]]:
1233
1275
  req.update(meta["required"])
1234
1276
  opt.update(o["capability"] for o in meta["optional"] if o["capability"] != "delegated")
1235
1277
  opt.update(o["capability"] for o in CODEC_CAPS)
1278
+ opt.update(o["capability"] for o in ALPHA_LINE_OPTIONAL)
1236
1279
  opt -= req
1237
1280
  return {"required": sorted(req), "optional": sorted(opt)}
1238
1281
 
@@ -1278,6 +1321,7 @@ def doctor() -> Dict[str, Any]:
1278
1321
  "encoders": _ff_listing("ffmpeg", "-encoders"),
1279
1322
  "filters": _ff_listing("ffmpeg", "-filters"),
1280
1323
  "bsfs": _ff_listing("ffmpeg", "-bsfs"),
1324
+ "decoders": _ff_listing("ffmpeg", "-decoders"),
1281
1325
  }
1282
1326
  sets = {k: set(v["names"]) for k, v in listings.items()}
1283
1327
  state: Dict[str, str] = {} # capability -> available | missing | unknown
@@ -1301,6 +1345,8 @@ def doctor() -> Dict[str, Any]:
1301
1345
  state[cap] = "available" if _exe("ffprobe") else "missing"
1302
1346
  elif cap.startswith("encoder:"):
1303
1347
  state[cap] = _from("encoders", cap[8:])
1348
+ elif cap.startswith("decoder:"):
1349
+ state[cap] = _from("decoders", cap[8:])
1304
1350
  elif cap == "filter:drawtext":
1305
1351
  listing_state = _from("filters", "drawtext")
1306
1352
  if listing_state == "available":
@@ -1461,6 +1507,8 @@ def _capability_fix_hint(cap: str, drawtext_crashed: bool = False) -> str:
1461
1507
  full_hint = "install/build ffmpeg with it enabled"
1462
1508
  if cap.startswith("encoder:"):
1463
1509
  return f"this ffmpeg build has no {cap[8:]} encoder; {full_hint}"
1510
+ if cap.startswith("decoder:"):
1511
+ return f"this ffmpeg build has no {cap[8:]} decoder; {full_hint}"
1464
1512
  if cap == "filter:drawtext" and not drawtext_crashed:
1465
1513
  return f"this ffmpeg build has no drawtext filter (no freetype); {full_hint}"
1466
1514
  if cap == "filter:drawtext":
@@ -1626,7 +1674,8 @@ def tool_spec(name: str, version: str) -> Dict[str, Any]:
1626
1674
  # 1.20.0: SKILL.md's own request-table rows for this tool, machine-readable
1627
1675
  "examples": skill_examples().get(name, []),
1628
1676
  "role": meta["role"],
1629
- "capabilities": {"required": list(meta["required"]), "optional": _merge_optional(meta["optional"], CODEC_CAPS if "codec" in schema["properties"] else [])},
1677
+ "capabilities": {"required": list(meta["required"]), "optional": _merge_optional(meta["optional"], (CODEC_CAPS if "codec" in schema["properties"] else [])
1678
+ + (ALPHA_LINE_OPTIONAL if name in ALPHA_LINE_TOOLS else []))},
1630
1679
  "inputs": list(meta["inputs"]),
1631
1680
  "outputs": list(meta["outputs"]),
1632
1681
  "input_schema": schema,
@@ -1887,7 +1936,7 @@ def build(detect: bool = True) -> Dict[str, Any]:
1887
1936
  version = skill_version()
1888
1937
  tools = [tool_spec(n, version) for n in public_tools()]
1889
1938
  wanted = required_capabilities()
1890
- caps: Dict[str, Any] = {"required": wanted["required"], "optional": wanted["optional"], "naming": "ffmpeg | ffprobe | encoder:<name> | filter:<name> | bsf:<name> | external:whisper | external:parakeet"}
1939
+ caps: Dict[str, Any] = {"required": wanted["required"], "optional": wanted["optional"], "naming": "ffmpeg | ffprobe | encoder:<name> | decoder:<name> | filter:<name> | bsf:<name> | external:whisper | external:parakeet"}
1891
1940
  if detect:
1892
1941
  d = doctor()
1893
1942
  caps.update({"available": d["available"], "missing": d["missing"], "missing_optional": d["missing_optional"],