wizz-method 2.21.0 → 2.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/THIRD-PARTY-NOTICES.md +18 -0
- package/package.json +4 -2
- package/skills-registry.yaml +41 -0
- package/src/bmm-skills/1-analysis/wizz-set-feature/SKILL.md +1 -1
- package/src/bmm-skills/4-implementation/wizz-code-review/SKILL.md +1 -1
- package/src/bmm-skills/4-implementation/wizz-create-story/SKILL.md +1 -1
- package/src/bmm-skills/4-implementation/wizz-dev-story/SKILL.md +8 -1
- package/src/bmm-skills/4-implementation/wizz-quick-dev/SKILL.md +2 -2
- package/src/bmm-skills/4-implementation/wizz-quick-dev/step-03-implement.md +2 -0
- package/src/bmm-skills/4-implementation/wizz-sprint-status/SKILL.md +1 -1
- package/src/core-skills/wizz-party-mode/customize.toml +3 -3
- package/src/modules/lowticket/skills/wizz-lowticket-trafego/references/reguas.md +2 -0
- package/src/modules/wizz/_shared/cerebro.md +1 -1
- package/src/modules/wizz/_shared/organizacao.md +2 -2
- package/src/modules/wizz/agents/wizz-copy/SKILL.md +1 -0
- package/src/modules/wizz/agents/wizz-copy/customize.toml +5 -0
- package/src/modules/wizz/agents/wizz-copy/knowledge/playbook.md +2 -1
- package/src/modules/wizz/agents/wizz-social/knowledge/blueprint-roteiro-viral.md +14 -0
- package/src/modules/wizz/agents/wizz-social/knowledge/legenda-instagram.md +12 -0
- package/src/skills-lib/wizz-cerebro/SKILL.md +18 -5
- package/src/skills-lib/wizz-design-needs/SKILL.md +7 -0
- package/src/skills-lib/wizz-design-needs/references/design-state.md +34 -2
- package/src/skills-lib/wizz-design-needs/references/gap-analysis.md +1 -1
- package/src/skills-lib/wizz-design-needs/scripts/design_state.py +305 -0
- package/src/skills-lib/wizz-design-needs/scripts/tests/test_design_state.py +143 -0
- package/src/skills-lib/wizz-impeccable/references/design-rules.md +18 -0
- package/src/skills-lib/wizz-motion-3d-director/references/motion-paths.md +2 -0
- package/src/skills-lib/wizz-paid-ads/SKILL.md +2 -1
- package/src/skills-lib/wizz-premium-landing-ui-researcher/SKILL.md +6 -2
- package/src/skills-lib/wizz-premium-landing-ui-researcher/references/audit-protocol.md +7 -34
- package/src/skills-lib/wizz-premium-landing-ui-researcher/references/component-sources.md +2 -0
- package/src/skills-lib/wizz-premium-landing-ui-researcher/references/design-sources.yaml +252 -0
- package/src/skills-lib/wizz-premium-landing-ui-researcher/references/mandatory-process.md +1 -1
- package/src/skills-lib/wizz-premium-landing-ui-researcher/references/mining-rubric.md +123 -0
- package/src/skills-lib/wizz-premium-landing-ui-researcher/references/source-first-protocol.md +10 -4
- package/src/skills-lib/wizz-premium-landing-ui-researcher/references/web-access-and-evidence.md +1 -1
- package/src/skills-lib/wizz-premium-landing-ui-researcher/scripts/design_sources.py +221 -0
- package/src/skills-lib/wizz-premium-landing-ui-researcher/scripts/tests/test_design_sources.py +130 -0
- package/src/skills-lib/wizz-router/references/routing-table-flat.md +2 -0
- package/src/skills-lib/wizz-taste-skill/references/anti-slop-tells.md +8 -0
- package/src/skills-lib/wizz-ui-component-curator/SKILL.md +8 -7
- package/src/skills-lib/wizz-video-effects/LICENSE-motionmaxxing.txt +202 -0
- package/src/skills-lib/wizz-video-effects/SKILL.md +5 -2
- package/src/skills-lib/wizz-video-effects/assets/chrome/collect_layout.js +113 -0
- package/src/skills-lib/wizz-video-effects/assets/effects.config.template.json +35 -0
- package/src/skills-lib/wizz-video-effects/references/data-contract.md +30 -1
- package/src/skills-lib/wizz-video-effects/references/gates.md +3 -1
- package/src/skills-lib/wizz-video-effects/scripts/check_chrome.py +376 -0
- package/src/skills-lib/wizz-video-effects/scripts/check_dead_frames.py +193 -0
- package/src/skills-lib/wizz-video-effects/scripts/config.py +13 -0
- package/src/skills-lib/wizz-video-effects/scripts/test_chrome.py +181 -0
- package/src/skills-lib/wizz-video-effects/scripts/test_dead_frames.py +171 -0
- package/src/skills-lib/wizz-video-styles/SKILL.md +6 -2
- package/src/skills-lib/wizz-video-styles/references/motion-laws.md +62 -0
- package/src/skills-lib/wizz-video-styles/references/styles/desafio-numero.md +1 -0
- package/src/skills-lib/wizz-video-styles/scripts/blind_review.py +159 -0
- package/src/skills-lib/wizz-video-styles/scripts/taste.mjs +110 -0
- package/src/skills-lib/wizz-video-styles/scripts/test_blind_review.py +82 -0
- package/src/skills-lib/wizz-vsl/LICENSE +49 -0
- package/src/skills-lib/wizz-vsl/SKILL.md +122 -0
- package/src/skills-lib/wizz-vsl/references/assembly-qa.md +123 -0
- package/src/skills-lib/wizz-vsl/references/blocks-and-leads.md +149 -0
- package/src/skills-lib/wizz-vsl/references/diagnosis.md +95 -0
- package/src/skills-lib/wizz-vsl/references/intake.md +200 -0
- package/src/skills-lib/wizz-vsl/references/mechanisms.md +138 -0
- package/src/skills-lib/wizz-vsl/references/niches.md +40 -0
- package/src/skills-lib/wizz-vsl/references/offer.md +91 -0
- package/src/skills-lib/wizz-vsl/references/production.md +75 -0
- package/tools/hooks/README.md +10 -9
- package/tools/hooks/wizz-arrumar.js +1 -0
- package/tools/hooks/wizz-artifact-guard.js +31 -5
- package/tools/hooks/wizz-cerebro-lib.js +36 -13
- package/tools/hooks/wizz-feature-lib.js +4 -1
- package/tools/hooks/wizz-feature.js +66 -76
- package/tools/hooks/wizz-sprint-archive-lib.js +311 -0
- package/tools/installer/core/hooks-settings.js +1 -0
- package/tools/installer/core/installer.js +6 -0
- package/tools/installer/modules/core-seed.js +72 -0
- package/tools/installer/modules/mcp-config.js +3 -0
- package/tools/installer/ui.js +16 -26
|
@@ -18,7 +18,7 @@ Numbers that decide anything (gaps, ceilings, depths) are never in the timeline:
|
|
|
18
18
|
| `shakes[]` | list | same | planner | short shakes (`budget.per_effect.shake.max_dur_s`) |
|
|
19
19
|
| `flashes[]` | list | same | planner, `fill_breaths.py` | opacity flashes |
|
|
20
20
|
| `interrupts[]` | list | same | planner | pattern interrupts |
|
|
21
|
-
| `elements[]` | list | `check_rhythm.py` | planner | element entries (cards, labels) that count as visual change |
|
|
21
|
+
| `elements[]` | list | `check_rhythm.py`, `check_chrome.py` | planner | element entries (cards, labels) that count as visual change |
|
|
22
22
|
| `ducking[]` | list | renderer | `sound.py duck --write` | narration duck windows |
|
|
23
23
|
|
|
24
24
|
### `words[]` (ctc-align output, unchanged)
|
|
@@ -64,6 +64,20 @@ Each item is a number (the instant) or an object with `at`:
|
|
|
64
64
|
"elements": [{ "at": 0.6, "kind": "text" }]
|
|
65
65
|
```
|
|
66
66
|
|
|
67
|
+
Text layers and bars that `check_chrome.py` should read carry their geometry (pixels of the final frame) and stay on screen until `until` (default: the end):
|
|
68
|
+
|
|
69
|
+
```json
|
|
70
|
+
"elements": [
|
|
71
|
+
{ "at": 0.6, "until": 4.0, "kind": "text", "text": "Pare de perder dinheiro", "box": [100, 760, 980, 860], "font_px": 96, "align": "center" },
|
|
72
|
+
{ "at": 1.0, "until": 3.0, "kind": "text", "text": "custa", "box": [400, 1300, 680, 1380], "font_px": 72, "role": "caption" },
|
|
73
|
+
{ "at": 0.0, "kind": "bar", "box": [0, 1880, 100, 1890], "box_end": [0, 1880, 1080, 1890] }
|
|
74
|
+
]
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
- `role`: `caption`, `legal` or `ui` (`chrome_lint.exempt_roles`) is not chrome. Optional `caps`, `tracking_em` (letter-spacing in em), `lines`.
|
|
78
|
+
- `box_end`: the bar's box at `until`; wider than `box` means a growing bar.
|
|
79
|
+
- Top-level `chrome_allow`: rules the chosen style measured on real videos (e.g. `["P1"]`), printed as allowed.
|
|
80
|
+
|
|
67
81
|
### `ducking[]`
|
|
68
82
|
|
|
69
83
|
Written by `python3 scripts/sound.py duck TIMELINE --write`. One window per group of SFX that sound over the **start of a spoken word**; nothing else ducks.
|
|
@@ -132,6 +146,19 @@ export const narrationVolume = (ducking: Duck[], fps: number, startFrame = 0) =>
|
|
|
132
146
|
}
|
|
133
147
|
```
|
|
134
148
|
|
|
149
|
+
## Layout snapshot (`check_chrome.py --snapshot`)
|
|
150
|
+
|
|
151
|
+
Written by `assets/chrome/collect_layout.js` from the opened HTML composition (instructions at the top of that file); not part of the timeline:
|
|
152
|
+
|
|
153
|
+
```json
|
|
154
|
+
{ "width": 1080, "height": 1920, "samples": [
|
|
155
|
+
{ "t": 1.0,
|
|
156
|
+
"blocks": [{ "text": "02 / 05", "box": [948, 40, 1040, 75], "font_px": 30, "caps": false, "tracking_em": 0, "align": "start", "lines": 1, "role": "" }],
|
|
157
|
+
"shapes": [{ "id": "bar", "kind": "bar", "box": [0, 1880, 1080, 1888] }] } ] }
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
`kind` is `bar` (any filled or bordered box), `media` (img, video, canvas, svg) or `surface` (inside `data-role="ui"`). Samples come in pairs 0.1 s apart, so a label flying through a corner is not taken for a parked one.
|
|
161
|
+
|
|
135
162
|
## Which script touches what
|
|
136
163
|
|
|
137
164
|
| Script | Reads | Writes | Exit codes |
|
|
@@ -140,6 +167,8 @@ export const narrationVolume = (ducking: Duck[], fps: number, startFrame = 0) =>
|
|
|
140
167
|
| `sound.py duck TIMELINE [--write]` | `sfx`, `scenes[].sfx`, `words`, `fps` | `ducking` | 0, 2 |
|
|
141
168
|
| `check_excess.py TIMELINE... [--warn]` | effects, `scenes`, `duration` | nothing | 0 pass, 1 fail, 2 error |
|
|
142
169
|
| `check_rhythm.py TIMELINE...` | `scenes`, effects, `elements`, `duration` | nothing | always 0 (warn only) |
|
|
170
|
+
| `check_chrome.py --timeline T \| --snapshot LAYOUT [--allow R]` | `elements[]` text layers and bars, `chrome_allow` (or the snapshot) | nothing | 0, 1, 2 |
|
|
143
171
|
| `check_safe_zone.py MP4 [--timeline T]` | `safe_zone_profile` | nothing | 0, 1, 2 |
|
|
144
172
|
| `measure_density.py MP4 [--timeline T]` | `safe_zone_profile` | nothing | 0, 1, 2 |
|
|
173
|
+
| `check_dead_frames.py MP4` | the final file only | nothing | 0, 1, 2 |
|
|
145
174
|
| `check_loudness.py MP4` | the final file only | nothing | 0, 1, 2 |
|
|
@@ -13,11 +13,13 @@ Thresholds are cited by config key. Every script exits `0` pass, `1` fail, `2` i
|
|
|
13
13
|
| `normalize_audio.py --check` | SFX folder | every file at `sound.reference_level_db`, peak under `sound.max_peak_db` | yes | silent tails: it measures the whole file, so trim first |
|
|
14
14
|
| `check_excess.py` | timeline JSON | every sound together (`sfx[]` and scene cut sounds) against the three refusals: unknown event, collision, same sound in a row; per effect kind: ceiling per minute, smallest gap, longest run in a row (`budget.per_effect.*`); dominant effects of different kinds closer than `budget.gap_min_global`; always reports the densest 10 s cluster | yes (`--warn` only while calibrating) | effects drawn inside the screens themselves (HyperFrames timelines); only what the timeline declares |
|
|
15
15
|
| `check_rhythm.py` | timeline JSON | longest stretch with no visual change (`rhythm.max_static_s`), cut cadence in the hook (`rhythm.hook_s`, `rhythm.hook_cut_every_s`) and body (`rhythm.body_cut_every_s`), CTA tail excluded (`rhythm.cta_tail_s`). SFX never count as change | **no, warns only** | motion inside a screen; its numbers come from weak sources ([editing-patterns.md](editing-patterns.md)) |
|
|
16
|
+
| `check_chrome.py` | timeline text layers (`--timeline`) or a DOM snapshot of the screens (`--snapshot`, from `assets/chrome/collect_layout.js`) | page chrome and slide look, thresholds in `chrome_lint.*`: C1 small label parked in a corner or edge band, C2 small tracked caps, C3 counter ("02 / 05", "Parte 3"), C4 timecode or metadata, C6 wide bar or hairline in the top or bottom band, C7 kicker right above a headline, D1 left headline block with media on the right, a sub-line or 2+ lines (alone: warn), P1 thin bar that grows. Roles in `chrome_lint.exempt_roles` (caption, legal, ui) are skipped; a rule the style measured goes in `chrome_allow` or `--allow` and is printed | yes | text in canvas, images, video or burned into footage; Remotion text not declared in `elements[]`; whether the flagged thing is still the right call. Rules come from one user's verdicts on horizontal launch films (motionmaxxing G5) |
|
|
16
17
|
| `check_safe_zone.py` | **final MP4** | per row, local contrast (std-dev, `safe_zone_gate.dp_min`) on every frame; the highest and lowest content rows must sit inside the piece's profile (`safe_zones.<profile>` top and bottom) | yes | whether what invades is text, a card or background glow; left and right edges. Full-screen footage fails by construction: frame it inside the zone. On a fail, extract the frame and look |
|
|
17
18
|
| `measure_density.py` | **final MP4** | how many of `density.n_bands` bands across the active safe zone have content (`density.dp_min`), sampled every `density.step_s`; fails under `density.min_filled` | yes | whether the content makes sense there: a band full of noise counts like a band full of information |
|
|
19
|
+
| `check_dead_frames.py` | **final MP4**, every frame | runs of flat frames (one uniform field: `dead_frames.flat_std` and `dead_frames.flat_range` on a small luma copy); more than `dead_frames.flat_max_frames` in a row fails. Frozen picture (frame difference under `dead_frames.frozen_diff`) longer than `dead_frames.frozen_warn_s` only warns. Prints the mean motion against `dead_frames.motion_band_reference` as a question | yes for flat runs; frozen is a warning | a talking head is never flat and almost never frozen (noise, breathing, compression), so a dead shot on camera passes; grain or a looping background keeps a dead slide "moving"; a dark frame with one small line of text is not flat (that is density). Thresholds come from horizontal launch films (motionmaxxing G2), not from vertical Reels |
|
|
18
20
|
| `check_loudness.py` | **final MP4 or audio** | integrated loudness and true peak (ffmpeg `loudnorm`) against `loudness.*`; prints the two-pass fix and warns when the fix will switch loudnorm to dynamic compression | yes | the balance inside the mix (voice vs SFX); that is the level contract and the ear |
|
|
19
21
|
|
|
20
|
-
Order that matches the pipeline: config and coverage once per project; excess and
|
|
22
|
+
Order that matches the pipeline: config and coverage once per project; excess, rhythm and chrome on the timeline before the expensive render; safe zone, density, dead frames and loudness on the final file.
|
|
21
23
|
|
|
22
24
|
## Two profiles, measured separately
|
|
23
25
|
|
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""PAGE-CHROME gate: does the piece look like a slide or a HUD?
|
|
3
|
+
|
|
4
|
+
Reads what text and shapes are on screen, never the pixels, from one of two
|
|
5
|
+
sources:
|
|
6
|
+
|
|
7
|
+
--snapshot LAYOUT.json a layout snapshot of an HTML composition
|
|
8
|
+
(HyperFrames, or Remotion Studio, which is also a DOM), written by
|
|
9
|
+
assets/chrome/collect_layout.js at a few seek times.
|
|
10
|
+
--timeline TIMELINE the Remotion timeline: the text layers declared in
|
|
11
|
+
elements[] (`text`, `box`, `font_px`, `role`, `until`).
|
|
12
|
+
|
|
13
|
+
Rules (thresholds in the config block chrome_lint):
|
|
14
|
+
C1 corner/edge label small text parked in a corner or an edge band FAIL
|
|
15
|
+
C2 tracked-caps label small uppercase text with wide letter-spacing FAIL
|
|
16
|
+
C3 counter "02 / 05", "01 - THE WAIT", "Parte 3", bare "01" FAIL
|
|
17
|
+
C4 production metadata timecode, FPS, BPM, resolution, REC, version FAIL
|
|
18
|
+
C6 header/footer bar a wide bar or hairline in the top or bottom band FAIL
|
|
19
|
+
C7 kicker over headline a small line right above a headline 2x its size FAIL
|
|
20
|
+
D1 deck layout left-anchored headline with media on the right,
|
|
21
|
+
a sub-line or 2+ lines: FAIL; alone: WARN
|
|
22
|
+
P1 progress bar a thin bar that grows over time FAIL
|
|
23
|
+
|
|
24
|
+
Not chrome: text whose `role` is in chrome_lint.exempt_roles (captions,
|
|
25
|
+
legally required ad text, product UI). A rule the chosen style measured on
|
|
26
|
+
real videos (the progress device of `desafio-numero`) is allowed with
|
|
27
|
+
`--allow P1` or `chrome_allow: ["P1"]` in the timeline; allowed rules are
|
|
28
|
+
printed, never silent.
|
|
29
|
+
|
|
30
|
+
What it cannot see: text drawn into a <canvas>, an image, a video or burned
|
|
31
|
+
into footage; text a Remotion component draws without declaring it in the
|
|
32
|
+
timeline; whether a flagged element is still the right call. A PASS is not
|
|
33
|
+
taste: look at the frames.
|
|
34
|
+
|
|
35
|
+
Rules ported from gate G5 of motionmaxxing's lint.mjs (Apache-2.0,
|
|
36
|
+
github.com/Tejashmakwana/motionmaxxing), rewritten for 9:16; see
|
|
37
|
+
THIRD-PARTY-NOTICES.md.
|
|
38
|
+
|
|
39
|
+
Exit code: 0 pass (warnings allowed), 1 fail, 2 input or config unreadable.
|
|
40
|
+
"""
|
|
41
|
+
from __future__ import annotations
|
|
42
|
+
|
|
43
|
+
import argparse
|
|
44
|
+
import json
|
|
45
|
+
import re
|
|
46
|
+
import sys
|
|
47
|
+
from dataclasses import dataclass
|
|
48
|
+
from pathlib import Path
|
|
49
|
+
|
|
50
|
+
import timeline as tl
|
|
51
|
+
from config import Config, ConfigError, add_config_arg, load, optional_block
|
|
52
|
+
|
|
53
|
+
BLOCK = "chrome_lint"
|
|
54
|
+
SEVERITY = {"C1": "FAIL", "C2": "FAIL", "C3": "FAIL", "C4": "FAIL", "C6": "FAIL",
|
|
55
|
+
"C7": "FAIL", "D1": "FAIL", "P1": "FAIL"}
|
|
56
|
+
NAMES = {"C1": "corner/edge label", "C2": "tracked-caps label", "C3": "counter",
|
|
57
|
+
"C4": "production metadata", "C6": "header/footer bar", "C7": "kicker over headline",
|
|
58
|
+
"D1": "deck layout (left headline block)", "P1": "progress bar"}
|
|
59
|
+
|
|
60
|
+
RX_COUNTER_NUM = re.compile(r"^\s*0?\d{1,2}\s*(?:[/·|]|-(?=\s*[A-Za-z]{3,}))\s*(?:0?\d{1,2}\b|[A-Za-zÀ-ú]{3,})")
|
|
61
|
+
RX_COUNTER_WORD = re.compile(
|
|
62
|
+
r"\b(chapter|scene|act|step|part|episode|shot|take|beat|"
|
|
63
|
+
r"cap[ií]tulo|cena|ato|passo|parte|etapa|epis[oó]dio)\s*0?\d", re.I)
|
|
64
|
+
RX_COUNTER_BARE = re.compile(r"^\s*0\d\s*$")
|
|
65
|
+
RX_META = re.compile(
|
|
66
|
+
r"\bBPM\b|\bFPS\b|\bFRAME\s*\d|\b(?:1920|3840|1280|1080|720)\s*[x×]\s*(?:1080|2160|720|1920|1280)\b"
|
|
67
|
+
r"|\bT\s*\+\s*\d|\bTC\b|\b\d{1,2}:\d{2}:\d{2}(?:[.:]\d+)?\b|\b\d{1,2}:\d{2}\.\d{1,3}\b|\bREC\b")
|
|
68
|
+
RX_VERSION = re.compile(r"\bv\d+\.\d+(?:\.\d+)?\b|©", re.I)
|
|
69
|
+
RX_24_7 = re.compile(r"^\s*24\s*/\s*7\b")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True)
|
|
73
|
+
class ChromeRules:
|
|
74
|
+
small_text: float
|
|
75
|
+
corner_x: float
|
|
76
|
+
corner_y: float
|
|
77
|
+
edge_band: float
|
|
78
|
+
caps_tracking_em: float
|
|
79
|
+
caps_max_size: float
|
|
80
|
+
counter_max_size: float
|
|
81
|
+
headline_min_size: float
|
|
82
|
+
kicker_ratio: float
|
|
83
|
+
kicker_gap: float
|
|
84
|
+
deck_left_x: float
|
|
85
|
+
bar_min_width: float
|
|
86
|
+
bar_max_height: float
|
|
87
|
+
bar_band: float
|
|
88
|
+
progress_min_growth: float
|
|
89
|
+
held_s: float
|
|
90
|
+
exempt_roles: frozenset
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def rules(cfg: Config) -> tuple[ChromeRules, bool]:
|
|
94
|
+
b, from_template = optional_block(cfg, BLOCK)
|
|
95
|
+
try:
|
|
96
|
+
nums = {k: float(b[k]) for k in ChromeRules.__dataclass_fields__ if k != "exempt_roles"}
|
|
97
|
+
r = ChromeRules(**nums, exempt_roles=frozenset(str(x) for x in b["exempt_roles"]))
|
|
98
|
+
except (KeyError, TypeError, ValueError) as e:
|
|
99
|
+
raise ConfigError(f"{BLOCK}: missing or invalid key ({e})") from e
|
|
100
|
+
bad = [k for k, v in nums.items() if v <= 0]
|
|
101
|
+
if bad:
|
|
102
|
+
raise ConfigError(f"{BLOCK}: must be > 0: {', '.join(bad)}")
|
|
103
|
+
return r, from_template
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# ------------------------------------------------------------------ inputs
|
|
107
|
+
|
|
108
|
+
@dataclass(frozen=True)
|
|
109
|
+
class Block:
|
|
110
|
+
text: str
|
|
111
|
+
x0: float
|
|
112
|
+
y0: float
|
|
113
|
+
x1: float
|
|
114
|
+
y1: float
|
|
115
|
+
size: float # font size / short side of the frame
|
|
116
|
+
caps: bool
|
|
117
|
+
tracking: float # letter-spacing in em
|
|
118
|
+
role: str
|
|
119
|
+
align: str
|
|
120
|
+
lines: int
|
|
121
|
+
|
|
122
|
+
@property
|
|
123
|
+
def cx(self) -> float:
|
|
124
|
+
return (self.x0 + self.x1) / 2
|
|
125
|
+
|
|
126
|
+
@property
|
|
127
|
+
def cy(self) -> float:
|
|
128
|
+
return (self.y0 + self.y1) / 2
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@dataclass(frozen=True)
|
|
132
|
+
class Shape:
|
|
133
|
+
sid: str
|
|
134
|
+
kind: str # bar | rule | media | surface
|
|
135
|
+
x0: float
|
|
136
|
+
y0: float
|
|
137
|
+
x1: float
|
|
138
|
+
y1: float
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _norm_box(box, w: float, h: float) -> tuple[float, float, float, float]:
|
|
142
|
+
x0, y0, x1, y1 = (float(v) for v in box)
|
|
143
|
+
return x0 / w, y0 / h, x1 / w, y1 / h
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _block(d: dict, w: float, h: float) -> Block | None:
|
|
147
|
+
text = str(d.get("text") or "").strip()
|
|
148
|
+
if not text or "box" not in d:
|
|
149
|
+
return None
|
|
150
|
+
x0, y0, x1, y1 = _norm_box(d["box"], w, h)
|
|
151
|
+
caps = bool(d.get("caps")) or (text.upper() == text and any(c.isalpha() for c in text))
|
|
152
|
+
return Block(text, x0, y0, x1, y1, float(d.get("font_px", 0)) / min(w, h), caps,
|
|
153
|
+
float(d.get("tracking_em", 0.0)), str(d.get("role") or ""),
|
|
154
|
+
str(d.get("align") or ""), int(d.get("lines", 1)))
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _shape(d: dict, i: int, w: float, h: float) -> Shape:
|
|
158
|
+
x0, y0, x1, y1 = _norm_box(d["box"], w, h)
|
|
159
|
+
return Shape(str(d.get("id", i)), str(d.get("kind", "bar")), x0, y0, x1, y1)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def samples_from_snapshot(data: dict) -> list[dict]:
|
|
163
|
+
"""[{t, blocks: [Block], shapes: [Shape]}] from a collect_layout.js file."""
|
|
164
|
+
w, h = float(data["width"]), float(data["height"])
|
|
165
|
+
out = []
|
|
166
|
+
for s in data.get("samples") or []:
|
|
167
|
+
blocks = [b for b in (_block(x, w, h) for x in s.get("blocks") or []) if b]
|
|
168
|
+
shapes = [_shape(x, i, w, h) for i, x in enumerate(s.get("shapes") or []) if "box" in x]
|
|
169
|
+
out.append({"t": float(s["t"]), "blocks": blocks, "shapes": shapes})
|
|
170
|
+
return sorted(out, key=lambda s: s["t"])
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def samples_from_timeline(data: dict, w: int, h: int, r: ChromeRules) -> list[dict]:
|
|
174
|
+
"""One sample per layer start, with every declared layer visible then.
|
|
175
|
+
A layer shorter than held_s is in flight and is skipped; a bar with a
|
|
176
|
+
`box_end` wider than its `box` is a growing bar (P1)."""
|
|
177
|
+
total, _ = tl.duration(data)
|
|
178
|
+
layers = []
|
|
179
|
+
for i, e in enumerate(data.get("elements") or []):
|
|
180
|
+
if not isinstance(e, dict) or "box" not in e:
|
|
181
|
+
continue
|
|
182
|
+
start = float(e.get("at", 0.0))
|
|
183
|
+
end = float(e.get("until", total))
|
|
184
|
+
if end - start < r.held_s:
|
|
185
|
+
continue
|
|
186
|
+
layers.append((start, end, i, e))
|
|
187
|
+
out = []
|
|
188
|
+
for t in sorted({start for start, _, _, _ in layers}):
|
|
189
|
+
blocks, shapes = [], []
|
|
190
|
+
for start, end, i, e in layers:
|
|
191
|
+
if not start <= t < end:
|
|
192
|
+
continue
|
|
193
|
+
if e.get("text"):
|
|
194
|
+
b = _block(e, w, h)
|
|
195
|
+
if b:
|
|
196
|
+
blocks.append(b)
|
|
197
|
+
elif e.get("kind") in ("bar", "rule", "media", "surface"):
|
|
198
|
+
shapes.append(_shape(e, i, w, h))
|
|
199
|
+
if e.get("box_end"):
|
|
200
|
+
shapes.append(_shape({**e, "box": e["box_end"]}, i, w, h))
|
|
201
|
+
out.append({"t": t, "blocks": blocks, "shapes": shapes, "timeline": True})
|
|
202
|
+
return out
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
# ------------------------------------------------------------------- rules
|
|
206
|
+
|
|
207
|
+
def _held(b: Block, t: float, samples: list[dict], r: ChromeRules) -> bool:
|
|
208
|
+
"""Parked, not flying through: the same text at the same place in the
|
|
209
|
+
neighbouring samples within held_s. No neighbour at all: held."""
|
|
210
|
+
near = [s for s in samples if s["t"] != t and abs(s["t"] - t) <= r.held_s]
|
|
211
|
+
if not near:
|
|
212
|
+
return True
|
|
213
|
+
return any(c.text == b.text and abs(c.x0 - b.x0) < 0.006 and abs(c.y0 - b.y0) < 0.006
|
|
214
|
+
for s in near for c in s["blocks"])
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _text_rules(b: Block, r: ChromeRules) -> list[str]:
|
|
218
|
+
hits = []
|
|
219
|
+
small = b.size <= r.small_text
|
|
220
|
+
corner = (b.cx < r.corner_x or b.cx > 1 - r.corner_x) and (b.cy < r.corner_y or b.cy > 1 - r.corner_y)
|
|
221
|
+
edge = b.y1 < r.edge_band or b.y0 > 1 - r.edge_band or b.x1 < r.edge_band / 2 or b.x0 > 1 - r.edge_band / 2
|
|
222
|
+
if small and (corner or edge):
|
|
223
|
+
hits.append("C1")
|
|
224
|
+
letters = sum(1 for c in b.text if c.isalpha())
|
|
225
|
+
if b.caps and b.tracking >= r.caps_tracking_em and b.size <= r.caps_max_size and letters >= 2:
|
|
226
|
+
hits.append("C2")
|
|
227
|
+
counter = RX_COUNTER_NUM.search(b.text) or RX_COUNTER_WORD.search(b.text) or RX_COUNTER_BARE.search(b.text)
|
|
228
|
+
if b.size <= r.counter_max_size and counter and not RX_24_7.search(b.text):
|
|
229
|
+
hits.append("C3")
|
|
230
|
+
if (RX_META.search(b.text) and b.size <= r.counter_max_size) or (RX_VERSION.search(b.text) and small):
|
|
231
|
+
hits.append("C4")
|
|
232
|
+
return hits
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _kickers(free: list[Block], r: ChromeRules) -> dict[int, Block]:
|
|
236
|
+
out = {}
|
|
237
|
+
for a in free:
|
|
238
|
+
for i, b in enumerate(free):
|
|
239
|
+
if a is b or a.size > r.small_text or b.size < max(a.size * r.kicker_ratio, r.headline_min_size):
|
|
240
|
+
continue
|
|
241
|
+
gap = b.y0 - a.y1
|
|
242
|
+
aligned = abs(a.x0 - b.x0) < 0.02 or abs(a.cx - b.cx) < 0.02
|
|
243
|
+
if -0.01 < gap < r.kicker_gap and aligned:
|
|
244
|
+
out[i] = a
|
|
245
|
+
return out
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _deck(free: list[Block], shapes: list[Shape], kickers: dict[int, Block], r: ChromeRules):
|
|
249
|
+
biggest = max((b.size for b in free), default=0.0)
|
|
250
|
+
for i, b in enumerate(free):
|
|
251
|
+
left = 0.02 <= b.x0 < r.deck_left_x and b.x1 < 0.78
|
|
252
|
+
if not (b.size >= r.headline_min_size and b.size >= 0.85 * biggest and left
|
|
253
|
+
and b.align != "center" and abs(b.cx - 0.5) > 0.06):
|
|
254
|
+
continue
|
|
255
|
+
why = []
|
|
256
|
+
if any(c is not b and 0.02 <= c.size <= 0.6 * b.size and -0.005 < c.y0 - b.y1 < 0.08
|
|
257
|
+
and abs(c.x0 - b.x0) < 0.03 for c in free):
|
|
258
|
+
why.append("sub-line")
|
|
259
|
+
if i in kickers:
|
|
260
|
+
why.append("kicker")
|
|
261
|
+
if any(s.kind in ("media", "surface") and 0.04 <= (s.x1 - s.x0) * (s.y1 - s.y0) <= 0.7
|
|
262
|
+
and (s.x0 + s.x1) / 2 > 0.5 and s.x0 > b.x1 - 0.05 and s.y1 > b.y0 and s.y0 < b.y1 + 0.2
|
|
263
|
+
for s in shapes):
|
|
264
|
+
why.append("media on the right")
|
|
265
|
+
if b.lines >= 2:
|
|
266
|
+
why.append(f"{b.lines} lines")
|
|
267
|
+
yield b, why
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _bars(shapes: list[Shape], r: ChromeRules) -> list[Shape]:
|
|
271
|
+
out = []
|
|
272
|
+
for s in shapes:
|
|
273
|
+
if s.kind in ("media", "surface"):
|
|
274
|
+
continue
|
|
275
|
+
w, h, cy = s.x1 - s.x0, s.y1 - s.y0, (s.y0 + s.y1) / 2
|
|
276
|
+
if w >= r.bar_min_width and h <= r.bar_max_height and (cy < r.bar_band or cy > 1 - r.bar_band):
|
|
277
|
+
out.append(s)
|
|
278
|
+
return out
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _progress(samples: list[dict], r: ChromeRules) -> list[tuple[str, float, float]]:
|
|
282
|
+
"""(id, first width, last width) of thin bars that grow over time."""
|
|
283
|
+
widths: dict[str, list[float]] = {}
|
|
284
|
+
for s in samples:
|
|
285
|
+
for sh in s["shapes"]:
|
|
286
|
+
if sh.kind not in ("media", "surface") and sh.y1 - sh.y0 <= 0.02:
|
|
287
|
+
widths.setdefault(sh.sid, []).append(sh.x1 - sh.x0)
|
|
288
|
+
out = []
|
|
289
|
+
for sid, ws in widths.items():
|
|
290
|
+
steps = [b - a for a, b in zip(ws, ws[1:])]
|
|
291
|
+
grows = ws[-1] - ws[0] >= r.progress_min_growth and all(d >= -0.005 for d in steps)
|
|
292
|
+
if len(ws) >= 2 and grows:
|
|
293
|
+
out.append((sid, ws[0], ws[-1]))
|
|
294
|
+
return out
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def lint(samples: list[dict], r: ChromeRules, allow: set[str]) -> dict:
|
|
298
|
+
hits: dict[tuple, dict] = {}
|
|
299
|
+
|
|
300
|
+
def add(rule: str, t: float, what: str, sev: str | None = None):
|
|
301
|
+
key = (rule, what)
|
|
302
|
+
h = hits.setdefault(key, {"rule": rule, "what": what, "times": [], "sev": sev or SEVERITY[rule]})
|
|
303
|
+
if t not in h["times"]:
|
|
304
|
+
h["times"].append(t)
|
|
305
|
+
|
|
306
|
+
for s in samples:
|
|
307
|
+
t = s["t"]
|
|
308
|
+
free = [b for b in s["blocks"] if b.role not in r.exempt_roles and _held(b, t, samples, r)]
|
|
309
|
+
for b in free:
|
|
310
|
+
for rule in _text_rules(b, r):
|
|
311
|
+
add(rule, t, b.text)
|
|
312
|
+
kickers = _kickers(free, r)
|
|
313
|
+
for i, a in kickers.items():
|
|
314
|
+
add("C7", t, f"{a.text!r} over {free[i].text!r}")
|
|
315
|
+
for b, why in _deck(free, s["shapes"], kickers, r):
|
|
316
|
+
add("D1", t, f"{b.text!r}" + (f" with {', '.join(why)}" if why else " alone"),
|
|
317
|
+
"FAIL" if why else "WARN")
|
|
318
|
+
for sh in _bars(s["shapes"], r):
|
|
319
|
+
add("C6", t, f"{sh.kind} {sh.sid} at y={sh.y0:.2f}-{sh.y1:.2f}")
|
|
320
|
+
for sid, w0, w1 in _progress(samples, r):
|
|
321
|
+
add("P1", samples[0]["t"], f"bar {sid} grows {w0:.2f} -> {w1:.2f} of the width")
|
|
322
|
+
used = sorted({h["rule"] for h in hits.values()} & allow)
|
|
323
|
+
kept = [h for h in hits.values() if h["rule"] not in allow]
|
|
324
|
+
kept.sort(key=lambda h: (h["rule"], h["times"][0]))
|
|
325
|
+
return {"hits": kept, "allowed": used, "passed": not any(h["sev"] == "FAIL" for h in kept)}
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
# --------------------------------------------------------------------- CLI
|
|
329
|
+
|
|
330
|
+
def report(rep: dict, source: str, n_samples: int, from_template: bool) -> int:
|
|
331
|
+
print(f"{source}: {n_samples} sample(s)")
|
|
332
|
+
if from_template:
|
|
333
|
+
print(f" note: no '{BLOCK}' block in the project config; using the template's")
|
|
334
|
+
for h in rep["hits"]:
|
|
335
|
+
times = ", ".join(f"{t:.2f}s" for t in sorted(h["times"])[:6])
|
|
336
|
+
print(f" {h['sev']:4} {h['rule']} {NAMES[h['rule']]}: {h['what']} [{times}]")
|
|
337
|
+
for rule in rep["allowed"]:
|
|
338
|
+
print(f" allowed {rule} {NAMES[rule]} (the style measured it)")
|
|
339
|
+
print(f"GATE chrome: {'PASS' if rep['passed'] else 'FAIL'}")
|
|
340
|
+
return 0 if rep["passed"] else 1
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def main(argv: list[str] | None = None) -> int:
|
|
344
|
+
ap = argparse.ArgumentParser(description="Page-chrome / slide-look lint on a layout snapshot or a timeline.")
|
|
345
|
+
add_config_arg(ap)
|
|
346
|
+
src = ap.add_mutually_exclusive_group(required=True)
|
|
347
|
+
src.add_argument("--snapshot", help="layout JSON written by assets/chrome/collect_layout.js")
|
|
348
|
+
src.add_argument("--timeline", help="timeline JSON with text layers in elements[]")
|
|
349
|
+
ap.add_argument("--allow", default="", help="comma-separated rules the chosen style measured (e.g. P1)")
|
|
350
|
+
args = ap.parse_args(argv)
|
|
351
|
+
try:
|
|
352
|
+
cfg = load(args.config)
|
|
353
|
+
r, from_template = rules(cfg)
|
|
354
|
+
allow = {a.strip() for a in args.allow.split(",") if a.strip()}
|
|
355
|
+
if args.snapshot:
|
|
356
|
+
p = Path(args.snapshot)
|
|
357
|
+
if not p.exists():
|
|
358
|
+
raise tl.TimelineError(f"snapshot not found: {p}")
|
|
359
|
+
samples = samples_from_snapshot(json.loads(p.read_text(encoding="utf-8")))
|
|
360
|
+
source = p.name
|
|
361
|
+
else:
|
|
362
|
+
data = tl.load(args.timeline)
|
|
363
|
+
allow |= set(data.get("chrome_allow") or [])
|
|
364
|
+
samples = samples_from_timeline(data, cfg.frame.width, cfg.frame.height, r)
|
|
365
|
+
source = Path(args.timeline).name
|
|
366
|
+
unknown = allow - set(SEVERITY)
|
|
367
|
+
if unknown:
|
|
368
|
+
raise ConfigError(f"unknown rule(s) in allow: {', '.join(sorted(unknown))}")
|
|
369
|
+
return report(lint(samples, r, allow), source, len(samples), from_template)
|
|
370
|
+
except (ConfigError, tl.TimelineError, json.JSONDecodeError, KeyError, ValueError, OSError) as e:
|
|
371
|
+
print(f"ERROR: {e}")
|
|
372
|
+
return 2
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
if __name__ == "__main__":
|
|
376
|
+
sys.exit(main())
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""DEAD-FRAME gate, measured on the FINAL MP4, every frame.
|
|
3
|
+
|
|
4
|
+
Two measures on a small luma copy of each frame (dead_frames.analysis_width x
|
|
5
|
+
dead_frames.analysis_height):
|
|
6
|
+
|
|
7
|
+
FLAT (blocks). A frame that is one uniform field: std-dev under
|
|
8
|
+
dead_frames.flat_std AND range under dead_frames.flat_range. A run of
|
|
9
|
+
MORE than dead_frames.flat_max_frames flat frames fails: a black or
|
|
10
|
+
blank gap between scenes, a render that lost a layer, a fade that
|
|
11
|
+
bottoms out on nothing. Shorter dips (a frame or three inside a
|
|
12
|
+
transition) are listed and ignored.
|
|
13
|
+
FROZEN (warns). A frame whose mean absolute difference to the previous
|
|
14
|
+
one is under dead_frames.frozen_diff. A frozen run longer than
|
|
15
|
+
dead_frames.frozen_warn_s is printed as a warning: a held card while
|
|
16
|
+
the voice talks may be the point (a price being read), so this never
|
|
17
|
+
blocks.
|
|
18
|
+
|
|
19
|
+
Plus a MOTION note: the mean frame-to-frame change against
|
|
20
|
+
dead_frames.motion_band_reference. It is a question, never a gate, and the
|
|
21
|
+
reference comes from horizontal brand films, not vertical Reels.
|
|
22
|
+
|
|
23
|
+
What it cannot see:
|
|
24
|
+
- A talking head is never flat and rarely frozen: sensor noise, breathing
|
|
25
|
+
and compression keep the frame difference above the threshold even when
|
|
26
|
+
the shot is visually dead. Boredom on camera is for the eye.
|
|
27
|
+
- Grain, a noise overlay or a looping background keep a dead slide
|
|
28
|
+
"moving". A caret or one small ticking element does too.
|
|
29
|
+
- A dark frame with one small line of text is not flat (its range is
|
|
30
|
+
high). Whether the frame is full enough is measure_density.py.
|
|
31
|
+
- Freezes under the threshold inside a region (half the frame still) do
|
|
32
|
+
not count: the measure is the whole frame.
|
|
33
|
+
|
|
34
|
+
Ported from gate G2 of motionmaxxing (Apache-2.0,
|
|
35
|
+
github.com/Tejashmakwana/motionmaxxing), adapted to 9:16; see
|
|
36
|
+
THIRD-PARTY-NOTICES.md.
|
|
37
|
+
|
|
38
|
+
Exit code: 0 pass (warnings allowed), 1 fail, 2 file or config unreadable.
|
|
39
|
+
"""
|
|
40
|
+
from __future__ import annotations
|
|
41
|
+
|
|
42
|
+
import argparse
|
|
43
|
+
import os
|
|
44
|
+
import subprocess
|
|
45
|
+
import sys
|
|
46
|
+
from dataclasses import dataclass
|
|
47
|
+
from typing import Iterable
|
|
48
|
+
|
|
49
|
+
from check_safe_zone import ReadError, all_frames, video_fps
|
|
50
|
+
from config import Config, ConfigError, add_config_arg, load, optional_block
|
|
51
|
+
|
|
52
|
+
BLOCK = "dead_frames"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class DeadFrameRules:
|
|
57
|
+
width: int
|
|
58
|
+
height: int
|
|
59
|
+
flat_std: float
|
|
60
|
+
flat_range: int
|
|
61
|
+
flat_max_frames: int
|
|
62
|
+
frozen_diff: float
|
|
63
|
+
frozen_warn_s: float
|
|
64
|
+
motion_band: tuple[float, float, float]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def rules(cfg: Config) -> tuple[DeadFrameRules, bool]:
|
|
68
|
+
"""(rules, from_template). Refuses an incoherent block."""
|
|
69
|
+
b, from_template = optional_block(cfg, BLOCK)
|
|
70
|
+
try:
|
|
71
|
+
band = tuple(float(v) for v in b["motion_band_reference"])
|
|
72
|
+
r = DeadFrameRules(
|
|
73
|
+
width=int(b["analysis_width"]), height=int(b["analysis_height"]),
|
|
74
|
+
flat_std=float(b["flat_std"]), flat_range=int(b["flat_range"]),
|
|
75
|
+
flat_max_frames=int(b["flat_max_frames"]), frozen_diff=float(b["frozen_diff"]),
|
|
76
|
+
frozen_warn_s=float(b["frozen_warn_s"]), motion_band=band)
|
|
77
|
+
except (KeyError, TypeError, ValueError) as e:
|
|
78
|
+
raise ConfigError(f"{BLOCK}: missing or invalid key ({e})") from e
|
|
79
|
+
if r.width <= 0 or r.height <= 0:
|
|
80
|
+
raise ConfigError(f"{BLOCK}.analysis_width and analysis_height must be > 0")
|
|
81
|
+
if r.flat_max_frames < 0 or r.flat_std <= 0 or r.flat_range <= 0:
|
|
82
|
+
raise ConfigError(f"{BLOCK}.flat_* must be > 0 (flat_max_frames >= 0)")
|
|
83
|
+
if r.frozen_diff <= 0 or r.frozen_warn_s <= 0:
|
|
84
|
+
raise ConfigError(f"{BLOCK}.frozen_diff and frozen_warn_s must be > 0")
|
|
85
|
+
if len(band) != 3 or not band[0] <= band[1] <= band[2]:
|
|
86
|
+
raise ConfigError(f"{BLOCK}.motion_band_reference must be [q1, median, q3]")
|
|
87
|
+
return r, from_template
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def is_flat(frame: bytes, r: DeadFrameRules) -> bool:
|
|
91
|
+
n = len(frame)
|
|
92
|
+
if n == 0:
|
|
93
|
+
return True
|
|
94
|
+
if max(frame) - min(frame) >= r.flat_range:
|
|
95
|
+
return False
|
|
96
|
+
s = sum(frame)
|
|
97
|
+
var = sum(v * v for v in frame) / n - (s / n) ** 2
|
|
98
|
+
return (var ** 0.5 if var > 0 else 0.0) < r.flat_std
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def mean_abs_diff(a: bytes, b: bytes) -> float:
|
|
102
|
+
return sum(abs(x - y) for x, y in zip(a, b)) / max(1, len(a))
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def runs(flags: list[bool]) -> list[tuple[int, int]]:
|
|
106
|
+
"""(first, last) index of every run of True."""
|
|
107
|
+
out, start = [], None
|
|
108
|
+
for i, f in enumerate(flags):
|
|
109
|
+
if f and start is None:
|
|
110
|
+
start = i
|
|
111
|
+
elif not f and start is not None:
|
|
112
|
+
out.append((start, i - 1))
|
|
113
|
+
start = None
|
|
114
|
+
if start is not None:
|
|
115
|
+
out.append((start, len(flags) - 1))
|
|
116
|
+
return out
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def analyze(frames: Iterable[bytes], fps: float, r: DeadFrameRules) -> dict:
|
|
120
|
+
flat, frozen, diffs, prev = [], [False], [], None
|
|
121
|
+
for fr in frames:
|
|
122
|
+
flat.append(is_flat(fr, r))
|
|
123
|
+
if prev is not None:
|
|
124
|
+
d = mean_abs_diff(fr, prev)
|
|
125
|
+
diffs.append(d)
|
|
126
|
+
frozen.append(d < r.frozen_diff)
|
|
127
|
+
prev = fr
|
|
128
|
+
if not flat:
|
|
129
|
+
raise ReadError("no frame decoded")
|
|
130
|
+
flat_runs = runs(flat)
|
|
131
|
+
too_long = [(s, e) for s, e in flat_runs if e - s + 1 > r.flat_max_frames]
|
|
132
|
+
# A flat run is also "frozen"; report it once, as flat.
|
|
133
|
+
frozen_runs = [(s - 1, e) for s, e in runs(frozen)
|
|
134
|
+
if (e - s + 2) / fps > r.frozen_warn_s and not all(flat[s:e + 1])]
|
|
135
|
+
return {
|
|
136
|
+
"frames": len(flat),
|
|
137
|
+
"fps": fps,
|
|
138
|
+
"flat_fail": too_long,
|
|
139
|
+
"flat_ignored": [x for x in flat_runs if x not in too_long],
|
|
140
|
+
"frozen_warn": frozen_runs,
|
|
141
|
+
"motion_mean": sum(diffs) / len(diffs) if diffs else 0.0,
|
|
142
|
+
"passed": not too_long,
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _span(s: int, e: int, fps: float) -> str:
|
|
147
|
+
return f"{s / fps:6.2f}s -> {(e + 1) / fps:6.2f}s ({e - s + 1} f)"
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def report(name: str, rep: dict, r: DeadFrameRules, from_template: bool) -> int:
|
|
151
|
+
fps = rep["fps"]
|
|
152
|
+
print(f"{name}: {rep['frames']} frames @ {fps:.2f} fps")
|
|
153
|
+
if from_template:
|
|
154
|
+
print(f" note: no '{BLOCK}' block in the project config; using the template's")
|
|
155
|
+
if rep["flat_fail"]:
|
|
156
|
+
print(f" FLAT: {len(rep['flat_fail'])} run(s) of more than "
|
|
157
|
+
f"dead_frames.flat_max_frames={r.flat_max_frames} uniform frames:")
|
|
158
|
+
for s, e in rep["flat_fail"][:10]:
|
|
159
|
+
print(f" {_span(s, e, fps)}")
|
|
160
|
+
else:
|
|
161
|
+
print(f" ok: no run of more than {r.flat_max_frames} flat frames")
|
|
162
|
+
if rep["flat_ignored"]:
|
|
163
|
+
print(f" {len(rep['flat_ignored'])} short dip(s) ignored: "
|
|
164
|
+
+ ", ".join(f"{s / fps:.2f}s" for s, _ in rep["flat_ignored"][:6]))
|
|
165
|
+
for s, e in rep["frozen_warn"][:10]:
|
|
166
|
+
print(f" WARN frozen picture {_span(s, e, fps)} > dead_frames.frozen_warn_s="
|
|
167
|
+
f"{r.frozen_warn_s}: is something alive on it, or is the hold the point?")
|
|
168
|
+
q1, med, q3 = r.motion_band
|
|
169
|
+
print(f" motion {rep['motion_mean']:.1f} (reference band {q1}-{q3}, median {med}, "
|
|
170
|
+
"horizontal brand films; a question, not a gate)")
|
|
171
|
+
return 0 if rep["passed"] else 1
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def main(argv: list[str] | None = None) -> int:
|
|
175
|
+
ap = argparse.ArgumentParser(description="Dead-frame gate: flat runs (block) and frozen picture (warn) on the final MP4.")
|
|
176
|
+
add_config_arg(ap)
|
|
177
|
+
ap.add_argument("video", help="final MP4")
|
|
178
|
+
args = ap.parse_args(argv)
|
|
179
|
+
try:
|
|
180
|
+
cfg = load(args.config)
|
|
181
|
+
r, from_template = rules(cfg)
|
|
182
|
+
if not os.path.exists(args.video):
|
|
183
|
+
raise ReadError(f"video not found: {args.video}")
|
|
184
|
+
frames = (f for _, f in all_frames(args.video, r.width, r.height))
|
|
185
|
+
rep = analyze(frames, video_fps(args.video), r)
|
|
186
|
+
return report(os.path.basename(args.video), rep, r, from_template)
|
|
187
|
+
except (ConfigError, ReadError, subprocess.SubprocessError, OSError) as e:
|
|
188
|
+
print(f"ERROR: {e}")
|
|
189
|
+
return 2
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
if __name__ == "__main__":
|
|
193
|
+
sys.exit(main())
|
|
@@ -540,6 +540,19 @@ def validate(c: Config) -> None:
|
|
|
540
540
|
_validate_audio_blocks(c)
|
|
541
541
|
|
|
542
542
|
|
|
543
|
+
def optional_block(c: Config, key: str) -> tuple[dict, bool]:
|
|
544
|
+
"""(block, from_template) for a block added after projects already had a
|
|
545
|
+
config. A project config without it uses the TEMPLATE's block, so the
|
|
546
|
+
number still lives in one file; the caller says so in its report."""
|
|
547
|
+
own = _read_json(c.source).get(key)
|
|
548
|
+
if isinstance(own, dict):
|
|
549
|
+
return _notes_out(own), False
|
|
550
|
+
tpl = _read_json(TEMPLATE).get(key)
|
|
551
|
+
if not isinstance(tpl, dict):
|
|
552
|
+
raise ConfigError(f"block {key!r} is missing from the config and from the template")
|
|
553
|
+
return _notes_out(tpl), True
|
|
554
|
+
|
|
555
|
+
|
|
543
556
|
# -------------------------------------------------------------------- CLI glue
|
|
544
557
|
|
|
545
558
|
|