ffmpeg-skill 3.2.2 → 3.3.0

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
 
@@ -75,6 +75,11 @@ __all__ = ["parse_srt", "write_srt", "transcribe", "whisper_word_timings", "die_
75
75
  "text_width_em"]
76
76
 
77
77
  ALIGN = {"bottom": 2, "top": 8, "center": 5, "bottom-left": 1, "bottom-right": 3, "top-left": 7, "top-right": 9}
78
+ # libass reads a force_style Alignment in the legacy SSA numbering (1-3 bottom, 5-7 top, 9-11
79
+ # middle), not the v4+ numpad one a Style line uses: only the bottom row is the same number in
80
+ # both. Through 3.2.3 the SRT burn passed the numpad value, so --position top landed mid-left,
81
+ # center top-left, top-left top-right (#436, measured on libass 0.17.1 and FFmpeg 9.0.2).
82
+ FORCE_STYLE_ALIGN = {"bottom": 2, "top": 6, "center": 10, "bottom-left": 1, "bottom-right": 3, "top-left": 5, "top-right": 7}
78
83
 
79
84
  TIME_RE = re.compile(
80
85
  r"^\s*(?P<a>[\d:.,@]+)\s*(?:-->|-|–|to)\s*(?P<b>[\d:.,@]+)\s+(?P<text>.+)$" # @ = the 1.9 @fps suffix
@@ -1750,7 +1755,7 @@ def main() -> int:
1750
1755
  f"Outline={args.outline:g}",
1751
1756
  f"Shadow={args.shadow:g}",
1752
1757
  f"Bold={-1 if args.bold else 0}",
1753
- f"Alignment={ALIGN[args.position]}",
1758
+ f"Alignment={FORCE_STYLE_ALIGN[args.position]}",
1754
1759
  f"MarginV={args.margin}",
1755
1760
  ]
1756
1761
  force = ",".join(style).replace("\\", "\\\\").replace("'", "\\'")