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.
- package/README.md +12 -9
- package/SKILL.md +5 -0
- package/docs/contract.md +20 -7
- package/package.json +1 -1
- package/references/gotchas.md +15 -0
- package/references/scripts.md +109 -6
- package/scripts/_common/__init__.py +4 -4
- package/scripts/_common/alpha.py +465 -0
- package/scripts/_common/decision.py +19 -7
- package/scripts/_common/probe.py +175 -9
- package/scripts/_common/runner.py +3 -2
- package/scripts/_contract.py +63 -14
- package/scripts/_smartcut.py +430 -0
- package/scripts/background.py +33 -6
- package/scripts/check.py +64 -1
- package/scripts/color.py +147 -30
- package/scripts/cut.py +94 -6
- package/scripts/export.py +12 -81
- package/scripts/graphics.py +51 -10
- package/scripts/overlay.py +49 -6
- package/scripts/probe.py +47 -1
package/scripts/_common/probe.py
CHANGED
|
@@ -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
|
|
482
|
-
"""
|
|
483
|
-
-read_intervals so a long file is not scanned end
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
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
|
-
|
|
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
|
package/scripts/_contract.py
CHANGED
|
@@ -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
|
|
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"],
|