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.
Files changed (80) hide show
  1. package/THIRD-PARTY-NOTICES.md +18 -0
  2. package/package.json +4 -2
  3. package/skills-registry.yaml +41 -0
  4. package/src/bmm-skills/1-analysis/wizz-set-feature/SKILL.md +1 -1
  5. package/src/bmm-skills/4-implementation/wizz-code-review/SKILL.md +1 -1
  6. package/src/bmm-skills/4-implementation/wizz-create-story/SKILL.md +1 -1
  7. package/src/bmm-skills/4-implementation/wizz-dev-story/SKILL.md +8 -1
  8. package/src/bmm-skills/4-implementation/wizz-quick-dev/SKILL.md +2 -2
  9. package/src/bmm-skills/4-implementation/wizz-quick-dev/step-03-implement.md +2 -0
  10. package/src/bmm-skills/4-implementation/wizz-sprint-status/SKILL.md +1 -1
  11. package/src/core-skills/wizz-party-mode/customize.toml +3 -3
  12. package/src/modules/lowticket/skills/wizz-lowticket-trafego/references/reguas.md +2 -0
  13. package/src/modules/wizz/_shared/cerebro.md +1 -1
  14. package/src/modules/wizz/_shared/organizacao.md +2 -2
  15. package/src/modules/wizz/agents/wizz-copy/SKILL.md +1 -0
  16. package/src/modules/wizz/agents/wizz-copy/customize.toml +5 -0
  17. package/src/modules/wizz/agents/wizz-copy/knowledge/playbook.md +2 -1
  18. package/src/modules/wizz/agents/wizz-social/knowledge/blueprint-roteiro-viral.md +14 -0
  19. package/src/modules/wizz/agents/wizz-social/knowledge/legenda-instagram.md +12 -0
  20. package/src/skills-lib/wizz-cerebro/SKILL.md +18 -5
  21. package/src/skills-lib/wizz-design-needs/SKILL.md +7 -0
  22. package/src/skills-lib/wizz-design-needs/references/design-state.md +34 -2
  23. package/src/skills-lib/wizz-design-needs/references/gap-analysis.md +1 -1
  24. package/src/skills-lib/wizz-design-needs/scripts/design_state.py +305 -0
  25. package/src/skills-lib/wizz-design-needs/scripts/tests/test_design_state.py +143 -0
  26. package/src/skills-lib/wizz-impeccable/references/design-rules.md +18 -0
  27. package/src/skills-lib/wizz-motion-3d-director/references/motion-paths.md +2 -0
  28. package/src/skills-lib/wizz-paid-ads/SKILL.md +2 -1
  29. package/src/skills-lib/wizz-premium-landing-ui-researcher/SKILL.md +6 -2
  30. package/src/skills-lib/wizz-premium-landing-ui-researcher/references/audit-protocol.md +7 -34
  31. package/src/skills-lib/wizz-premium-landing-ui-researcher/references/component-sources.md +2 -0
  32. package/src/skills-lib/wizz-premium-landing-ui-researcher/references/design-sources.yaml +252 -0
  33. package/src/skills-lib/wizz-premium-landing-ui-researcher/references/mandatory-process.md +1 -1
  34. package/src/skills-lib/wizz-premium-landing-ui-researcher/references/mining-rubric.md +123 -0
  35. package/src/skills-lib/wizz-premium-landing-ui-researcher/references/source-first-protocol.md +10 -4
  36. package/src/skills-lib/wizz-premium-landing-ui-researcher/references/web-access-and-evidence.md +1 -1
  37. package/src/skills-lib/wizz-premium-landing-ui-researcher/scripts/design_sources.py +221 -0
  38. package/src/skills-lib/wizz-premium-landing-ui-researcher/scripts/tests/test_design_sources.py +130 -0
  39. package/src/skills-lib/wizz-router/references/routing-table-flat.md +2 -0
  40. package/src/skills-lib/wizz-taste-skill/references/anti-slop-tells.md +8 -0
  41. package/src/skills-lib/wizz-ui-component-curator/SKILL.md +8 -7
  42. package/src/skills-lib/wizz-video-effects/LICENSE-motionmaxxing.txt +202 -0
  43. package/src/skills-lib/wizz-video-effects/SKILL.md +5 -2
  44. package/src/skills-lib/wizz-video-effects/assets/chrome/collect_layout.js +113 -0
  45. package/src/skills-lib/wizz-video-effects/assets/effects.config.template.json +35 -0
  46. package/src/skills-lib/wizz-video-effects/references/data-contract.md +30 -1
  47. package/src/skills-lib/wizz-video-effects/references/gates.md +3 -1
  48. package/src/skills-lib/wizz-video-effects/scripts/check_chrome.py +376 -0
  49. package/src/skills-lib/wizz-video-effects/scripts/check_dead_frames.py +193 -0
  50. package/src/skills-lib/wizz-video-effects/scripts/config.py +13 -0
  51. package/src/skills-lib/wizz-video-effects/scripts/test_chrome.py +181 -0
  52. package/src/skills-lib/wizz-video-effects/scripts/test_dead_frames.py +171 -0
  53. package/src/skills-lib/wizz-video-styles/SKILL.md +6 -2
  54. package/src/skills-lib/wizz-video-styles/references/motion-laws.md +62 -0
  55. package/src/skills-lib/wizz-video-styles/references/styles/desafio-numero.md +1 -0
  56. package/src/skills-lib/wizz-video-styles/scripts/blind_review.py +159 -0
  57. package/src/skills-lib/wizz-video-styles/scripts/taste.mjs +110 -0
  58. package/src/skills-lib/wizz-video-styles/scripts/test_blind_review.py +82 -0
  59. package/src/skills-lib/wizz-vsl/LICENSE +49 -0
  60. package/src/skills-lib/wizz-vsl/SKILL.md +122 -0
  61. package/src/skills-lib/wizz-vsl/references/assembly-qa.md +123 -0
  62. package/src/skills-lib/wizz-vsl/references/blocks-and-leads.md +149 -0
  63. package/src/skills-lib/wizz-vsl/references/diagnosis.md +95 -0
  64. package/src/skills-lib/wizz-vsl/references/intake.md +200 -0
  65. package/src/skills-lib/wizz-vsl/references/mechanisms.md +138 -0
  66. package/src/skills-lib/wizz-vsl/references/niches.md +40 -0
  67. package/src/skills-lib/wizz-vsl/references/offer.md +91 -0
  68. package/src/skills-lib/wizz-vsl/references/production.md +75 -0
  69. package/tools/hooks/README.md +10 -9
  70. package/tools/hooks/wizz-arrumar.js +1 -0
  71. package/tools/hooks/wizz-artifact-guard.js +31 -5
  72. package/tools/hooks/wizz-cerebro-lib.js +36 -13
  73. package/tools/hooks/wizz-feature-lib.js +4 -1
  74. package/tools/hooks/wizz-feature.js +66 -76
  75. package/tools/hooks/wizz-sprint-archive-lib.js +311 -0
  76. package/tools/installer/core/hooks-settings.js +1 -0
  77. package/tools/installer/core/installer.js +6 -0
  78. package/tools/installer/modules/core-seed.js +72 -0
  79. package/tools/installer/modules/mcp-config.js +3 -0
  80. 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 rhythm on the timeline before the expensive render; safe zone, density and loudness on the final file.
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