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
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
"""The alpha channel: reading it, keeping it through a re-encode, and checking it was kept.
|
|
2
|
+
|
|
3
|
+
export.py's alpha presets (#417) measured the alpha of a written master against its source's; the
|
|
4
|
+
helpers moved here so the tools that composite or grade (color.py, graphics.py, overlay.py) and
|
|
5
|
+
background.py's transparent canvas can do the same:
|
|
6
|
+
|
|
7
|
+
- `--keep-alpha` (color, graphics, overlay) writes the picture with its alpha plane, in one of the
|
|
8
|
+
ALPHA_CODECS lines (`--alpha-codec`, ProRes 4444 by default); background.py's `--transparent`
|
|
9
|
+
writes a fully transparent canvas in the same lines. `--alpha-mode straight|premultiplied` says
|
|
10
|
+
how the colour is stored, as export.py's does.
|
|
11
|
+
- An alpha line keeps the source's colour tags and converts no matrix, as `--codec prores` does
|
|
12
|
+
(`keeps_source_tags()` in decision.py): an alpha master is an editing master.
|
|
13
|
+
- Every completed run on a source whose alpha is really used (a frame below OPAQUE) gets an
|
|
14
|
+
`alpha` verification step. Without `--keep-alpha` the output's picture has no alpha and that step
|
|
15
|
+
fails (the output reported `verified: true` with the alpha gone before this module existed);
|
|
16
|
+
`--drop-alpha` says the flattening is wanted, and a note records it instead.
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import re
|
|
23
|
+
import subprocess
|
|
24
|
+
import threading
|
|
25
|
+
from typing import Any, Dict, List, Optional
|
|
26
|
+
|
|
27
|
+
from _common import runner as _runner
|
|
28
|
+
from _common.runner import STATE, ffmpeg_encoders, require_tool, run
|
|
29
|
+
|
|
30
|
+
# A pixel format that carries an alpha plane (yuva*, rgba, argb, gbrap, ya8/ya16, ...).
|
|
31
|
+
ALPHA_PIX = re.compile(r"^(yuva|gbrap|ya\d|rgba|bgra|argb|abgr|rgba64|bgra64)")
|
|
32
|
+
|
|
33
|
+
ALPHA_TOLERANCE = 2.0 # of 255, per frame (or on the mean and the most transparent frame, below)
|
|
34
|
+
OPAQUE = 254.5 # a frame whose mean alpha is at least this is fully opaque
|
|
35
|
+
|
|
36
|
+
_MEASURE_W, _MEASURE_H = 64, 36 # the measuring copy: small enough to read every frame, large enough for exact edges
|
|
37
|
+
|
|
38
|
+
# The lines --keep-alpha / --transparent write. `pix_fmt` is what the encoder is asked for and is
|
|
39
|
+
# read back (the `alpha` step's `codec` field); `ext` are the containers that hold it.
|
|
40
|
+
ALPHA_CODECS: Dict[str, Dict[str, Any]] = {
|
|
41
|
+
"prores4444": {"video": ["-c:v", "prores_ks", "-profile:v", "4", "-vendor", "apl0", "-pix_fmt", "yuva444p10le",
|
|
42
|
+
"-alpha_bits", "16"],
|
|
43
|
+
"encoder": "prores_ks", "pix_fmt": "yuva444p10le", "ext": (".mov", ".mkv"),
|
|
44
|
+
"audio": ["-c:a", "pcm_s16le"], "desc": "ProRes 4444 with 16-bit alpha"},
|
|
45
|
+
"qtrle": {"video": ["-c:v", "qtrle", "-pix_fmt", "argb"], "encoder": "qtrle", "pix_fmt": "argb", "ext": (".mov",),
|
|
46
|
+
"audio": ["-c:a", "pcm_s16le"], "desc": "QuickTime Animation, lossless RGB + alpha"},
|
|
47
|
+
"png": {"video": ["-c:v", "png", "-pix_fmt", "rgba"], "encoder": "png", "pix_fmt": "rgba", "ext": (".mov", ".mkv"),
|
|
48
|
+
"audio": ["-c:a", "pcm_s16le"], "desc": "PNG frames in a .mov/.mkv, lossless RGB + alpha"},
|
|
49
|
+
"vp9": {"video": ["-c:v", "libvpx-vp9", "-pix_fmt", "yuva420p", "-crf", "20", "-b:v", "0", "-row-mt", "1"],
|
|
50
|
+
"encoder": "libvpx-vp9", "pix_fmt": "yuva420p", "ext": (".webm", ".mkv"),
|
|
51
|
+
"audio": ["-c:a", "libopus", "-b:a", "128k"], "desc": "VP9 with an alpha layer (CRF 20)"},
|
|
52
|
+
}
|
|
53
|
+
ALPHA_CODEC_DEFAULT = "prores4444"
|
|
54
|
+
ALPHA_MODES = ("straight", "premultiplied")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
_DECODERS: "Optional[set]" = None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def ffmpeg_decoders() -> set:
|
|
61
|
+
"""Names from `ffmpeg -decoders`, read once (empty when the list cannot be read), as
|
|
62
|
+
runner.ffmpeg_encoders() reads the encoders: the libvpx VP9 decoder is a build option of its
|
|
63
|
+
own, so the encoder list does not say whether it is there."""
|
|
64
|
+
global _DECODERS
|
|
65
|
+
if _DECODERS is None:
|
|
66
|
+
_DECODERS = _runner._read_ff_list("-decoders", r"^\s*[VAS][.\w]{5}\s+(\S+)")
|
|
67
|
+
return _DECODERS
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def libvpx_vp9_decoder() -> bool:
|
|
71
|
+
"""True when the build lists libvpx-vp9 among its decoders, or when the list could not be read
|
|
72
|
+
(unknown is not "missing": the explicit decoder is kept and ffmpeg itself says if it is absent).
|
|
73
|
+
False only when the list was read and does not have it."""
|
|
74
|
+
decoders = ffmpeg_decoders()
|
|
75
|
+
return not decoders or "libvpx-vp9" in decoders
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def vp9_decoder_args(codec: Optional[str]) -> List[str]:
|
|
79
|
+
"""The input option that decodes a VP9 stream with libvpx-vp9: FFmpeg's native vp9 decoder
|
|
80
|
+
drops the alpha layer (a WebM's yuva420p comes out yuv420p, fully opaque). [] for any other
|
|
81
|
+
codec, or when this build has no libvpx-vp9 decoder (decoder_args_for() refuses that case
|
|
82
|
+
where the alpha matters)."""
|
|
83
|
+
return ["-c:v", "libvpx-vp9"] if codec == "vp9" and libvpx_vp9_decoder() else []
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def vp9_alpha_tagged(path: str) -> bool:
|
|
87
|
+
"""True when the first video stream is VP9 stored with an alpha layer (Matroska/WebM's
|
|
88
|
+
ALPHA_MODE=1 stream tag): ffprobe reports such a stream as yuv420p, so the pixel format alone
|
|
89
|
+
does not show the alpha. A metadata read, so a dry run can decide without decoding."""
|
|
90
|
+
proc = run([require_tool("ffprobe"), "-v", "error", "-select_streams", "v:0", "-show_entries",
|
|
91
|
+
"stream=codec_name:stream_tags", "-of", "json", path], quiet=True)
|
|
92
|
+
try:
|
|
93
|
+
streams = json.loads(proc.stdout or "{}").get("streams") or []
|
|
94
|
+
except ValueError:
|
|
95
|
+
return False
|
|
96
|
+
s = streams[0] if streams else {}
|
|
97
|
+
return s.get("codec_name") == "vp9" and any(k.lower() == "alpha_mode" and str(v).strip() == "1"
|
|
98
|
+
for k, v in (s.get("tags") or {}).items())
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def has_alpha_plane(meta: Dict[str, Any], path: str) -> bool:
|
|
102
|
+
"""True when the first video stream of `path` (probed as `meta`) stores an alpha plane: a pixel
|
|
103
|
+
format with one, or a VP9 stream tagged with an alpha layer. Metadata only; whether the alpha
|
|
104
|
+
is used (any pixel below opaque) takes alpha_stats()."""
|
|
105
|
+
v = (meta or {}).get("video") or {}
|
|
106
|
+
# a palette (pal8: an indexed PNG/GIF) can carry transparency in the palette itself (tRNS),
|
|
107
|
+
# which the pixel format does not show; it is measured like an alpha plane
|
|
108
|
+
return (bool(ALPHA_PIX.match(v.get("pix_fmt") or "")) or v.get("pix_fmt") == "pal8"
|
|
109
|
+
or (v.get("codec") == "vp9" and vp9_alpha_tagged(path)))
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def frame_means(cmd_head: List[str], path: str, chain: str) -> "Dict[str, object] | None":
|
|
113
|
+
"""Every decoded frame of `path` through `chain` to a 64x36 grey copy: {mean (over every frame),
|
|
114
|
+
min (the lowest frame's mean), frames, per_frame (each frame's mean, in decode order)}. None
|
|
115
|
+
when no frame decodes."""
|
|
116
|
+
w, h = _MEASURE_W, _MEASURE_H
|
|
117
|
+
cmd = cmd_head + ["-i", path, "-map", "0:v:0", "-vf", f"{chain},scale={w}:{h}:flags=area,format=gray",
|
|
118
|
+
"-f", "rawvideo", "-"]
|
|
119
|
+
# Read frame by frame as ffmpeg writes them, not the whole pipe at once: the raw copy is 2304
|
|
120
|
+
# bytes a frame (~250 MB for an hour at 30 fps), the per-frame means a float each. The same
|
|
121
|
+
# wall-clock limit as run_analysis(), which this replaces here.
|
|
122
|
+
_runner._refuse_url_media(cmd)
|
|
123
|
+
limit = _runner._limit_for(cmd)
|
|
124
|
+
size = w * h
|
|
125
|
+
frames: List[float] = []
|
|
126
|
+
child = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, **_runner.child_kwargs())
|
|
127
|
+
_runner._watch(child, cmd)
|
|
128
|
+
expired = threading.Event()
|
|
129
|
+
|
|
130
|
+
def _expire() -> None:
|
|
131
|
+
expired.set()
|
|
132
|
+
child.kill()
|
|
133
|
+
|
|
134
|
+
timer = threading.Timer(limit, _expire) if limit else None
|
|
135
|
+
if timer:
|
|
136
|
+
timer.daemon = True
|
|
137
|
+
timer.start()
|
|
138
|
+
try:
|
|
139
|
+
pending = b""
|
|
140
|
+
while True:
|
|
141
|
+
chunk = child.stdout.read(size * 64)
|
|
142
|
+
if not chunk:
|
|
143
|
+
break
|
|
144
|
+
pending += chunk
|
|
145
|
+
whole = len(pending) - len(pending) % size
|
|
146
|
+
for i in range(0, whole, size):
|
|
147
|
+
frames.append(sum(pending[i:i + size]) / size)
|
|
148
|
+
pending = pending[whole:]
|
|
149
|
+
child.wait()
|
|
150
|
+
finally:
|
|
151
|
+
if timer:
|
|
152
|
+
timer.cancel()
|
|
153
|
+
_runner._unwatch(child)
|
|
154
|
+
child.stdout.close()
|
|
155
|
+
if expired.is_set():
|
|
156
|
+
_runner._timed_out(cmd, limit or 0)
|
|
157
|
+
if child.returncode != 0 or not frames:
|
|
158
|
+
return None
|
|
159
|
+
return {"mean": sum(frames) / len(frames), "min": min(frames), "frames": len(frames), "per_frame": frames}
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def alpha_stats(path: str, codec: Optional[str] = None) -> "Dict[str, object] | None":
|
|
163
|
+
"""The alpha (0-255) of every decoded frame, on a 64x36 copy: {mean, min, frames, per_frame}.
|
|
164
|
+
255.0 is fully opaque, and a picture with no alpha plane reads 255 too (format=rgba fills it).
|
|
165
|
+
Every frame, not the first: a clip that opens fully opaque and turns transparent later would
|
|
166
|
+
read as opaque from its first frame, in the source and in a written file that lost its alpha.
|
|
167
|
+
A VP9 file is decoded with libvpx-vp9 (vp9_decoder_args). None when no frame decodes."""
|
|
168
|
+
head = [require_tool("ffmpeg"), "-hide_banner", "-loglevel", "error", "-nostdin"] + vp9_decoder_args(codec)
|
|
169
|
+
return frame_means(head, path, "format=rgba,alphaextract")
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def alpha_compare(written, source) -> Dict[str, object]:
|
|
173
|
+
"""The written file's alpha against the source's. With the same number of frames, frame by
|
|
174
|
+
frame (frame i against frame i, each within ALPHA_TOLERANCE): an aggregate let a loss confined
|
|
175
|
+
to a few frames through (one of 200 frames turned opaque moves the mean by ~1). With a
|
|
176
|
+
different count (a variable-rate source conformed to a constant rate repeats or drops frames,
|
|
177
|
+
so indices no longer pair), the mean and the most transparent frame, and `paired: false`
|
|
178
|
+
says so. {ok, paired, worst_delta, worst_frame}."""
|
|
179
|
+
if written is None or source is None:
|
|
180
|
+
return {"ok": False, "paired": None, "worst_delta": None, "worst_frame": None}
|
|
181
|
+
w, s = written.get("per_frame") or [], source.get("per_frame") or []
|
|
182
|
+
if w and len(w) == len(s):
|
|
183
|
+
deltas = [abs(a - b) for a, b in zip(w, s)]
|
|
184
|
+
worst = max(range(len(deltas)), key=deltas.__getitem__)
|
|
185
|
+
return {"ok": deltas[worst] <= ALPHA_TOLERANCE, "paired": True, "worst_delta": round(deltas[worst], 2),
|
|
186
|
+
"worst_frame": worst}
|
|
187
|
+
delta = max(abs(written["mean"] - source["mean"]), abs(written["min"] - source["min"]))
|
|
188
|
+
return {"ok": delta <= ALPHA_TOLERANCE, "paired": False, "worst_delta": round(delta, 2), "worst_frame": None}
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def rounded(st) -> "Dict[str, float] | None":
|
|
192
|
+
return None if st is None else {"mean": round(st["mean"], 2), "min": round(st["min"], 2), "frames": st["frames"]}
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
# source_alpha()'s answer for a source that stores an alpha plane whose alpha could not be read:
|
|
196
|
+
# not "opaque" (that would let an output that dropped it pass), so every check on it fails
|
|
197
|
+
UNMEASURED: Dict[str, Any] = {"unmeasured": True}
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _measured(st):
|
|
201
|
+
return None if st is None or st.get("unmeasured") else st
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def uses_alpha(st) -> bool:
|
|
205
|
+
"""True when a measured clip has a frame that is not fully opaque (its alpha is used), or when
|
|
206
|
+
a clip that stores an alpha plane could not be measured (UNMEASURED)."""
|
|
207
|
+
return st is not None and (bool(st.get("unmeasured")) or st["min"] < OPAQUE)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
# --- the flags -----------------------------------------------------------------------------------
|
|
211
|
+
|
|
212
|
+
def add_alpha_args(ap, transparent: bool = False) -> None:
|
|
213
|
+
"""--keep-alpha / --drop-alpha (or background.py's --transparent), --alpha-codec, --alpha-mode."""
|
|
214
|
+
g = ap.add_argument_group("alpha")
|
|
215
|
+
if transparent:
|
|
216
|
+
g.add_argument("--transparent", action="store_true",
|
|
217
|
+
help="write a fully transparent canvas (alpha 0 in every pixel) in an alpha line (--alpha-codec), "
|
|
218
|
+
"a base layer for graphics.py / overlay.py --keep-alpha; --color/--gradient do not apply")
|
|
219
|
+
else:
|
|
220
|
+
g.add_argument("--keep-alpha", action="store_true",
|
|
221
|
+
help="keep the source's alpha: write the output in an alpha line (--alpha-codec, default "
|
|
222
|
+
"prores4444; needs a .mov/.mkv, or .webm for vp9) with the source's colour tags kept, and "
|
|
223
|
+
"check the alpha after (the `alpha` verification step). Refused on a source without an alpha plane")
|
|
224
|
+
g.add_argument("--drop-alpha", action="store_true",
|
|
225
|
+
help="the source has alpha and an opaque output is wanted: the alpha is dropped on purpose and a note "
|
|
226
|
+
"says so. Without it (or --keep-alpha) an output that loses a used alpha fails the `alpha` step")
|
|
227
|
+
g.add_argument("--alpha-codec", choices=sorted(ALPHA_CODECS), default=None,
|
|
228
|
+
help="the alpha line: prores4444 (default; ProRes 4444, 16-bit alpha), qtrle (QuickTime Animation, "
|
|
229
|
+
".mov), png (PNG frames, .mov/.mkv), vp9 (VP9 with alpha, .webm/.mkv)")
|
|
230
|
+
g.add_argument("--alpha-mode", choices=list(ALPHA_MODES), default=None,
|
|
231
|
+
help="how the colour is written beside the alpha: straight (default, as composited) or premultiplied "
|
|
232
|
+
"by the alpha; reported as alpha_mode")
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def alpha_requested(args) -> bool:
|
|
236
|
+
return bool(getattr(args, "keep_alpha", False) or getattr(args, "transparent", False))
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def check_alpha_args(args, output: Optional[str]) -> Optional[str]:
|
|
240
|
+
"""Validates the alpha flags against each other and the output name; returns the alpha codec
|
|
241
|
+
(None when no alpha line was asked for). Sets STATE.alpha_line so the colour helpers keep the
|
|
242
|
+
source's tags (decision.keeps_source_tags)."""
|
|
243
|
+
from _common.emit import die # emit imports runner; imported here to keep this module light
|
|
244
|
+
flag = "--transparent" if hasattr(args, "transparent") else "--keep-alpha"
|
|
245
|
+
if not alpha_requested(args):
|
|
246
|
+
if args.alpha_codec or args.alpha_mode:
|
|
247
|
+
die(f"--alpha-codec and --alpha-mode apply with {flag}", kind="input")
|
|
248
|
+
return None
|
|
249
|
+
if getattr(args, "drop_alpha", False):
|
|
250
|
+
die("--keep-alpha and --drop-alpha ask for opposite things: give one", kind="input")
|
|
251
|
+
if STATE.codec:
|
|
252
|
+
die(f"{flag} writes its own alpha line (--alpha-codec); --codec {STATE.codec} cannot carry an alpha channel",
|
|
253
|
+
kind="input", hint=f"drop --codec, or drop {flag}")
|
|
254
|
+
codec = args.alpha_codec or ALPHA_CODEC_DEFAULT
|
|
255
|
+
line = ALPHA_CODECS[codec]
|
|
256
|
+
if output is not None:
|
|
257
|
+
ext = os.path.splitext(output)[1].lower()
|
|
258
|
+
if ext not in line["ext"]:
|
|
259
|
+
die(f"{flag} --alpha-codec {codec} needs a {' or '.join(line['ext'])} output; {output} cannot hold it",
|
|
260
|
+
kind="input", hint=f"pass -o NAME{line['ext'][0]}")
|
|
261
|
+
encoders = ffmpeg_encoders()
|
|
262
|
+
if encoders and line["encoder"] not in encoders:
|
|
263
|
+
die(f"--alpha-codec {codec} needs the {line['encoder']} encoder and this ffmpeg build has none",
|
|
264
|
+
kind="missing_tool", error_extra={"reason": "encoder_missing", "details": {"name": line["encoder"]}},
|
|
265
|
+
hint="python3 scripts/_contract.py doctor lists the encoders this build has; --alpha-codec prores4444 or png "
|
|
266
|
+
"need only FFmpeg's own encoders")
|
|
267
|
+
STATE.alpha_line = codec
|
|
268
|
+
return codec
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def default_ext(codec: str) -> str:
|
|
272
|
+
return ALPHA_CODECS[codec]["ext"][0].lstrip(".")
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def video_line(codec: str) -> List[str]:
|
|
276
|
+
return list(ALPHA_CODECS[codec]["video"])
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def audio_line(codec: str) -> List[str]:
|
|
280
|
+
return list(ALPHA_CODECS[codec]["audio"])
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
_TAG_KEYS = (("colorspace", "color_space", "-colorspace"), ("color_primaries", "color_primaries", "-color_primaries"),
|
|
284
|
+
("color_trc", "color_transfer", "-color_trc"))
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _source_tags(meta: Optional[Dict[str, Any]]) -> List[tuple]:
|
|
288
|
+
v = (meta or {}).get("video") or {}
|
|
289
|
+
return [(param, v.get(key), flag) for param, key, flag in _TAG_KEYS
|
|
290
|
+
if v.get(key) and v.get(key) not in ("unknown", "reserved", "unspecified")]
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def finish_chain(codec: str, mode: Optional[str], meta: Optional[Dict[str, Any]] = None) -> str:
|
|
294
|
+
"""The end of a filter chain that hands an alpha picture to the line: premultiplied colour
|
|
295
|
+
when asked, the source's colour tags put back on the frames (`setparams`: a grade's RGB legs
|
|
296
|
+
and an RGB layer's conversion relabel them, BT.601 or unknown), then the line's own pixel
|
|
297
|
+
format (so no later conversion can drop the plane)."""
|
|
298
|
+
tags = _source_tags(meta)
|
|
299
|
+
label = ("setparams=" + ":".join(f"{p}={val}" for p, val, _ in tags) + ",") if tags else ""
|
|
300
|
+
# premultiplied colour is colour x alpha in RGB: multiplied there (a YUV premultiply scales the
|
|
301
|
+
# limited-range offsets too), after the tags are back so both conversions use the same matrix
|
|
302
|
+
return (label + ("format=gbrap,premultiply=inplace=1," if mode == "premultiplied" else "")
|
|
303
|
+
+ f"format={ALPHA_CODECS[codec]['pix_fmt']}")
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def tag_args(meta: Optional[Dict[str, Any]]) -> List[str]:
|
|
307
|
+
"""The source's colour tags as output options, so the written stream says what the frames do
|
|
308
|
+
(an alpha line keeps the source's tags, as --codec prores does)."""
|
|
309
|
+
out: List[str] = []
|
|
310
|
+
for _p, val, flag in _source_tags(meta):
|
|
311
|
+
out += [flag, val]
|
|
312
|
+
return out
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def written_line(output_meta: Dict[str, Any], output: str) -> Dict[str, Any]:
|
|
316
|
+
"""What the written stream is: {codec, pix_fmt, alpha_plane} (alpha_plane: the stream stores one)."""
|
|
317
|
+
v = (output_meta or {}).get("video") or {}
|
|
318
|
+
return {"codec": v.get("codec"), "pix_fmt": v.get("pix_fmt"), "alpha_plane": has_alpha_plane(output_meta, output)}
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def kept_step(output: str, output_meta: Dict[str, Any], source: "Dict[str, object] | None", *,
|
|
322
|
+
composite: bool, provenance: str = "input_premeasurement") -> Dict[str, Any]:
|
|
323
|
+
"""The `alpha` verification step of a --keep-alpha / --transparent run.
|
|
324
|
+
|
|
325
|
+
`composite` False (color.py, background.py --transparent against an all-zero source): the
|
|
326
|
+
alpha is not touched by the operation, so the written alpha must equal the source's frame by
|
|
327
|
+
frame (alpha_compare). `composite` True (graphics.py, overlay.py): layers drawn over the
|
|
328
|
+
picture add coverage, so the written alpha may only be at or above the source's in every
|
|
329
|
+
frame, and must not have gone opaque in every frame the source was transparent in (what an
|
|
330
|
+
alpha dropped on the way reads as). In both, the written stream must store an alpha plane."""
|
|
331
|
+
unmeasured = bool(source and source.get("unmeasured"))
|
|
332
|
+
source = _measured(source)
|
|
333
|
+
line = written_line(output_meta, output)
|
|
334
|
+
written = alpha_stats(output, line["codec"])
|
|
335
|
+
step: Dict[str, Any] = {"step": "alpha", "property": "alpha_mean", "tolerance": ALPHA_TOLERANCE,
|
|
336
|
+
"expected": None if source is None else round(source["mean"], 2),
|
|
337
|
+
"measured": None if written is None else round(written["mean"], 2),
|
|
338
|
+
"frames": {"expected": rounded(source), "measured": rounded(written)},
|
|
339
|
+
"written": line, "provenance": provenance}
|
|
340
|
+
if unmeasured:
|
|
341
|
+
step["reason"] = "source_alpha_unmeasured"
|
|
342
|
+
if not composite:
|
|
343
|
+
cmp = alpha_compare(written, source)
|
|
344
|
+
step.update(comparison=cmp, ok=bool(cmp["ok"] and line["alpha_plane"]))
|
|
345
|
+
return step
|
|
346
|
+
step["property"] = "alpha_at_least_source"
|
|
347
|
+
if written is None or source is None:
|
|
348
|
+
step.update(ok=False, comparison={"ok": False, "paired": None, "worst_delta": None, "worst_frame": None})
|
|
349
|
+
return step
|
|
350
|
+
w, s = written["per_frame"], source["per_frame"]
|
|
351
|
+
paired = len(w) == len(s)
|
|
352
|
+
if paired:
|
|
353
|
+
deficits = [b - a for a, b in zip(w, s)]
|
|
354
|
+
worst = max(range(len(deficits)), key=deficits.__getitem__)
|
|
355
|
+
below = deficits[worst] > ALPHA_TOLERANCE
|
|
356
|
+
opaque_where_transparent = all(a >= OPAQUE for a, b in zip(w, s) if b < OPAQUE) and any(b < OPAQUE for b in s)
|
|
357
|
+
cmp = {"paired": True, "worst_delta": round(max(deficits[worst], 0.0), 2), "worst_frame": worst}
|
|
358
|
+
else:
|
|
359
|
+
below = written["min"] < source["min"] - ALPHA_TOLERANCE
|
|
360
|
+
opaque_where_transparent = written["min"] >= OPAQUE and source["min"] < OPAQUE
|
|
361
|
+
cmp = {"paired": False, "worst_delta": round(max(source["min"] - written["min"], 0.0), 2), "worst_frame": None}
|
|
362
|
+
ok = line["alpha_plane"] and not below and not opaque_where_transparent
|
|
363
|
+
cmp["ok"] = ok
|
|
364
|
+
if opaque_where_transparent:
|
|
365
|
+
step["reason"] = "opaque_everywhere"
|
|
366
|
+
elif below:
|
|
367
|
+
step["reason"] = "alpha_below_source"
|
|
368
|
+
elif not line["alpha_plane"]:
|
|
369
|
+
step["reason"] = "no_alpha_plane"
|
|
370
|
+
step.update(comparison=cmp, ok=ok)
|
|
371
|
+
return step
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def lost_step(output: str, output_meta: Dict[str, Any], source: "Dict[str, object] | None") -> Dict[str, Any]:
|
|
375
|
+
"""The `alpha` step of a run without --keep-alpha on a source whose alpha is used: the output
|
|
376
|
+
stores no alpha plane (every re-encoding line but the alpha ones is opaque), so it fails, with
|
|
377
|
+
the reason and the flag that keeps it. Measured, not assumed: a written file that does carry
|
|
378
|
+
the source's alpha passes. A source whose alpha could not be measured (UNMEASURED) fails
|
|
379
|
+
with reason source_alpha_unmeasured: nothing shows the output kept what it had."""
|
|
380
|
+
unmeasured = bool(source and source.get("unmeasured"))
|
|
381
|
+
source = _measured(source)
|
|
382
|
+
line = written_line(output_meta, output)
|
|
383
|
+
written = alpha_stats(output, line["codec"]) if line["alpha_plane"] else None
|
|
384
|
+
cmp = alpha_compare(written, source) if written is not None else {"ok": False, "paired": None,
|
|
385
|
+
"worst_delta": None, "worst_frame": None}
|
|
386
|
+
step: Dict[str, Any] = {"step": "alpha", "property": "alpha_mean", "ok": bool(cmp["ok"]), "comparison": cmp,
|
|
387
|
+
"tolerance": ALPHA_TOLERANCE,
|
|
388
|
+
"expected": None if source is None else round(source["mean"], 2),
|
|
389
|
+
"measured": round(written["mean"], 2) if written is not None else 255.0,
|
|
390
|
+
"frames": {"expected": rounded(source), "measured": rounded(written)},
|
|
391
|
+
"written": line, "provenance": "input_premeasurement"}
|
|
392
|
+
if not step["ok"]:
|
|
393
|
+
step.update(reason="source_alpha_unmeasured" if unmeasured and line["alpha_plane"] else "alpha_dropped",
|
|
394
|
+
attribution="artifact",
|
|
395
|
+
hint="pass --keep-alpha to write an alpha line, or --drop-alpha when an opaque output is wanted")
|
|
396
|
+
return step
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def source_alpha(args, meta: Dict[str, Any], path: str) -> "Dict[str, object] | None":
|
|
400
|
+
"""The source's alpha measured once, before the run, when it can matter: the source stores an
|
|
401
|
+
alpha plane and this is not a dry run. Refuses --keep-alpha on a source without one (nothing to
|
|
402
|
+
keep). Returns None when there is nothing to measure."""
|
|
403
|
+
from _common.emit import die
|
|
404
|
+
if STATE.dry_run and _runner.dry_run_input_pending(path):
|
|
405
|
+
# a planned input an earlier step will write (a --transparent canvas in a recipe): there is
|
|
406
|
+
# nothing to probe yet, so the alpha checks wait for the real run
|
|
407
|
+
return None
|
|
408
|
+
plane = has_alpha_plane(meta, path)
|
|
409
|
+
codec = ((meta or {}).get("video") or {}).get("codec")
|
|
410
|
+
if plane and codec == "vp9" and not libvpx_vp9_decoder():
|
|
411
|
+
_no_vp9_decoder(path)
|
|
412
|
+
if getattr(args, "keep_alpha", False) and not plane:
|
|
413
|
+
die(f"--keep-alpha keeps the source's alpha channel and {path} has none "
|
|
414
|
+
f"({((meta or {}).get('video') or {}).get('pix_fmt')}, fully opaque)", kind="input",
|
|
415
|
+
hint="background.py --transparent makes a transparent canvas to draw on; drop --keep-alpha for an opaque output")
|
|
416
|
+
if not plane or STATE.dry_run:
|
|
417
|
+
return None
|
|
418
|
+
return alpha_stats(path, codec) or UNMEASURED
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def decoder_args_for(meta: Dict[str, Any], path: str) -> List[str]:
|
|
422
|
+
"""The input options a source with alpha needs to arrive with it: libvpx-vp9 for a VP9 stream
|
|
423
|
+
tagged with an alpha layer (the native decoder drops it). [] for anything else."""
|
|
424
|
+
v = (meta or {}).get("video") or {}
|
|
425
|
+
if v.get("codec") != "vp9" or not vp9_alpha_tagged(path):
|
|
426
|
+
return []
|
|
427
|
+
if not libvpx_vp9_decoder():
|
|
428
|
+
# FFmpeg's own decoder would read it without the alpha, and a layer would cover what is
|
|
429
|
+
# under it: refused, not composited wrong
|
|
430
|
+
_no_vp9_decoder(path)
|
|
431
|
+
return ["-c:v", "libvpx-vp9"]
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def _no_vp9_decoder(path: str) -> None:
|
|
435
|
+
from _common.emit import die
|
|
436
|
+
die(f"{path} is VP9 with an alpha layer, and this ffmpeg build has no libvpx-vp9 decoder to read it "
|
|
437
|
+
"(FFmpeg's own VP9 decoder drops the alpha)", kind="missing_tool",
|
|
438
|
+
error_extra={"reason": "decoder_missing", "details": {"name": "libvpx-vp9"}},
|
|
439
|
+
hint="use an ffmpeg built with libvpx, or a ProRes 4444 / qtrle / PNG source")
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def report(args, codec: Optional[str], src: "Dict[str, object] | None", output: Optional[str],
|
|
443
|
+
extra: Dict[str, Any], composite: bool, provenance: str = "input_premeasurement") -> None:
|
|
444
|
+
"""Adds the alpha fields and the `alpha` step to a result's `extra` (after the write)."""
|
|
445
|
+
from _common.emit import note, warn
|
|
446
|
+
from _common.probe import probe
|
|
447
|
+
if codec:
|
|
448
|
+
extra["alpha_mode"] = args.alpha_mode or "straight"
|
|
449
|
+
extra["alpha_codec"] = codec
|
|
450
|
+
if output and not STATE.dry_run:
|
|
451
|
+
extra.setdefault("verification", []).append(kept_step(output, probe(output, role="output"), src,
|
|
452
|
+
composite=composite, provenance=provenance))
|
|
453
|
+
return
|
|
454
|
+
if not uses_alpha(src) or not output or STATE.dry_run:
|
|
455
|
+
return
|
|
456
|
+
if getattr(args, "drop_alpha", False):
|
|
457
|
+
note("the source's alpha channel was dropped (--drop-alpha): the output is opaque")
|
|
458
|
+
extra["alpha_dropped"] = True
|
|
459
|
+
return
|
|
460
|
+
step = lost_step(output, probe(output, role="output"), src)
|
|
461
|
+
extra.setdefault("verification", []).append(step)
|
|
462
|
+
if not step["ok"]:
|
|
463
|
+
extra["alpha_dropped"] = True
|
|
464
|
+
warn("the source has an alpha channel and this output does not keep it: pass --keep-alpha to keep it, "
|
|
465
|
+
"or --drop-alpha when an opaque output is wanted", "alpha_dropped")
|
|
@@ -741,6 +741,13 @@ def _x264_raw(crf: int, preset: str, keep_bt709: bool = True) -> List[str]:
|
|
|
741
741
|
return args
|
|
742
742
|
|
|
743
743
|
|
|
744
|
+
def keeps_source_tags() -> bool:
|
|
745
|
+
"""True when the line being written is an editing master that keeps the source's colour tags
|
|
746
|
+
and converts no matrix: --codec prores, or an alpha line (--keep-alpha / --transparent,
|
|
747
|
+
_common.alpha). The BT.709 conversions and labels below are for lines tagged BT.709."""
|
|
748
|
+
return STATE.codec == "prores" or STATE.alpha_line is not None
|
|
749
|
+
|
|
750
|
+
|
|
744
751
|
def x264_args(crf: int = 18, preset: str = "medium", keep_bt709: bool = True,
|
|
745
752
|
source: Optional[Dict[str, Any]] = None) -> List[str]:
|
|
746
753
|
"""SDR H.264 encoder args -- or, when --codec named another encoder, that encoder's SDR args
|
|
@@ -753,7 +760,7 @@ def x264_args(crf: int = 18, preset: str = "medium", keep_bt709: bool = True,
|
|
|
753
760
|
args = encoder_args(STATE.codec, crf, preset, None, keep_bt709)
|
|
754
761
|
else:
|
|
755
762
|
args = _maybe_hw("h264", crf, None, keep_bt709, _x264_raw(crf, preset, keep_bt709))
|
|
756
|
-
return args + (bt709_matrix_args(source) if keep_bt709 and
|
|
763
|
+
return args + (bt709_matrix_args(source) if keep_bt709 and not keeps_source_tags() else [])
|
|
757
764
|
|
|
758
765
|
|
|
759
766
|
def video_args(meta: Optional[Dict[str, Any]], crf: int = 18, preset: str = "medium", convert_matrix: bool = True,
|
|
@@ -777,7 +784,7 @@ def video_args(meta: Optional[Dict[str, Any]], crf: int = 18, preset: str = "med
|
|
|
777
784
|
video_args(None)); its RGB or BT.601 pixels are converted to the BT.709 that line is tagged with.
|
|
778
785
|
"""
|
|
779
786
|
v = (meta or {}).get("video") or {}
|
|
780
|
-
fix = bt709_matrix_args(meta if source is None else source) if convert_matrix and
|
|
787
|
+
fix = bt709_matrix_args(meta if source is None else source) if convert_matrix and not keeps_source_tags() else []
|
|
781
788
|
if STATE.codec:
|
|
782
789
|
return encoder_args(STATE.codec, crf, preset, meta) + fix
|
|
783
790
|
if not v.get("bt2020_or_hdr"):
|
|
@@ -790,7 +797,7 @@ def matrix_chain(meta: Optional[Dict[str, Any]], line_meta: Optional[Dict[str, A
|
|
|
790
797
|
video_args(line_meta, convert_matrix=False): the conversion one source's own chain starts
|
|
791
798
|
with ("<filter>," -- bt709_matrix_filter), or "" when that source needs none or the line does
|
|
792
799
|
not tag BT.709 (an HDR/BT.2020 line keeps its tags, --codec prores keeps the source's)."""
|
|
793
|
-
if
|
|
800
|
+
if keeps_source_tags() or ((line_meta or {}).get("video") or {}).get("bt2020_or_hdr"):
|
|
794
801
|
return ""
|
|
795
802
|
f = bt709_matrix_filter(meta)
|
|
796
803
|
return f + "," if f else ""
|
|
@@ -801,7 +808,7 @@ def label_chain(meta: Optional[Dict[str, Any]], line_meta: Optional[Dict[str, An
|
|
|
801
808
|
cell beside a converted cell), encoded through video_args(line_meta): the BT.709 label an
|
|
802
809
|
untagged one's chain starts with ("<setparams>," -- bt709_label_filter), or "" (a tagged, RGB or
|
|
803
810
|
HDR picture, or a line that does not tag BT.709)."""
|
|
804
|
-
if
|
|
811
|
+
if keeps_source_tags() or ((line_meta or {}).get("video") or {}).get("bt2020_or_hdr"):
|
|
805
812
|
return ""
|
|
806
813
|
f = bt709_label_filter(meta)
|
|
807
814
|
return f + "," if f else ""
|
|
@@ -813,7 +820,7 @@ def range_chain(meta: Optional[Dict[str, Any]], line_meta: Optional[Dict[str, An
|
|
|
813
820
|
takes after its matrix_chain()/label_chain() ("<filter>," -- limited_range_filter), or "" (a
|
|
814
821
|
limited, RGB or HDR picture, or a line that does not tag BT.709). Every SDR picture such a
|
|
815
822
|
tool mixes is then limited range, whatever each one was: the filters mix samples, not colours."""
|
|
816
|
-
if
|
|
823
|
+
if keeps_source_tags() or ((line_meta or {}).get("video") or {}).get("bt2020_or_hdr"):
|
|
817
824
|
return ""
|
|
818
825
|
f = limited_range_filter(meta)
|
|
819
826
|
return f + "," if f else ""
|
|
@@ -835,7 +842,12 @@ def rgb_chain_to_bt709(line_meta: Optional[Dict[str, Any]], pix_fmt: str = "yuva
|
|
|
835
842
|
is encoded through video_args(line_meta): "," + the conversion to BT.709 YUV to end that chain
|
|
836
843
|
with (rgb_to_bt709_filter), or "" when the line does not tag BT.709 (the lines matrix_chain()
|
|
837
844
|
leaves alone). Without it the graph's own RGB-to-YUV step used FFmpeg's BT.601 default."""
|
|
838
|
-
|
|
845
|
+
v = (line_meta or {}).get("video") or {}
|
|
846
|
+
if STATE.alpha_line is not None and v.get("color_space") == "bt709" and not v.get("bt2020_or_hdr"):
|
|
847
|
+
# an alpha line keeps the source's tags: on a BT.709-tagged picture an RGB layer is made
|
|
848
|
+
# BT.709 too (FFmpeg 6.1's own conversion is BT.601), in 4:4:4 with its alpha plane
|
|
849
|
+
return "," + rgb_to_bt709_filter("yuva444p")
|
|
850
|
+
if keeps_source_tags() or v.get("bt2020_or_hdr"):
|
|
839
851
|
return ""
|
|
840
852
|
return "," + rgb_to_bt709_filter(pix_fmt)
|
|
841
853
|
|
|
@@ -843,7 +855,7 @@ def rgb_chain_to_bt709(line_meta: Optional[Dict[str, Any]], pix_fmt: str = "yuva
|
|
|
843
855
|
def draws_chroma(colours: Sequence[Any], line_meta: Optional[Dict[str, Any]]) -> bool:
|
|
844
856
|
"""True when one of `colours` has chroma (is_neutral_color) and the line video_args(line_meta)
|
|
845
857
|
tags the output BT.709 -- when a drawn colour needs drawn_chain() at all."""
|
|
846
|
-
return (not all(is_neutral_color(c) for c in colours if c is not None) and
|
|
858
|
+
return (not all(is_neutral_color(c) for c in colours if c is not None) and not keeps_source_tags()
|
|
847
859
|
and not ((line_meta or {}).get("video") or {}).get("bt2020_or_hdr"))
|
|
848
860
|
|
|
849
861
|
|