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,430 @@
1
+ """cut.py --smart: a keyframe-aware cut that re-encodes only the partial GOPs at each cut point.
2
+
3
+ A segment [s, e) on a CFR, progressive H.264 source is split at its keyframes:
4
+
5
+ head [first frame at/after s, K1) re-encoded (K1 = first keyframe at or after s)
6
+ copy [K1, K2) stream-copied, whole closed GOPs
7
+ tail [K2, first frame at/after e) re-encoded (K2 = last keyframe at or before e)
8
+
9
+ A part that is empty (s on a keyframe, e on a keyframe or the end of the video) is left out; a
10
+ segment with no keyframe inside it is one re-encoded part. The re-encoded parts are encoded with
11
+ libx264 matched to the source stream (profile, level, pixel format, reorder depth, frame size,
12
+ SAR, colour tags) and every part is written as MPEG-TS, where each keyframe carries its own
13
+ parameter sets in-band, so the decoder switches between the encoder's SPS/PPS and the source's at
14
+ every join. The parts are joined with the concat demuxer (each part's exact length stated), and
15
+ the sound is cut from the source on the same frame grid and encoded once, in the same command.
16
+
17
+ The result is verified before it is moved into place, and any failure falls back to the full
18
+ re-encode with the reason stated:
19
+
20
+ frames the output's frame count and presentation times: one frame per frame duration from
21
+ 0, none missing, none repeated
22
+ packets every copied part's packets against the source's (MD5 of each packet with the
23
+ parameter-set and delimiter NAL units removed, which the in-band switch adds)
24
+ decode framemd5 of each re-encoded part as written against the same frames decoded from
25
+ the output, and of the first DECODE_CHECK_MAX_FRAMES frames of each copied
26
+ part against the source's
27
+ params the re-encoded parts' stream parameters against the source's, and the output's
28
+ time base against the source's
29
+
30
+ Nothing here decides where to cut; it only executes the cut it is given.
31
+ """
32
+ import os
33
+ from fractions import Fraction
34
+ from typing import Any, Dict, List, Optional, Tuple
35
+
36
+ from _common import STATE, aac_args, concat_list_line, ffmpeg_base, require_tool, run
37
+ from _common.runner import fail_if_classified
38
+
39
+ # the stream parameters a re-encoded part must share with the source (ffprobe -show_streams)
40
+ MATCH_FIELDS = ("codec_name", "profile", "level", "pix_fmt", "width", "height", "sample_aspect_ratio",
41
+ "has_b_frames", "color_space", "color_range", "color_transfer", "color_primaries")
42
+ # what libx264 here can encode to match: 8-bit 4:2:0 (full range as yuvj420p)
43
+ SMART_PIX_FMTS = ("yuv420p", "yuvj420p")
44
+ SMART_PROFILES = {"Constrained Baseline": "baseline", "Baseline": "baseline", "Main": "main", "High": "high"}
45
+ SMART_EXTS = (".mp4", ".mov", ".m4v", ".mkv")
46
+ # the first frames of a copied part are decoded and compared against the source's; capped so a long part
47
+ # does not make the check cost a long decode
48
+ DECODE_CHECK_MAX_FRAMES = 300
49
+ # an input -ss lands this far after the keyframe it is asked for (ffprobe prints microseconds)
50
+ _SEEK_PAST = 1e-4
51
+ _DTS_MARGIN = 1e-4
52
+
53
+
54
+ class SmartFallback(Exception):
55
+ """The smart cut cannot be made or did not verify: `reason` is the stated cause."""
56
+
57
+ def __init__(self, reason: str, code: str):
58
+ super().__init__(reason)
59
+ self.reason, self.code = reason, code
60
+
61
+
62
+ def stream_params(path: str) -> Optional[Dict[str, Any]]:
63
+ """The first video stream's parameters (MATCH_FIELDS plus field_order, time_base, r_frame_rate)."""
64
+ import json
65
+ fields = ",".join(MATCH_FIELDS + ("field_order", "time_base", "r_frame_rate"))
66
+ proc = run([require_tool("ffprobe"), "-v", "error", "-select_streams", "v:0", "-show_entries",
67
+ f"stream={fields}", "-of", "json", path], quiet=True, check=False)
68
+ try:
69
+ streams = json.loads(proc.stdout or "{}").get("streams") or []
70
+ except ValueError:
71
+ return None
72
+ return streams[0] if proc.returncode == 0 and streams else None
73
+
74
+
75
+ def _norm(field: str, value: Any) -> Any:
76
+ if field == "profile":
77
+ return SMART_PROFILES.get(str(value), value)
78
+ if field == "sample_aspect_ratio" and value in (None, "0:1", "N/A"):
79
+ return "1:1"
80
+ return value
81
+
82
+
83
+ def mismatches(want: Dict[str, Any], got: Dict[str, Any]) -> List[str]:
84
+ """`field source/part` for every MATCH_FIELDS value the part does not share."""
85
+ return [f"{f} {want.get(f)}/{got.get(f)}" for f in MATCH_FIELDS if _norm(f, want.get(f)) != _norm(f, got.get(f))]
86
+
87
+
88
+ def ineligible(params: Optional[Dict[str, Any]], meta: Dict[str, Any], ext: str) -> Optional[Tuple[str, str]]:
89
+ """(reason, code) when the source or the output cannot take a smart cut, else None."""
90
+ if params is None:
91
+ return "the source's video stream parameters could not be read", "params_unreadable"
92
+ if ext.lower() not in SMART_EXTS:
93
+ return f"--smart writes {', '.join(SMART_EXTS)}; {ext} is not one", "container"
94
+ if params.get("codec_name") != "h264":
95
+ return (f"--smart matches the source with libx264, so it takes H.264; this source is "
96
+ f"{params.get('codec_name')}", "codec")
97
+ if params.get("pix_fmt") not in SMART_PIX_FMTS:
98
+ return f"--smart encodes 8-bit 4:2:0; this source is {params.get('pix_fmt')}", "pix_fmt"
99
+ if str(params.get("profile")) not in SMART_PROFILES:
100
+ return f"H.264 profile {params.get('profile')} cannot be matched by libx264 here", "profile"
101
+ if params.get("field_order") not in (None, "progressive", "unknown"):
102
+ return f"the source is interlaced ({params.get('field_order')})", "interlaced"
103
+ if int(params.get("has_b_frames") or 0) > 2:
104
+ return f"a reorder depth of {params.get('has_b_frames')} frames cannot be matched", "reorder_depth"
105
+ if not _rate(params):
106
+ return "the source states no frame rate", "frame_rate"
107
+ if (meta.get("video") or {}).get("bt2020_or_hdr"):
108
+ return "an HDR or BT.2020 source is encoded on the 10-bit HEVC line, not matched", "hdr"
109
+ return None
110
+
111
+
112
+ def _rate(params: Dict[str, Any]) -> Optional[Fraction]:
113
+ try:
114
+ q = Fraction(str(params.get("r_frame_rate") or ""))
115
+ except (ValueError, ZeroDivisionError):
116
+ return None
117
+ return q if q > 0 else None
118
+
119
+
120
+ def x264_line(params: Dict[str, Any], crf: int, preset: str) -> List[str]:
121
+ """libx264 arguments that reproduce the source stream's parameters (MATCH_FIELDS)."""
122
+ level = int(params.get("level") or 0)
123
+ depth = int(params.get("has_b_frames") or 0)
124
+ # the reorder depth decides how far each part's decode times run ahead of its presentation
125
+ # times; parts of different depth would collide in decode order at the join
126
+ b = "bframes=0" if depth == 0 else ("bframes=3:b-pyramid=none" if depth == 1 else "bframes=3:b-pyramid=normal")
127
+ args = ["-c:v", "libx264", "-preset", preset, "-crf", str(crf),
128
+ "-profile:v", SMART_PROFILES[str(params["profile"])], "-pix_fmt", params["pix_fmt"],
129
+ "-x264-params", f"{b}:open-gop=0"]
130
+ if level:
131
+ args += ["-level:v", f"{level // 10}.{level % 10}"]
132
+ for flag, field in (("-colorspace", "color_space"), ("-color_primaries", "color_primaries"),
133
+ ("-color_trc", "color_transfer"), ("-color_range", "color_range")):
134
+ value = params.get(field)
135
+ if value and value != "unknown":
136
+ args += [flag, str(value)]
137
+ return args
138
+
139
+
140
+ def frames_between(frames: List[float], lo: float, hi: Optional[float]) -> List[float]:
141
+ return [p for p in frames if p >= lo - 1e-6 and (hi is None or p < hi - 1e-6)]
142
+
143
+
144
+ def plan_segment(packets: List[Tuple[Any, Any, bool]], s: float, e: float, vend: Optional[float],
145
+ gop_is_open) -> List[Dict[str, Any]]:
146
+ """The parts of one segment, in order: {kind: reencode|copy, start, end (None: to the end of
147
+ the video), frames}. Raises SmartFallback when a keyframe the copy would start or stop at
148
+ opens its GOP or the packets around a cut were not read."""
149
+ frames = sorted(p[0] for p in packets if p[0] is not None)
150
+ keyidx = [i for i, p in enumerate(packets) if p[2] and p[0] is not None and p[1] is not None]
151
+ seg = frames_between(frames, s, e)
152
+ if not seg:
153
+ raise SmartFallback(f"no frame of the source lies in {s:.3f}-{e:.3f}s", "no_frames")
154
+ first, last = seg[0], seg[-1]
155
+ to_end = vend is not None and e >= vend - 1e-6
156
+ end_bound = None if to_end else next((p for p in frames if p >= e - 1e-6), None)
157
+ if end_bound is None and not to_end:
158
+ raise SmartFallback(f"the frames after {e:.3f}s were not read", "packets")
159
+ k1 = next((i for i in keyidx if packets[i][0] >= first - 1e-6), None)
160
+ if k1 is None or packets[k1][0] > last + 1e-6:
161
+ return [{"kind": "reencode", "start": first, "end": end_bound, "frames": len(seg)}]
162
+ if gop_is_open(packets, k1):
163
+ raise SmartFallback(f"the keyframe at {packets[k1][0]:.3f}s opens its GOP (frames decoded after it are shown "
164
+ "before it), so a copy cannot start there", "open_gop")
165
+ k1_pts = packets[k1][0]
166
+ # where the copy stops: the last keyframe presented at or before the end bound, decoded after K1
167
+ stops = [i for i in keyidx if packets[i][1] > packets[k1][1] + 1e-9
168
+ and (end_bound is None or packets[i][0] <= end_bound + 1e-6)]
169
+ k2 = stops[-1] if stops else None
170
+ if k2 is not None and gop_is_open(packets, k2):
171
+ raise SmartFallback(f"the keyframe at {packets[k2][0]:.3f}s opens its GOP, so the copy cannot stop there",
172
+ "open_gop")
173
+ copy_end = packets[k2][0] if k2 is not None else None
174
+ if copy_end is None and not to_end:
175
+ # no keyframe after K1 inside the segment: the copy would run past the end in its last GOP
176
+ return [{"kind": "reencode", "start": first, "end": end_bound, "frames": len(seg)}]
177
+ parts: List[Dict[str, Any]] = []
178
+ if first < k1_pts - 1e-6:
179
+ parts.append({"kind": "reencode", "start": first, "end": k1_pts,
180
+ "frames": len(frames_between(frames, first, k1_pts))})
181
+ parts.append({"kind": "copy", "start": k1_pts, "end": copy_end, "key_dts": packets[k1][1],
182
+ "end_dts": packets[k2][1] if k2 is not None else None})
183
+ if copy_end is not None and (end_bound is None or copy_end < end_bound - 1e-6):
184
+ tail = frames_between(frames, copy_end, end_bound)
185
+ if tail:
186
+ parts.append({"kind": "reencode", "start": copy_end, "end": end_bound, "frames": len(tail)})
187
+ return parts
188
+
189
+
190
+ def _framehashes(cmd: List[str], code: str) -> List[Tuple[int, str]]:
191
+ """(size, md5) per line of an ffmpeg -f framemd5 run (packets with -c copy, frames otherwise).
192
+ A run that failed is no evidence, even when the hashes it printed match: a classified failure
193
+ (a full disk) fails the run, any other is a fallback with `code`."""
194
+ proc = run(cmd, quiet=True, check=False)
195
+ fail_if_classified(proc)
196
+ if proc.returncode != 0:
197
+ lines = [ln.strip() for ln in (proc.stderr or "").splitlines() if ln.strip()]
198
+ raise SmartFallback(f"the check could not hash {cmd[cmd.index('-i') + 1]}: "
199
+ f"{lines[-1] if lines else f'exit code {proc.returncode}'}", code)
200
+ out = []
201
+ for line in (proc.stdout or "").splitlines():
202
+ if not line or line.startswith("#"):
203
+ continue
204
+ cols = [c.strip() for c in line.split(",")]
205
+ if len(cols) >= 6:
206
+ try:
207
+ out.append((int(cols[4]), cols[5]))
208
+ except ValueError:
209
+ continue
210
+ return out
211
+
212
+
213
+ _NORMALIZE = "h264_mp4toannexb,filter_units=remove_types=7|8|9"
214
+
215
+
216
+ def packet_hashes(path: str, seek: Optional[float] = None, t: Optional[float] = None) -> List[Tuple[int, str]]:
217
+ """The first video stream's packets as (size, md5) with SPS, PPS and delimiters removed."""
218
+ ff = require_tool("ffmpeg")
219
+ cmd = [ff, "-hide_banner", "-loglevel", "error", "-nostdin"]
220
+ if seek is not None:
221
+ cmd += ["-ss", f"{seek:.6f}"]
222
+ cmd += ["-i", path]
223
+ if t is not None:
224
+ cmd += ["-t", f"{t:.6f}"]
225
+ return _framehashes(cmd + ["-map", "0:v:0", "-c", "copy", "-bsf:v", _NORMALIZE, "-f", "framemd5", "-"], "packets")
226
+
227
+
228
+ def frame_hashes(path: str, at: Optional[float], count: int) -> List[Tuple[int, str]]:
229
+ """framemd5 of `count` decoded frames from `at` (seconds, an accurate seek; None from the start)."""
230
+ ff = require_tool("ffmpeg")
231
+ cmd = [ff, "-hide_banner", "-loglevel", "error", "-nostdin"]
232
+ if at is not None and at > 0:
233
+ cmd += ["-ss", f"{at:.6f}"]
234
+ return _framehashes(cmd + ["-i", path, "-map", "0:v:0", "-frames:v", str(count), "-flags:v", "+bitexact",
235
+ "-fflags", "+bitexact", "-f", "framemd5", "-"], "decode")
236
+
237
+
238
+ def output_pts(path: str) -> List[float]:
239
+ proc = run([require_tool("ffprobe"), "-v", "error", "-select_streams", "v:0", "-show_entries", "packet=pts_time",
240
+ "-of", "csv=p=0", path], quiet=True, check=False)
241
+ out = []
242
+ for line in (proc.stdout or "").split():
243
+ try:
244
+ out.append(float(line.strip(",")))
245
+ except ValueError:
246
+ continue
247
+ return sorted(out)
248
+
249
+
250
+ def _write(cmd: List[str], what: str) -> None:
251
+ """Run one of the smart cut's ffmpeg writes. A failure the runner classifies (a full disk, a
252
+ missing input) fails the run as it would anywhere; any other is a fallback with ffmpeg's reason
253
+ (x264 refusing the matched profile at --quality 0, a copy the muxer rejects)."""
254
+ proc = run(cmd, check=False)
255
+ fail_if_classified(proc)
256
+ if proc.returncode != 0:
257
+ lines = [ln.strip() for ln in (proc.stderr or "").splitlines() if ln.strip()]
258
+ why = next((ln for ln in lines if "rror" in ln), lines[-1] if lines else f"exit code {proc.returncode}")
259
+ raise SmartFallback(f"ffmpeg could not write {what}: {why}", "encode")
260
+
261
+
262
+ def _count(n_frames: int, rate: Fraction) -> str:
263
+ """A part's exact length for the concat list's `duration`, in seconds."""
264
+ return f"{float(Fraction(n_frames) / rate):.9f}"
265
+
266
+
267
+ def execute(src: str, segments: List[Tuple[float, float]], dst: str, meta: Dict[str, Any], params: Dict[str, Any],
268
+ packets, crf: int, preset: str, tmp: str, *, vend: Optional[float], gop_is_open, landing,
269
+ origin, declick: float, seek_margin: float) -> Dict[str, Any]:
270
+ """Write the smart cut of `segments` to `dst` and verify it (module docstring). Returns the
271
+ `smart` document; raises SmartFallback with the reason when it cannot be made or does not verify.
272
+ `landing(src, t)` is cut.seek_keyframe, `origin(path)` cut.file_origin."""
273
+ rate = _rate(params)
274
+ frame = float(1 / rate)
275
+ half = frame / 2
276
+ x264 = x264_line(params, crf, preset)
277
+ plans = [plan_segment(packets, s, e, vend, gop_is_open) for s, e in segments]
278
+ has_a = bool(meta.get("audio"))
279
+ for si, ((s, _), segment_parts) in enumerate(zip(segments, plans)):
280
+ # the sound is trimmed on the picture's grid from the segment's first frame; a picture that
281
+ # starts later than a frame after the segment (video that starts after its audio) would
282
+ # lose the sound before it, which the full re-encode keeps
283
+ late = segment_parts[0]["start"] - s
284
+ if has_a and late > frame + 1e-6:
285
+ raise SmartFallback(f"the picture of segment {si + 1} starts {late:.3f}s after it does, and the sound "
286
+ "before it is kept only by a full re-encode", "av_offset")
287
+ parts: List[Dict[str, Any]] = []
288
+ for si, segment_parts in enumerate(plans):
289
+ for part in segment_parts:
290
+ part = dict(part, segment=si, path=os.path.join(tmp, f"part{len(parts):03d}.ts"))
291
+ if part["kind"] == "reencode":
292
+ m = min(seek_margin, part["start"])
293
+ trim = f"trim=start={m - half:.6f}" + (f":end={m + part['frames'] * frame - half:.6f}")
294
+ _write(ffmpeg_base() + ["-ss", f"{part['start'] - m:.6f}", "-i", src, "-map", "0:v:0", "-an", "-sn", "-dn",
295
+ "-vf", f"{trim},setpts=PTS-STARTPTS", "-frames:v", str(part["frames"]),
296
+ "-fps_mode", "passthrough"] + x264 + ["-f", "mpegts", part["path"]],
297
+ f"the re-encoded part from {part['start']:.3f}s")
298
+ else:
299
+ target = part["start"] + _SEEK_PAST
300
+ landed = None if STATE.dry_run else landing(src, target)
301
+ if not STATE.dry_run and (landed is None or abs(landed[0] - part["start"]) > 1e-4):
302
+ raise SmartFallback(f"a stream copy from the keyframe at {part['start']:.3f}s lands at "
303
+ f"{'an unreadable place' if landed is None else f'{landed[0]:.3f}s'}", "seek")
304
+ part["seek"] = target
305
+ part["t"] = None if part["end_dts"] is None else part["end_dts"] - target - _DTS_MARGIN
306
+ _write(ffmpeg_base() + ["-ss", f"{target:.6f}", "-i", src] + ([] if part["t"] is None else ["-t", f"{part['t']:.6f}"])
307
+ + ["-map", "0:v:0", "-an", "-sn", "-dn", "-c", "copy", "-f", "mpegts", part["path"]],
308
+ f"the copied part from {part['start']:.3f}s")
309
+ if not STATE.dry_run:
310
+ written = output_pts(part["path"])
311
+ if part["kind"] == "copy":
312
+ expected = None if part["end"] is None else round((part["end"] - part["start"]) * rate)
313
+ if expected is not None and len(written) != expected:
314
+ raise SmartFallback(f"the copy from {part['start']:.3f}s holds {len(written)} frames where the "
315
+ f"source has {expected}", "copy_count")
316
+ part["frames"] = len(written)
317
+ elif len(written) != part["frames"]:
318
+ raise SmartFallback(f"the re-encode from {part['start']:.3f}s holds {len(written)} frames, not "
319
+ f"{part['frames']}", "encode_count")
320
+ if part["kind"] == "reencode":
321
+ got = stream_params(part["path"]) or {}
322
+ diff = mismatches(params, got)
323
+ if diff:
324
+ raise SmartFallback("the re-encoded part does not match the source: " + ", ".join(diff), "params_mismatch")
325
+ parts.append(part)
326
+ listfile = os.path.join(tmp, "smart.txt")
327
+ with open(listfile, "w", encoding="utf-8") as fh:
328
+ for part in parts:
329
+ fh.write(concat_list_line(part["path"]) + "\n")
330
+ if part.get("frames"):
331
+ fh.write(f"duration {_count(part['frames'], rate)}\n")
332
+ ext = os.path.splitext(dst)[1].lower()
333
+ cmd = ffmpeg_base() + ["-f", "concat", "-safe", "0", "-i", listfile]
334
+ graph: List[str] = []
335
+ if has_a:
336
+ # the sound of each segment from the source, on the picture's frame grid, encoded once
337
+ pads = ""
338
+ for i, (s, e) in enumerate(segments):
339
+ first = plans[i][0]["start"]
340
+ last = plans[i][-1]
341
+ end = last["end"] if last["end"] is not None else e
342
+ m = min(seek_margin, s)
343
+ cmd += ["-ss", f"{s - m:.6f}", "-t", f"{m + (end - s) + 1.0:.6f}", "-i", src]
344
+ a, b = m + (first - s), m + (end - s)
345
+ fade = min(declick, (b - a) / 2)
346
+ fades = (f",afade=t=in:st=0:d={fade:.6f}" if fade > 0 and i > 0 else "")
347
+ fades += (f",afade=t=out:st={b - a - fade:.6f}:d={fade:.6f}" if fade > 0 and i < len(segments) - 1 else "")
348
+ # padded to its picture's length: sound that ends early (a track shorter than the
349
+ # picture) must not pull the next segment's sound forward
350
+ graph.append(f"[{i + 1}:a:0]atrim=start={a:.6f}:end={b:.6f},asetpts=PTS-{a:.6f}/TB,"
351
+ f"aresample=async=1:first_pts=0,apad=whole_dur={b - a:.6f}{fades}[a{i}]")
352
+ pads += f"[a{i}]"
353
+ graph.append(f"{pads}concat=n={len(segments)}:v=0:a=1[a]")
354
+ cmd += ["-filter_complex", ";".join(graph), "-map", "0:v:0", "-map", "[a]"] + aac_args()
355
+ else:
356
+ cmd += ["-map", "0:v:0"]
357
+ cmd += ["-c:v", "copy", "-bsf:v", "filter_units=remove_types=9", "-sn", "-dn"]
358
+ tb = str(params.get("time_base") or "")
359
+ if ext in (".mp4", ".mov", ".m4v"):
360
+ if tb.startswith("1/"):
361
+ cmd += ["-video_track_timescale", tb[2:]]
362
+ cmd += ["-tag:v", "avc1", "-movflags", "+faststart"]
363
+ _write(cmd + [dst], "the joined parts")
364
+ doc: Dict[str, Any] = {
365
+ "applied": True,
366
+ "parts": [{"segment": p["segment"] + 1, "kind": p["kind"], "start": round(p["start"], 6),
367
+ "end": None if p["end"] is None else round(p["end"], 6), "frames": p.get("frames")} for p in parts],
368
+ "reencoded_frames": sum(p.get("frames") or 0 for p in parts if p["kind"] == "reencode"),
369
+ "copied_frames": sum(p.get("frames") or 0 for p in parts if p["kind"] == "copy"),
370
+ "matched": {f: params.get(f) for f in MATCH_FIELDS},
371
+ "encoder": x264,
372
+ }
373
+ if STATE.dry_run:
374
+ doc["checks"] = None
375
+ return doc
376
+ doc["checks"] = verify(src, dst, parts, params, rate, origin)
377
+ doc["time_base"] = doc["checks"]["time_base"]
378
+ return doc
379
+
380
+
381
+ def verify(src: str, dst: str, parts: List[Dict[str, Any]], params: Dict[str, Any], rate: Fraction, origin) -> Dict[str, Any]:
382
+ """The checks in the module docstring; raises SmartFallback on the first that fails."""
383
+ frame = float(1 / rate)
384
+ half = frame / 2
385
+ pts = output_pts(dst)
386
+ expected = sum(p["frames"] for p in parts)
387
+ if len(pts) != expected:
388
+ raise SmartFallback(f"the output holds {len(pts)} frames where the parts hold {expected}", "frames")
389
+ worst = max((abs(t - pts[0] - i * frame) for i, t in enumerate(pts)), default=0.0)
390
+ if worst >= half:
391
+ at = next(i for i, t in enumerate(pts) if abs(t - pts[0] - i * frame) >= half)
392
+ raise SmartFallback(f"frame {at} of the output is presented at {pts[at] - pts[0]:.6f}s, off the "
393
+ f"{frame:.6f}s grid (a gap or a repeated frame at a join)", "frames")
394
+ out_packets = packet_hashes(dst)
395
+ if len(out_packets) != expected:
396
+ raise SmartFallback(f"the output holds {len(out_packets)} video packets where the parts hold {expected}", "packets")
397
+ dst_origin = origin(dst)
398
+ offset, copied, decoded = 0, 0, 0
399
+ for p in parts:
400
+ n = p["frames"]
401
+ at_out = pts[offset] - dst_origin - half
402
+ if p["kind"] == "copy":
403
+ source = packet_hashes(src, p["seek"], p["t"])
404
+ if source != out_packets[offset:offset + n]:
405
+ bad = next((i for i, (x, y) in enumerate(zip(source, out_packets[offset:offset + n])) if x != y),
406
+ min(len(source), n))
407
+ raise SmartFallback(f"copied packet {bad} of the part from {p['start']:.3f}s differs from the source's",
408
+ "packets")
409
+ copied += n
410
+ g = min(n, DECODE_CHECK_MAX_FRAMES)
411
+ want = frame_hashes(src, p["start"] - half, g)
412
+ else:
413
+ g = n
414
+ want = frame_hashes(p["path"], None, g)
415
+ got = frame_hashes(dst, at_out, g)
416
+ # a short list on both sides (a frame that did not decode) is not a match of g frames
417
+ if len(want) != g or want != got:
418
+ raise SmartFallback(f"the frames decoded from the output's {p['kind']} part at {pts[offset] - pts[0]:.3f}s "
419
+ f"differ from the {'source' if p['kind'] == 'copy' else 'encoded part'}", "decode")
420
+ decoded += g
421
+ offset += n
422
+ out_params = stream_params(dst) or {}
423
+ if out_params.get("time_base") != params.get("time_base"):
424
+ raise SmartFallback(f"the output's time base is {out_params.get('time_base')}, the source's "
425
+ f"{params.get('time_base')}", "time_base")
426
+ diff = mismatches(params, out_params)
427
+ if diff:
428
+ raise SmartFallback("the output's stream does not match the source: " + ", ".join(diff), "params_mismatch")
429
+ return {"frames": expected, "grid_worst_seconds": round(worst, 6), "copied_packets_equal": copied,
430
+ "frames_decoded_equal": decoded, "time_base": out_params.get("time_base")}
@@ -4,17 +4,22 @@
4
4
  No input file: a silent, fixed-duration, exact-size clip generated entirely
5
5
  by ffmpeg's own source filters (`color` for solid, `gradients` for a
6
6
  two-colour gradient) -- for a title card background, a placeholder behind a
7
- logo, or a base layer for overlay.py to composite onto.
7
+ logo, or a base layer for overlay.py to composite onto. --transparent writes a
8
+ fully transparent canvas in an alpha line (ProRes 4444 by default, --alpha-codec),
9
+ the base layer graphics.py and overlay.py --keep-alpha draw on.
8
10
 
9
11
  Examples:
10
12
  python3 background.py --duration 3 --width 1920 --height 1080 --color 0x101010
11
13
  python3 background.py --duration 5 --width 1080 --height 1920 --gradient 0xff6a00:0x0057ff --angle 45
14
+ python3 background.py --duration 4 --width 1920 --height 1080 --transparent -o canvas.mov
12
15
  """
13
16
  import argparse
14
17
  import math
15
18
  import sys
16
19
 
17
- from _common import add_common, apply_common, die, draws_chroma, emit, ffmpeg_base, info, parse_time, probe, rgb_chain_to_bt709, run, validate_color, video_args, X264_PRESETS, time_arg, fmt_secs
20
+ from _common.alpha import add_alpha_args, check_alpha_args, finish_chain, report as alpha_report, video_line
21
+ from _common.emit import note
22
+ from _common import STATE, add_common, apply_common, die, draws_chroma, emit, ffmpeg_base, info, parse_time, probe, rgb_chain_to_bt709, run, validate_color, video_args, X264_PRESETS, time_arg, fmt_secs
18
23
 
19
24
 
20
25
  def main() -> int:
@@ -30,6 +35,7 @@ def main() -> int:
30
35
  ap.add_argument("--angle", type=float, default=0.0, help="gradient angle in degrees (with --gradient, default 0 = left to right)")
31
36
  ap.set_defaults(crf=18) # --quality's default (the --crf alias was removed in 2.0)
32
37
  ap.add_argument("--preset", default="medium", choices=X264_PRESETS, help="x264 preset")
38
+ add_alpha_args(ap, transparent=True)
33
39
  add_common(ap)
34
40
  args = ap.parse_args()
35
41
  apply_common(args)
@@ -43,8 +49,17 @@ def main() -> int:
43
49
  die(f"--width/--height must be > 0, got width={args.width} height={args.height}")
44
50
  if args.width % 2 or args.height % 2:
45
51
  die(f"--width/--height must be even (4:2:0 chroma), got width={args.width} height={args.height}")
52
+ alpha_codec = check_alpha_args(args, args.output)
46
53
 
47
- if args.gradient:
54
+ if alpha_codec:
55
+ # a canvas with nothing on it: alpha 0 everywhere, black colour beneath (what straight and
56
+ # premultiplied colour agree on), in the alpha line's own pixel format
57
+ if args.gradient or args.color != ap.get_default("color"):
58
+ die("--transparent writes a canvas with nothing on it: --color/--gradient do not apply", kind="input")
59
+ src_filter = (f"color=c=black@0:size={args.width}x{args.height}:rate={args.fps:g},format=rgba,"
60
+ + finish_chain(alpha_codec, args.alpha_mode))
61
+ colours = []
62
+ elif args.gradient:
48
63
  try:
49
64
  c0, c1 = args.gradient.split(":")
50
65
  except ValueError:
@@ -66,7 +81,7 @@ def main() -> int:
66
81
  validate_color(args.color, "--color")
67
82
  src_filter = f"color=c={args.color}:size={args.width}x{args.height}:rate={args.fps:g}"
68
83
  colours = [args.color]
69
- if draws_chroma(colours, None):
84
+ if colours and draws_chroma(colours, None):
70
85
  # the colours are made in RGB and converted to the BT.709 the output is tagged with: left to
71
86
  # the encoder line's own conversion (or `color`'s YUV), they were BT.601 under that tag
72
87
  # (pure red decoded as (255,24,0)); a grey is the same under every matrix
@@ -74,14 +89,26 @@ def main() -> int:
74
89
 
75
90
  output = args.output
76
91
  cmd = ffmpeg_base() + ["-f", "lavfi", "-i", src_filter, "-t", f"{target:.3f}"]
77
- cmd += video_args(None, args.crf, args.preset)
92
+ if alpha_codec:
93
+ cmd += video_line(alpha_codec)
94
+ if STATE.hw:
95
+ note(f"--hw: --alpha-codec {alpha_codec} stays on the CPU (no VideoToolbox encoder writes it with its alpha)")
96
+ else:
97
+ cmd += video_args(None, args.crf, args.preset)
78
98
  cmd += ["-an", output]
79
99
  run(cmd)
80
100
 
81
101
  result = probe(output, role="output")
82
102
  v = result["video"]
83
103
  info(f"wrote {output} ({fmt_secs(result['duration'])}, {v['width']}x{v['height']}, {v['fps']:g}fps)")
84
- emit(output)
104
+ extra: dict = {}
105
+ if alpha_codec:
106
+ # read back: the written alpha must be 0 in every frame (expected: a canvas of the same
107
+ # frame count, all transparent)
108
+ frames = result["video"].get("nb_frames") or round(target * args.fps)
109
+ clear = None if STATE.dry_run else {"mean": 0.0, "min": 0.0, "frames": int(frames), "per_frame": [0.0] * int(frames)}
110
+ alpha_report(args, alpha_codec, clear, output, extra, composite=False, provenance="specification")
111
+ emit(output, **extra)
85
112
  return 0
86
113
 
87
114
 
package/scripts/check.py CHANGED
@@ -29,6 +29,11 @@ WARN when there is any); and, from one more video pass and a one-frame decode (2
29
29
  (single frames that differ from both neighbours while they match each other: WARN when there is
30
30
  any) and first frame (one flat colour, the frame a feed's autoplay shows first: WARN). Without it
31
31
  the row set is unchanged.
32
+
33
+ --levels bt709|ebu-r103 (opt-in) decodes every frame once more and adds a legal levels row: the share
34
+ of luma and of chroma samples outside the standard's legal range, counted exactly per frame, with
35
+ min/max Y/U/V (FAIL when any frame's share passes --levels-tolerance, 1 % by default). The JSON
36
+ `levels` document repeats it per clip and per --levels-window seconds (10 by default).
32
37
  """
33
38
  import argparse
34
39
  import json
@@ -39,7 +44,7 @@ from fractions import Fraction
39
44
  from typing import Any, Dict, List, Optional, Tuple
40
45
 
41
46
  from _platforms import PLATFORMS, PLATFORM_CHOICES, TP_TOLERANCE_DB, spec_of, resolve as resolve_platform
42
- from _common.probe import (COMPARE_SSIM_WARN, CONTENT_FLAT_STD, SILENT_PEAK_DBFS, compare_pictures, detect_content,
47
+ from _common.probe import (LEGAL_RANGE_CHOICES, measure_signal_range, COMPARE_SSIM_WARN, CONTENT_FLAT_STD, SILENT_PEAK_DBFS, compare_pictures, detect_content,
43
48
  detect_flashes, first_frame_stats, presentation_times, measured_level_dbfs)
44
49
  from _common.emit import file_identity
45
50
  from _common import STATE, add_common, apply_common, die, emit, info, probe, require_tool, run, run_analysis, dry_run_input_pending
@@ -72,6 +77,11 @@ BLACK_WARN, BLACK_FAIL = 0.10, 0.95 # share of the duration that is black
72
77
  FROZEN_WARN_S, FROZEN_WARN_SHARE = 3.0, 0.30 # longest frozen span: WARN above max(3 s, 30%)
73
78
  FROZEN_FAIL = 0.95 # a frozen span covering this share = whole video frozen
74
79
  SILENCE_WARN, SILENCE_FAIL = 0.50, 0.95 # share of the duration below SILENT_PEAK_DBFS
80
+ # --levels: the share of one frame's luma (or chroma) samples that may sit outside the legal range
81
+ # before the row FAILs -- room for a filter's overshoot at a sharp edge, not a licence for a clipped sky
82
+ LEVELS_TOLERANCE_PCT = 1.0
83
+ # pixel formats whose planes are Y'CbCr (an RGB or grey picture has no luma/chroma codes to count)
84
+ _YUV_PREFIXES = ("yuv", "yuvj", "nv", "p01", "p21", "p41", "y21", "uyvy", "yuyv", "ayuv", "vuya", "xv")
75
85
 
76
86
 
77
87
  STATE_NOTES: List[str] = []
@@ -114,6 +124,40 @@ def aspect_name(w: int, h: int) -> str:
114
124
  return f"{f.numerator}:{f.denominator}"
115
125
 
116
126
 
127
+ def levels_row(args: argparse.Namespace, v: Dict[str, Any], row: Any) -> "Optional[Dict[str, Any]]":
128
+ """--levels: measure the signal range (measure_signal_range) and add the legal levels row.
129
+ Returns the JSON `levels` document, or None when nothing was measured."""
130
+ expect = f"every frame <= {args.levels_tolerance:g}% of luma and of chroma samples outside {args.levels}"
131
+ pix = str(v.get("pix_fmt") or "")
132
+ if not v:
133
+ return None
134
+ if not pix.startswith(_YUV_PREFIXES):
135
+ row("legal levels", "WARN", f"not measured: {pix or 'unknown'} is not a Y'CbCr picture", expect,
136
+ "legal levels are measured on the Y'CbCr file a broadcaster receives: check the encoded delivery, not an RGB master",
137
+ reason="an RGB or grey picture has no luma and chroma codes to count")
138
+ return None
139
+ doc = measure_signal_range(args.input, args.levels, args.levels_window, pix_fmt=pix)
140
+ if doc is None:
141
+ row("legal levels", "WARN", "could not measure", expect, "re-run check.py --levels, or look.py the picture",
142
+ reason="the decode failed or stopped part-way, so the signal range of the whole file is unknown")
143
+ return None
144
+ pct, worst = doc["out_of_range_pct"], doc["worst_frame"]
145
+ over = worst["y_pct"] > args.levels_tolerance or worst["chroma_pct"] > args.levels_tolerance
146
+ at = "" if worst["time"] is None else f" at {worst['time']:.2f}s"
147
+ lo, hi = doc["min"], doc["max"]
148
+ value = (f"luma {pct['y']:.3f}%, chroma {pct['chroma']:.3f}% out of range (worst frame{at}: luma {worst['y_pct']:.3f}%, "
149
+ f"chroma {worst['chroma_pct']:.3f}%); Y {lo['y']}-{hi['y']}, U {lo['u']}-{hi['u']}, V {lo['v']}-{hi['v']}")
150
+ full = v.get("color_range") == "pc" or pix.startswith("yuvj")
151
+ if full:
152
+ STATE_NOTES.append("--levels: the picture is tagged full range (0-255 codes by design), so it was counted against "
153
+ "the limited-range limits a broadcast file uses; convert it to limited range before judging the row")
154
+ row("legal levels", "FAIL" if over else "PASS", value, expect,
155
+ f"color.py {args.input} --legalize {args.levels} (clamps after the 4:2:0 conversion and measures the result), or "
156
+ "pull the highlights and shadows in with a soft clip in the grade and check again" if over else "",
157
+ reason="a broadcaster's QC rejects or clips a picture whose levels leave the legal range" if over else "")
158
+ return doc
159
+
160
+
117
161
  def main() -> int:
118
162
  STATE_NOTES.clear() # module state: a second main() in one process starts without the first's notes
119
163
  ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
@@ -127,6 +171,15 @@ def main() -> int:
127
171
  ap.add_argument("--no-loudness", action="store_true", help="skip the loudness measurement (faster)")
128
172
  ap.add_argument("--content", action="store_true",
129
173
  help="also check the content (one decode pass, plus an audio-only and a video-only one): black, frozen, silence, dropouts, flashes and first frame rows")
174
+ ap.add_argument("--levels", choices=LEGAL_RANGE_CHOICES, default=None,
175
+ help="also measure the broadcast signal range (one more decode of every frame): a legal levels row with "
176
+ "the share of luma and chroma samples outside bt709 (16-235 / 16-240) or ebu-r103 (luma -1..103 %%), "
177
+ "and min/max Y/U/V; the JSON levels document has it per clip and per --levels-window")
178
+ ap.add_argument("--levels-window", type=float, default=10.0, metavar="SECONDS",
179
+ help="--levels: report the shares for every window of this many seconds too (default 10; 0 = the whole clip only)")
180
+ ap.add_argument("--levels-tolerance", type=float, default=LEVELS_TOLERANCE_PCT, metavar="PCT",
181
+ help="--levels: the legal levels row FAILs when one frame has more than this percentage of its luma or of "
182
+ "its chroma samples out of range (default %(default)g; 0 = any sample)")
130
183
  ap.add_argument("--compare", metavar="REFERENCE",
131
184
  help="also measure the picture against REFERENCE, frame by frame (SSIM and PSNR, one pass): a "
132
185
  "similarity row, WARN when the mean SSIM is under 0.98, with the worst frame's time")
@@ -153,6 +206,10 @@ def main() -> int:
153
206
  spec["tp"] = args.tp
154
207
  if args.max_mb is not None:
155
208
  spec["max_bytes"] = int(args.max_mb * 1024 * 1024)
209
+ if args.levels_window < 0:
210
+ die(f"--levels-window {args.levels_window:g}: a window is 0 (the whole clip only) or a number of seconds")
211
+ if not 0 <= args.levels_tolerance <= 100:
212
+ die(f"--levels-tolerance {args.levels_tolerance:g} is outside 0..100 (percent of a frame's samples)")
156
213
 
157
214
  expected_identity = None
158
215
  if args.expect_identity is not None:
@@ -384,6 +441,10 @@ def main() -> int:
384
441
  "again); a noise gate makes these on purpose" if gaps else "",
385
442
  reason="a short stretch of digital silence in the middle of the sound is usually lost audio at a join or a glitched decode" if gaps else "")
386
443
 
444
+ levels_doc = None
445
+ if args.levels and not dry_run_input_pending(args.input):
446
+ levels_doc = levels_row(args, v, row)
447
+
387
448
  if args.compare and not dry_run_input_pending(args.input) and not dry_run_input_pending(args.compare):
388
449
  ref = probe(args.compare)
389
450
  rv = ref.get("video") or {}
@@ -457,6 +518,8 @@ def main() -> int:
457
518
  for n in notes:
458
519
  print(f" note: {n}")
459
520
  extra: Dict[str, Any] = {"notes": notes} if notes else {}
521
+ if levels_doc is not None:
522
+ extra["levels"] = levels_doc
460
523
  if identity is not None:
461
524
  extra["input_identity"] = identity
462
525
  if failed: