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 +3 -0
- takecheck/__main__.py +4 -0
- takecheck/checks/__init__.py +21 -0
- takecheck/checks/audio_check.py +184 -0
- takecheck/checks/frame_checks.py +302 -0
- takecheck/checks/spec_check.py +51 -0
- takecheck/checks/subtitle_check.py +144 -0
- takecheck/cli.py +175 -0
- takecheck/clip.py +47 -0
- takecheck/frames.py +240 -0
- takecheck/probe.py +100 -0
- takecheck/report.py +241 -0
- takecheck/selftest.py +539 -0
- takecheck/spec.py +195 -0
- takecheck-0.1.0.dist-info/METADATA +199 -0
- takecheck-0.1.0.dist-info/RECORD +20 -0
- takecheck-0.1.0.dist-info/WHEEL +5 -0
- takecheck-0.1.0.dist-info/entry_points.txt +2 -0
- takecheck-0.1.0.dist-info/licenses/LICENSE +21 -0
- takecheck-0.1.0.dist-info/top_level.txt +1 -0
takecheck/__init__.py
ADDED
takecheck/__main__.py
ADDED
|
@@ -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)
|