ffmpeg-skill 3.2.2 → 3.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -9
- package/SKILL.md +5 -0
- package/docs/contract.md +20 -7
- package/package.json +1 -1
- package/references/gotchas.md +15 -0
- package/references/scripts.md +109 -6
- package/scripts/_common/__init__.py +4 -4
- package/scripts/_common/alpha.py +465 -0
- package/scripts/_common/decision.py +19 -7
- package/scripts/_common/probe.py +175 -9
- package/scripts/_common/runner.py +3 -2
- package/scripts/_contract.py +63 -14
- package/scripts/_smartcut.py +430 -0
- package/scripts/background.py +33 -6
- package/scripts/check.py +64 -1
- package/scripts/color.py +147 -30
- package/scripts/cut.py +94 -6
- package/scripts/export.py +12 -81
- package/scripts/graphics.py +51 -10
- package/scripts/overlay.py +49 -6
- package/scripts/probe.py +47 -1
package/scripts/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=
|
|
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)
|
|
179
|
-
# converting it (decision.video_args), so the legs use
|
|
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
|
|
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=
|
|
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
|
-
|
|
364
|
-
|
|
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
|
-
|
|
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
|
-
|
|
452
|
-
info(f"note: {
|
|
453
|
-
sdr_extra = {"sdr_path": sdr_path, "notes": [
|
|
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=
|
|
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
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
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
|
-
|
|
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 = "
|
|
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
|
|
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
|
|
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
|
-
#
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
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
|