takecheck 0.1.0__py3-none-any.whl

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.
takecheck/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """takecheck — pytest for AI-generated video."""
2
+
3
+ __version__ = "0.1.0"
takecheck/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
@@ -0,0 +1,21 @@
1
+ """Individual checks. Each check inspects one aspect of a take."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Any
7
+
8
+ PASS = "pass"
9
+ FAIL = "fail"
10
+ WARN = "warn"
11
+ SKIP = "skip"
12
+
13
+
14
+ @dataclass
15
+ class CheckResult:
16
+ """Outcome of a single check."""
17
+
18
+ name: str
19
+ status: str
20
+ detail: str
21
+ measured: dict[str, Any] = field(default_factory=dict)
@@ -0,0 +1,184 @@
1
+ """Audio checks: is there a track, is it audible, is it clipped, how loud?
2
+
3
+ Everything here comes from ``ffmpeg`` filters that already do the measuring, so
4
+ no audio code is decoded into Python: ``volumedetect`` gives peak and mean
5
+ level, ``silencedetect`` gives the silent stretches, and ``ebur128`` gives the
6
+ integrated loudness that broadcasters actually care about.
7
+
8
+ Nothing in this module needs numpy.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import re
14
+ import shutil
15
+ import subprocess
16
+ from dataclasses import dataclass, field
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ from ..probe import VideoInfo
21
+ from . import FAIL, PASS, CheckResult
22
+
23
+ # A peak at or above this dBFS means the waveform is hitting the ceiling.
24
+ CLIP_PEAK_DB = -0.1
25
+ # Silence shorter than this is a pause, not a defect.
26
+ MIN_SILENCE_SECONDS = 0.30
27
+ # Silence quieter than this counts as silence.
28
+ SILENCE_NOISE_DB = -50.0
29
+ # More than this share of the clip being silent is a broken audio track.
30
+ MAX_SILENT_FRACTION = 0.60
31
+ # Integrated loudness outside this window is a mixing problem, not a defect;
32
+ # we report it as a warning rather than failing the take.
33
+ LOUDNESS_RANGE = (-30.0, -10.0)
34
+
35
+ _MEAN_RE = re.compile(r"mean_volume:\s*(-?\d+(?:\.\d+)?) dB")
36
+ _MAX_RE = re.compile(r"max_volume:\s*(-?\d+(?:\.\d+)?) dB")
37
+ _SILENCE_START_RE = re.compile(r"silence_start:\s*(-?\d+(?:\.\d+)?)")
38
+ _SILENCE_END_RE = re.compile(r"silence_end:\s*(-?\d+(?:\.\d+)?)")
39
+ _LOUDNESS_RE = re.compile(r"^\s*I:\s*(-?\d+(?:\.\d+)?)\s*LUFS", re.MULTILINE)
40
+
41
+
42
+ @dataclass
43
+ class AudioStats:
44
+ """Raw measurements, so they can be tested without a CheckResult."""
45
+
46
+ duration: float = 0.0
47
+ mean_db: float | None = None
48
+ peak_db: float | None = None
49
+ loudness_lufs: float | None = None
50
+ silent_seconds: float = 0.0
51
+ silent_segments: list[tuple[float, float]] = field(default_factory=list)
52
+
53
+ @property
54
+ def silent_fraction(self) -> float:
55
+ if self.duration <= 0:
56
+ return 0.0
57
+ return min(1.0, self.silent_seconds / self.duration)
58
+
59
+
60
+ def _ffmpeg() -> str:
61
+ executable = shutil.which("ffmpeg")
62
+ if executable is None:
63
+ raise RuntimeError("ffmpeg not found on PATH (install ffmpeg)")
64
+ return executable
65
+
66
+
67
+ def _analyse(path: Path, duration: float) -> AudioStats:
68
+ """Run ffmpeg once with several measurement filters chained."""
69
+ filters = (
70
+ f"silencedetect=noise={SILENCE_NOISE_DB}dB:d={MIN_SILENCE_SECONDS},"
71
+ "volumedetect,ebur128"
72
+ )
73
+ cmd = [
74
+ _ffmpeg(),
75
+ "-v",
76
+ "info",
77
+ "-nostats",
78
+ "-i",
79
+ str(path),
80
+ "-af",
81
+ filters,
82
+ "-f",
83
+ "null",
84
+ "-",
85
+ ]
86
+ proc = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", errors="replace")
87
+ # ffmpeg reports measurements on stderr and still exits 0 for a valid file.
88
+ log = proc.stderr or ""
89
+
90
+ stats = AudioStats(duration=duration)
91
+
92
+ match = _MEAN_RE.search(log)
93
+ if match:
94
+ stats.mean_db = float(match.group(1))
95
+ match = _MAX_RE.search(log)
96
+ if match:
97
+ stats.peak_db = float(match.group(1))
98
+ match = _LOUDNESS_RE.search(log)
99
+ if match:
100
+ stats.loudness_lufs = float(match.group(1))
101
+
102
+ starts = [float(value) for value in _SILENCE_START_RE.findall(log)]
103
+ ends = [float(value) for value in _SILENCE_END_RE.findall(log)]
104
+ for index, start in enumerate(starts):
105
+ # A still-open silence runs to the end of the file.
106
+ end = ends[index] if index < len(ends) else duration
107
+ if end > start:
108
+ stats.silent_segments.append((start, end))
109
+ stats.silent_seconds += end - start
110
+
111
+ return stats
112
+
113
+
114
+ def check_audio(info: VideoInfo, expect) -> CheckResult:
115
+ """Verify the audio track exists, is audible, and is not clipped.
116
+
117
+ Presence is deliberately left to ``spec_check``: when a track is missing the
118
+ spec check already fails with "audio track missing", and reporting it a
119
+ second time here would put two red lines on one problem. This check adds
120
+ the measurements spec compliance does not make -- silence, clipping and
121
+ loudness -- and enforces ``audio: forbidden``, which only exists here.
122
+ """
123
+ mode = expect.audio or "optional"
124
+
125
+ if mode == "forbidden":
126
+ status = FAIL if info.has_audio else PASS
127
+ detail = (
128
+ "audio track present but forbidden"
129
+ if info.has_audio
130
+ else "no audio track, as required"
131
+ )
132
+ return CheckResult(
133
+ "audio",
134
+ status,
135
+ detail,
136
+ {"checked": True, "mode": mode, "has_audio": info.has_audio},
137
+ )
138
+
139
+ if not info.has_audio:
140
+ # Missing-track verdicts belong to ``spec`` (see the docstring).
141
+ return CheckResult(
142
+ "audio",
143
+ PASS,
144
+ "no audio track to measure (presence is checked by `spec`)",
145
+ {"checked": False, "mode": mode, "has_audio": False},
146
+ )
147
+
148
+ stats = _analyse(info.path, info.duration)
149
+ measured: dict[str, Any] = {
150
+ "checked": True,
151
+ "mode": mode,
152
+ "has_audio": True,
153
+ "mean_db": stats.mean_db,
154
+ "peak_db": stats.peak_db,
155
+ "loudness_lufs": stats.loudness_lufs,
156
+ "silent_seconds": round(stats.silent_seconds, 3),
157
+ "silent_fraction": round(stats.silent_fraction, 3),
158
+ "silent_segments": len(stats.silent_segments),
159
+ "max_silent_fraction": MAX_SILENT_FRACTION,
160
+ "clip_peak_db": CLIP_PEAK_DB,
161
+ }
162
+
163
+ problems: list[str] = []
164
+ if stats.silent_fraction > MAX_SILENT_FRACTION:
165
+ problems.append(
166
+ f"{stats.silent_fraction:.0%} of the clip is silent "
167
+ f"(> {MAX_SILENT_FRACTION:.0%})"
168
+ )
169
+ if stats.peak_db is not None and stats.peak_db >= CLIP_PEAK_DB:
170
+ problems.append(f"peak {stats.peak_db:.1f} dBFS hits the ceiling (clipping)")
171
+
172
+ if problems:
173
+ return CheckResult("audio", FAIL, "; ".join(problems), measured)
174
+
175
+ loudness = (
176
+ f", {stats.loudness_lufs:.1f} LUFS" if stats.loudness_lufs is not None else ""
177
+ )
178
+ peak = f"peak {stats.peak_db:.1f} dB" if stats.peak_db is not None else "peak unknown"
179
+ return CheckResult(
180
+ "audio",
181
+ PASS,
182
+ f"{peak}{loudness}, {stats.silent_fraction:.0%} silent",
183
+ measured,
184
+ )
@@ -0,0 +1,302 @@
1
+ """Frame-level checks: frozen, duplicate, blank, flicker, motion.
2
+
3
+ All five read the same decoded clip (see :mod:`takecheck.clip`), so adding them
4
+ costs no extra ffmpeg pass. Every check reports the numbers it measured, not
5
+ just a verdict -- a failing run has to explain itself.
6
+
7
+ **Localised defects are attributed to exactly one check.** ``frozen``,
8
+ ``duplicate``, ``blank`` and ``flicker`` all describe an event at a specific
9
+ time, so they must not report the same event: a black stretch is ``blank``
10
+ only, a freeze is ``frozen`` only, and ``duplicate``/``flicker`` skip blank
11
+ frames and frozen stretches. Otherwise one bad moment would produce four red
12
+ lines and bury the actual diagnosis.
13
+
14
+ ``motion`` is deliberately outside that rule: it is a whole-clip aggregate
15
+ ("does this clip move at all over its length"), not an event with a timestamp,
16
+ so it answers a different question. A fully black clip genuinely is both blank
17
+ *and* motionless, and reporting both is accurate rather than redundant.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from typing import Any
23
+
24
+ import numpy as np
25
+
26
+ from ..clip import Clip
27
+ from ..spec import Expect
28
+ from . import FAIL, PASS, CheckResult
29
+
30
+ # Two consecutive frames count as "the same picture" at or below this pHash
31
+ # Hamming distance (0 = identical, 64 = nothing in common).
32
+ FROZEN_HASH_DISTANCE = 3
33
+ # A freeze has to last at least this many frames before it is even considered,
34
+ # so that a single skipped frame is not reported as a still stretch.
35
+ FROZEN_MIN_FRAMES = 5
36
+ # Frames closer together than this are neighbours, not duplicates: immediate
37
+ # repeats are what ``frozen`` is for.
38
+ DUPLICATE_MIN_GAP = 5
39
+ # Share of comparable frames allowed to be repeats before the clip is rejected.
40
+ DUPLICATE_MAX_RATIO = 0.01
41
+ # Mean luma below/above these is treated as a black or blown-out frame.
42
+ BLANK_DARK = 16.0
43
+ BLANK_BRIGHT = 240.0
44
+ # Sliding window (frames) used for the flicker extreme-range measurement.
45
+ FLICKER_WINDOW = 5
46
+
47
+
48
+ def _runs(mask: np.ndarray, min_len: int) -> list[tuple[int, int]]:
49
+ """Return ``(start, end)`` index pairs for runs of ``True`` >= ``min_len``.
50
+
51
+ ``end`` is inclusive, in the index space of ``mask``.
52
+ """
53
+ if mask.size == 0:
54
+ return []
55
+ padded = np.concatenate(([False], mask, [False]))
56
+ edges = np.flatnonzero(padded[1:] != padded[:-1])
57
+ runs = [(int(a), int(b - 1)) for a, b in zip(edges[::2], edges[1::2], strict=False)]
58
+ return [run for run in runs if run[1] - run[0] + 1 >= min_len]
59
+
60
+
61
+ def _fmt_ranges(ranges: list[tuple[float, float]], limit: int = 3) -> str:
62
+ shown = ", ".join(f"{a:.2f}-{b:.2f}s" for a, b in ranges[:limit])
63
+ if len(ranges) > limit:
64
+ shown += f", +{len(ranges) - limit} more"
65
+ return shown
66
+
67
+
68
+ def _seconds(pairs: list[tuple[int, int]], fps: float) -> list[tuple[float, float]]:
69
+ return [(a / fps, (b + 1) / fps) for a, b in pairs]
70
+
71
+
72
+ def frozen_runs(clip: Clip, expect: Expect) -> list[tuple[int, int]]:
73
+ """Frame index ranges that count as a freeze, inclusive.
74
+
75
+ Indices refer to frames, not to the ``hash_delta`` array.
76
+
77
+ Blank frames are excluded: a stretch of pure black is a *blank* defect, and
78
+ it would otherwise be reported here as well, double-counting one problem.
79
+ """
80
+ if clip.count < FROZEN_MIN_FRAMES:
81
+ return []
82
+ # One entry per adjacent pair, so pair i compares frame i and frame i+1.
83
+ still = clip.metrics.hash_delta <= FROZEN_HASH_DISTANCE
84
+ luma = clip.metrics.luma
85
+ blank = (luma < BLANK_DARK) | (luma > BLANK_BRIGHT)
86
+ # Drop pairs where either frame is blank, so a run cannot span black frames.
87
+ still &= ~blank[:-1] & ~blank[1:]
88
+ allowed = int(round(clip.ms_to_frames(expect.frozen_max_ms)))
89
+ # A run of N pairs spans N+1 frames, so require N+1 > allowed.
90
+ min_pairs = max(FROZEN_MIN_FRAMES - 1, allowed)
91
+ return [(start, end + 1) for start, end in _runs(still, min_pairs)]
92
+
93
+
94
+ def check_frozen(clip: Clip, expect: Expect) -> CheckResult:
95
+ """Flag stretches where nothing moves for longer than ``frozen_max_ms``."""
96
+ runs = frozen_runs(clip, expect)
97
+ longest_frames = max((end - start + 1 for start, end in runs), default=0)
98
+
99
+ measured: dict[str, Any] = {
100
+ "longest_run_ms": round(clip.ms(longest_frames), 1),
101
+ "frozen_runs": len(runs),
102
+ "allowed_ms": expect.frozen_max_ms,
103
+ "hash_distance": FROZEN_HASH_DISTANCE,
104
+ "total_frames": clip.count,
105
+ }
106
+ if not runs:
107
+ return CheckResult(
108
+ "frozen",
109
+ PASS,
110
+ f"longest still stretch {measured['longest_run_ms']:.0f}ms "
111
+ f"(limit {expect.frozen_max_ms}ms)",
112
+ measured,
113
+ )
114
+
115
+ ranges = _seconds(runs, clip.fps)
116
+ measured["ranges"] = [[round(a, 3), round(b, 3)] for a, b in ranges]
117
+ detail = (
118
+ f"{len(runs)} frozen stretch(es) over {expect.frozen_max_ms}ms: "
119
+ f"{_fmt_ranges(ranges)} (longest {measured['longest_run_ms']:.0f}ms)"
120
+ )
121
+ return CheckResult("frozen", FAIL, detail, measured)
122
+
123
+
124
+ def check_duplicate(clip: Clip, expect: Expect) -> CheckResult:
125
+ """Flag frames that replay an earlier frame after the picture has moved on.
126
+
127
+ Blank frames and frames inside a freeze are skipped: those are reported by
128
+ ``blank`` and ``frozen``, and counting them here would report one defect
129
+ three times over.
130
+ """
131
+ hashes = clip.metrics.hashes
132
+ luma = clip.metrics.luma
133
+ total = len(hashes)
134
+ if total == 0:
135
+ return CheckResult("duplicate", FAIL, "no frames decoded", {"total_frames": 0})
136
+
137
+ usable = (luma >= BLANK_DARK) & (luma <= BLANK_BRIGHT)
138
+ for start, end in frozen_runs(clip, expect):
139
+ usable[start : end + 1] = False
140
+
141
+ repeats: list[int] = []
142
+ seen: dict[int, int] = {}
143
+ for index in range(total):
144
+ if not usable[index]:
145
+ continue
146
+ key = int(hashes[index])
147
+ first = seen.get(key)
148
+ if first is None:
149
+ seen[key] = index
150
+ elif index - first >= DUPLICATE_MIN_GAP:
151
+ repeats.append(index)
152
+
153
+ counted = int(usable.sum())
154
+ ratio = len(repeats) / counted if counted else 0.0
155
+ measured: dict[str, Any] = {
156
+ "duplicate_frames": len(repeats),
157
+ "duplicate_ratio": round(ratio, 4),
158
+ "allowed_ratio": DUPLICATE_MAX_RATIO,
159
+ "min_gap_frames": DUPLICATE_MIN_GAP,
160
+ "compared_frames": counted,
161
+ "total_frames": total,
162
+ }
163
+ if ratio <= DUPLICATE_MAX_RATIO:
164
+ return CheckResult(
165
+ "duplicate",
166
+ PASS,
167
+ f"{len(repeats)}/{counted} frames replay an earlier frame ({ratio:.1%})",
168
+ measured,
169
+ )
170
+
171
+ times = [index / clip.fps for index in repeats[:5]]
172
+ measured["first_repeats_s"] = [round(t, 3) for t in times]
173
+ detail = (
174
+ f"{len(repeats)}/{counted} frames replay an earlier frame ({ratio:.1%} > "
175
+ f"{DUPLICATE_MAX_RATIO:.0%}), first at {times[0]:.2f}s"
176
+ )
177
+ return CheckResult("duplicate", FAIL, detail, measured)
178
+
179
+
180
+ def check_blank(clip: Clip, expect: Expect) -> CheckResult:
181
+ """Count frames that are essentially black or blown out to white."""
182
+ luma = clip.metrics.luma
183
+ dark = luma < BLANK_DARK
184
+ bright = luma > BLANK_BRIGHT
185
+ bad = dark | bright
186
+
187
+ measured: dict[str, Any] = {
188
+ "blank_frames": int(bad.sum()),
189
+ "dark_frames": int(dark.sum()),
190
+ "bright_frames": int(bright.sum()),
191
+ "allowed_frames": expect.black_max_frames,
192
+ "min_luma": round(float(luma.min()), 2) if luma.size else 0.0,
193
+ "max_luma": round(float(luma.max()), 2) if luma.size else 0.0,
194
+ "mean_luma": round(float(luma.mean()), 2) if luma.size else 0.0,
195
+ "total_frames": clip.count,
196
+ }
197
+ if measured["blank_frames"] <= expect.black_max_frames:
198
+ return CheckResult(
199
+ "blank",
200
+ PASS,
201
+ f"{measured['blank_frames']} blank frame(s), "
202
+ f"luma {measured['min_luma']:.0f}-{measured['max_luma']:.0f}",
203
+ measured,
204
+ )
205
+
206
+ ranges = _seconds(_runs(bad, 1), clip.fps)
207
+ measured["ranges"] = [[round(a, 3), round(b, 3)] for a, b in ranges]
208
+ detail = (
209
+ f"{measured['blank_frames']} blank frame(s) (allowed {expect.black_max_frames}) "
210
+ f"at {_fmt_ranges(ranges)}: "
211
+ f"{measured['dark_frames']} dark, {measured['bright_frames']} bright"
212
+ )
213
+ return CheckResult("blank", FAIL, detail, measured)
214
+
215
+
216
+ def check_flicker(clip: Clip, expect: Expect) -> CheckResult:
217
+ """Measure the worst brightness swing inside a short sliding window.
218
+
219
+ ``flicker_max`` is a fraction of full scale: a clip whose mean luma jumps
220
+ by 35% of 0..255 within a handful of frames is visibly strobing. Windows
221
+ that contain a blank frame are ignored -- a cut to black is not a flicker
222
+ and is already reported by ``blank``.
223
+ """
224
+ luma = clip.metrics.luma
225
+ if luma.size == 0:
226
+ return CheckResult("flicker", FAIL, "no frames decoded", {"frames": 0})
227
+
228
+ blank = (luma < BLANK_DARK) | (luma > BLANK_BRIGHT)
229
+ window = max(2, min(FLICKER_WINDOW, luma.size))
230
+ swings = np.zeros(luma.size, dtype=np.float32)
231
+ for index in range(luma.size):
232
+ lo = max(0, index - window + 1)
233
+ if blank[lo : index + 1].any():
234
+ continue
235
+ swings[index] = np.ptp(luma[lo : index + 1])
236
+
237
+ swing = float(swings.max())
238
+ normalised = swing / 255.0
239
+
240
+ measured: dict[str, Any] = {
241
+ "max_window_swing": round(swing, 2),
242
+ "normalised_swing": round(normalised, 4),
243
+ "allowed": expect.flicker_max,
244
+ "window_frames": window,
245
+ "mean_abs_step": round(float(np.abs(np.diff(luma)).mean()) / 255.0, 4)
246
+ if luma.size > 1
247
+ else 0.0,
248
+ "total_frames": clip.count,
249
+ }
250
+ if normalised <= expect.flicker_max:
251
+ return CheckResult(
252
+ "flicker",
253
+ PASS,
254
+ f"worst {window}-frame luma swing {swing:.0f}/255 "
255
+ f"({normalised:.1%} <= {expect.flicker_max:.0%})",
256
+ measured,
257
+ )
258
+
259
+ index = int(swings.argmax())
260
+ measured["worst_at_s"] = round(index / clip.fps, 3)
261
+ detail = (
262
+ f"luma swings {swing:.0f}/255 ({normalised:.1%}) within {window} frames at "
263
+ f"{measured['worst_at_s']:.2f}s, over the {expect.flicker_max:.0%} limit"
264
+ )
265
+ return CheckResult("flicker", FAIL, detail, measured)
266
+
267
+
268
+ def check_motion(clip: Clip, expect: Expect) -> CheckResult:
269
+ """Reject a clip whose frames barely differ, i.e. a near-static render."""
270
+ motion = clip.metrics.motion
271
+ if motion.size == 0:
272
+ measured = {
273
+ "mean_motion": 0.0,
274
+ "min_motion": 0.0,
275
+ "allowed_min": expect.motion_min,
276
+ "total_frames": clip.count,
277
+ }
278
+ return CheckResult("motion", FAIL, "only one frame, cannot measure motion", measured)
279
+
280
+ mean_motion = float(motion.mean())
281
+ measured: dict[str, Any] = {
282
+ "mean_motion": round(mean_motion, 5),
283
+ "min_motion": round(float(motion.min()), 5),
284
+ "max_motion": round(float(motion.max()), 5),
285
+ "allowed_min": expect.motion_min,
286
+ "still_frames": int((motion < expect.motion_min).sum()),
287
+ "total_frames": clip.count,
288
+ }
289
+ if mean_motion >= expect.motion_min:
290
+ return CheckResult(
291
+ "motion",
292
+ PASS,
293
+ f"mean frame-to-frame motion {mean_motion:.4f} >= {expect.motion_min:.4f}",
294
+ measured,
295
+ )
296
+ return CheckResult(
297
+ "motion",
298
+ FAIL,
299
+ f"mean frame-to-frame motion {mean_motion:.4f} is below the required "
300
+ f"{expect.motion_min:.4f} (near-static output)",
301
+ measured,
302
+ )
@@ -0,0 +1,51 @@
1
+ """Spec compliance: does the file match the declared duration/fps/frames/audio?"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from ..probe import VideoInfo
8
+ from ..spec import Expect
9
+ from . import FAIL, PASS, CheckResult
10
+
11
+ DURATION_TOL = 0.05 # seconds
12
+ FPS_TOL = 0.01
13
+
14
+
15
+ def check_spec(info: VideoInfo, expect: Expect) -> CheckResult:
16
+ """Compare probed facts with the declared expectations."""
17
+ problems: list[str] = []
18
+ measured: dict[str, Any] = {
19
+ "duration": round(info.duration, 3),
20
+ "fps": round(info.fps, 3),
21
+ "frames": info.frames,
22
+ "resolution": f"{info.width}x{info.height}",
23
+ "audio": info.has_audio,
24
+ }
25
+
26
+ if expect.duration is not None and abs(info.duration - expect.duration) > DURATION_TOL:
27
+ problems.append(f"duration {info.duration:.2f}s != {expect.duration:.2f}s")
28
+
29
+ if expect.fps is not None and abs(info.fps - expect.fps) > FPS_TOL:
30
+ problems.append(f"fps {info.fps:g} != {expect.fps:g}")
31
+
32
+ if expect.frames is not None and info.frames != expect.frames:
33
+ suffix = "" if info.frames_exact else " (estimated)"
34
+ problems.append(f"frames {info.frames}{suffix} != {expect.frames}")
35
+
36
+ expected_size = expect.width_height()
37
+ if expected_size is not None and (info.width, info.height) != expected_size:
38
+ problems.append(f"resolution {info.width}x{info.height} != {expect.resolution}")
39
+
40
+ if expect.audio == "required" and not info.has_audio:
41
+ problems.append("audio track missing")
42
+ if expect.audio == "forbidden" and info.has_audio:
43
+ problems.append("audio track present but forbidden")
44
+
45
+ if problems:
46
+ return CheckResult("spec", FAIL, "; ".join(problems), measured)
47
+
48
+ summary = (
49
+ f"{info.width}x{info.height} | {info.fps:g}fps | {info.frames}f | {info.duration:.2f}s"
50
+ )
51
+ return CheckResult("spec", PASS, summary, measured)