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.
@@ -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 STATE.codec != "prores" else [])
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 STATE.codec != "prores" else []
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 STATE.codec == "prores" or ((line_meta or {}).get("video") or {}).get("bt2020_or_hdr"):
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 STATE.codec == "prores" or ((line_meta or {}).get("video") or {}).get("bt2020_or_hdr"):
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 STATE.codec == "prores" or ((line_meta or {}).get("video") or {}).get("bt2020_or_hdr"):
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
- if STATE.codec == "prores" or ((line_meta or {}).get("video") or {}).get("bt2020_or_hdr"):
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 STATE.codec != "prores"
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