wizz-method 2.21.0 → 2.23.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 (179) hide show
  1. package/THIRD-PARTY-NOTICES.md +24 -0
  2. package/package.json +4 -2
  3. package/removals.txt +3 -0
  4. package/skills-registry.yaml +133 -4
  5. package/src/bmm-skills/1-analysis/research/wizz-market-research/steps/step-06-research-completion.md +27 -0
  6. package/src/bmm-skills/1-analysis/wizz-set-feature/SKILL.md +1 -1
  7. package/src/bmm-skills/4-implementation/wizz-code-review/SKILL.md +1 -1
  8. package/src/bmm-skills/4-implementation/wizz-create-story/SKILL.md +1 -1
  9. package/src/bmm-skills/4-implementation/wizz-dev-story/SKILL.md +8 -1
  10. package/src/bmm-skills/4-implementation/wizz-quick-dev/SKILL.md +2 -2
  11. package/src/bmm-skills/4-implementation/wizz-quick-dev/step-03-implement.md +2 -0
  12. package/src/bmm-skills/4-implementation/wizz-sprint-status/SKILL.md +1 -1
  13. package/src/core-skills/wizz-party-mode/customize.toml +3 -3
  14. package/src/modules/lowticket/README.md +2 -1
  15. package/src/modules/lowticket/module.yaml +1 -1
  16. package/src/modules/lowticket/skills/wizz-clone-do-expert/SKILL.md +86 -0
  17. package/src/modules/lowticket/skills/wizz-clone-do-expert/references/clone-prompt-template.md +21 -0
  18. package/src/modules/lowticket/skills/wizz-lowticket-trafego/references/reguas.md +2 -0
  19. package/src/modules/wizz/_shared/cerebro.md +1 -1
  20. package/src/modules/wizz/_shared/organizacao.md +2 -2
  21. package/src/modules/wizz/_shared/video-pipeline.md +2 -0
  22. package/src/modules/wizz/agents/wizz-copy/SKILL.md +1 -0
  23. package/src/modules/wizz/agents/wizz-copy/customize.toml +5 -0
  24. package/src/modules/wizz/agents/wizz-copy/knowledge/playbook.md +3 -2
  25. package/src/modules/wizz/agents/wizz-growth/customize.toml +7 -2
  26. package/src/modules/wizz/agents/wizz-growth/knowledge/playbook.md +3 -2
  27. package/src/modules/wizz/agents/wizz-social/knowledge/blueprint-roteiro-viral.md +14 -0
  28. package/src/modules/wizz/agents/wizz-social/knowledge/legenda-instagram.md +12 -0
  29. package/src/skills-lib/wizz-cerebro/SKILL.md +18 -5
  30. package/src/skills-lib/wizz-design-needs/SKILL.md +7 -0
  31. package/src/skills-lib/wizz-design-needs/references/design-state.md +34 -2
  32. package/src/skills-lib/wizz-design-needs/references/gap-analysis.md +1 -1
  33. package/src/skills-lib/wizz-design-needs/scripts/design_state.py +305 -0
  34. package/src/skills-lib/wizz-design-needs/scripts/tests/test_design_state.py +143 -0
  35. package/src/skills-lib/wizz-impeccable/references/design-rules.md +18 -0
  36. package/src/skills-lib/wizz-ios-build/SKILL.md +61 -0
  37. package/src/skills-lib/wizz-motion-3d-director/references/motion-paths.md +2 -0
  38. package/src/skills-lib/wizz-offer-forge/SKILL.md +2 -2
  39. package/src/skills-lib/wizz-paid-ads/SKILL.md +2 -1
  40. package/src/skills-lib/wizz-paywall-upgrade-cro/SKILL.md +1 -0
  41. package/src/skills-lib/{wizz-pesquisa-de-publico-do-piva → wizz-pesquisa-de-publico}/README.md +13 -13
  42. package/src/skills-lib/{wizz-pesquisa-de-publico-do-piva → wizz-pesquisa-de-publico}/SKILL.md +11 -7
  43. package/src/skills-lib/{wizz-pesquisa-de-publico-do-piva → wizz-pesquisa-de-publico}/assets/template.html +8 -21
  44. package/src/skills-lib/wizz-pesquisa-de-publico/references/interview-mode.md +69 -0
  45. package/src/skills-lib/wizz-premium-landing-ui-researcher/SKILL.md +6 -2
  46. package/src/skills-lib/wizz-premium-landing-ui-researcher/references/audit-protocol.md +7 -34
  47. package/src/skills-lib/wizz-premium-landing-ui-researcher/references/component-sources.md +25 -0
  48. package/src/skills-lib/wizz-premium-landing-ui-researcher/references/design-sources.yaml +325 -0
  49. package/src/skills-lib/wizz-premium-landing-ui-researcher/references/mandatory-process.md +1 -1
  50. package/src/skills-lib/wizz-premium-landing-ui-researcher/references/mining-rubric.md +123 -0
  51. package/src/skills-lib/wizz-premium-landing-ui-researcher/references/source-first-protocol.md +10 -4
  52. package/src/skills-lib/wizz-premium-landing-ui-researcher/references/web-access-and-evidence.md +1 -1
  53. package/src/skills-lib/wizz-premium-landing-ui-researcher/scripts/design_sources.py +221 -0
  54. package/src/skills-lib/wizz-premium-landing-ui-researcher/scripts/tests/test_design_sources.py +130 -0
  55. package/src/skills-lib/wizz-router/references/routing-table-flat.md +7 -1
  56. package/src/skills-lib/wizz-seo-audit/SKILL.md +4 -0
  57. package/src/skills-lib/wizz-seo-audit/references/gsc-regex-perguntas.md +64 -0
  58. package/src/skills-lib/wizz-signup-flow-cro/SKILL.md +1 -0
  59. package/src/skills-lib/wizz-signup-flow-cro/references/trial-abuse.md +40 -0
  60. package/src/skills-lib/wizz-taste-skill/references/anti-slop-tells.md +8 -0
  61. package/src/skills-lib/wizz-ui-component-curator/SKILL.md +8 -7
  62. package/src/skills-lib/wizz-ui-ux-pro-max/SKILL.md +1 -1
  63. package/src/skills-lib/wizz-ui-ux-pro-max/references/common-rules-checklist.md +11 -0
  64. package/src/skills-lib/wizz-video-effects/LICENSE-motionmaxxing.txt +202 -0
  65. package/src/skills-lib/wizz-video-effects/SKILL.md +7 -2
  66. package/src/skills-lib/wizz-video-effects/assets/chrome/collect_layout.js +113 -0
  67. package/src/skills-lib/wizz-video-effects/assets/effects.config.template.json +45 -0
  68. package/src/skills-lib/wizz-video-effects/references/data-contract.md +32 -1
  69. package/src/skills-lib/wizz-video-effects/references/gates.md +4 -1
  70. package/src/skills-lib/wizz-video-effects/references/one-take.md +41 -0
  71. package/src/skills-lib/wizz-video-effects/scripts/check_chrome.py +376 -0
  72. package/src/skills-lib/wizz-video-effects/scripts/check_dead_frames.py +193 -0
  73. package/src/skills-lib/wizz-video-effects/scripts/check_one_take.py +159 -0
  74. package/src/skills-lib/wizz-video-effects/scripts/config.py +13 -0
  75. package/src/skills-lib/wizz-video-effects/scripts/test_chrome.py +181 -0
  76. package/src/skills-lib/wizz-video-effects/scripts/test_dead_frames.py +171 -0
  77. package/src/skills-lib/wizz-video-effects/scripts/test_one_take.py +126 -0
  78. package/src/skills-lib/wizz-video-styles/LICENSE-lemo-opuscar.txt +21 -0
  79. package/src/skills-lib/wizz-video-styles/SKILL.md +16 -7
  80. package/src/skills-lib/wizz-video-styles/assets/film-styles/dark-keynote.html +78 -0
  81. package/src/skills-lib/wizz-video-styles/assets/film-styles/remotion/Riso.tsx +63 -0
  82. package/src/skills-lib/wizz-video-styles/assets/film-styles/risograph.html +69 -0
  83. package/src/skills-lib/wizz-video-styles/assets/film-styles/swiss-motion.html +84 -0
  84. package/src/skills-lib/wizz-video-styles/references/check-ins.md +25 -0
  85. package/src/skills-lib/wizz-video-styles/references/film-styles/DIRECTOR.md +139 -0
  86. package/src/skills-lib/wizz-video-styles/references/film-styles/archive-lighttable.md +109 -0
  87. package/src/skills-lib/wizz-video-styles/references/film-styles/art-deco.md +112 -0
  88. package/src/skills-lib/wizz-video-styles/references/film-styles/ascii-crt.md +115 -0
  89. package/src/skills-lib/wizz-video-styles/references/film-styles/backrooms.md +125 -0
  90. package/src/skills-lib/wizz-video-styles/references/film-styles/blueprint.md +120 -0
  91. package/src/skills-lib/wizz-video-styles/references/film-styles/brick-toy.md +114 -0
  92. package/src/skills-lib/wizz-video-styles/references/film-styles/briefing-board.md +122 -0
  93. package/src/skills-lib/wizz-video-styles/references/film-styles/caption-news.md +137 -0
  94. package/src/skills-lib/wizz-video-styles/references/film-styles/cel-anime-80s.md +119 -0
  95. package/src/skills-lib/wizz-video-styles/references/film-styles/city-symphony.md +142 -0
  96. package/src/skills-lib/wizz-video-styles/references/film-styles/countdown.md +132 -0
  97. package/src/skills-lib/wizz-video-styles/references/film-styles/crayon-book.md +109 -0
  98. package/src/skills-lib/wizz-video-styles/references/film-styles/dark-keynote.md +117 -0
  99. package/src/skills-lib/wizz-video-styles/references/film-styles/data-physical.md +119 -0
  100. package/src/skills-lib/wizz-video-styles/references/film-styles/dataviz.md +123 -0
  101. package/src/skills-lib/wizz-video-styles/references/film-styles/engraving.md +123 -0
  102. package/src/skills-lib/wizz-video-styles/references/film-styles/game-show.md +118 -0
  103. package/src/skills-lib/wizz-video-styles/references/film-styles/glass-product.md +110 -0
  104. package/src/skills-lib/wizz-video-styles/references/film-styles/halftone-dossier.md +122 -0
  105. package/src/skills-lib/wizz-video-styles/references/film-styles/hd-2d.md +111 -0
  106. package/src/skills-lib/wizz-video-styles/references/film-styles/hologram-hud.md +129 -0
  107. package/src/skills-lib/wizz-video-styles/references/film-styles/impasto.md +124 -0
  108. package/src/skills-lib/wizz-video-styles/references/film-styles/ink-wash.md +120 -0
  109. package/src/skills-lib/wizz-video-styles/references/film-styles/iso-infographic.md +117 -0
  110. package/src/skills-lib/wizz-video-styles/references/film-styles/kino-montage.md +151 -0
  111. package/src/skills-lib/wizz-video-styles/references/film-styles/lidar-survey.md +118 -0
  112. package/src/skills-lib/wizz-video-styles/references/film-styles/living-screencast.md +124 -0
  113. package/src/skills-lib/wizz-video-styles/references/film-styles/lowpoly-island.md +112 -0
  114. package/src/skills-lib/wizz-video-styles/references/film-styles/microgame.md +122 -0
  115. package/src/skills-lib/wizz-video-styles/references/film-styles/microscope.md +108 -0
  116. package/src/skills-lib/wizz-video-styles/references/film-styles/midcentury-toon.md +124 -0
  117. package/src/skills-lib/wizz-video-styles/references/film-styles/news-package.md +158 -0
  118. package/src/skills-lib/wizz-video-styles/references/film-styles/newsreel.md +136 -0
  119. package/src/skills-lib/wizz-video-styles/references/film-styles/one-line.md +114 -0
  120. package/src/skills-lib/wizz-video-styles/references/film-styles/paper-lantern.md +116 -0
  121. package/src/skills-lib/wizz-video-styles/references/film-styles/paper-popup.md +119 -0
  122. package/src/skills-lib/wizz-video-styles/references/film-styles/papercut-red.md +110 -0
  123. package/src/skills-lib/wizz-video-styles/references/film-styles/pictogram-motion.md +119 -0
  124. package/src/skills-lib/wizz-video-styles/references/film-styles/pixel-rpg.md +117 -0
  125. package/src/skills-lib/wizz-video-styles/references/film-styles/risograph.md +125 -0
  126. package/src/skills-lib/wizz-video-styles/references/film-styles/rubber-hose.md +112 -0
  127. package/src/skills-lib/wizz-video-styles/references/film-styles/satellite-briefing.md +116 -0
  128. package/src/skills-lib/wizz-video-styles/references/film-styles/scifi-toon.md +121 -0
  129. package/src/skills-lib/wizz-video-styles/references/film-styles/shadow-puppet.md +124 -0
  130. package/src/skills-lib/wizz-video-styles/references/film-styles/silent-film.md +109 -0
  131. package/src/skills-lib/wizz-video-styles/references/film-styles/silkscreen-poster.md +140 -0
  132. package/src/skills-lib/wizz-video-styles/references/film-styles/spy-titles.md +116 -0
  133. package/src/skills-lib/wizz-video-styles/references/film-styles/stained-glass.md +123 -0
  134. package/src/skills-lib/wizz-video-styles/references/film-styles/swiss-motion.md +115 -0
  135. package/src/skills-lib/wizz-video-styles/references/film-styles/teardown.md +127 -0
  136. package/src/skills-lib/wizz-video-styles/references/film-styles/telestrator.md +137 -0
  137. package/src/skills-lib/wizz-video-styles/references/film-styles/tilt-shift.md +107 -0
  138. package/src/skills-lib/wizz-video-styles/references/film-styles/ukiyoe.md +112 -0
  139. package/src/skills-lib/wizz-video-styles/references/film-styles/urban-sketch.md +126 -0
  140. package/src/skills-lib/wizz-video-styles/references/film-styles/visual-investigation.md +140 -0
  141. package/src/skills-lib/wizz-video-styles/references/film-styles/watercolor.md +113 -0
  142. package/src/skills-lib/wizz-video-styles/references/film-styles/white-model.md +115 -0
  143. package/src/skills-lib/wizz-video-styles/references/film-styles/whiteboard.md +118 -0
  144. package/src/skills-lib/wizz-video-styles/references/film-styles/woodcut.md +122 -0
  145. package/src/skills-lib/wizz-video-styles/references/film-styles.md +140 -0
  146. package/src/skills-lib/wizz-video-styles/references/insert-recipes.md +33 -0
  147. package/src/skills-lib/wizz-video-styles/references/motion-laws.md +62 -0
  148. package/src/skills-lib/wizz-video-styles/references/styles/desafio-numero.md +1 -0
  149. package/src/skills-lib/wizz-video-styles/scripts/beat_grid.py +38 -6
  150. package/src/skills-lib/wizz-video-styles/scripts/blind_review.py +159 -0
  151. package/src/skills-lib/wizz-video-styles/scripts/taste.mjs +110 -0
  152. package/src/skills-lib/wizz-video-styles/scripts/test_blind_review.py +82 -0
  153. package/src/skills-lib/wizz-vsl/LICENSE +49 -0
  154. package/src/skills-lib/wizz-vsl/SKILL.md +122 -0
  155. package/src/skills-lib/wizz-vsl/references/assembly-qa.md +123 -0
  156. package/src/skills-lib/wizz-vsl/references/blocks-and-leads.md +149 -0
  157. package/src/skills-lib/wizz-vsl/references/diagnosis.md +95 -0
  158. package/src/skills-lib/wizz-vsl/references/intake.md +200 -0
  159. package/src/skills-lib/wizz-vsl/references/mechanisms.md +138 -0
  160. package/src/skills-lib/wizz-vsl/references/niches.md +40 -0
  161. package/src/skills-lib/wizz-vsl/references/offer.md +91 -0
  162. package/src/skills-lib/wizz-vsl/references/production.md +75 -0
  163. package/src/skills-lib/wizz-web-security/SKILL.md +2 -0
  164. package/tools/hooks/README.md +10 -9
  165. package/tools/hooks/wizz-arrumar.js +1 -0
  166. package/tools/hooks/wizz-artifact-guard.js +31 -5
  167. package/tools/hooks/wizz-cerebro-lib.js +36 -13
  168. package/tools/hooks/wizz-feature-lib.js +4 -1
  169. package/tools/hooks/wizz-feature.js +66 -76
  170. package/tools/hooks/wizz-sprint-archive-lib.js +311 -0
  171. package/tools/installer/core/hooks-settings.js +1 -0
  172. package/tools/installer/core/installer.js +6 -0
  173. package/tools/installer/guided/labels.js +16 -0
  174. package/tools/installer/install-text.js +2 -0
  175. package/tools/installer/modules/cli-config.js +3 -0
  176. package/tools/installer/modules/core-seed.js +72 -0
  177. package/tools/installer/modules/mcp-config.js +3 -0
  178. package/tools/installer/ui.js +22 -26
  179. /package/src/skills-lib/{wizz-pesquisa-de-publico-do-piva → wizz-pesquisa-de-publico}/prompts.md +0 -0
@@ -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())