ffmpeg-skill 3.2.1 → 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/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] = []
@@ -1204,8 +1204,11 @@ def _grid_from_source(path: str, min_confidence: float) -> "dict":
1204
1204
  die(f"--snap-source {path}: this JSON has no beat_grid -- produce one with "
1205
1205
  "`scenes.py MUSIC --beats --json`", kind="input")
1206
1206
  samples = decode_pcm_mono(path, BEAT_RATE, check=False)
1207
- env = rms_envelope(samples, max(1, int(round(BEAT_RATE * 0.01))))
1208
- return beat_grid(env, 0.01, min_confidence=min_confidence)
1207
+ block = max(1, int(round(BEAT_RATE * 0.01)))
1208
+ env = rms_envelope(samples, block)
1209
+ # the envelope's real step is block / rate (220 / 22050 = 9.977 ms), not the 10 ms asked for:
1210
+ # timing the onsets at 10 ms stretched the grid 0.23 % (68 ms over 30 s) -- see scenes.py
1211
+ return beat_grid(env, block / BEAT_RATE, min_confidence=min_confidence)
1209
1212
 
1210
1213
 
1211
1214
  def snap_segments(args, segments, meta, total):
@@ -1300,6 +1303,70 @@ def _join_workspace():
1300
1303
  pass
1301
1304
 
1302
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
+
1303
1370
  def main() -> int:
1304
1371
  global DECLICK_S
1305
1372
  ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
@@ -1313,6 +1380,11 @@ def main() -> int:
1313
1380
  ap.add_argument("--remove", metavar="START-END[,...]",
1314
1381
  help="the inverse of --segments: drop these ranges and keep the rest, joined in order "
1315
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")
1316
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)")
1317
1389
  el = ap.add_mutually_exclusive_group()
1318
1390
  el.add_argument("--edit-list", action="store_true",
@@ -1365,6 +1437,9 @@ def main() -> int:
1365
1437
  total = meta.get("duration") or 0.0
1366
1438
  # why every segment re-encodes, if one of these forces it; `requested` is read before the
1367
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")
1368
1443
  forced: List[str] = ["requested"] if args.accurate else []
1369
1444
  if STATE.codec:
1370
1445
  # "cut this and make it HEVC": a stream copy keeps the source codec, so the request is a
@@ -1509,7 +1584,21 @@ def main() -> int:
1509
1584
  outcomes: List[dict] = []
1510
1585
  join_reencoded = False
1511
1586
  join_check = None
1512
- 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:
1513
1602
  outcomes.append(cut_one(args.input, segments[0][0], segments[0][1], output, args.accurate, args.crf, args.preset, args.tolerance, meta,
1514
1603
  edit_list_ok=not args.no_edit_list))
1515
1604
  elif hold_needed or args.accurate:
@@ -1673,7 +1762,8 @@ def main() -> int:
1673
1762
  # mode: "copy" (nothing re-encoded at any stage, the join included), "accurate" (--accurate was
1674
1763
  # asked for or forced), "hybrid" (asked for lossless but a segment or the join re-encoded anyway;
1675
1764
  # reencode_reason says why)
1676
- 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"))
1677
1767
  notes: List[str] = vfr_notes + join_notes + [n for o in outcomes for n in o.get("notes") or []]
1678
1768
  single = outcomes[0] if len(outcomes) == 1 else {}
1679
1769
  stored = single.get("stored_preroll_seconds")
@@ -1734,15 +1824,16 @@ def main() -> int:
1734
1824
  edit_list=bool(single.get("edit_list")), stored_preroll_seconds=stored, av_start_skew_seconds=skew,
1735
1825
  join_check=join_check, segment_end_snap_seconds=end_snaps,
1736
1826
  # the joins were faded only where the concat filter re-encoded them
1737
- declick_ms=(DECLICK_S * 1000 if len(segments) > 1 and (join_reencoded or args.accurate) and meta.get("audio")
1738
- 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),
1739
1829
  notes=notes, **({"vfr_check": vfr_check} if vfr_check else {}),
1740
1830
  nearest_keyframes=sorted(NEAREST_KEYFRAMES) if NEAREST_KEYFRAMES else None,
1741
1831
  # the trade the caller can offer instead of a re-encode (eval e02: "without losing quality")
1742
1832
  lossless_alternative=(f"--start {min(NEAREST_KEYFRAMES, key=lambda k: abs(k - segments[0][0])):.3f} lands on a keyframe: "
1743
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")
1744
1834
  if mode == "hybrid" and NEAREST_KEYFRAMES and len(segments) == 1 else None,
1745
- 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 {}))
1746
1837
  return 0
1747
1838
 
1748
1839
 
package/scripts/export.py CHANGED
@@ -172,86 +172,11 @@ PLATFORM_OF["youtube4k"] = PLATFORM_TABLE["youtube"]["check"]
172
172
  PRESET_CHOICES: List[str] = sorted(set(PRESETS) | {a for a in _ALIASES if resolve_platform(a) in PRESETS})
173
173
 
174
174
 
175
- # A pixel format that carries an alpha plane (yuva*, rgba, argb, gbrap, ya8/ya16, ...).
176
- _ALPHA_PIX = re.compile(r"^(yuva|gbrap|ya\d|rgba|bgra|argb|abgr|rgba64|bgra64)")
177
-
178
-
179
- def vp9_decoder_args(codec: "str | None") -> List[str]:
180
- """The input option that decodes a VP9 stream with libvpx-vp9: FFmpeg's native vp9 decoder
181
- drops the alpha layer (a WebM's yuva420p comes out yuv420p, fully opaque). [] for any other
182
- codec, or when this build has no libvpx (its encoder list stands in for the decoder's)."""
183
- return ["-c:v", "libvpx-vp9"] if codec == "vp9" and "libvpx-vp9" in ffmpeg_encoders() else []
184
-
185
-
186
- def vp9_alpha_tagged(path: str) -> bool:
187
- """True when the first video stream is VP9 stored with an alpha layer (Matroska/WebM's
188
- ALPHA_MODE=1 stream tag): ffprobe reports such a stream as yuv420p, so the pixel format alone
189
- does not show the alpha. A metadata read, so a dry run can decide without decoding."""
190
- proc = run([require_tool("ffprobe"), "-v", "error", "-select_streams", "v:0", "-show_entries",
191
- "stream=codec_name:stream_tags", "-of", "json", path], quiet=True)
192
- try:
193
- streams = json.loads(proc.stdout or "{}").get("streams") or []
194
- except ValueError:
195
- return False
196
- s = streams[0] if streams else {}
197
- return s.get("codec_name") == "vp9" and any(k.lower() == "alpha_mode" and str(v).strip() == "1"
198
- for k, v in (s.get("tags") or {}).items())
199
-
200
-
201
- _MEASURE_W, _MEASURE_H = 64, 36 # the measuring copy: small enough to read every frame, large enough for exact edges
202
-
203
-
204
- def _frame_means(cmd_head: List[str], path: str, chain: str) -> "Dict[str, object] | None":
205
- """Every decoded frame of `path` through `chain` to a 64x36 grey copy: {mean (over every frame),
206
- min (the lowest frame's mean), frames, per_frame (each frame's mean, in decode order)}. None
207
- when no frame decodes."""
208
- w, h = _MEASURE_W, _MEASURE_H
209
- cmd = cmd_head + ["-i", path, "-map", "0:v:0", "-vf", f"{chain},scale={w}:{h}:flags=area,format=gray",
210
- "-f", "rawvideo", "-"]
211
- proc = _run_analysis(cmd, check=False, text=False)
212
- data = proc.stdout or b""
213
- size = w * h
214
- if proc.returncode != 0 or len(data) < size:
215
- return None
216
- frames = [sum(data[i:i + size]) / size for i in range(0, len(data) - size + 1, size)]
217
- return {"mean": sum(frames) / len(frames), "min": min(frames), "frames": len(frames), "per_frame": frames}
218
-
219
-
220
- def alpha_stats(path: str, codec: "str | None" = None) -> "Dict[str, object] | None":
221
- """The alpha (0-255) of every decoded frame, on a 64x36 copy: {mean, min, frames, per_frame}.
222
- 255.0 is fully opaque, and a picture with no alpha plane reads 255 too (format=rgba fills it).
223
- Every frame, not the first: a clip that opens fully opaque and turns transparent later would
224
- read as opaque from its first frame, in the source and in a written file that lost its alpha.
225
- A VP9 file is decoded with libvpx-vp9 (vp9_decoder_args). None when no frame decodes."""
226
- head = [require_tool("ffmpeg"), "-hide_banner", "-loglevel", "error", "-nostdin"] + vp9_decoder_args(codec)
227
- return _frame_means(head, path, "format=rgba,alphaextract")
228
-
229
-
230
- ALPHA_TOLERANCE = 2.0 # of 255, per frame (or on the mean and the most transparent frame, below)
231
- OPAQUE = 254.5 # a frame whose mean alpha is at least this is fully opaque
232
-
233
-
234
- def _alpha_compare(written, source) -> Dict[str, object]:
235
- """The written file's alpha against the source's. With the same number of frames, frame by
236
- frame (frame i against frame i, each within ALPHA_TOLERANCE): an aggregate let a loss confined
237
- to a few frames through (one of 200 frames turned opaque moves the mean by ~1). With a
238
- different count (a variable-rate source conformed to a constant rate repeats or drops frames,
239
- so indices no longer pair), the mean and the most transparent frame, and `paired: false`
240
- says so. {ok, paired, worst_delta, worst_frame}."""
241
- if written is None or source is None:
242
- return {"ok": False, "paired": None, "worst_delta": None, "worst_frame": None}
243
- w, s = written.get("per_frame") or [], source.get("per_frame") or []
244
- if w and len(w) == len(s):
245
- deltas = [abs(a - b) for a, b in zip(w, s)]
246
- worst = max(range(len(deltas)), key=deltas.__getitem__)
247
- return {"ok": deltas[worst] <= ALPHA_TOLERANCE, "paired": True, "worst_delta": round(deltas[worst], 2),
248
- "worst_frame": worst}
249
- delta = max(abs(written["mean"] - source["mean"]), abs(written["min"] - source["min"]))
250
- return {"ok": delta <= ALPHA_TOLERANCE, "paired": False, "worst_delta": round(delta, 2), "worst_frame": None}
251
-
252
-
253
- def _rounded(st) -> "Dict[str, float] | None":
254
- return None if st is None else {"mean": round(st["mean"], 2), "min": round(st["min"], 2), "frames": st["frames"]}
175
+ # The alpha helpers (#417) live in _common.alpha since color.py, graphics.py, overlay.py and
176
+ # background.py write alpha lines too; the names this module has always had stay bound here.
177
+ from _common.alpha import (ALPHA_PIX as _ALPHA_PIX, ALPHA_TOLERANCE, OPAQUE, alpha_stats, # noqa: E402
178
+ alpha_compare as _alpha_compare, frame_means as _frame_means, rounded as _rounded,
179
+ decoder_args_for, vp9_alpha_tagged)
255
180
 
256
181
 
257
182
  def key_path(fill: str) -> str:
@@ -598,6 +523,11 @@ def main() -> int:
598
523
  # a pixel format with an alpha plane, or a VP9 stream stored with one (probed as yuv420p);
599
524
  # a dry run decides on these metadata reads alone and decodes nothing
600
525
  has_alpha_plane = bool(_ALPHA_PIX.match(meta["video"].get("pix_fmt") or "")) or vp9_alpha_tagged(args.input)
526
+ # a VP9 source stored with alpha on a build whose decoder list was read without libvpx-vp9
527
+ # is refused (decoder_missing) before it is measured: FFmpeg's own VP9 decoder reads it
528
+ # opaque, so the source and the written line both measured 255 and the alpha step passed
529
+ # with the transparency gone (#433 review). An unreadable list keeps the decoder named.
530
+ dec_args = decoder_args_for(meta, args.input)
601
531
  if not STATE.dry_run:
602
532
  source_alpha = alpha_stats(args.input, meta["video"].get("codec"))
603
533
  if not has_alpha_plane and (STATE.dry_run or source_alpha is None or source_alpha["min"] >= OPAQUE):
@@ -640,7 +570,8 @@ def main() -> int:
640
570
  # the key/fill branch below writes ProRes and returns before the size is applied or measured
641
571
  die("--max-mb runs a two-pass x264 encode; --preset key-fill writes ProRes (a fill and a key), which has no size target")
642
572
  # an alpha source in VP9 is decoded with libvpx-vp9, or its alpha never reaches the encoder
643
- dec_args = vp9_decoder_args(meta["video"].get("codec")) if p.get("alpha") else []
573
+ # (decoder_args_for, above: [] for anything that is not VP9 stored with alpha)
574
+ dec_args = dec_args if p.get("alpha") else []
644
575
  cmd = ffmpeg_base() + (REPAIR_INPUT_ARGS if args.repair else []) + dec_args + ["-i", args.input]
645
576
  # Subtitle and data streams (a soft-subtitle track muxed upstream, a camera's timecode) are
646
577
  # kept where the container can hold them: ffmpeg's default stream selection takes one video