ffmpeg-skill 3.2.1 → 3.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -11
- package/SKILL.md +6 -1
- package/docs/contract.md +21 -8
- package/package.json +1 -1
- package/references/gotchas.md +15 -0
- package/references/scripts.md +127 -7
- package/scripts/_common/__init__.py +4 -4
- package/scripts/_common/alpha.py +465 -0
- package/scripts/_common/decision.py +153 -15
- package/scripts/_common/probe.py +175 -9
- package/scripts/_common/runner.py +3 -2
- package/scripts/_contract.py +69 -17
- package/scripts/_smartcut.py +430 -0
- package/scripts/background.py +33 -6
- package/scripts/check.py +64 -1
- package/scripts/color.py +147 -30
- package/scripts/cut.py +99 -8
- package/scripts/export.py +12 -81
- package/scripts/graphics.py +51 -10
- package/scripts/overlay.py +49 -6
- package/scripts/probe.py +47 -1
- package/scripts/scenes.py +46 -11
|
@@ -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")}
|
package/scripts/background.py
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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:
|