takecheck 0.1.0__tar.gz

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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 yqyq-ai
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,199 @@
1
+ Metadata-Version: 2.4
2
+ Name: takecheck
3
+ Version: 0.1.0
4
+ Summary: pytest for AI-generated video: turn 'is this take usable?' into an executable spec and a CI gate.
5
+ Author: yqyq-ai
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/yqyq-ai/takecheck
8
+ Keywords: video,qa,comfyui,generative-ai,ci
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Topic :: Multimedia :: Video
18
+ Classifier: Topic :: Software Development :: Quality Assurance
19
+ Requires-Python: >=3.10
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE
22
+ Requires-Dist: PyYAML>=6.0
23
+ Requires-Dist: numpy>=1.24
24
+ Provides-Extra: dev
25
+ Requires-Dist: pytest>=8; extra == "dev"
26
+ Requires-Dist: ruff>=0.5; extra == "dev"
27
+ Dynamic: license-file
28
+
29
+ # takecheck
30
+
31
+ [![CI](https://github.com/yqyq-ai/takecheck/actions/workflows/ci.yml/badge.svg)](https://github.com/yqyq-ai/takecheck/actions/workflows/ci.yml)
32
+
33
+ **pytest for AI-generated video.** Turn “is this take usable?” into an executable spec and a CI gate.
34
+
35
+ > **Status: v0.1.0 — the full mechanical check set.**
36
+ > Spec compliance plus frozen / duplicate / blank / flicker / motion / subtitle residue / audio,
37
+ > with a self-contained HTML report. PyPI publication is pending credentials.
38
+
39
+ ## Why
40
+
41
+ A 20-minute render should not be judged by scrubbing frames by hand. `takecheck` reads a `takes.yml`
42
+ spec, compares the delivered file against it, and returns **pass / fail + the reason** — in seconds,
43
+ on CPU, while the GPU is still rendering.
44
+
45
+ Generation pipelines fail in ways a thumbnail cannot show: a shot freezes for 300 ms, the camera starts
46
+ flickering, the reference clip’s burned-in subtitles survive the redraw, the audio track silently
47
+ disappears, the last frame of segment 3 does not match the first frame of segment 4. Each is cheap to
48
+ detect and expensive to discover after a 20-minute render — or after stitching the whole film.
49
+
50
+ Existing tools do not cover this layer: VMAF/DOVER/DVQA score perceptual quality against a reference
51
+ or a learned aesthetic prior, VideoGen-Eval is an LLM-API research harness, and ComfyUI QC nodes check
52
+ tensors inside a graph instead of the delivered file.
53
+
54
+ ## Install
55
+
56
+ ```bash
57
+ pip install takecheck
58
+ ```
59
+
60
+ Requires `ffmpeg` / `ffprobe` on `PATH`. No PyTorch, no GPU — `numpy` is the only numeric dependency.
61
+
62
+ ## Quick start
63
+
64
+ `takes.yml`:
65
+
66
+ ```yaml
67
+ name: h3-ep12-shot03
68
+ expect:
69
+ duration: 5.0
70
+ fps: 24
71
+ frames: 121
72
+ resolution: 1280x720
73
+ audio: required
74
+ subtitles: forbidden
75
+ frozen_max_ms: 200
76
+ black_max_frames: 0
77
+ flicker_max: 0.35
78
+ motion_min: 0.002
79
+ ```
80
+
81
+ ```bash
82
+ takecheck check out/shot03.mp4 --spec takes.yml --json report.json --html report.html
83
+ ```
84
+
85
+ ```console
86
+ FAIL h3-ep12-shot03 (shot03.mp4)
87
+ PASS spec 1280x720 | 24fps | 121f | 5.04s
88
+ FAIL frozen 1 frozen stretch(es) over 200ms: 0.00-0.83s (longest 833ms)
89
+ PASS duplicate 0/120 frames replay an earlier frame (0.0%)
90
+ PASS blank 0 blank frame(s), luma 12-241
91
+ PASS flicker worst 5-frame luma swing 9/255 (3.5% <= 35%)
92
+ PASS motion mean frame-to-frame motion 0.0412 >= 0.0020
93
+ PASS subtitle no persistent text band (row contrast 1.28 < 2 on 0% of frames)
94
+ PASS audio peak -3.2 dB, -18.4 LUFS, 0% silent
95
+ ```
96
+
97
+ Add `--html report.html` for a self-contained report: a contact sheet with failing moments outlined in
98
+ red, every measured value, and the raw JSON inline. No external assets, so it can be emailed or
99
+ attached to a PR as a single file.
100
+
101
+ Exit codes: `0` pass, `1` fail, `2` bad spec / unreadable file.
102
+
103
+ ### What the checks measure
104
+
105
+ Every check reports the numbers behind its verdict in `--json` and `--html`, not just PASS/FAIL.
106
+
107
+ | Check | Signal | Fails when |
108
+ |---|---|---|
109
+ | `frozen` | pHash Hamming distance between consecutive frames | ≤ 3 for longer than `frozen_max_ms` |
110
+ | `duplicate` | repeated frame hashes, ignoring neighbours, blanks and freezes | more than 1% of frames replay an earlier one |
111
+ | `blank` | mean luma per frame | below 16 or above 240, more than `black_max_frames` |
112
+ | `flicker` | peak-to-peak luma inside a 5-frame window | above `flicker_max` (fraction of 0–255) |
113
+ | `motion` | mean absolute frame-to-frame difference | below `motion_min` |
114
+ | `subtitle` | busiest row's horizontal activity ÷ that band's median row | above 2.0 on ≥ 30% of frames, when `subtitles: forbidden` |
115
+ | `audio` | peak dBFS, EBU R128 loudness, silent fraction | peak ≥ −0.1 dBFS, or more than 60% silent |
116
+
117
+ Three design choices worth knowing:
118
+
119
+ - **One defect, one check.** A cut to black is reported by `blank` only; `duplicate` skips blank frames,
120
+ and `flicker` skips windows containing them. A freeze is reported by `frozen`, and `duplicate` ignores
121
+ frames a freeze already explains. A missing audio track is reported by the spec check, so `audio`
122
+ stays quiet about it instead of failing twice for one problem.
123
+ - **`motion` is exempt on purpose.** It is a whole-clip aggregate ("does this clip move at all"), not an
124
+ event with a timestamp, so it may legitimately co-fire with a local check: a fully black clip really
125
+ is both blank and motionless.
126
+ - **Thresholds are calibrated, not guessed.** `motion_min` defaults to `0.002` because a normally moving
127
+ clip measures ≈ 0.009 and a still render ≈ 0.000 on the bundled fixtures. Real footage differs — set
128
+ the values in `takes.yml` for your own pipeline.
129
+ - **Deliberately slow shots read as still.** A clip slowed down 8x measures `mean_motion ≈ 0.0010` and
130
+ will fail the default `motion_min` of `0.002`. If your pipeline uses slow motion or static
131
+ compositions on purpose, lower `motion_min` (or set it to `0`) in `takes.yml`.
132
+
133
+ ### Known limitations
134
+
135
+ - **The `subtitle` check has only been validated on synthetic footage.** Its fixtures use a hard white
136
+ bar on black. Real subtitle ghosts are semi-transparent and blurred, and a scene that legitimately
137
+ contains text in the lower third will trip the check. Treat the default threshold as a starting
138
+ point, not a calibrated value.
139
+ - **Loudness is reported, not enforced.** Only clipping and excessive silence fail the audio check;
140
+ a quiet or hot mix is recorded under `measured` for you to judge.
141
+ - **Long clips are untested.** Decoding is streamed, so memory should stay flat, but a 20-minute render
142
+ has not been benchmarked.
143
+
144
+ ## Roadmap
145
+
146
+ - [x] v0.0.1 — spec format, ffprobe compliance check, CLI, `takecheck selftest`
147
+ - [x] v0.1.0 — frozen / duplicate / blank / flicker / motion / subtitle / audio, HTML report
148
+ - [ ] v0.1.1 — publish to PyPI (pending credentials)
149
+ - [ ] v0.2.0 — calibration against accepted/rejected takes, head/tail continuity
150
+ - [ ] v0.3.0 — pipeline lint (custom-node provenance, model hash pinning)
151
+ - [ ] v0.4.0 — pluggable VLM judge
152
+ - [ ] v0.2.0 — calibration against accepted/rejected takes, head/tail continuity
153
+ - [ ] v0.3.0 — pipeline lint (custom-node provenance, model hash pinning)
154
+ - [ ] v0.4.0 — pluggable VLM judge
155
+
156
+ ## Test media
157
+
158
+ `testmedia/synthetic/` holds seven tiny clips that each reproduce exactly one defect — frozen frames,
159
+ duplicate frames, blank frames, flicker, missing audio, burned-in subtitles — plus `good.mp4` as the
160
+ control that must always pass. Rebuild them with:
161
+
162
+ ```bash
163
+ python scripts/make_testmedia.py
164
+ ```
165
+
166
+ The script also verifies each clip against its expected profile (duration, frame count, audio) and
167
+ runs in CI, so the fixtures cannot silently rot. A second script asserts the *verdict* for each one:
168
+
169
+ ```bash
170
+ python scripts/check_synthetic.py
171
+ ```
172
+
173
+ It fails if `good.mp4` trips any check, or if a defect clip stops being caught — so a threshold change
174
+ cannot quietly break detection.
175
+
176
+ Real footage never goes in the repository: `testmedia/real/` is git-ignored.
177
+
178
+ ## Development
179
+
180
+ ```bash
181
+ pip install -e ".[dev]"
182
+ pytest -q
183
+ takecheck selftest # builds synthetic clips in a temp dir; needs ffmpeg
184
+ python scripts/check_synthetic.py
185
+ ```
186
+
187
+ Note for contributors on Windows: `drawtext` needs an explicit `fontfile=`, otherwise ffmpeg aborts
188
+ with a Fontconfig error instead of drawing anything. The test helpers search a list of common font
189
+ paths for this reason.
190
+
191
+ ## 中文简介
192
+
193
+ `takecheck` 是给 AI 生成视频用的**验收层**:用 `takes.yml` 声明一条片子该满足什么(时长、帧率、
194
+ 无静帧、无字幕残影、音轨完整),一条命令返回通过与否和原因,可直接当 CI 门禁。
195
+ 全程 CPU、秒级,能与渲染并行,不占显存。
196
+
197
+ ## License
198
+
199
+ MIT.
@@ -0,0 +1,171 @@
1
+ # takecheck
2
+
3
+ [![CI](https://github.com/yqyq-ai/takecheck/actions/workflows/ci.yml/badge.svg)](https://github.com/yqyq-ai/takecheck/actions/workflows/ci.yml)
4
+
5
+ **pytest for AI-generated video.** Turn “is this take usable?” into an executable spec and a CI gate.
6
+
7
+ > **Status: v0.1.0 — the full mechanical check set.**
8
+ > Spec compliance plus frozen / duplicate / blank / flicker / motion / subtitle residue / audio,
9
+ > with a self-contained HTML report. PyPI publication is pending credentials.
10
+
11
+ ## Why
12
+
13
+ A 20-minute render should not be judged by scrubbing frames by hand. `takecheck` reads a `takes.yml`
14
+ spec, compares the delivered file against it, and returns **pass / fail + the reason** — in seconds,
15
+ on CPU, while the GPU is still rendering.
16
+
17
+ Generation pipelines fail in ways a thumbnail cannot show: a shot freezes for 300 ms, the camera starts
18
+ flickering, the reference clip’s burned-in subtitles survive the redraw, the audio track silently
19
+ disappears, the last frame of segment 3 does not match the first frame of segment 4. Each is cheap to
20
+ detect and expensive to discover after a 20-minute render — or after stitching the whole film.
21
+
22
+ Existing tools do not cover this layer: VMAF/DOVER/DVQA score perceptual quality against a reference
23
+ or a learned aesthetic prior, VideoGen-Eval is an LLM-API research harness, and ComfyUI QC nodes check
24
+ tensors inside a graph instead of the delivered file.
25
+
26
+ ## Install
27
+
28
+ ```bash
29
+ pip install takecheck
30
+ ```
31
+
32
+ Requires `ffmpeg` / `ffprobe` on `PATH`. No PyTorch, no GPU — `numpy` is the only numeric dependency.
33
+
34
+ ## Quick start
35
+
36
+ `takes.yml`:
37
+
38
+ ```yaml
39
+ name: h3-ep12-shot03
40
+ expect:
41
+ duration: 5.0
42
+ fps: 24
43
+ frames: 121
44
+ resolution: 1280x720
45
+ audio: required
46
+ subtitles: forbidden
47
+ frozen_max_ms: 200
48
+ black_max_frames: 0
49
+ flicker_max: 0.35
50
+ motion_min: 0.002
51
+ ```
52
+
53
+ ```bash
54
+ takecheck check out/shot03.mp4 --spec takes.yml --json report.json --html report.html
55
+ ```
56
+
57
+ ```console
58
+ FAIL h3-ep12-shot03 (shot03.mp4)
59
+ PASS spec 1280x720 | 24fps | 121f | 5.04s
60
+ FAIL frozen 1 frozen stretch(es) over 200ms: 0.00-0.83s (longest 833ms)
61
+ PASS duplicate 0/120 frames replay an earlier frame (0.0%)
62
+ PASS blank 0 blank frame(s), luma 12-241
63
+ PASS flicker worst 5-frame luma swing 9/255 (3.5% <= 35%)
64
+ PASS motion mean frame-to-frame motion 0.0412 >= 0.0020
65
+ PASS subtitle no persistent text band (row contrast 1.28 < 2 on 0% of frames)
66
+ PASS audio peak -3.2 dB, -18.4 LUFS, 0% silent
67
+ ```
68
+
69
+ Add `--html report.html` for a self-contained report: a contact sheet with failing moments outlined in
70
+ red, every measured value, and the raw JSON inline. No external assets, so it can be emailed or
71
+ attached to a PR as a single file.
72
+
73
+ Exit codes: `0` pass, `1` fail, `2` bad spec / unreadable file.
74
+
75
+ ### What the checks measure
76
+
77
+ Every check reports the numbers behind its verdict in `--json` and `--html`, not just PASS/FAIL.
78
+
79
+ | Check | Signal | Fails when |
80
+ |---|---|---|
81
+ | `frozen` | pHash Hamming distance between consecutive frames | ≤ 3 for longer than `frozen_max_ms` |
82
+ | `duplicate` | repeated frame hashes, ignoring neighbours, blanks and freezes | more than 1% of frames replay an earlier one |
83
+ | `blank` | mean luma per frame | below 16 or above 240, more than `black_max_frames` |
84
+ | `flicker` | peak-to-peak luma inside a 5-frame window | above `flicker_max` (fraction of 0–255) |
85
+ | `motion` | mean absolute frame-to-frame difference | below `motion_min` |
86
+ | `subtitle` | busiest row's horizontal activity ÷ that band's median row | above 2.0 on ≥ 30% of frames, when `subtitles: forbidden` |
87
+ | `audio` | peak dBFS, EBU R128 loudness, silent fraction | peak ≥ −0.1 dBFS, or more than 60% silent |
88
+
89
+ Three design choices worth knowing:
90
+
91
+ - **One defect, one check.** A cut to black is reported by `blank` only; `duplicate` skips blank frames,
92
+ and `flicker` skips windows containing them. A freeze is reported by `frozen`, and `duplicate` ignores
93
+ frames a freeze already explains. A missing audio track is reported by the spec check, so `audio`
94
+ stays quiet about it instead of failing twice for one problem.
95
+ - **`motion` is exempt on purpose.** It is a whole-clip aggregate ("does this clip move at all"), not an
96
+ event with a timestamp, so it may legitimately co-fire with a local check: a fully black clip really
97
+ is both blank and motionless.
98
+ - **Thresholds are calibrated, not guessed.** `motion_min` defaults to `0.002` because a normally moving
99
+ clip measures ≈ 0.009 and a still render ≈ 0.000 on the bundled fixtures. Real footage differs — set
100
+ the values in `takes.yml` for your own pipeline.
101
+ - **Deliberately slow shots read as still.** A clip slowed down 8x measures `mean_motion ≈ 0.0010` and
102
+ will fail the default `motion_min` of `0.002`. If your pipeline uses slow motion or static
103
+ compositions on purpose, lower `motion_min` (or set it to `0`) in `takes.yml`.
104
+
105
+ ### Known limitations
106
+
107
+ - **The `subtitle` check has only been validated on synthetic footage.** Its fixtures use a hard white
108
+ bar on black. Real subtitle ghosts are semi-transparent and blurred, and a scene that legitimately
109
+ contains text in the lower third will trip the check. Treat the default threshold as a starting
110
+ point, not a calibrated value.
111
+ - **Loudness is reported, not enforced.** Only clipping and excessive silence fail the audio check;
112
+ a quiet or hot mix is recorded under `measured` for you to judge.
113
+ - **Long clips are untested.** Decoding is streamed, so memory should stay flat, but a 20-minute render
114
+ has not been benchmarked.
115
+
116
+ ## Roadmap
117
+
118
+ - [x] v0.0.1 — spec format, ffprobe compliance check, CLI, `takecheck selftest`
119
+ - [x] v0.1.0 — frozen / duplicate / blank / flicker / motion / subtitle / audio, HTML report
120
+ - [ ] v0.1.1 — publish to PyPI (pending credentials)
121
+ - [ ] v0.2.0 — calibration against accepted/rejected takes, head/tail continuity
122
+ - [ ] v0.3.0 — pipeline lint (custom-node provenance, model hash pinning)
123
+ - [ ] v0.4.0 — pluggable VLM judge
124
+ - [ ] v0.2.0 — calibration against accepted/rejected takes, head/tail continuity
125
+ - [ ] v0.3.0 — pipeline lint (custom-node provenance, model hash pinning)
126
+ - [ ] v0.4.0 — pluggable VLM judge
127
+
128
+ ## Test media
129
+
130
+ `testmedia/synthetic/` holds seven tiny clips that each reproduce exactly one defect — frozen frames,
131
+ duplicate frames, blank frames, flicker, missing audio, burned-in subtitles — plus `good.mp4` as the
132
+ control that must always pass. Rebuild them with:
133
+
134
+ ```bash
135
+ python scripts/make_testmedia.py
136
+ ```
137
+
138
+ The script also verifies each clip against its expected profile (duration, frame count, audio) and
139
+ runs in CI, so the fixtures cannot silently rot. A second script asserts the *verdict* for each one:
140
+
141
+ ```bash
142
+ python scripts/check_synthetic.py
143
+ ```
144
+
145
+ It fails if `good.mp4` trips any check, or if a defect clip stops being caught — so a threshold change
146
+ cannot quietly break detection.
147
+
148
+ Real footage never goes in the repository: `testmedia/real/` is git-ignored.
149
+
150
+ ## Development
151
+
152
+ ```bash
153
+ pip install -e ".[dev]"
154
+ pytest -q
155
+ takecheck selftest # builds synthetic clips in a temp dir; needs ffmpeg
156
+ python scripts/check_synthetic.py
157
+ ```
158
+
159
+ Note for contributors on Windows: `drawtext` needs an explicit `fontfile=`, otherwise ffmpeg aborts
160
+ with a Fontconfig error instead of drawing anything. The test helpers search a list of common font
161
+ paths for this reason.
162
+
163
+ ## 中文简介
164
+
165
+ `takecheck` 是给 AI 生成视频用的**验收层**:用 `takes.yml` 声明一条片子该满足什么(时长、帧率、
166
+ 无静帧、无字幕残影、音轨完整),一条命令返回通过与否和原因,可直接当 CI 门禁。
167
+ 全程 CPU、秒级,能与渲染并行,不占显存。
168
+
169
+ ## License
170
+
171
+ MIT.
@@ -0,0 +1,58 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "takecheck"
7
+ version = "0.1.0"
8
+ description = "pytest for AI-generated video: turn 'is this take usable?' into an executable spec and a CI gate."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "yqyq-ai" }]
13
+ keywords = ["video", "qa", "comfyui", "generative-ai", "ci"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.10",
20
+ "Programming Language :: Python :: 3.11",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Programming Language :: Python :: 3.13",
23
+ "Topic :: Multimedia :: Video",
24
+ "Topic :: Software Development :: Quality Assurance",
25
+ ]
26
+ dependencies = ["PyYAML>=6.0", "numpy>=1.24"]
27
+
28
+ [project.optional-dependencies]
29
+ dev = ["pytest>=8", "ruff>=0.5"]
30
+
31
+ [project.scripts]
32
+ takecheck = "takecheck.cli:main"
33
+
34
+ [project.urls]
35
+ Homepage = "https://github.com/yqyq-ai/takecheck"
36
+
37
+ [tool.setuptools.packages.find]
38
+ where = ["src"]
39
+
40
+ [tool.pytest.ini_options]
41
+ testpaths = ["tests"]
42
+ # Keep pytest's scratch dirs out of the repository root: on some Windows sandboxes the
43
+ # system temp directory is not writable by Python, so pytest would otherwise drop a
44
+ # `pytest-of-*` directory next to the sources. A bare relative path works because its
45
+ # parent (the project root) always exists.
46
+ addopts = "--basetemp=.tmp"
47
+
48
+ [tool.ruff]
49
+ line-length = 100
50
+ target-version = "py310"
51
+
52
+ [tool.ruff.lint]
53
+ select = ["E", "F", "I", "UP", "B"]
54
+
55
+ [tool.ruff.lint.per-file-ignores]
56
+ # The report embeds a stylesheet as one string per rule; wrapping CSS inside a
57
+ # Python literal hurts readability far more than a long line does.
58
+ "src/takecheck/report.py" = ["E501"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ """takecheck — pytest for AI-generated video."""
2
+
3
+ __version__ = "0.1.0"
@@ -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
+ )