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.
- takecheck-0.1.0/LICENSE +21 -0
- takecheck-0.1.0/PKG-INFO +199 -0
- takecheck-0.1.0/README.md +171 -0
- takecheck-0.1.0/pyproject.toml +58 -0
- takecheck-0.1.0/setup.cfg +4 -0
- takecheck-0.1.0/src/takecheck/__init__.py +3 -0
- takecheck-0.1.0/src/takecheck/__main__.py +4 -0
- takecheck-0.1.0/src/takecheck/checks/__init__.py +21 -0
- takecheck-0.1.0/src/takecheck/checks/audio_check.py +184 -0
- takecheck-0.1.0/src/takecheck/checks/frame_checks.py +302 -0
- takecheck-0.1.0/src/takecheck/checks/spec_check.py +51 -0
- takecheck-0.1.0/src/takecheck/checks/subtitle_check.py +144 -0
- takecheck-0.1.0/src/takecheck/cli.py +175 -0
- takecheck-0.1.0/src/takecheck/clip.py +47 -0
- takecheck-0.1.0/src/takecheck/frames.py +240 -0
- takecheck-0.1.0/src/takecheck/probe.py +100 -0
- takecheck-0.1.0/src/takecheck/report.py +241 -0
- takecheck-0.1.0/src/takecheck/selftest.py +539 -0
- takecheck-0.1.0/src/takecheck/spec.py +195 -0
- takecheck-0.1.0/src/takecheck.egg-info/PKG-INFO +199 -0
- takecheck-0.1.0/src/takecheck.egg-info/SOURCES.txt +27 -0
- takecheck-0.1.0/src/takecheck.egg-info/dependency_links.txt +1 -0
- takecheck-0.1.0/src/takecheck.egg-info/entry_points.txt +2 -0
- takecheck-0.1.0/src/takecheck.egg-info/requires.txt +6 -0
- takecheck-0.1.0/src/takecheck.egg-info/top_level.txt +1 -0
- takecheck-0.1.0/tests/test_cli.py +283 -0
- takecheck-0.1.0/tests/test_frame_checks.py +432 -0
- takecheck-0.1.0/tests/test_media_checks.py +351 -0
- takecheck-0.1.0/tests/test_spec.py +59 -0
takecheck-0.1.0/LICENSE
ADDED
|
@@ -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.
|
takecheck-0.1.0/PKG-INFO
ADDED
|
@@ -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
|
+
[](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
|
+
[](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,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
|
+
)
|