ffmpeg-skill 3.2.2 → 3.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -10
- package/SKILL.md +5 -0
- package/bin/install.js +11 -1
- package/docs/contract.md +33 -7
- package/package.json +2 -1
- package/references/gotchas.md +22 -0
- package/references/scripts.md +109 -6
- package/scripts/_common/__init__.py +4 -4
- package/scripts/_common/alpha.py +465 -0
- package/scripts/_common/color.py +18 -3
- package/scripts/_common/decision.py +19 -7
- package/scripts/_common/emit.py +71 -0
- package/scripts/_common/probe.py +196 -9
- package/scripts/_common/runner.py +11 -5
- package/scripts/_contract.py +65 -14
- package/scripts/_smartcut.py +430 -0
- package/scripts/background.py +33 -6
- package/scripts/caption.py +6 -1
- 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/stabilize.py +14 -3
package/scripts/_common/emit.py
CHANGED
|
@@ -163,6 +163,74 @@ def _report_dropped_hdr10_metadata(meta: Dict[str, Any], ctx: "Context") -> None
|
|
|
163
163
|
ctx.notes.append(note_line)
|
|
164
164
|
|
|
165
165
|
|
|
166
|
+
# source_carry: the items compared, in report order. The first group is content a viewer or
|
|
167
|
+
# listener notices missing and raises the warning; the second is tags, listed but never warned
|
|
168
|
+
# about (every camera file has a creation_time, so a warning for it would fire on every run).
|
|
169
|
+
CARRY_CONTENT = ("subtitles", "chapters", "cover_art", "audio_channels")
|
|
170
|
+
CARRY_TAGS = ("creation_time", "audio_language", "timecode")
|
|
171
|
+
_CARRY_NAMES = {"subtitles": "subtitle track", "chapters": "chapters", "cover_art": "cover picture",
|
|
172
|
+
"audio_channels": "audio channels"}
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def source_carry(output: Optional[str], ctx: "Context", extra: Dict[str, Any]) -> "Optional[Dict[str, List[str]]]":
|
|
176
|
+
"""What the first input carried besides its picture and sound, and whether the written file
|
|
177
|
+
still has it (#401): {"kept": [...], "dropped": [...]}, or None when the source had none of
|
|
178
|
+
it or the two files are not comparable. Measured on the written file, so the tool, encoder or
|
|
179
|
+
FFmpeg version that dropped it does not matter. A report, not a check: `verified` is not
|
|
180
|
+
touched. Not compared: two or more picture or sound sources (which one's items the output
|
|
181
|
+
should carry is the tool's choice), an output that is not a media file, and items bound to a
|
|
182
|
+
stream the output does not have at all (a sound-only extract has no subtitle track or
|
|
183
|
+
timecode to lose; a picture-only output has no audio channels). Audio channels are an item
|
|
184
|
+
only for a surround source (more than two)."""
|
|
185
|
+
from .probe import MEDIA_EXT, _STILL_EXT, carry_inventory
|
|
186
|
+
first = getattr(STATE, "first_input", None)
|
|
187
|
+
if not first or not output:
|
|
188
|
+
return None
|
|
189
|
+
# a tool that reads its own output back (cut.py measures the cut it wrote) probed it as an
|
|
190
|
+
# input too: that is not a second source
|
|
191
|
+
own = os.path.realpath(output)
|
|
192
|
+
if any(len(set(getattr(STATE, name, None) or ()) - {own}) > 1 for name in ("video_inputs", "audio_inputs")):
|
|
193
|
+
return None
|
|
194
|
+
ext = os.path.splitext(output)[1].lower()
|
|
195
|
+
if ext not in MEDIA_EXT or ext in _STILL_EXT:
|
|
196
|
+
return None # a still, a contact sheet or an animated GIF holds none of these
|
|
197
|
+
src, out = carry_inventory(first[0]), carry_inventory(output)
|
|
198
|
+
if not src or not out or os.path.realpath(first[0]) == os.path.realpath(output):
|
|
199
|
+
return None
|
|
200
|
+
kept: List[str] = []
|
|
201
|
+
dropped: List[str] = []
|
|
202
|
+
for item in CARRY_CONTENT + CARRY_TAGS:
|
|
203
|
+
if not src.get(item):
|
|
204
|
+
continue
|
|
205
|
+
if item == "audio_channels" and src[item] <= 2:
|
|
206
|
+
continue # mono and stereo are what every tool writes; only a surround layout can be folded down
|
|
207
|
+
if item in ("subtitles", "timecode") and src["has_video"] and not out["has_video"]:
|
|
208
|
+
continue
|
|
209
|
+
if item in ("audio_channels", "audio_language") and not out["has_audio"]:
|
|
210
|
+
continue
|
|
211
|
+
(kept if out.get(item, 0) >= src[item] else dropped).append(item)
|
|
212
|
+
if not kept and not dropped:
|
|
213
|
+
return None
|
|
214
|
+
said = set()
|
|
215
|
+
if extra.get("dropped_non_av_streams") or any(w.get("code") == "non_av_streams_dropped" for w in ctx.fallbacks):
|
|
216
|
+
said.add("subtitles") # the tool's own field already says the track went
|
|
217
|
+
loud = [i for i in dropped if i in CARRY_CONTENT and i not in said]
|
|
218
|
+
if loud:
|
|
219
|
+
parts = []
|
|
220
|
+
for i in loud:
|
|
221
|
+
if i == "audio_channels":
|
|
222
|
+
parts.append(f"{src[i] - out.get(i, 0)} of its {src[i]} audio channels")
|
|
223
|
+
else:
|
|
224
|
+
parts.append("its " + _CARRY_NAMES[i])
|
|
225
|
+
warn("the source carried " + ", ".join(parts) + " that this output does not have "
|
|
226
|
+
"(source_carry lists everything kept and dropped)",
|
|
227
|
+
"source_items_dropped", ctx=ctx, dropped=loud)
|
|
228
|
+
note_line = ctx.warnings[-1]["message"] if ctx.warnings else None
|
|
229
|
+
if note_line and note_line not in ctx.notes:
|
|
230
|
+
ctx.notes.append(note_line)
|
|
231
|
+
return {"kept": kept, "dropped": dropped}
|
|
232
|
+
|
|
233
|
+
|
|
166
234
|
def refuse(kind: str, reason: str, msg: str, *, hint: Optional[str] = None, code: int = 1,
|
|
167
235
|
ctx: "Optional[Context]" = None, extra: "Optional[Dict[str, Any]]" = None, **details: Any) -> "None":
|
|
168
236
|
"""die() for a refusal a caller has to act on: `error.reason` is a stable snake_case code for
|
|
@@ -710,6 +778,9 @@ def emit(output: Optional[str], *, ctx: "Optional[Context]" = None, **extra: Any
|
|
|
710
778
|
meta = verify_output(output) # dies (status: failed, kind: output) if the artifact is unusable
|
|
711
779
|
_report_dropped_audio_tracks(meta, ctx)
|
|
712
780
|
_report_dropped_hdr10_metadata(meta, ctx)
|
|
781
|
+
carry = source_carry(output, ctx, extra)
|
|
782
|
+
if carry and "source_carry" not in extra:
|
|
783
|
+
extra["source_carry"] = carry
|
|
713
784
|
if ctx.json:
|
|
714
785
|
doc: Dict[str, Any] = {"status": "completed", "output": output, "dry_run": ctx.dry_run, "commands": list(ctx.commands)}
|
|
715
786
|
if meta:
|
package/scripts/_common/probe.py
CHANGED
|
@@ -402,9 +402,30 @@ def probe(path: str, role: str = "input") -> Dict[str, Any]:
|
|
|
402
402
|
(out.get("video") or {}).get("hdr10_static"))
|
|
403
403
|
if role == "input" and out["timecode"].get("value") and getattr(STATE, "source_timecode", None) is None:
|
|
404
404
|
STATE.source_timecode = (out["timecode"]["value"], duration)
|
|
405
|
+
# what a source carries besides its picture and sound, for emit()'s source_carry comparison:
|
|
406
|
+
# kept beside the probe, not in it, so no probe document changes
|
|
407
|
+
lang = (audio.get("tags") or {}).get("language") if audio else None
|
|
408
|
+
_CARRY[os.path.realpath(path)] = {
|
|
409
|
+
"subtitles": len(subs),
|
|
410
|
+
"chapters": len(raw.get("chapters") or []),
|
|
411
|
+
"cover_art": sum(1 for s in streams if s.get("codec_type") == "video" and s.get("disposition", {}).get("attached_pic", 0)),
|
|
412
|
+
"creation_time": 1 if any(k.lower() == "creation_time" for k in (fmt.get("tags") or {})) else 0,
|
|
413
|
+
"audio_language": 1 if lang and lang.lower() != "und" else 0,
|
|
414
|
+
"timecode": 1 if out["timecode"].get("value") else 0,
|
|
415
|
+
"audio_channels": (_to_int(audio.get("channels")) or 0) if audio else 0,
|
|
416
|
+
"has_video": bool(video), "has_audio": bool(audio),
|
|
417
|
+
}
|
|
405
418
|
return out
|
|
406
419
|
|
|
407
420
|
|
|
421
|
+
_CARRY: Dict[str, Dict[str, Any]] = {}
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def carry_inventory(path: str) -> "Optional[Dict[str, Any]]":
|
|
425
|
+
"""The carry-over items probe() last read off `path` (see emit()'s source_carry), or None."""
|
|
426
|
+
return _CARRY.get(os.path.realpath(path)) if path else None
|
|
427
|
+
|
|
428
|
+
|
|
408
429
|
def _hdr10_static(ffprobe: str, path: str, stream: Dict[str, Any]) -> "Optional[Dict[str, Any]]":
|
|
409
430
|
"""A PQ stream's mastering display and content light level, from the stream (an mdcv/clli box,
|
|
410
431
|
a Matroska colour element) or else its first frame (the HEVC SEI / AV1 metadata OBU): one
|
|
@@ -478,25 +499,35 @@ def _bit_depth(pix_fmt: Optional[str]) -> int:
|
|
|
478
499
|
return n if 8 <= n <= 16 else 8
|
|
479
500
|
|
|
480
501
|
|
|
481
|
-
def
|
|
482
|
-
"""
|
|
483
|
-
-read_intervals so a long file is not scanned end
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
502
|
+
def keyframe_times(path: str, lo: float, hi: float, stream: str = "v:0") -> Optional[List[float]]:
|
|
503
|
+
"""Keyframe timestamps of `stream` read from lo to hi seconds (file time), ascending and as
|
|
504
|
+
ffprobe prints them (microseconds). Read with -read_intervals so a long file is not scanned end
|
|
505
|
+
to end; the read starts at the keyframe at or before lo, so the first may lie before it. None
|
|
506
|
+
when ffprobe fails (unmeasured, not "no keyframes")."""
|
|
507
|
+
# an interval's end is exclusive (6.1: a keyframe at exactly hi is not read), so read a
|
|
508
|
+
# little past it and drop what lies beyond hi
|
|
509
|
+
proc = run([require_tool("ffprobe"), "-v", "error", "-select_streams", stream, "-skip_frame", "nokey",
|
|
510
|
+
"-read_intervals", f"{max(0.0, lo):.6f}%{hi + 0.01:.6f}", "-show_entries", "frame=pts_time",
|
|
488
511
|
"-of", "csv=p=0", path], quiet=True, check=False)
|
|
489
512
|
if proc.returncode != 0:
|
|
490
|
-
return
|
|
513
|
+
return None
|
|
491
514
|
out: List[float] = []
|
|
492
515
|
for line in proc.stdout.splitlines():
|
|
493
516
|
try:
|
|
494
|
-
|
|
517
|
+
k = float(line.strip().rstrip(","))
|
|
495
518
|
except ValueError:
|
|
496
519
|
continue
|
|
520
|
+
if k <= hi + 1e-6:
|
|
521
|
+
out.append(k)
|
|
497
522
|
return sorted(set(out))
|
|
498
523
|
|
|
499
524
|
|
|
525
|
+
def keyframes_near(path: str, t: float, window: float = 5.0) -> List[float]:
|
|
526
|
+
"""Video keyframe timestamps within +-window seconds of t (to the ms), ascending; empty when
|
|
527
|
+
ffprobe cannot say (cut.py's best-effort hints; probe.py --keyframes-near uses keyframe_times)."""
|
|
528
|
+
return sorted(set(round(k, 3) for k in keyframe_times(path, t - window, t + window) or []))
|
|
529
|
+
|
|
530
|
+
|
|
500
531
|
# frame timing: is a stream's frame rate really variable? (cut.py's VFR guard)
|
|
501
532
|
TIMING_WINDOWS = 5 # sampled windows per file, fewer on a short one
|
|
502
533
|
TIMING_WINDOW_SECONDS = 6
|
|
@@ -609,6 +640,162 @@ def measured_level_dbfs(path: str, seconds: Optional[float] = 120.0,
|
|
|
609
640
|
return {"mean_dbfs": float(m_mean.group(1)), "peak_dbfs": float(m_max.group(1))}
|
|
610
641
|
|
|
611
642
|
|
|
643
|
+
# Legal signal range of limited-range Y'CbCr (check.py --levels, color.py --legalize). Each standard
|
|
644
|
+
# is stated in percent of the nominal swing, so the code limits follow from the bit depth: 0 % luma is
|
|
645
|
+
# code 16 << (depth - 8), 100 % is 235 << (depth - 8); chroma's nominal swing is 16..240 (scaled the
|
|
646
|
+
# same way) around the midpoint. A sample is legal when its code lies inside [lo, hi] inclusive.
|
|
647
|
+
# bt709 ITU-R BT.709 nominal range: luma 0..100 %, chroma 16..240 (8-bit) -- no excursion.
|
|
648
|
+
# ebu-r103 EBU R 103 tolerance: luma -1 %..103 %, RGB components -5 %..105 %. Chroma is held to the
|
|
649
|
+
# widest Cb/Cr an RGB triple inside -5 %..105 % can produce (|C| <= 0.55 of the RGB span,
|
|
650
|
+
# i.e. 8-bit 5..251); RGB gamut itself is not measured here (a per-plane Y'CbCr count).
|
|
651
|
+
LEGAL_RANGES: Dict[str, Dict[str, Any]] = {
|
|
652
|
+
"bt709": {"luma_pct": (0.0, 100.0), "chroma_half_swing": 0.5,
|
|
653
|
+
"reference": "ITU-R BT.709 nominal range: luma 16-235, chroma 16-240 (8-bit; 10-bit 64-940 / 64-960)"},
|
|
654
|
+
"ebu-r103": {"luma_pct": (-1.0, 103.0), "chroma_half_swing": 0.55,
|
|
655
|
+
"reference": "EBU R 103 tolerance: luma -1 %..103 % (8-bit 14-241), chroma as far as RGB -5 %..105 % "
|
|
656
|
+
"reaches (8-bit 5-251)"},
|
|
657
|
+
}
|
|
658
|
+
LEGAL_RANGE_CHOICES = sorted(LEGAL_RANGES)
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
def legal_limits(standard: str, bit_depth: int = 8) -> Dict[str, Tuple[int, int]]:
|
|
662
|
+
"""{"y": (lo, hi), "c": (lo, hi)}: the inclusive legal code range of a limited-range sample at
|
|
663
|
+
`bit_depth`. A limit that falls between two codes is rounded inward (the code past it is out)."""
|
|
664
|
+
spec = LEGAL_RANGES[standard]
|
|
665
|
+
scale = 1 << (max(8, int(bit_depth)) - 8)
|
|
666
|
+
black, white = 16 * scale, 235 * scale
|
|
667
|
+
span = white - black
|
|
668
|
+
p_lo, p_hi = spec["luma_pct"]
|
|
669
|
+
y = (math.ceil(black + span * p_lo / 100.0 - 1e-9), math.floor(white + span * (p_hi - 100.0) / 100.0 + 1e-9))
|
|
670
|
+
mid, cspan = 128 * scale, 224 * scale
|
|
671
|
+
half = spec["chroma_half_swing"] * cspan
|
|
672
|
+
c = (max(0, math.ceil(mid - half - 1e-9)), min((256 * scale) - 1, math.floor(mid + half + 1e-9)))
|
|
673
|
+
return {"y": y, "c": c}
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
_COMPONENT_DEPTHS: Dict[str, int] = {}
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
def component_depth(pix_fmt: Optional[str]) -> int:
|
|
680
|
+
"""Bits per luma sample of `pix_fmt`, from ffmpeg's own pixel-format table (`-pix_fmts`,
|
|
681
|
+
BIT_DEPTHS column): the depth the planes really hold, which the name does not always say --
|
|
682
|
+
nv12 is 8-bit 4:2:0 (the 12 is bits per pixel), p010le is 10-bit. Falls back to _bit_depth()
|
|
683
|
+
when ffmpeg does not list the format."""
|
|
684
|
+
name = str(pix_fmt or "")
|
|
685
|
+
if not _COMPONENT_DEPTHS:
|
|
686
|
+
proc = run_analysis([require_tool("ffmpeg"), "-hide_banner", "-pix_fmts"], check=False)
|
|
687
|
+
for line in (proc.stdout or "").splitlines():
|
|
688
|
+
cols = line.split()
|
|
689
|
+
if len(cols) == 5 and re.fullmatch(r"\d+(?:-\d+)*", cols[4]):
|
|
690
|
+
_COMPONENT_DEPTHS[cols[1]] = int(cols[4].split("-")[0])
|
|
691
|
+
return _COMPONENT_DEPTHS.get(name) or _bit_depth(name)
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
def _metadata_frames(text: str, keys: Optional[frozenset] = None) -> List[Tuple[float, Dict[str, float]]]:
|
|
695
|
+
"""metadata=print's output: one (pts_time, {key: value}) per `frame:` block, signalstats keys only
|
|
696
|
+
(only those in `keys` when given: signalstats prints ~25 per frame and an hour is 100k frames)."""
|
|
697
|
+
frames: List[Tuple[float, Dict[str, float]]] = []
|
|
698
|
+
for line in text.splitlines():
|
|
699
|
+
if line.startswith("frame:"):
|
|
700
|
+
m = re.search(r"pts_time:\s*(-?[0-9.]+|nan)", line)
|
|
701
|
+
t = float(m.group(1)) if m and m.group(1) != "nan" else float("nan")
|
|
702
|
+
frames.append((t, {}))
|
|
703
|
+
elif "lavfi.signalstats." in line and "=" in line and frames:
|
|
704
|
+
key, val = line.split("lavfi.signalstats.", 1)[1].split("=", 1)
|
|
705
|
+
key = key.strip()
|
|
706
|
+
if keys is not None and key not in keys:
|
|
707
|
+
continue
|
|
708
|
+
try:
|
|
709
|
+
frames[-1][1][key] = float(val)
|
|
710
|
+
except ValueError:
|
|
711
|
+
pass
|
|
712
|
+
return frames
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
# the signalstats keys measure_signal_range reads: the frame's own extremes and BRNG, and the mask's means
|
|
716
|
+
_OWN_KEYS = frozenset({"BRNG", "YMIN", "YMAX", "UMIN", "UMAX", "VMIN", "VMAX"})
|
|
717
|
+
_MASK_KEYS = frozenset({"YAVG", "UAVG", "VAVG"})
|
|
718
|
+
|
|
719
|
+
|
|
720
|
+
def measure_signal_range(path: str, standard: str, window: float = 10.0,
|
|
721
|
+
bit_depth: Optional[int] = None, pix_fmt: Optional[str] = None) -> Optional[Dict[str, Any]]:
|
|
722
|
+
"""Share of luma and chroma samples outside `standard`'s legal range, per clip and per `window`
|
|
723
|
+
seconds, plus min/max Y/U/V, from one decode of every frame of the first video stream.
|
|
724
|
+
|
|
725
|
+
The count is exact, not sampled from a histogram: each plane goes through lutyuv, which maps an
|
|
726
|
+
out-of-range code to the depth's top code (255 at 8 bits) and every other code to 0, and
|
|
727
|
+
signalstats' mean of that mask is the top code x the out-of-range share of that plane in that frame. Chroma planes are
|
|
728
|
+
counted at their own (subsampled) resolution, so "chroma" is the share of Cb and Cr samples. A
|
|
729
|
+
second signalstats ahead of the mask reads the frame's own MIN/MAX and BRNG (the share of pixels
|
|
730
|
+
with any component outside the BT.601/709 nominal range, signalstats' fixed limits). Every frame
|
|
731
|
+
holds the same number of samples, so a clip's share is the mean of its frames' shares.
|
|
732
|
+
|
|
733
|
+
The limits and the mask use `bit_depth`, or else the depth of `pix_fmt`'s planes
|
|
734
|
+
(component_depth(): nv12 is 8-bit, not 12). None when the measurement is not whole: nothing
|
|
735
|
+
decoded, ffmpeg failed part-way (a share from the frames read before the failure would grade a
|
|
736
|
+
file nobody measured to the end), or the two passes counted different frames."""
|
|
737
|
+
if standard not in LEGAL_RANGES:
|
|
738
|
+
die(f"unknown legal range {standard!r}; choose from {', '.join(LEGAL_RANGE_CHOICES)}")
|
|
739
|
+
import tempfile
|
|
740
|
+
from _common.decision import escape_filter_path
|
|
741
|
+
ffmpeg = require_tool("ffmpeg")
|
|
742
|
+
depth = int(bit_depth or component_depth(pix_fmt))
|
|
743
|
+
lim = legal_limits(standard, depth)
|
|
744
|
+
(ylo, yhi), (clo, chi) = lim["y"], lim["c"]
|
|
745
|
+
top = (1 << depth) - 1 # a literal: lutyuv's own maxval is 235 for limited-range luma, 240 for chroma
|
|
746
|
+
out = str(top) + "*(lt(val\\,{lo})+gt(val\\,{hi}))"
|
|
747
|
+
mask = (f"lutyuv=y='{out.format(lo=ylo, hi=yhi)}':u='{out.format(lo=clo, hi=chi)}'"
|
|
748
|
+
f":v='{out.format(lo=clo, hi=chi)}'")
|
|
749
|
+
with tempfile.TemporaryDirectory(prefix="ffskill_levels_") as tmp:
|
|
750
|
+
stats_file = os.path.join(tmp, "stats.txt")
|
|
751
|
+
vf = (f"signalstats=stat=brng,metadata=print:file={escape_filter_path(stats_file)},"
|
|
752
|
+
f"{mask},signalstats,metadata=print:file=-")
|
|
753
|
+
cmd = [ffmpeg, "-hide_banner", "-nostdin", "-i", path, "-map", "0:v:0", "-an", "-sn", "-dn", "-vf", vf, "-f", "null", "-"]
|
|
754
|
+
proc = run_analysis(cmd, check=False, record=True)
|
|
755
|
+
try:
|
|
756
|
+
with open(stats_file, encoding="utf-8", errors="replace") as f:
|
|
757
|
+
own = _metadata_frames(f.read(), _OWN_KEYS)
|
|
758
|
+
except OSError:
|
|
759
|
+
own = []
|
|
760
|
+
masks = _metadata_frames(proc.stdout or "", _MASK_KEYS)
|
|
761
|
+
if proc.returncode != 0 or not own or len(own) != len(masks):
|
|
762
|
+
return None
|
|
763
|
+
t0 = next((t for t, _ in own if t == t), 0.0)
|
|
764
|
+
rows: List[Dict[str, Any]] = []
|
|
765
|
+
for (t, s), (_, m) in zip(own, masks, strict=True):
|
|
766
|
+
y, u, v = (m.get(k, 0.0) / top * 100.0 for k in ("YAVG", "UAVG", "VAVG"))
|
|
767
|
+
rows.append({"t": (t - t0) if t == t else None, "y": y, "u": u, "v": v, "brng": s.get("BRNG", 0.0) * 100.0,
|
|
768
|
+
"min": {k: s.get(k.upper() + "MIN") for k in "yuv"}, "max": {k: s.get(k.upper() + "MAX") for k in "yuv"}})
|
|
769
|
+
|
|
770
|
+
def summary(frames: List[Dict[str, Any]]) -> Dict[str, Any]:
|
|
771
|
+
k = len(frames)
|
|
772
|
+
share = {p: sum(f[p] for f in frames) / k for p in ("y", "u", "v", "brng")}
|
|
773
|
+
worst = max(frames, key=lambda f: max(f["y"], (f["u"] + f["v"]) / 2))
|
|
774
|
+
def ext(which: str, fn: Any) -> Dict[str, Any]:
|
|
775
|
+
vals = {p: [f[which][p] for f in frames if f[which][p] is not None] for p in "yuv"}
|
|
776
|
+
return {p: (int(fn(v)) if v else None) for p, v in vals.items()}
|
|
777
|
+
return {"frames": k,
|
|
778
|
+
"out_of_range_pct": {"y": round(share["y"], 4), "u": round(share["u"], 4), "v": round(share["v"], 4),
|
|
779
|
+
"chroma": round((share["u"] + share["v"]) / 2, 4)},
|
|
780
|
+
"brng_pct": round(share["brng"], 4),
|
|
781
|
+
"worst_frame": {"time": None if worst["t"] is None else round(worst["t"], 3),
|
|
782
|
+
"y_pct": round(worst["y"], 4), "chroma_pct": round((worst["u"] + worst["v"]) / 2, 4)},
|
|
783
|
+
"min": ext("min", min), "max": ext("max", max)}
|
|
784
|
+
|
|
785
|
+
doc = {"standard": standard, "reference": LEGAL_RANGES[standard]["reference"], "bit_depth": depth,
|
|
786
|
+
"legal": {"y": list(lim["y"]), "c": list(lim["c"])}, "window_seconds": window, **summary(rows)}
|
|
787
|
+
windows: List[Dict[str, Any]] = []
|
|
788
|
+
if window and window > 0:
|
|
789
|
+
buckets: Dict[int, List[Dict[str, Any]]] = {}
|
|
790
|
+
for i, f in enumerate(rows):
|
|
791
|
+
buckets.setdefault(int((f["t"] if f["t"] is not None else i) // window), []).append(f)
|
|
792
|
+
for idx in sorted(buckets):
|
|
793
|
+
w = summary(buckets[idx])
|
|
794
|
+
windows.append({"start": round(idx * window, 3), "end": round((idx + 1) * window, 3), **w})
|
|
795
|
+
doc["windows"] = windows
|
|
796
|
+
return doc
|
|
797
|
+
|
|
798
|
+
|
|
612
799
|
def analyze_levels(path: str, seconds: float = 20.0) -> Dict[str, Any]:
|
|
613
800
|
"""Sample luma/saturation statistics (signalstats) and guess whether the picture is Log-encoded.
|
|
614
801
|
|
|
@@ -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
|
|
@@ -1066,9 +1067,14 @@ class _OutputLock:
|
|
|
1066
1067
|
def _odd_dimension_retry(cmd: List[str], stderr: str) -> Optional[List[str]]:
|
|
1067
1068
|
"""An odd-sized source (641x359 screen captures, some 4:4:4 masters) fails every yuv420p
|
|
1068
1069
|
encode with "width/height not divisible by 2" (sweep F8, 15 tools). Return the same command
|
|
1069
|
-
with an even-dimension scale
|
|
1070
|
+
with an even-dimension scale appended to its -vf chain (or a new -vf when the command had
|
|
1070
1071
|
none); None when the failure is something else or the graph is a -filter_complex the
|
|
1071
|
-
caller has to fix itself.
|
|
1072
|
+
caller has to fix itself.
|
|
1073
|
+
|
|
1074
|
+
Appended, not prepended: what the encoder refuses is the frame the chain hands it, and a
|
|
1075
|
+
chain can make an even frame odd again (squaring non-square pixels to 636x1401, a conversion
|
|
1076
|
+
that restores the source's own size). Through 3.2.3 the scale went first, so caption.py on an
|
|
1077
|
+
odd-sized clip retried and failed the same way (#438)."""
|
|
1072
1078
|
if "not divisible by 2" not in stderr or EVEN_SCALE in cmd or any(EVEN_SCALE in a for a in cmd):
|
|
1073
1079
|
return None
|
|
1074
1080
|
if "-filter_complex" in cmd:
|
|
@@ -1076,7 +1082,7 @@ def _odd_dimension_retry(cmd: List[str], stderr: str) -> Optional[List[str]]:
|
|
|
1076
1082
|
new = list(cmd)
|
|
1077
1083
|
if "-vf" in new:
|
|
1078
1084
|
i = new.index("-vf") + 1
|
|
1079
|
-
new[i] =
|
|
1085
|
+
new[i] = new[i] + "," + EVEN_SCALE
|
|
1080
1086
|
return new
|
|
1081
1087
|
if "-c:v" in new and new[new.index("-c:v") + 1] == "copy":
|
|
1082
1088
|
return None
|
|
@@ -2025,7 +2031,7 @@ def _read_ff_list(flag: str, pattern: str) -> set:
|
|
|
2025
2031
|
why = f"{tool} {flag} could not run: {exc}"
|
|
2026
2032
|
if why:
|
|
2027
2033
|
_LIST_ERRORS[flag] = why
|
|
2028
|
-
what = {"-encoders": "encoder", "-bsfs": "bitstream filter"}.get(flag, "filter")
|
|
2034
|
+
what = {"-encoders": "encoder", "-decoders": "decoder", "-bsfs": "bitstream filter"}.get(flag, "filter")
|
|
2029
2035
|
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
2036
|
"ffmpeg_list_unreadable", list=flag.lstrip("-"), tool=tool, error=why)
|
|
2031
2037
|
return names
|