ffmpeg-skill 1.15.0 → 1.16.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,472 @@
1
+ """ffprobe and the measured facts read back off a file: the probe document every script starts
2
+ from, the verification every script ends with, and the level/waveform measurements.
3
+
4
+ Reading only -- the choices made from these facts live in decision.py.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import math
10
+ import os
11
+ import re
12
+ from fractions import Fraction
13
+ from typing import Any, Dict, List, Optional, Sequence, Tuple
14
+ from _common.emit import die
15
+ from _common.runner import STATE, dry_run_input_pending, require_tool, run, run_analysis
16
+
17
+
18
+ def fingerprint(path: str) -> Dict[str, Any]:
19
+ """Size plus a sha256 over the first and last 8 MiB: enough to notice a re-export, a re-trim
20
+ or a swapped file, cheap enough for a multi-GB source (hashing a whole master would make
21
+ planning slower than the edit)."""
22
+ import hashlib
23
+ st = os.stat(path)
24
+ h = hashlib.sha256()
25
+ chunk = 8 * 1024 * 1024
26
+ with open(path, "rb") as f:
27
+ h.update(f.read(chunk))
28
+ if st.st_size > 2 * chunk:
29
+ f.seek(-chunk, os.SEEK_END)
30
+ h.update(f.read(chunk))
31
+ elif st.st_size > chunk:
32
+ h.update(f.read())
33
+ return {"path": os.path.abspath(path), "size": st.st_size, "sha256_head_tail": h.hexdigest()}
34
+
35
+
36
+ def decode_pcm_mono(path: str, sample_rate: int, seconds: Optional[float] = None, start: float = 0.0,
37
+ *, check: bool = True) -> List[float]:
38
+ """Decode (part of) a file's audio to mono float samples in [-1, 1) at `sample_rate` via a
39
+ single ffmpeg pass under --timeout. Shared by scenes.py (audio envelope for cut scoring) and
40
+ sync.py (cross-correlation); an undecodable input is kind ffmpeg when check=True, else []."""
41
+ ffmpeg = require_tool("ffmpeg")
42
+ cmd = [ffmpeg, "-hide_banner", "-loglevel", "error", "-nostdin"]
43
+ if start:
44
+ cmd += ["-ss", f"{start:.3f}"]
45
+ cmd += ["-i", path]
46
+ if seconds is not None:
47
+ cmd += ["-t", f"{seconds:.3f}"]
48
+ cmd += ["-vn", "-ac", "1", "-ar", str(sample_rate), "-f", "s16le", "-"]
49
+ proc = run_analysis(cmd, check=False, text=False)
50
+ if proc.returncode != 0 or not proc.stdout:
51
+ if check:
52
+ die(f"could not decode audio from {path}:\n{proc.stderr.decode(errors='replace').strip()}", kind="ffmpeg")
53
+ return []
54
+ n = len(proc.stdout) // 2
55
+ import struct
56
+ return [v / 32768.0 for v in struct.unpack(f"<{n}h", proc.stdout[: n * 2])]
57
+
58
+
59
+ def rms_envelope(samples: Sequence[float], step: int, *, full_blocks_only: bool = False, remove_mean: bool = False) -> List[float]:
60
+ """RMS per block of `step` samples. full_blocks_only drops a short tail block (sync.py: every
61
+ block must be the same length for the correlation); remove_mean subtracts the envelope's mean
62
+ (sync.py: so silence does not correlate). scenes.py keeps the tail and the absolute level."""
63
+ step = max(1, int(step))
64
+ n = len(samples)
65
+ stop = n - step + 1 if full_blocks_only else n
66
+ env: List[float] = []
67
+ for i in range(0, max(0, stop), step):
68
+ block = samples[i:i + step]
69
+ env.append(math.sqrt(sum(x * x for x in block) / len(block)))
70
+ if remove_mean and env:
71
+ mean = sum(env) / len(env)
72
+ env = [e - mean for e in env]
73
+ return env
74
+
75
+
76
+ MEDIA_EXT = {".mp4", ".mov", ".mkv", ".webm", ".m4v", ".avi", ".ts", ".mts", ".m2ts", ".mxf", ".3gp", ".wmv", ".gif",
77
+ ".wav", ".flac", ".mp3", ".m4a", ".aac", ".ogg", ".opus", ".aif", ".aiff", ".caf", ".wma", ".png", ".jpg", ".jpeg", ".webp"}
78
+
79
+
80
+ def _output_failed(path: str, why: str) -> "None":
81
+ """An ffmpeg run reported success but the artifact is not usable: say so, and do not leave a
82
+ 0-byte file behind that a later step could mistake for a result."""
83
+ try:
84
+ if os.path.exists(path) and os.path.getsize(path) == 0:
85
+ os.remove(path)
86
+ why += " (empty file removed)"
87
+ except OSError:
88
+ pass
89
+ die(f"output verification failed: {path}: {why}", kind="output")
90
+
91
+
92
+ def verify_output(path: str) -> Dict[str, Any]:
93
+ """The success criterion for every writing tool: the file exists, is not empty and ffprobe
94
+ can read at least one stream from it. Non-media artifacts (srt, edl, html, md) only need to
95
+ exist and be non-empty. Returns the probe (empty dict for non-media)."""
96
+ if not os.path.exists(path):
97
+ _output_failed(path, "not written")
98
+ if os.path.getsize(path) == 0:
99
+ _output_failed(path, "0 bytes")
100
+ if os.path.splitext(path)[1].lower() not in MEDIA_EXT:
101
+ return {}
102
+ meta = probe(path, role="output")
103
+ if not meta.get("video") and not meta.get("audio"):
104
+ _output_failed(path, "no video or audio stream")
105
+ return meta
106
+
107
+
108
+ def probe(path: str, role: str = "input") -> Dict[str, Any]:
109
+ """Return a compact, script-friendly description of a media file.
110
+
111
+ role="output" marks a file this tool just wrote: a read failure is then reported as an
112
+ output-verification failure (kind "output") instead of an input problem."""
113
+ if not os.path.exists(path):
114
+ if role == "output" and not STATE.dry_run:
115
+ _output_failed(path, "not written")
116
+ if STATE.dry_run:
117
+ # width/height/fps are honestly 0/0/0.0 -- "not measured", matching duration/size_bytes
118
+ # below -- because this is a dry run: the file doesn't exist yet, so there is nothing to
119
+ # probe. Earlier this stub used plausible-looking placeholders (1920x1080x30.0) instead,
120
+ # which some tools' dry-run summary line echoed verbatim as if it were a real computed
121
+ # preview (#77). That was reverted once, because a couple of call sites divided by these
122
+ # values for aspect-ratio math and crashed on a real 0 (join.py, fit.py); those call
123
+ # sites are now guarded to treat 0 as "unknown" and fall back sanely instead of dividing
124
+ # by it, so the stub can finally report the honest, unknown value.
125
+ return {"file": path, "dry_run": True, "format": None, "duration": 0.0, "size_bytes": 0, "bitrate": None,
126
+ "video": {"codec": None, "width": 0, "height": 0, "fps": 0.0, "pix_fmt": None, "hdr": False,
127
+ "color_transfer": None, "color_primaries": None, "rotation": 0, "variable_frame_rate_suspected": False},
128
+ "audio": {"codec": None, "channels": 0, "sample_rate": 0}, "subtitle_streams": 0, "data_streams": 0}
129
+ die(f"input not found: {path}")
130
+ ffprobe = require_tool("ffprobe")
131
+ proc = run(
132
+ [ffprobe, "-v", "error", "-print_format", "json", "-show_format", "-show_streams", "-show_chapters", path],
133
+ quiet=True,
134
+ check=False,
135
+ )
136
+ if proc.returncode != 0:
137
+ if role == "output":
138
+ _output_failed(path, f"ffprobe cannot read it:\n{proc.stderr.strip()}")
139
+ die(f"ffprobe failed on {path}:\n{proc.stderr.strip()}")
140
+ try:
141
+ raw = json.loads(proc.stdout or "{}")
142
+ except ValueError as e:
143
+ if role == "output":
144
+ _output_failed(path, f"ffprobe printed unreadable JSON: {e}")
145
+ die(f"ffprobe printed unreadable JSON for {path}: {e}", kind="ffmpeg")
146
+ fmt = raw.get("format", {})
147
+ streams = raw.get("streams", [])
148
+ video = next((s for s in streams if s.get("codec_type") == "video" and s.get("disposition", {}).get("attached_pic", 0) == 0), None)
149
+ audio = next((s for s in streams if s.get("codec_type") == "audio"), None)
150
+ subs = [s for s in streams if s.get("codec_type") == "subtitle"]
151
+ data_stream_count = sum(1 for s in streams if s.get("codec_type") in ("data", "attachment"))
152
+
153
+ duration = _to_float(fmt.get("duration"))
154
+ if duration is None and video:
155
+ duration = _to_float(video.get("duration"))
156
+ if duration is None and audio:
157
+ duration = _to_float(audio.get("duration"))
158
+ if duration and STATE.duration_hint is None:
159
+ STATE.duration_hint = duration
160
+
161
+ out: Dict[str, Any] = {
162
+ "file": path,
163
+ "format": fmt.get("format_name"),
164
+ "duration": duration,
165
+ "size_bytes": _to_int(fmt.get("size")),
166
+ "bitrate": _to_int(fmt.get("bit_rate")),
167
+ "video": None,
168
+ "audio": None,
169
+ "subtitle_streams": len(subs),
170
+ "data_streams": data_stream_count,
171
+ # container-level chapter markers and the common tags, so metadata.py's result is
172
+ # verifiable the same way every other tool's is (additive keys, 1.x-safe)
173
+ "chapters": [{
174
+ "index": n,
175
+ "start": _to_float(ch.get("start_time")),
176
+ "end": _to_float(ch.get("end_time")),
177
+ "title": (ch.get("tags") or {}).get("title"),
178
+ } for n, ch in enumerate(raw.get("chapters") or [])],
179
+ "tags": {k.lower(): v for k, v in (fmt.get("tags") or {}).items() if k.lower() in ("title", "artist", "album", "comment", "date", "genre")},
180
+ # every subtitle stream in file order: index n here is `-map 0:s:n`
181
+ "subtitle_stream_details": [{
182
+ "index": n,
183
+ "codec": s.get("codec_name"),
184
+ "language": (s.get("tags") or {}).get("language"),
185
+ "title": (s.get("tags") or {}).get("title"),
186
+ } for n, s in enumerate(subs)],
187
+ }
188
+ if video:
189
+ r_rate = _fraction(video.get("r_frame_rate"))
190
+ avg_rate = _fraction(video.get("avg_frame_rate"))
191
+ fps = float(avg_rate) if avg_rate else (float(r_rate) if r_rate else None)
192
+ vfr = bool(r_rate and avg_rate and abs(float(r_rate) - float(avg_rate)) > 0.01)
193
+ w, h = _to_int(video.get("width")), _to_int(video.get("height"))
194
+ rotation = 0
195
+ for sd in video.get("side_data_list", []) or []:
196
+ if "rotation" in sd:
197
+ rotation = int(round(float(sd["rotation"])))
198
+ if "rotate" in (video.get("tags") or {}):
199
+ try:
200
+ rotation = int(video["tags"]["rotate"])
201
+ except ValueError:
202
+ pass
203
+ pix = video.get("pix_fmt") or ""
204
+ trc = video.get("color_transfer") or ""
205
+ prim = video.get("color_primaries") or ""
206
+ hdr = trc in ("smpte2084", "arib-std-b67") or prim == "bt2020"
207
+ dovi = None
208
+ for sd in video.get("side_data_list", []) or []:
209
+ if "dv_profile" in sd or "DOVI" in str(sd.get("side_data_type", "")):
210
+ dovi = {"profile": sd.get("dv_profile"), "level": sd.get("dv_level"), "bl_compatibility_id": sd.get("dv_bl_signal_compatibility_id")}
211
+ if dovi: # a Dolby Vision stream is HDR even when its base layer tags are missing
212
+ hdr = True
213
+ out["video"] = {
214
+ "codec": video.get("codec_name"),
215
+ "profile": video.get("profile"),
216
+ "width": w,
217
+ "height": h,
218
+ "display_aspect": video.get("display_aspect_ratio") or _aspect_string(w, h),
219
+ "fps": round(fps, 3) if fps else None,
220
+ "r_frame_rate": video.get("r_frame_rate"),
221
+ "avg_frame_rate": video.get("avg_frame_rate"),
222
+ "variable_frame_rate_suspected": vfr,
223
+ "pix_fmt": video.get("pix_fmt"),
224
+ "bit_depth": _bit_depth(pix),
225
+ "hdr": hdr,
226
+ # 1.9 (2.0 A1 pre-shipped as a parallel key): true only for a PQ / HLG transfer or Dolby
227
+ # Vision, i.e. a genuinely HDR signal. `hdr` also counts BT.2020 primaries on an SDR
228
+ # transfer ("BT.2020 SDR" in hdr_format) and keeps that meaning until 2.0 renames it.
229
+ "hdr_signal": trc in ("smpte2084", "arib-std-b67") or bool(dovi),
230
+ "hdr_format": (("Dolby Vision %s" % (("profile %s" % dovi["profile"]) if dovi and dovi.get("profile") is not None else "")).strip() if dovi else
231
+ "HDR10/PQ" if trc == "smpte2084" else "HLG" if trc == "arib-std-b67" else "BT.2020 SDR" if hdr else None),
232
+ "dolby_vision": dovi,
233
+ "color_space": video.get("color_space"),
234
+ "color_primaries": video.get("color_primaries"),
235
+ "color_transfer": video.get("color_transfer"),
236
+ "color_range": video.get("color_range"),
237
+ "rotation": rotation,
238
+ "nb_frames": _to_int(video.get("nb_frames")),
239
+ "bitrate": _to_int(video.get("bit_rate")),
240
+ }
241
+ if audio:
242
+ out["audio"] = {
243
+ "codec": audio.get("codec_name"),
244
+ "channels": _to_int(audio.get("channels")),
245
+ "channel_layout": audio.get("channel_layout"),
246
+ "sample_rate": _to_int(audio.get("sample_rate")),
247
+ "bitrate": _to_int(audio.get("bit_rate")),
248
+ }
249
+ # every audio stream in file order: index n here is `-map 0:a:n` (audio.py --audio-stream n)
250
+ out["audio_streams"] = [{
251
+ "index": n,
252
+ "codec": a.get("codec_name"),
253
+ "channels": _to_int(a.get("channels")),
254
+ "channel_layout": a.get("channel_layout"),
255
+ "sample_rate": _to_int(a.get("sample_rate")),
256
+ "language": (a.get("tags") or {}).get("language"),
257
+ "title": (a.get("tags") or {}).get("title"),
258
+ } for n, a in enumerate(s for s in streams if s.get("codec_type") == "audio")]
259
+ return out
260
+
261
+
262
+ def _bit_depth(pix_fmt: Optional[str]) -> int:
263
+ """Bits per component from a pixel format name. `"10" in pix` used to read yuv410p (4:1:0
264
+ chroma) as 10-bit; the depth is the number that ends the name (before an le/be suffix):
265
+ yuv420p10le -> 10, gbrp12be -> 12, gray16le -> 16, yuv410p / yuv420p / rgb24 -> 8."""
266
+ m = re.search(r"(\d{1,2})(?:le|be)?$", pix_fmt or "")
267
+ if not m:
268
+ return 8
269
+ n = int(m.group(1))
270
+ if n in (24, 32): # packed 8-bit rgb24/bgr32/rgb0 etc.
271
+ return 8
272
+ if n in (48, 64): # packed 16-bit rgb48/rgba64
273
+ return 16
274
+ return n if 8 <= n <= 16 else 8
275
+
276
+
277
+ def keyframes_near(path: str, t: float, window: float = 5.0) -> List[float]:
278
+ """Video keyframe timestamps within +-window seconds of t, ascending. Read with
279
+ -read_intervals so a long file is not scanned end to end; empty when ffprobe cannot say."""
280
+ ffprobe = require_tool("ffprobe")
281
+ lo = max(0.0, t - window)
282
+ proc = run([ffprobe, "-v", "error", "-select_streams", "v:0", "-skip_frame", "nokey",
283
+ "-read_intervals", f"{lo:.3f}%{t + window:.3f}", "-show_entries", "frame=pts_time",
284
+ "-of", "csv=p=0", path], quiet=True, check=False)
285
+ if proc.returncode != 0:
286
+ return []
287
+ out: List[float] = []
288
+ for line in proc.stdout.splitlines():
289
+ try:
290
+ out.append(round(float(line.strip().rstrip(",")), 3))
291
+ except ValueError:
292
+ continue
293
+ return sorted(set(out))
294
+
295
+
296
+ def measured_level_dbfs(path: str, seconds: float = 120.0) -> Optional[Dict[str, float]]:
297
+ """Mean and peak level of the first `seconds` of audio (volumedetect), in dBFS; None if unmeasurable.
298
+ Cheap enough to run once as a hint when a threshold-based tool found nothing."""
299
+ ffmpeg = require_tool("ffmpeg")
300
+ proc = run_analysis([ffmpeg, "-hide_banner", "-nostdin", "-t", f"{seconds:.0f}", "-i", path, "-vn",
301
+ "-af", "volumedetect", "-f", "null", "-"], check=False)
302
+ m_mean = re.search(r"mean_volume:\s*(-?[0-9.]+) dB", proc.stderr)
303
+ m_max = re.search(r"max_volume:\s*(-?[0-9.]+) dB", proc.stderr)
304
+ if not (m_mean and m_max):
305
+ return None
306
+ return {"mean_dbfs": float(m_mean.group(1)), "peak_dbfs": float(m_max.group(1))}
307
+
308
+
309
+ def analyze_levels(path: str, seconds: float = 20.0) -> Dict[str, Any]:
310
+ """Sample luma/saturation statistics (signalstats) and guess whether the picture is Log-encoded.
311
+
312
+ Log gammas (S-Log3, V-Log, C-Log, HLG-looking flat profiles) put black around 90-95/255 and
313
+ white below ~235 with low saturation: the image looks grey and flat but is tagged as plain SDR.
314
+ """
315
+ ffmpeg = require_tool("ffmpeg")
316
+ cmd = [ffmpeg, "-hide_banner", "-nostdin", "-t", f"{seconds:.1f}", "-i", path, "-an",
317
+ "-vf", "fps=2,signalstats,metadata=print:file=-", "-f", "null", "-"]
318
+ proc = run_analysis(cmd, check=False)
319
+ vals: Dict[str, List[float]] = {}
320
+ for line in proc.stdout.splitlines():
321
+ if "lavfi.signalstats." in line and "=" in line:
322
+ key, val = line.split("lavfi.signalstats.", 1)[1].split("=", 1)
323
+ try:
324
+ vals.setdefault(key, []).append(float(val))
325
+ except ValueError:
326
+ pass
327
+ if not vals.get("YAVG"):
328
+ return {"error": "no frames analysed"}
329
+ def mean(k: str) -> float:
330
+ v = vals.get(k) or [0.0]
331
+ return sum(v) / len(v)
332
+ ymin, ymax, yavg, sat = min(vals.get("YMIN") or [0]), max(vals.get("YMAX") or [255]), mean("YAVG"), mean("SATAVG")
333
+ # signalstats reports in the source bit depth; normalise everything to an 8-bit scale
334
+ scale = 1.0
335
+ if ymax > 255 or yavg > 255:
336
+ scale = 1 / 4.0 if ymax <= 1023 else (1 / 16.0 if ymax <= 4095 else 1 / 256.0) # 10 / 12 / 16-bit
337
+ ymin, ymax, yavg, sat = ymin * scale, ymax * scale, yavg * scale, sat * scale
338
+ # 5th/95th percentile of per-frame lows/highs is more robust than the absolute min/max
339
+ lows = sorted(x * scale for x in (vals.get("YLOW") or vals.get("YMIN") or [0]))
340
+ highs = sorted(x * scale for x in (vals.get("YHIGH") or vals.get("YMAX") or [255]))
341
+ p_low = lows[len(lows) // 20]
342
+ p_high = highs[-1 - len(highs) // 20]
343
+ looks_log = p_low >= 64 and p_high <= 235 and sat < 40
344
+ return {
345
+ "scale": "8-bit equivalent",
346
+ "y_min": round(ymin, 1), "y_max": round(ymax, 1), "y_avg": round(yavg, 1), "y_low_p5": round(p_low, 1), "y_high_p95": round(p_high, 1),
347
+ "saturation_avg": round(sat, 1),
348
+ "looks_like_log": looks_log,
349
+ "note": ("flat, low-contrast, desaturated picture tagged as SDR: probably a Log profile (S-Log/V-Log/C-Log). "
350
+ "Apply the camera's conversion LUT with color.py --lut" if looks_log else "contrast and saturation look like normal display-referred SDR"),
351
+ }
352
+
353
+
354
+ def _to_float(v: Any) -> Optional[float]:
355
+ try:
356
+ return float(v) if v is not None else None
357
+ except (TypeError, ValueError):
358
+ return None
359
+
360
+
361
+ def _to_int(v: Any) -> Optional[int]:
362
+ try:
363
+ return int(v) if v is not None else None
364
+ except (TypeError, ValueError):
365
+ return None
366
+
367
+
368
+ def _fraction(v: Optional[str]) -> Optional[Fraction]:
369
+ if not v or v in ("0/0", "0"):
370
+ return None
371
+ try:
372
+ f = Fraction(v)
373
+ return f if f > 0 else None
374
+ except (ValueError, ZeroDivisionError):
375
+ return None
376
+
377
+
378
+ def _aspect_string(w: Optional[int], h: Optional[int]) -> Optional[str]:
379
+ if not w or not h:
380
+ return None
381
+ f = Fraction(w, h)
382
+ return f"{f.numerator}:{f.denominator}"
383
+
384
+
385
+ # --------------------------------------------------------------- structure detectors (1.16)
386
+ # silencedetect and scdet, lifted out of silence.py and scenes.py byte-for-byte in 1.16.0 so that
387
+ # metadata.py --auto-chapters can measure structure without importing another tool (no script in
388
+ # scripts/ imports a sibling tool; only the _-prefixed modules are shared). silence.py and
389
+ # scenes.py import them back from here, so their behaviour is unchanged.
390
+
391
+ SIL_RE = re.compile(r"silence_(start|end): ([0-9.]+)")
392
+
393
+
394
+ def detect_silences(path: str, threshold: float, min_silence: float) -> "List[Tuple[float, float]]":
395
+ if dry_run_input_pending(path):
396
+ return []
397
+ ffmpeg = require_tool("ffmpeg")
398
+ cmd = [ffmpeg, "-hide_banner", "-nostdin", "-i", path, "-vn", "-af",
399
+ f"silencedetect=noise={threshold}dB:d={min_silence}", "-f", "null", "-"]
400
+ proc = run_analysis(cmd, check=False, record=True)
401
+ if proc.returncode != 0:
402
+ die(f"silencedetect failed:\n{proc.stderr.strip()[-800:]}", kind="ffmpeg")
403
+ silences: "List[Tuple[float, float]]" = []
404
+ start = None
405
+ for kind, val in SIL_RE.findall(proc.stderr):
406
+ if kind == "start":
407
+ start = float(val)
408
+ elif start is not None:
409
+ silences.append((start, float(val)))
410
+ start = None
411
+ if start is not None: # silence runs to the end
412
+ silences.append((start, float("inf")))
413
+ return silences
414
+
415
+
416
+ SCORE_RE = re.compile(r"frame:(\d+)\s+pts:\d+\s+pts_time:([0-9.]+)")
417
+
418
+
419
+ def detect_scenes(path: str, threshold: float, min_len: float, duration: float, ratio: float = 3.0) -> "List[float]":
420
+ """Scene cuts = frames whose scdet score is above `threshold` AND stands out from its
421
+ neighbourhood (score > ratio x median of the surrounding +-12 frames). Sustained motion,
422
+ flashes and fast pans raise the score on many consecutive frames and are rejected;
423
+ a real cut is a one-frame spike. On real footage this roughly doubles precision at
424
+ equal recall compared with the raw scdet threshold."""
425
+ ffmpeg = require_tool("ffmpeg")
426
+ proc = run_analysis([ffmpeg, "-hide_banner", "-nostdin", "-i", path, "-an", "-vf",
427
+ "scale=320:-2,scdet=threshold=0,metadata=print:file=-", "-f", "null", "-"])
428
+ # No `sc_pass=1` on scdet: on FFmpeg 5.x that option means "pass only the frames whose
429
+ # score exceeds the threshold", so every truly static frame (score exactly 0 -- a title
430
+ # card, colour bars) is dropped before metadata=print and the frame numbers are re-counted
431
+ # without them. The +-12-frame neighbourhood around a real cut then fills with the moving
432
+ # segment's scores instead of the still one's zeros, the cut fails the ratio test, and a
433
+ # 4 s smptebars scene made the cuts on both sides of it disappear (found by the 5.1.1 CI
434
+ # job, #146). 6.1+ passes every frame either way. Scores are still indexed by frame number
435
+ # and any frame the filter did not report counts as 0, so a build that drops frames again
436
+ # cannot shift the neighbourhood.
437
+ by_frame: "Dict[int, Tuple[float, float]]" = {}
438
+ cur = None
439
+ for line in proc.stdout.splitlines():
440
+ m = SCORE_RE.match(line)
441
+ if m:
442
+ cur = (int(m.group(1)), float(m.group(2)))
443
+ continue
444
+ if line.startswith("lavfi.scd.score=") and cur is not None:
445
+ try:
446
+ by_frame[cur[0]] = (cur[1], float(line.split("=", 1)[1]))
447
+ except ValueError:
448
+ pass
449
+ cuts = [0.0]
450
+ if not by_frame:
451
+ return cuts
452
+ n_frames = max(by_frame) + 1
453
+ times: "List[float]" = [by_frame[i][0] if i in by_frame else -1.0 for i in range(n_frames)]
454
+ scores: "List[float]" = [by_frame[i][1] if i in by_frame else 0.0 for i in range(n_frames)]
455
+ w = 12
456
+ for i, sc in enumerate(scores):
457
+ if sc < threshold:
458
+ continue
459
+ lo, hi = max(0, i - w), min(len(scores), i + w + 1)
460
+ neigh = sorted(scores[lo:i] + scores[i + 1:hi])
461
+ med = neigh[len(neigh) // 2] if neigh else 0.0
462
+ if sc < ratio * max(med, 0.5):
463
+ continue
464
+ # keep only the local maximum inside +-2 frames
465
+ if any(scores[j] > sc for j in range(max(0, i - 2), min(len(scores), i + 3)) if j != i):
466
+ continue
467
+ t = times[i]
468
+ if t - cuts[-1] >= min_len:
469
+ cuts.append(t)
470
+ if duration - cuts[-1] < min_len and len(cuts) > 1:
471
+ cuts.pop()
472
+ return cuts