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/scripts/check.py CHANGED
@@ -29,6 +29,11 @@ WARN when there is any); and, from one more video pass and a one-frame decode (2
29
29
  (single frames that differ from both neighbours while they match each other: WARN when there is
30
30
  any) and first frame (one flat colour, the frame a feed's autoplay shows first: WARN). Without it
31
31
  the row set is unchanged.
32
+
33
+ --levels bt709|ebu-r103 (opt-in) decodes every frame once more and adds a legal levels row: the share
34
+ of luma and of chroma samples outside the standard's legal range, counted exactly per frame, with
35
+ min/max Y/U/V (FAIL when any frame's share passes --levels-tolerance, 1 % by default). The JSON
36
+ `levels` document repeats it per clip and per --levels-window seconds (10 by default).
32
37
  """
33
38
  import argparse
34
39
  import json
@@ -39,7 +44,7 @@ from fractions import Fraction
39
44
  from typing import Any, Dict, List, Optional, Tuple
40
45
 
41
46
  from _platforms import PLATFORMS, PLATFORM_CHOICES, TP_TOLERANCE_DB, spec_of, resolve as resolve_platform
42
- from _common.probe import (COMPARE_SSIM_WARN, CONTENT_FLAT_STD, SILENT_PEAK_DBFS, compare_pictures, detect_content,
47
+ from _common.probe import (LEGAL_RANGE_CHOICES, measure_signal_range, COMPARE_SSIM_WARN, CONTENT_FLAT_STD, SILENT_PEAK_DBFS, compare_pictures, detect_content,
43
48
  detect_flashes, first_frame_stats, presentation_times, measured_level_dbfs)
44
49
  from _common.emit import file_identity
45
50
  from _common import STATE, add_common, apply_common, die, emit, info, probe, require_tool, run, run_analysis, dry_run_input_pending
@@ -72,6 +77,11 @@ BLACK_WARN, BLACK_FAIL = 0.10, 0.95 # share of the duration that is black
72
77
  FROZEN_WARN_S, FROZEN_WARN_SHARE = 3.0, 0.30 # longest frozen span: WARN above max(3 s, 30%)
73
78
  FROZEN_FAIL = 0.95 # a frozen span covering this share = whole video frozen
74
79
  SILENCE_WARN, SILENCE_FAIL = 0.50, 0.95 # share of the duration below SILENT_PEAK_DBFS
80
+ # --levels: the share of one frame's luma (or chroma) samples that may sit outside the legal range
81
+ # before the row FAILs -- room for a filter's overshoot at a sharp edge, not a licence for a clipped sky
82
+ LEVELS_TOLERANCE_PCT = 1.0
83
+ # pixel formats whose planes are Y'CbCr (an RGB or grey picture has no luma/chroma codes to count)
84
+ _YUV_PREFIXES = ("yuv", "yuvj", "nv", "p01", "p21", "p41", "y21", "uyvy", "yuyv", "ayuv", "vuya", "xv")
75
85
 
76
86
 
77
87
  STATE_NOTES: List[str] = []
@@ -114,6 +124,40 @@ def aspect_name(w: int, h: int) -> str:
114
124
  return f"{f.numerator}:{f.denominator}"
115
125
 
116
126
 
127
+ def levels_row(args: argparse.Namespace, v: Dict[str, Any], row: Any) -> "Optional[Dict[str, Any]]":
128
+ """--levels: measure the signal range (measure_signal_range) and add the legal levels row.
129
+ Returns the JSON `levels` document, or None when nothing was measured."""
130
+ expect = f"every frame <= {args.levels_tolerance:g}% of luma and of chroma samples outside {args.levels}"
131
+ pix = str(v.get("pix_fmt") or "")
132
+ if not v:
133
+ return None
134
+ if not pix.startswith(_YUV_PREFIXES):
135
+ row("legal levels", "WARN", f"not measured: {pix or 'unknown'} is not a Y'CbCr picture", expect,
136
+ "legal levels are measured on the Y'CbCr file a broadcaster receives: check the encoded delivery, not an RGB master",
137
+ reason="an RGB or grey picture has no luma and chroma codes to count")
138
+ return None
139
+ doc = measure_signal_range(args.input, args.levels, args.levels_window, pix_fmt=pix)
140
+ if doc is None:
141
+ row("legal levels", "WARN", "could not measure", expect, "re-run check.py --levels, or look.py the picture",
142
+ reason="the decode failed or stopped part-way, so the signal range of the whole file is unknown")
143
+ return None
144
+ pct, worst = doc["out_of_range_pct"], doc["worst_frame"]
145
+ over = worst["y_pct"] > args.levels_tolerance or worst["chroma_pct"] > args.levels_tolerance
146
+ at = "" if worst["time"] is None else f" at {worst['time']:.2f}s"
147
+ lo, hi = doc["min"], doc["max"]
148
+ value = (f"luma {pct['y']:.3f}%, chroma {pct['chroma']:.3f}% out of range (worst frame{at}: luma {worst['y_pct']:.3f}%, "
149
+ f"chroma {worst['chroma_pct']:.3f}%); Y {lo['y']}-{hi['y']}, U {lo['u']}-{hi['u']}, V {lo['v']}-{hi['v']}")
150
+ full = v.get("color_range") == "pc" or pix.startswith("yuvj")
151
+ if full:
152
+ STATE_NOTES.append("--levels: the picture is tagged full range (0-255 codes by design), so it was counted against "
153
+ "the limited-range limits a broadcast file uses; convert it to limited range before judging the row")
154
+ row("legal levels", "FAIL" if over else "PASS", value, expect,
155
+ f"color.py {args.input} --legalize {args.levels} (clamps after the 4:2:0 conversion and measures the result), or "
156
+ "pull the highlights and shadows in with a soft clip in the grade and check again" if over else "",
157
+ reason="a broadcaster's QC rejects or clips a picture whose levels leave the legal range" if over else "")
158
+ return doc
159
+
160
+
117
161
  def main() -> int:
118
162
  STATE_NOTES.clear() # module state: a second main() in one process starts without the first's notes
119
163
  ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
@@ -127,6 +171,15 @@ def main() -> int:
127
171
  ap.add_argument("--no-loudness", action="store_true", help="skip the loudness measurement (faster)")
128
172
  ap.add_argument("--content", action="store_true",
129
173
  help="also check the content (one decode pass, plus an audio-only and a video-only one): black, frozen, silence, dropouts, flashes and first frame rows")
174
+ ap.add_argument("--levels", choices=LEGAL_RANGE_CHOICES, default=None,
175
+ help="also measure the broadcast signal range (one more decode of every frame): a legal levels row with "
176
+ "the share of luma and chroma samples outside bt709 (16-235 / 16-240) or ebu-r103 (luma -1..103 %%), "
177
+ "and min/max Y/U/V; the JSON levels document has it per clip and per --levels-window")
178
+ ap.add_argument("--levels-window", type=float, default=10.0, metavar="SECONDS",
179
+ help="--levels: report the shares for every window of this many seconds too (default 10; 0 = the whole clip only)")
180
+ ap.add_argument("--levels-tolerance", type=float, default=LEVELS_TOLERANCE_PCT, metavar="PCT",
181
+ help="--levels: the legal levels row FAILs when one frame has more than this percentage of its luma or of "
182
+ "its chroma samples out of range (default %(default)g; 0 = any sample)")
130
183
  ap.add_argument("--compare", metavar="REFERENCE",
131
184
  help="also measure the picture against REFERENCE, frame by frame (SSIM and PSNR, one pass): a "
132
185
  "similarity row, WARN when the mean SSIM is under 0.98, with the worst frame's time")
@@ -153,6 +206,10 @@ def main() -> int:
153
206
  spec["tp"] = args.tp
154
207
  if args.max_mb is not None:
155
208
  spec["max_bytes"] = int(args.max_mb * 1024 * 1024)
209
+ if args.levels_window < 0:
210
+ die(f"--levels-window {args.levels_window:g}: a window is 0 (the whole clip only) or a number of seconds")
211
+ if not 0 <= args.levels_tolerance <= 100:
212
+ die(f"--levels-tolerance {args.levels_tolerance:g} is outside 0..100 (percent of a frame's samples)")
156
213
 
157
214
  expected_identity = None
158
215
  if args.expect_identity is not None:
@@ -384,6 +441,10 @@ def main() -> int:
384
441
  "again); a noise gate makes these on purpose" if gaps else "",
385
442
  reason="a short stretch of digital silence in the middle of the sound is usually lost audio at a join or a glitched decode" if gaps else "")
386
443
 
444
+ levels_doc = None
445
+ if args.levels and not dry_run_input_pending(args.input):
446
+ levels_doc = levels_row(args, v, row)
447
+
387
448
  if args.compare and not dry_run_input_pending(args.input) and not dry_run_input_pending(args.compare):
388
449
  ref = probe(args.compare)
389
450
  rv = ref.get("video") or {}
@@ -457,6 +518,8 @@ def main() -> int:
457
518
  for n in notes:
458
519
  print(f" note: {n}")
459
520
  extra: Dict[str, Any] = {"notes": notes} if notes else {}
521
+ if levels_doc is not None:
522
+ extra["levels"] = levels_doc
460
523
  if identity is not None:
461
524
  extra["input_identity"] = identity
462
525
  if failed:
package/scripts/color.py CHANGED
@@ -16,6 +16,15 @@ Examples:
16
16
  python3 color.py flat.mp4 --correct --exposure 0.3 --contrast 1.1 --saturation 1.05 --temperature 5600 --tint -0.05
17
17
  python3 color.py flat.mp4 --correct --gamma 1.2 --lift 0.04 --gain -0.03 # three-way shadows/gamma/highlights
18
18
  python3 color.py flat.mp4 --correct --levels-in-black 16 --levels-in-white 235 --curves medium_contrast
19
+ python3 color.py logo_4444.mov --correct --exposure 0.3 --keep-alpha -o logo_graded.mov # alpha kept, ProRes 4444
20
+ python3 color.py graded.mp4 --correct --legalize ebu-r103 # clamp only: no correction values given
21
+ python3 color.py log.mov --lut to709.cube --legalize bt709 # the LUT, then the clamp, one encode
22
+
23
+ --legalize bt709|ebu-r103 (with --to-sdr, --lut or --correct) clamps every luma and chroma sample into
24
+ the standard's legal range after the picture has become 8-bit 4:2:0, the last filter before the
25
+ encoder, then measures the written file the way check.py --levels does (`legalize` in the JSON): the
26
+ encoder's error around clamped edges can leave a few decoded samples out, and the legal_levels step
27
+ passes with up to 1 % of one frame's luma or chroma samples out of range.
19
28
  """
20
29
  import argparse
21
30
  import json
@@ -24,8 +33,11 @@ import sys
24
33
  from typing import List, Optional
25
34
 
26
35
  from _common.runner import fail_if_classified
27
- from _common import STATE, add_common, analyze_levels, apply_common, emit, aac_args, cfr_args, default_output, die, escape_filter_path, ffmpeg_base, ffmpeg_bsfs, info, probe, require_tool, run, run_analysis, run_keeping_subtitles, x264_args, X264_PRESETS, fmt_secs
28
- from _common.emit import warn
36
+ from _common import STATE, add_common, analyze_levels, LEGAL_RANGE_CHOICES, legal_limits, measure_signal_range, apply_common, emit, aac_args, cfr_args, default_output, die, escape_filter_path, ffmpeg_base, ffmpeg_bsfs, info, probe, require_tool, run, run_analysis, run_keeping_subtitles, x264_args, X264_PRESETS, fmt_secs
37
+ from _common.emit import note, warn
38
+ from _common.alpha import (add_alpha_args, audio_line, check_alpha_args, decoder_args_for, default_ext, finish_chain,
39
+ report as alpha_report, source_alpha, tag_args, video_line)
40
+ from _common.decision import keeps_source_tags
29
41
 
30
42
  # the curves of FFmpeg's CPU `tonemap` filter (every version from 5.1 to 9). bt2390 was listed
31
43
  # until 2.24 but exists only in tonemap_opencl/libplacebo, so choosing it always failed.
@@ -84,7 +96,7 @@ def _checked_levels(args: argparse.Namespace, flag: str) -> int:
84
96
  return value
85
97
 
86
98
 
87
- def correction_chain(args: argparse.Namespace) -> str:
99
+ def correction_chain(args: argparse.Namespace, out_fmt: str = "yuv420p") -> str:
88
100
  """Always-present filter stages, in a fixed order chosen so each stage sees a picture already
89
101
  corrected by the previous one: exposure (linear light level) -> white balance (temperature/tint/
90
102
  lift/gain, so contrast/saturation act on colour-balanced footage) -> contrast/saturation/gamma
@@ -124,7 +136,7 @@ def correction_chain(args: argparse.Namespace) -> str:
124
136
  # "unchanged" adds no term at all
125
137
  rgb.append(f"colortemperature=temperature={temperature:g}")
126
138
  rgb.append(f"colorbalance=rs={lift:g}:gs={lift:g}:bs={lift:g}:rm={rm:g}:gm={gm:g}:bm={bm:g}:rh={gain:g}:gh={gain:g}:bh={gain:g}")
127
- terms = [_rgb_stage(rgb), f"eq=contrast={contrast:g}:saturation={saturation:g}:gamma={gamma:g}"]
139
+ terms = [_rgb_stage(rgb, out_fmt), f"eq=contrast={contrast:g}:saturation={saturation:g}:gamma={gamma:g}"]
128
140
  rgb2 = []
129
141
  if (in_black, in_white, out_black, out_white) != (0, 255, 0, 255):
130
142
  rimin, rimax = in_black / 255.0, in_white / 255.0
@@ -138,11 +150,11 @@ def correction_chain(args: argparse.Namespace) -> str:
138
150
  if args.curves:
139
151
  rgb2.append(f"curves=preset={args.curves}")
140
152
  if rgb2:
141
- terms.append(_rgb_stage(rgb2))
153
+ terms.append(_rgb_stage(rgb2, out_fmt))
142
154
  return ",".join(terms)
143
155
 
144
156
 
145
- def _rgb_stage(filters: list) -> str:
157
+ def _rgb_stage(filters: list, out_fmt: str = "yuv420p") -> str:
146
158
  """Wrap a run of RGB-only filters in explicit, matching YUV<->RGB conversions.
147
159
 
148
160
  exposure/colortemperature/colorbalance/colorlevels/curves take RGB, so libavfilter inserts a
@@ -154,16 +166,17 @@ def _rgb_stage(filters: list) -> str:
154
166
  identity on every source (39 dB, the same as an untagged one always got); bt601 on both
155
167
  sides measured better than bt709 on both (34 dB) because swscale's 601 path round-trips
156
168
  8-bit 4:2:0 more exactly. The matrix here is only the working space of the conversion pair,
157
- never a tag: the output carries the encoder's BT.709 tags as before."""
169
+ never a tag: the output carries the encoder's BT.709 tags as before. `out_fmt` is yuv444p for
170
+ an alpha line (--keep-alpha), whose 4:4:4 master would otherwise get 4:2:0 chroma."""
158
171
  return ("scale=in_color_matrix=bt601,format=gbrpf32le," + ",".join(filters)
159
- + ",scale=out_color_matrix=bt601,format=yuv420p")
172
+ + f",scale=out_color_matrix=bt601,format={out_fmt}")
160
173
 
161
174
 
162
175
  # swscale's names for the non-BT.709 matrices a --codec prores re-encode keeps (it does not convert)
163
176
  _SWS_MATRIX = {"smpte170m": "bt601", "bt470bg": "bt601", "smpte240m": "smpte240m", "fcc": "fcc"}
164
177
 
165
178
 
166
- def lut_legs(meta=None) -> "tuple":
179
+ def lut_legs(meta=None, out_fmt: str = "yuv420p") -> "tuple":
167
180
  """(to RGB, back to YUV) around --lut's lut3d: explicit BT.709 conversions through float RGB,
168
181
  the matrix the picture holds when it reaches them (an untagged or BT.709 source as it is, a
169
182
  BT.601 or RGB one after x264_args(source=...)'s conversion), limited range on the way out.
@@ -175,13 +188,14 @@ def lut_legs(meta=None) -> "tuple":
175
188
  _rgb_stage) these legs use the BT.709 the picture is: an identity LUT then returns every sample
176
189
  as --lut-strength 0, which applies none, does (6.1, BT.709/BT.601/untagged, limited and full
177
190
  range, 8- and 10-bit). The input range is read from the frames, as every scale here reads it."""
178
- # --codec prores keeps a BT.601 (or other non-BT.709) source's matrix and tags instead of
179
- # converting it (decision.video_args), so the legs use that matrix (#416 review)
191
+ # --codec prores (and an alpha line, --keep-alpha) keeps a BT.601 (or other non-BT.709)
192
+ # source's matrix and tags instead of converting it (decision.video_args), so the legs use
193
+ # that matrix (#416 review)
180
194
  matrix = "bt709"
181
- if STATE.codec == "prores":
195
+ if keeps_source_tags():
182
196
  matrix = _SWS_MATRIX.get(((meta or {}).get("video") or {}).get("color_space"), "bt709")
183
197
  return (f"scale=in_color_matrix={matrix},format=gbrpf32le",
184
- f"scale=out_color_matrix={matrix}:out_range=tv,format=yuv420p")
198
+ f"scale=out_color_matrix={matrix}:out_range=tv,format={out_fmt}")
185
199
 
186
200
 
187
201
  def hdr_to_sdr_chain(meta: dict, tonemap: str, peak: float, desat: float) -> str:
@@ -300,7 +314,7 @@ def retag_mismatch(output: str, want: tuple) -> "Optional[str]":
300
314
  return shown if frame in ((None,) * 3, stream) else f"{shown} (decoded frames: {'/'.join(str(t) for t in frame)})"
301
315
 
302
316
 
303
- def retag(args: argparse.Namespace, meta: dict, has_audio: bool) -> int:
317
+ def retag(args: argparse.Namespace, meta: dict, has_audio: bool, src_alpha=lambda: None) -> int:
304
318
  """--retag: rewrite the colour description, never the pixels.
305
319
 
306
320
  First a stream copy of every stream: the container options plus, where the codec keeps its
@@ -360,11 +374,53 @@ def retag(args: argparse.Namespace, meta: dict, has_audio: bool) -> int:
360
374
  info(f"wrote {output} (tags -> {args.retag})")
361
375
  check = [] if STATE.dry_run else [{"step": "color_tags", "ok": True, "color_space": names[0],
362
376
  "color_primaries": names[1], "color_transfer": names[2]}]
363
- emit(output, reencoded=reencoded, dropped_non_av_streams=dropped_streams, verification=check,
364
- **({"notes": notes} if notes else {}))
377
+ extra = {"reencoded": reencoded, "dropped_non_av_streams": dropped_streams, "verification": check}
378
+ if reencoded:
379
+ # the fallback re-encode is H.264, which holds no alpha: on a source whose alpha is used the
380
+ # `alpha` step fails (a stream copy keeps every pixel, alpha included, and needs no step)
381
+ alpha_report(args, None, src_alpha(), output, extra, composite=False)
382
+ emit(output, **extra, **({"notes": notes} if notes else {}))
365
383
  return 0
366
384
 
367
385
 
386
+ def correction_is_identity(args: argparse.Namespace) -> bool:
387
+ """--correct with every value at its no-op default and no --curves: nothing to correct."""
388
+ return (all(getattr(args, k) == CORRECTION[k][0] for k in CORRECTION)
389
+ and all(getattr(args, k) == LEVELS[k][0] for k in LEVELS) and args.curves is None)
390
+
391
+
392
+ def legal_clamp(standard: str) -> str:
393
+ """lutyuv clamping an 8-bit 4:2:0 picture into `standard`'s legal codes (legal_limits)."""
394
+ lim = legal_limits(standard, 8)
395
+ (ylo, yhi), (clo, chi) = lim["y"], lim["c"]
396
+ return f"lutyuv=y='clip(val\\,{ylo}\\,{yhi})':u='clip(val\\,{clo}\\,{chi})':v='clip(val\\,{clo}\\,{chi})'"
397
+
398
+
399
+ # --legalize: the share of one frame's luma (or chroma) samples the encode may leave out of range
400
+ # before the legal_levels step fails -- the same allowance check.py --levels gives by default. The
401
+ # clamp holds every sample the encoder receives; a lossy encoder's error around a clipped edge can
402
+ # still land a few decoded samples a code or two outside, which is what this measures.
403
+ LEGALIZE_TOLERANCE_PCT = 1.0
404
+
405
+
406
+ def legalize_report(standard: str, output: str) -> "tuple":
407
+ """Measure the written file against `standard` ({standard, limits, output}, verification list)."""
408
+ doc = measure_signal_range(output, standard, window=0, bit_depth=8)
409
+ if doc is None:
410
+ return ({"standard": standard, "output": None},
411
+ [{"step": "legal_levels", "ok": False, "reason": "the written file did not decode for the measurement"}])
412
+ worst = doc["worst_frame"]
413
+ ok = worst["y_pct"] <= LEGALIZE_TOLERANCE_PCT and worst["chroma_pct"] <= LEGALIZE_TOLERANCE_PCT
414
+ pct = doc["out_of_range_pct"]
415
+ if pct["y"] or pct["chroma"]:
416
+ warn(f"--legalize {standard}: the encoded file still has {pct['y']:.4f}% of luma and {pct['chroma']:.4f}% of chroma "
417
+ f"samples out of range (the encoder's error around clamped areas; worst frame {worst['y_pct']:.4f}% / "
418
+ f"{worst['chroma_pct']:.4f}%)", "legalize_residual")
419
+ return ({"standard": standard, "reference": doc["reference"], "legal": doc["legal"], "output": doc},
420
+ [{"step": "legal_levels", "ok": ok, "standard": standard, "out_of_range_pct": pct,
421
+ "worst_frame": worst, "tolerance_pct": LEGALIZE_TOLERANCE_PCT}])
422
+
423
+
368
424
  def main() -> int:
369
425
  ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
370
426
  ap.add_argument("input")
@@ -375,6 +431,10 @@ def main() -> int:
375
431
  mode.add_argument("--retag", choices=["bt709", "bt2020-pq", "bt2020-hlg", "bt601"], help="rewrite colour tags only (no re-encode)")
376
432
  mode.add_argument("--strip-dovi", action="store_true", help="remove the Dolby Vision RPU (profile 8.4 iPhone clips) so players use the plain HLG/HDR10 base layer; stream copy")
377
433
  mode.add_argument("--correct", action="store_true", help="typed primary colour correction: --exposure/--contrast/--saturation/--temperature/--tint/--gamma/--lift/--gain/--levels-*/--curves")
434
+ ap.add_argument("--legalize", choices=LEGAL_RANGE_CHOICES, default=None,
435
+ help="with --to-sdr, --lut or --correct: clamp luma and chroma into the bt709 (16-235 / 16-240) or ebu-r103 "
436
+ "(luma 14-241, chroma 5-251) legal range after the 8-bit 4:2:0 conversion, then measure the written "
437
+ "file's out-of-range share (JSON legalize). --correct with no correction values clamps only")
378
438
  ap.add_argument("--tonemap", choices=TONEMAPS, default="hable", help="tone-mapping curve (default hable)")
379
439
  ap.add_argument("--peak", type=float, default=1000.0, help="source peak brightness in nits used for PQ (default 1000)")
380
440
  ap.add_argument("--desat", type=float, default=0.0, help="tonemap desaturation strength (default 0)")
@@ -401,6 +461,7 @@ def main() -> int:
401
461
  "a successful --retag stream-copy all streams untouched, so the flag has nothing to select there.")
402
462
  ap.set_defaults(crf=18) # --quality's default (the --crf alias was removed in 2.0)
403
463
  ap.add_argument("--preset", default="medium", choices=X264_PRESETS)
464
+ add_alpha_args(ap)
404
465
  add_common(ap)
405
466
  args = ap.parse_args()
406
467
  apply_common(args)
@@ -415,6 +476,35 @@ def main() -> int:
415
476
  die(f"--audio-stream {args.audio_stream}: input has {len(audio_streams)} audio stream(s), 0..{len(audio_streams) - 1}")
416
477
  if args.audio_stream and not audio_streams:
417
478
  die("--audio-stream needs an input with audio streams")
479
+ if args.legalize and (args.strip_dovi or args.retag):
480
+ die(f"--legalize changes pixels, so it needs a mode that re-encodes (--to-sdr, --lut or --correct); "
481
+ f"{'--strip-dovi' if args.strip_dovi else '--retag'} is a stream copy")
482
+ if args.legalize and STATE.codec not in (None, "h264"):
483
+ die(f"--legalize clamps the 8-bit 4:2:0 picture the H.264 line encodes; --codec {STATE.codec} converts it again "
484
+ f"after the clamp (another depth or chroma format), which can move samples back out of range. Leave --codec "
485
+ f"unset, or run check.py --levels on that encode")
486
+ if args.legalize and args.keep_alpha:
487
+ # the clamp and its measurement are defined on the 8-bit 4:2:0 picture the H.264 line encodes;
488
+ # the alpha line grades at 4:4:4 and every --alpha-codec converts that picture again after the
489
+ # clamp (10-bit ProRes 4444, RGB qtrle/PNG, VP9's yuva420p) -- the same reason --codec is refused
490
+ die("--legalize clamps the 8-bit 4:2:0 picture the H.264 line encodes; --keep-alpha grades at 4:4:4 and its "
491
+ "--alpha-codec converts the picture again after the clamp (10-bit, RGB or yuva420p), which can move samples "
492
+ "back out of range",
493
+ kind="input", hint="legalize the opaque delivery (--drop-alpha, or no alpha flag), or run check.py --levels "
494
+ "on the alpha master")
495
+ if args.keep_alpha and not (args.correct or args.lut):
496
+ # --retag and --strip-dovi are stream copies, which keep an alpha channel as they keep every
497
+ # pixel; --to-sdr's tone map ends in the SDR line's own 4:2:0 (an HDR alpha master is not a case here)
498
+ die("--keep-alpha applies to --correct and --lut (--retag and --strip-dovi copy the stream, alpha included)",
499
+ kind="input")
500
+ if args.drop_alpha and not (args.correct or args.lut or args.to_sdr):
501
+ die("--drop-alpha applies to the re-encodes (--correct, --lut, --to-sdr); --retag and --strip-dovi copy the stream, "
502
+ "alpha included", kind="input")
503
+ alpha_codec = check_alpha_args(args, args.output)
504
+ src_alpha = source_alpha(args, meta, args.input) if (args.correct or args.lut or args.to_sdr) else None
505
+ # an alpha line is graded at 4:4:4: its master would otherwise come back with 4:2:0 chroma
506
+ out_fmt = "yuv444p" if alpha_codec else "yuv420p"
507
+ ext = default_ext(alpha_codec) if alpha_codec else None
418
508
 
419
509
  if args.strip_dovi:
420
510
  output = args.output or default_output(args.input, "nodv")
@@ -433,7 +523,7 @@ def main() -> int:
433
523
  return 0
434
524
 
435
525
  if args.retag:
436
- return retag(args, meta, has_audio)
526
+ return retag(args, meta, has_audio, lambda: source_alpha(args, meta, args.input))
437
527
 
438
528
  measurements = None
439
529
  sdr_extra: dict = {}
@@ -443,22 +533,22 @@ def main() -> int:
443
533
  if is_bt2020_sdr(v):
444
534
  vf = bt2020_sdr_to_bt709_chain(meta)
445
535
  sdr_path = "gamut"
446
- note = (f"BT.2020 primaries on an SDR transfer ({v.get('color_transfer')}): converted the gamut to BT.709 "
536
+ sdr_note = (f"BT.2020 primaries on an SDR transfer ({v.get('color_transfer')}): converted the gamut to BT.709 "
447
537
  f"without a tone map (--tonemap/--peak/--desat do not apply)")
448
538
  else:
449
539
  vf = hdr_to_sdr_chain(meta, args.tonemap, args.peak, args.desat)
450
540
  sdr_path = "tonemap"
451
- note = f"tone-mapped {v.get('hdr_format') or 'untagged input as PQ (--force)'} to SDR BT.709 with {args.tonemap}"
452
- info(f"note: {note}")
453
- sdr_extra = {"sdr_path": sdr_path, "notes": [note]}
541
+ sdr_note = f"tone-mapped {v.get('hdr_format') or 'untagged input as PQ (--force)'} to SDR BT.709 with {args.tonemap}"
542
+ info(f"note: {sdr_note}")
543
+ sdr_extra = {"sdr_path": sdr_path, "notes": [sdr_note]}
454
544
  output = args.output or default_output(args.input, "sdr")
455
545
  tag = "sdr"
456
546
  elif args.correct:
457
547
  if v.get("bt2020_or_hdr") and not args.force:
458
548
  die(f"{args.input} is HDR ({v.get('hdr_format')}); --correct works on SDR pixels and would tag PQ/HLG data as BT.709 "
459
549
  f"without a tone map (sweep F10). Run --to-sdr first, or --force to grade the raw values anyway")
460
- vf = correction_chain(args)
461
- output = args.output or default_output(args.input, "correct")
550
+ vf = "" if args.legalize and correction_is_identity(args) else correction_chain(args, out_fmt)
551
+ output = args.output or default_output(args.input, "correct", ext)
462
552
  tag = "correct"
463
553
  # OBSERVED technical measurements (signalstats: luma / saturation distribution), never a
464
554
  # "looks better" judgement -- the same primitive probe.py --analyze uses for Log detection.
@@ -472,26 +562,47 @@ def main() -> int:
472
562
  if not (0.0 <= args.lut_strength <= 1.0):
473
563
  die(f"--lut-strength {args.lut_strength:g} is outside 0..1")
474
564
  lut = f"lut3d=file={escape_filter_path(args.lut)}:interp=tetrahedral"
475
- to_rgb, to_yuv = lut_legs(meta)
565
+ to_rgb, to_yuv = lut_legs(meta, out_fmt)
476
566
  if args.lut_strength == 0.0:
477
567
  # 0 means "no LUT at all" -- without this branch, 0 (falling outside the open interval
478
568
  # below) landed in the same "apply the LUT at full strength" fallback as an out-of-range
479
569
  # value did before the guard above existed: the one strength value documented to mean
480
570
  # "don't grade it" instead silently graded it at 100%, the opposite of what was asked.
481
- vf = "format=yuv420p"
571
+ vf = f"format={out_fmt}"
482
572
  elif args.lut_strength < 1.0:
483
573
  # blend graded and original
484
574
  # blended in RGB, both pictures through the same two legs
485
575
  vf = f"{to_rgb},split[o][g];[g]{lut}[g2];[o][g2]blend=all_mode=normal:all_opacity={args.lut_strength:g},{to_yuv}"
486
576
  else:
487
577
  vf = f"{to_rgb},{lut},{to_yuv}"
488
- output = args.output or default_output(args.input, "lut")
578
+ output = args.output or default_output(args.input, "lut", ext)
489
579
  tag = "lut"
490
580
 
491
- cmd = ffmpeg_base() + ["-i", args.input, "-vf", vf, "-map", "0:v:0", "-map", f"0:a:{args.audio_stream}?"]
492
- # --lut/--correct grade the source's own pixels, so a BT.601 source is converted to the BT.709
493
- # the line is tagged with; --to-sdr's zscale chain already writes BT.709 and converts nothing more
494
- cmd += x264_args(args.crf, args.preset, source=None if args.to_sdr else meta) + cfr_args(meta) + (aac_args() if has_audio else [])
581
+ if args.legalize:
582
+ # the last filter before the encoder, on the 4:2:0 picture it encodes: a clamp ahead of the
583
+ # chroma subsampling would let the subsampling filter's overshoot put samples back out
584
+ # a full-range picture (yuvj420p, or tagged pc) is converted to limited range first: its codes
585
+ # span 0-255 by design, and clamping them as they are would crush its blacks and whites into
586
+ # the limits instead of scaling them there. A limited-range source gets the chain it always had.
587
+ full = v.get("color_range") == "pc" or str(v.get("pix_fmt") or "").startswith("yuvj")
588
+ vf = ((vf + "," if vf else "") + ("scale=out_range=tv," if full else "")
589
+ + "format=yuv420p," + legal_clamp(args.legalize))
590
+ if alpha_codec:
591
+ # --keep-alpha: the grade works on the colour and never touches the alpha, which is split off
592
+ # the source, kept as it is and merged back (alphamerge) in front of the alpha line; the
593
+ # source's colour tags are kept, as --codec prores keeps them (no BT.709 conversion)
594
+ fc = (f"[0:v]split[ka_c][ka_a];[ka_c]{vf}[ka_g];[ka_a]alphaextract[ka_m];"
595
+ f"[ka_g][ka_m]alphamerge,{finish_chain(alpha_codec, args.alpha_mode, meta)}[ka_v]")
596
+ cmd = ffmpeg_base() + decoder_args_for(meta, args.input) + ["-i", args.input, "-filter_complex", fc,
597
+ "-map", "[ka_v]", "-map", f"0:a:{args.audio_stream}?"]
598
+ cmd += video_line(alpha_codec) + tag_args(meta) + cfr_args(meta) + (audio_line(alpha_codec) if has_audio else [])
599
+ if STATE.hw:
600
+ note(f"--hw: --alpha-codec {alpha_codec} stays on the CPU (no VideoToolbox encoder writes it with its alpha)")
601
+ else:
602
+ cmd = ffmpeg_base() + ["-i", args.input, "-vf", vf, "-map", "0:v:0", "-map", f"0:a:{args.audio_stream}?"]
603
+ # --lut/--correct grade the source's own pixels, so a BT.601 source is converted to the BT.709
604
+ # the line is tagged with; --to-sdr's zscale chain already writes BT.709 and converts nothing more
605
+ cmd += x264_args(args.crf, args.preset, source=None if args.to_sdr else meta) + cfr_args(meta) + (aac_args() if has_audio else [])
495
606
  dropped_streams = run_keeping_subtitles(cmd, output)
496
607
  r = probe(output, role="output")
497
608
  info(f"wrote {output} ({fmt_secs(r['duration'])}, {r['video']['width']}x{r['video']['height']}, "
@@ -500,6 +611,12 @@ def main() -> int:
500
611
  if measurements is not None:
501
612
  measurements["output"] = analyze_levels(output)
502
613
  extra["measurements"] = measurements
614
+ # the source's alpha against the output's: kept (--keep-alpha), or lost and failing the `alpha`
615
+ # step (an opaque line on a source whose alpha is used), or dropped on purpose (--drop-alpha)
616
+ alpha_report(args, alpha_codec, src_alpha, output, extra, composite=False)
617
+ if args.legalize and not STATE.dry_run:
618
+ extra["legalize"], check = legalize_report(args.legalize, output)
619
+ extra["verification"] = extra.get("verification", []) + check
503
620
  emit(output, **extra)
504
621
  return 0
505
622
 
package/scripts/cut.py CHANGED
@@ -63,7 +63,7 @@ from _common import (beat_grid, snap_points, decode_pcm_mono, rms_envelope, BEAT
63
63
  from _common import require_tool
64
64
  from _common.probe import TS_EXTS, signatures_match, stream_signature
65
65
  from _common.decision import grid_at_or_after
66
- from _common import warn, sibling_temp, source_codec_video_args, STATE, add_common, apply_common, audio_codec_for, emit, aac_args, cfr_args, default_output, die, ffmpeg_base, info, is_audio_output, time_arg, probe, run, X264_PRESETS, keyframes_near, MissingFpsError, concat_list_line, place_output, refuse_output_is_input, fmt_secs, measure_frame_timing, dry_run_input_pending
66
+ from _common import warn, record_fallback, refuse_existing_outputs, sibling_temp, source_codec_video_args, STATE, add_common, apply_common, audio_codec_for, emit, aac_args, cfr_args, default_output, die, ffmpeg_base, info, is_audio_output, time_arg, probe, run, X264_PRESETS, keyframes_near, MissingFpsError, concat_list_line, place_output, refuse_output_is_input, fmt_secs, measure_frame_timing, dry_run_input_pending
67
67
 
68
68
  # outputs whose re-encode dropped a subtitle/data stream (reported as dropped_non_av_streams)
69
69
  DROPPED_STREAMS: List[str] = []
@@ -1303,6 +1303,70 @@ def _join_workspace():
1303
1303
  pass
1304
1304
 
1305
1305
 
1306
+ def run_smart(args, segments, output: str, meta: dict, forced: List[str], hold_needed: bool, total: float) -> dict:
1307
+ """--smart: the keyframe-aware cut (_smartcut), written beside the output and moved into place once
1308
+ verified. Returns the `smart` document; {applied: false, reason, reason_code} when it could not be
1309
+ made or did not verify, and the caller re-encodes the whole cut."""
1310
+ import _smartcut
1311
+
1312
+ def fallback(reason: str, code: str) -> dict:
1313
+ return {"applied": False, "reason": reason, "reason_code": code}
1314
+
1315
+ # an existing output is refused before the parts are rendered and checked, not at placement
1316
+ refuse_existing_outputs([output])
1317
+ video = meta.get("video") or {}
1318
+ if not video or is_audio_output(output):
1319
+ return fallback("the cut has no picture to copy (an audio output or an audio-only source)", "no_video")
1320
+ if dry_run_input_pending(args.input):
1321
+ return fallback("the input does not exist yet, so its keyframes cannot be read", "pending_input")
1322
+ for why in forced:
1323
+ if why in ("vfr", "vfr_inconclusive"):
1324
+ return fallback("the source's frame timing is variable or could not be measured; a copy of it cannot be "
1325
+ "placed frame-accurately", "vfr")
1326
+ if why == "codec":
1327
+ return fallback("--codec asks for another codec, so nothing can be copied", "codec")
1328
+ if hold_needed:
1329
+ return fallback("a segment runs past the end of the video, and only a re-encode can hold its last frame",
1330
+ "hold")
1331
+ if meta.get("audio") and len(segments) > JOIN_CHUNK:
1332
+ # the sound of every segment is one input of the join command; past this many the command
1333
+ # line can outgrow Windows' limit, and the full re-encode joins in chunks of this size
1334
+ return fallback(f"{len(segments)} segments with sound: the smart join takes at most {JOIN_CHUNK}, the "
1335
+ "full re-encode joins them in chunks", "segments")
1336
+ ext = os.path.splitext(output)[1] or ".mp4"
1337
+ params = _smartcut.stream_params(args.input)
1338
+ bad = _smartcut.ineligible(params, meta, ext)
1339
+ if bad:
1340
+ return fallback(*bad)
1341
+ packets = video_packets(args.input, segments, 0.5, total)
1342
+ if not packets:
1343
+ return fallback("the source's video packets could not be read", "packets")
1344
+ with _join_workspace() as (tmp, temps):
1345
+ dst = output if STATE.dry_run else sibling_temp(output, "smart")
1346
+ if not STATE.dry_run:
1347
+ temps.append(dst)
1348
+ try:
1349
+ doc = _smartcut.execute(args.input, segments, dst, meta, params, packets, args.crf, args.preset, tmp,
1350
+ vend=video_end(args.input, meta), gop_is_open=gop_is_open, landing=seek_keyframe,
1351
+ origin=file_origin, declick=DECLICK_S, seek_margin=SEEK_MARGIN)
1352
+ except _smartcut.SmartFallback as exc:
1353
+ return fallback(exc.reason, exc.code)
1354
+ if not STATE.dry_run:
1355
+ place_output(dst, output, move=True)
1356
+ if meta.get("subtitle_streams") or meta.get("data_streams"):
1357
+ DROPPED_STREAMS.append(output)
1358
+ return doc
1359
+
1360
+
1361
+ def smart_verification(doc: dict) -> List[dict]:
1362
+ """The --smart checks as verification steps (all passed: a failed one fell back before this)."""
1363
+ c = doc["checks"]
1364
+ return [{"step": "smart_frames", "ok": True, "frames": c["frames"], "grid_worst_seconds": c["grid_worst_seconds"]},
1365
+ {"step": "smart_packets", "ok": True, "copied_packets_equal": c["copied_packets_equal"]},
1366
+ {"step": "smart_decode", "ok": True, "frames_decoded_equal": c["frames_decoded_equal"]},
1367
+ {"step": "smart_params", "ok": True, "time_base": c["time_base"]}]
1368
+
1369
+
1306
1370
  def main() -> int:
1307
1371
  global DECLICK_S
1308
1372
  ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
@@ -1316,6 +1380,11 @@ def main() -> int:
1316
1380
  ap.add_argument("--remove", metavar="START-END[,...]",
1317
1381
  help="the inverse of --segments: drop these ranges and keep the rest, joined in order "
1318
1382
  "(e.g. '1:20-2:05,10:00-10:30'); overlapping ranges merge")
1383
+ ap.add_argument("--smart", action="store_true",
1384
+ help="keyframe-aware cut of an H.264 source: re-encode only from each cut point to the next keyframe "
1385
+ "(and from the last keyframe to the end point), matched to the source's profile, level, pixel "
1386
+ "format, reorder depth and colour tags, and stream-copy the whole GOPs between; verified (frame "
1387
+ "grid, copied packets, decoded frames) or re-encoded in full with the reason in smart.reason")
1319
1388
  ap.add_argument("--accurate", action="store_true", help="always re-encode for frame-accurate (video) / sample-accurate (audio) cuts (default: lossless -c copy, re-encoding only when the keyframe snap exceeds --tolerance)")
1320
1389
  el = ap.add_mutually_exclusive_group()
1321
1390
  el.add_argument("--edit-list", action="store_true",
@@ -1368,6 +1437,9 @@ def main() -> int:
1368
1437
  total = meta.get("duration") or 0.0
1369
1438
  # why every segment re-encodes, if one of these forces it; `requested` is read before the
1370
1439
  # guards below overwrite args.accurate, so a forced re-encode is never reported as asked for
1440
+ if args.smart and args.accurate:
1441
+ die("--smart re-encodes only the partial GOPs at each cut and copies the rest; --accurate re-encodes all of it: "
1442
+ "choose one")
1371
1443
  forced: List[str] = ["requested"] if args.accurate else []
1372
1444
  if STATE.codec:
1373
1445
  # "cut this and make it HEVC": a stream copy keeps the source codec, so the request is a
@@ -1512,7 +1584,21 @@ def main() -> int:
1512
1584
  outcomes: List[dict] = []
1513
1585
  join_reencoded = False
1514
1586
  join_check = None
1515
- if len(segments) == 1:
1587
+ smart_doc = None
1588
+ if args.smart:
1589
+ smart_doc = run_smart(args, segments, output, meta, forced, hold_needed, total)
1590
+ if not smart_doc["applied"]:
1591
+ # the stated fallback: the full re-encode --accurate makes, with the reason reported
1592
+ record_fallback(f"--smart: {smart_doc['reason']}; re-encoding the whole cut instead", "smart_fallback",
1593
+ reason_code=smart_doc["reason_code"])
1594
+ join_notes.append(f"--smart fell back to a full re-encode: {smart_doc['reason']}")
1595
+ args.accurate = True
1596
+ forced.append("smart_fallback")
1597
+ if smart_doc is not None and smart_doc["applied"]:
1598
+ # every part copied and no sound (a silent source cut on keyframes) re-encodes nothing
1599
+ smart_encoded = bool(smart_doc["reencoded_frames"] or meta.get("audio"))
1600
+ outcomes = [_outcome(meta, output, smart_encoded, ["smart"] if smart_encoded else []) for _ in segments]
1601
+ elif len(segments) == 1:
1516
1602
  outcomes.append(cut_one(args.input, segments[0][0], segments[0][1], output, args.accurate, args.crf, args.preset, args.tolerance, meta,
1517
1603
  edit_list_ok=not args.no_edit_list))
1518
1604
  elif hold_needed or args.accurate:
@@ -1676,7 +1762,8 @@ def main() -> int:
1676
1762
  # mode: "copy" (nothing re-encoded at any stage, the join included), "accurate" (--accurate was
1677
1763
  # asked for or forced), "hybrid" (asked for lossless but a segment or the join re-encoded anyway;
1678
1764
  # reencode_reason says why)
1679
- mode = "copy" if not reencoded else ("accurate" if args.accurate else "hybrid")
1765
+ mode = ("smart" if smart_doc is not None and smart_doc["applied"] else
1766
+ "copy" if not reencoded else ("accurate" if args.accurate else "hybrid"))
1680
1767
  notes: List[str] = vfr_notes + join_notes + [n for o in outcomes for n in o.get("notes") or []]
1681
1768
  single = outcomes[0] if len(outcomes) == 1 else {}
1682
1769
  stored = single.get("stored_preroll_seconds")
@@ -1737,15 +1824,16 @@ def main() -> int:
1737
1824
  edit_list=bool(single.get("edit_list")), stored_preroll_seconds=stored, av_start_skew_seconds=skew,
1738
1825
  join_check=join_check, segment_end_snap_seconds=end_snaps,
1739
1826
  # the joins were faded only where the concat filter re-encoded them
1740
- declick_ms=(DECLICK_S * 1000 if len(segments) > 1 and (join_reencoded or args.accurate) and meta.get("audio")
1741
- else None),
1827
+ declick_ms=(DECLICK_S * 1000 if len(segments) > 1 and meta.get("audio")
1828
+ and (join_reencoded or args.accurate or mode == "smart") else None),
1742
1829
  notes=notes, **({"vfr_check": vfr_check} if vfr_check else {}),
1743
1830
  nearest_keyframes=sorted(NEAREST_KEYFRAMES) if NEAREST_KEYFRAMES else None,
1744
1831
  # the trade the caller can offer instead of a re-encode (eval e02: "without losing quality")
1745
1832
  lossless_alternative=(f"--start {min(NEAREST_KEYFRAMES, key=lambda k: abs(k - segments[0][0])):.3f} lands on a keyframe: "
1746
1833
  f"stream copy with no re-encode, {abs(min(NEAREST_KEYFRAMES, key=lambda k: abs(k - segments[0][0])) - segments[0][0]):.2f}s off the requested start")
1747
1834
  if mode == "hybrid" and NEAREST_KEYFRAMES and len(segments) == 1 else None,
1748
- snap=snap_result)
1835
+ snap=snap_result, **({"smart": smart_doc} if smart_doc is not None else {}),
1836
+ **({"verification": smart_verification(smart_doc)} if smart_doc is not None and smart_doc.get("checks") else {}))
1749
1837
  return 0
1750
1838
 
1751
1839