wizz-method 2.20.1 → 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 (116) 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-funil/SKILL.md +1 -1
  13. package/src/modules/lowticket/skills/wizz-lowticket-guia/SKILL.md +2 -2
  14. package/src/modules/lowticket/skills/wizz-lowticket-metodologia/SKILL.md +5 -5
  15. package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/01-principios.md +1 -1
  16. package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/02-diagnostico-funil.md +1 -1
  17. package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/03-campanha-bidcap.md +1 -1
  18. package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/04-criativos.md +2 -2
  19. package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/04a-criativos-ia.md +6 -6
  20. package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/04b-criativos-formatos.md +1 -1
  21. package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/04c-criativos-roteiro.md +5 -5
  22. package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/05-oferta.md +1 -1
  23. package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/06-pagina-vendas.md +3 -1
  24. package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/06a-instagram.md +2 -2
  25. package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/06b-player-vsl.md +1 -1
  26. package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/07-funil-upsell.md +1 -1
  27. package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/07a-copy-humanizacao.md +1 -1
  28. package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/08-pesquisa-publico.md +1 -1
  29. package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/09-especialista-coproducao.md +1 -1
  30. package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/10-levantar-caixa.md +1 -1
  31. package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/11-checklist-roi.md +1 -1
  32. package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/12-mineracao-ofertas.md +1 -1
  33. package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/12a-mineracao-veredito.md +1 -1
  34. package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/12b-fabrica-de-ofertas.md +2 -2
  35. package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/12c-anatomia-low-ticket.md +3 -1
  36. package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/13-benchmarks-vsl.md +1 -1
  37. package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/14-vocabulario.md +1 -1
  38. package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/15-como-estender.md +2 -2
  39. package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/17-rastreamento.md +1 -1
  40. package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/18-copy-desejo-oferta.md +3 -3
  41. package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/INDEX.md +14 -12
  42. package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/guardrails.md +18 -70
  43. package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/pontos-em-aberto.md +9 -9
  44. package/src/modules/lowticket/skills/wizz-lowticket-minerador/SKILL.md +1 -1
  45. package/src/modules/lowticket/skills/wizz-lowticket-pagina/SKILL.md +1 -1
  46. package/src/modules/lowticket/skills/wizz-lowticket-trafego/SKILL.md +1 -1
  47. package/src/modules/lowticket/skills/wizz-lowticket-trafego/references/reguas.md +2 -0
  48. package/src/modules/wizz/_shared/cerebro.md +1 -1
  49. package/src/modules/wizz/_shared/organizacao.md +2 -2
  50. package/src/modules/wizz/agents/wizz-copy/SKILL.md +1 -0
  51. package/src/modules/wizz/agents/wizz-copy/customize.toml +5 -0
  52. package/src/modules/wizz/agents/wizz-copy/knowledge/playbook.md +2 -1
  53. package/src/modules/wizz/agents/wizz-growth/knowledge/playbook.md +1 -1
  54. package/src/modules/wizz/agents/wizz-social/knowledge/blueprint-roteiro-viral.md +14 -0
  55. package/src/modules/wizz/agents/wizz-social/knowledge/legenda-instagram.md +12 -0
  56. package/src/skills-lib/wizz-cerebro/SKILL.md +18 -5
  57. package/src/skills-lib/wizz-design-needs/SKILL.md +7 -0
  58. package/src/skills-lib/wizz-design-needs/references/design-state.md +34 -2
  59. package/src/skills-lib/wizz-design-needs/references/gap-analysis.md +1 -1
  60. package/src/skills-lib/wizz-design-needs/scripts/design_state.py +305 -0
  61. package/src/skills-lib/wizz-design-needs/scripts/tests/test_design_state.py +143 -0
  62. package/src/skills-lib/wizz-impeccable/references/design-rules.md +18 -0
  63. package/src/skills-lib/wizz-motion-3d-director/references/motion-paths.md +2 -0
  64. package/src/skills-lib/wizz-paid-ads/SKILL.md +2 -1
  65. package/src/skills-lib/wizz-premium-landing-ui-researcher/SKILL.md +6 -2
  66. package/src/skills-lib/wizz-premium-landing-ui-researcher/references/audit-protocol.md +7 -34
  67. package/src/skills-lib/wizz-premium-landing-ui-researcher/references/component-sources.md +2 -0
  68. package/src/skills-lib/wizz-premium-landing-ui-researcher/references/design-sources.yaml +252 -0
  69. package/src/skills-lib/wizz-premium-landing-ui-researcher/references/mandatory-process.md +1 -1
  70. package/src/skills-lib/wizz-premium-landing-ui-researcher/references/mining-rubric.md +123 -0
  71. package/src/skills-lib/wizz-premium-landing-ui-researcher/references/source-first-protocol.md +10 -4
  72. package/src/skills-lib/wizz-premium-landing-ui-researcher/references/web-access-and-evidence.md +1 -1
  73. package/src/skills-lib/wizz-premium-landing-ui-researcher/scripts/design_sources.py +221 -0
  74. package/src/skills-lib/wizz-premium-landing-ui-researcher/scripts/tests/test_design_sources.py +130 -0
  75. package/src/skills-lib/wizz-router/references/routing-table-flat.md +2 -0
  76. package/src/skills-lib/wizz-taste-skill/references/anti-slop-tells.md +8 -0
  77. package/src/skills-lib/wizz-ui-component-curator/SKILL.md +8 -7
  78. package/src/skills-lib/wizz-video-effects/LICENSE-motionmaxxing.txt +202 -0
  79. package/src/skills-lib/wizz-video-effects/SKILL.md +5 -2
  80. package/src/skills-lib/wizz-video-effects/assets/chrome/collect_layout.js +113 -0
  81. package/src/skills-lib/wizz-video-effects/assets/effects.config.template.json +35 -0
  82. package/src/skills-lib/wizz-video-effects/references/data-contract.md +30 -1
  83. package/src/skills-lib/wizz-video-effects/references/gates.md +3 -1
  84. package/src/skills-lib/wizz-video-effects/scripts/check_chrome.py +376 -0
  85. package/src/skills-lib/wizz-video-effects/scripts/check_dead_frames.py +193 -0
  86. package/src/skills-lib/wizz-video-effects/scripts/config.py +13 -0
  87. package/src/skills-lib/wizz-video-effects/scripts/test_chrome.py +181 -0
  88. package/src/skills-lib/wizz-video-effects/scripts/test_dead_frames.py +171 -0
  89. package/src/skills-lib/wizz-video-styles/SKILL.md +6 -2
  90. package/src/skills-lib/wizz-video-styles/references/motion-laws.md +62 -0
  91. package/src/skills-lib/wizz-video-styles/references/styles/desafio-numero.md +1 -0
  92. package/src/skills-lib/wizz-video-styles/scripts/blind_review.py +159 -0
  93. package/src/skills-lib/wizz-video-styles/scripts/taste.mjs +110 -0
  94. package/src/skills-lib/wizz-video-styles/scripts/test_blind_review.py +82 -0
  95. package/src/skills-lib/wizz-vsl/LICENSE +49 -0
  96. package/src/skills-lib/wizz-vsl/SKILL.md +122 -0
  97. package/src/skills-lib/wizz-vsl/references/assembly-qa.md +123 -0
  98. package/src/skills-lib/wizz-vsl/references/blocks-and-leads.md +149 -0
  99. package/src/skills-lib/wizz-vsl/references/diagnosis.md +95 -0
  100. package/src/skills-lib/wizz-vsl/references/intake.md +200 -0
  101. package/src/skills-lib/wizz-vsl/references/mechanisms.md +138 -0
  102. package/src/skills-lib/wizz-vsl/references/niches.md +40 -0
  103. package/src/skills-lib/wizz-vsl/references/offer.md +91 -0
  104. package/src/skills-lib/wizz-vsl/references/production.md +75 -0
  105. package/tools/hooks/README.md +10 -9
  106. package/tools/hooks/wizz-arrumar.js +1 -0
  107. package/tools/hooks/wizz-artifact-guard.js +31 -5
  108. package/tools/hooks/wizz-cerebro-lib.js +36 -13
  109. package/tools/hooks/wizz-feature-lib.js +4 -1
  110. package/tools/hooks/wizz-feature.js +66 -76
  111. package/tools/hooks/wizz-sprint-archive-lib.js +311 -0
  112. package/tools/installer/core/hooks-settings.js +1 -0
  113. package/tools/installer/core/installer.js +6 -0
  114. package/tools/installer/modules/core-seed.js +72 -0
  115. package/tools/installer/modules/mcp-config.js +3 -0
  116. package/tools/installer/ui.js +16 -26
@@ -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
 
@@ -0,0 +1,181 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Tests of check_chrome.py: the page-chrome / slide-look rules on a layout
3
+ snapshot (DOM of a HyperFrames composition) and on the text layers of a
4
+ Remotion timeline."""
5
+ import io
6
+ import json
7
+ import tempfile
8
+ import unittest
9
+ from contextlib import redirect_stdout
10
+ from pathlib import Path
11
+
12
+ import check_chrome as CC
13
+ from config import TEMPLATE, load
14
+
15
+ CFG = load(TEMPLATE)
16
+ RULES = CC.rules(CFG)[0]
17
+ W, H = 1080, 1920
18
+
19
+
20
+ def block(text, x0, y0, x1, y1, font=60, **kw):
21
+ return {"text": text, "box": [x0 * W, y0 * H, x1 * W, y1 * H], "font_px": font, **kw}
22
+
23
+
24
+ def snap(*blocks, shapes=(), t=1.0):
25
+ return {"width": W, "height": H, "samples": [{"t": t, "blocks": list(blocks), "shapes": list(shapes)}]}
26
+
27
+
28
+ def lint(data, allow=()):
29
+ samples = CC.samples_from_snapshot(data)
30
+ return CC.lint(samples, RULES, set(allow))
31
+
32
+
33
+ def rules_hit(rep):
34
+ return {h["rule"] for h in rep["hits"]}
35
+
36
+
37
+ class TestPageChrome(unittest.TestCase):
38
+ def test_clean_reel_passes(self):
39
+ rep = lint(snap(block("O erro que custa caro", 0.1, 0.40, 0.9, 0.48, font=96, align="center"),
40
+ block("custa", 0.35, 0.70, 0.65, 0.74, font=72, role="caption")))
41
+ self.assertTrue(rep["passed"], rep["hits"])
42
+
43
+ def test_corner_brand_label_fails(self):
44
+ rep = lint(snap(block("WIZZ", 0.05, 0.03, 0.15, 0.05, font=30)))
45
+ self.assertIn("C1", rules_hit(rep))
46
+ self.assertFalse(rep["passed"])
47
+
48
+ def test_tracked_caps_label_fails(self):
49
+ rep = lint(snap(block("NOVO MÉTODO", 0.3, 0.30, 0.7, 0.32, font=36, caps=True, tracking_em=0.2)))
50
+ self.assertIn("C2", rules_hit(rep))
51
+
52
+ def test_counter_fails(self):
53
+ for text in ("02 / 05", "01 - THE WAIT", "Parte 3", "Step 2"):
54
+ with self.subTest(text=text):
55
+ rep = lint(snap(block(text, 0.4, 0.5, 0.6, 0.52, font=40)))
56
+ self.assertIn("C3", rules_hit(rep))
57
+
58
+ def test_twenty_four_seven_is_not_a_counter(self):
59
+ rep = lint(snap(block("24/7 suporte", 0.4, 0.5, 0.6, 0.52, font=40)))
60
+ self.assertNotIn("C3", rules_hit(rep))
61
+
62
+ def test_timecode_and_metadata_fail(self):
63
+ for text in ("00:00:12", "30 FPS", "1080x1920", "REC"):
64
+ with self.subTest(text=text):
65
+ rep = lint(snap(block(text, 0.4, 0.5, 0.6, 0.52, font=40)))
66
+ self.assertIn("C4", rules_hit(rep))
67
+
68
+ def test_progress_bar_growing_fails(self):
69
+ data = {"width": W, "height": H, "samples": [
70
+ {"t": t, "blocks": [], "shapes": [{"id": "bar", "kind": "bar", "box": [0, 10, w * W, 18]}]}
71
+ for t, w in ((1.0, 0.1), (2.0, 0.3), (3.0, 0.5))]}
72
+ rep = lint(data)
73
+ self.assertIn("P1", rules_hit(rep))
74
+
75
+ def test_header_bar_fails(self):
76
+ rep = lint(snap(shapes=[{"kind": "bar", "box": [0, 0, W, 0.06 * H], "filled": True}]))
77
+ self.assertIn("C6", rules_hit(rep))
78
+
79
+ def test_full_frame_background_is_not_a_bar(self):
80
+ rep = lint(snap(shapes=[{"kind": "bar", "box": [0, 0, W, H], "filled": True}]))
81
+ self.assertNotIn("C6", rules_hit(rep))
82
+
83
+ def test_kicker_over_headline_fails(self):
84
+ rep = lint(snap(block("o segredo", 0.1, 0.38, 0.4, 0.40, font=30),
85
+ block("Pare de perder dinheiro", 0.1, 0.41, 0.9, 0.47, font=90)))
86
+ self.assertIn("C7", rules_hit(rep))
87
+
88
+ def test_left_headline_beside_right_media_fails(self):
89
+ rep = lint(snap(block("Seu app", 0.05, 0.40, 0.45, 0.46, font=90, align="left"),
90
+ shapes=[{"kind": "media", "box": [0.55 * W, 0.30 * H, 0.95 * W, 0.70 * H]}]))
91
+ hits = [h for h in rep["hits"] if h["rule"] == "D1"]
92
+ self.assertTrue(hits)
93
+ self.assertEqual(hits[0]["sev"], "FAIL")
94
+
95
+ def test_left_headline_alone_only_warns(self):
96
+ rep = lint(snap(block("Seu app", 0.05, 0.40, 0.45, 0.46, font=90, align="left")))
97
+ hits = [h for h in rep["hits"] if h["rule"] == "D1"]
98
+ self.assertEqual([h["sev"] for h in hits], ["WARN"])
99
+ self.assertTrue(rep["passed"])
100
+
101
+
102
+ class TestExemptions(unittest.TestCase):
103
+ def test_caption_and_legal_roles_are_not_chrome(self):
104
+ rep = lint(snap(block("PARTE 2", 0.4, 0.5, 0.6, 0.52, font=40, role="caption"),
105
+ block("Resultados variam. CNPJ 00.000", 0.05, 0.95, 0.6, 0.97, font=24, role="legal")))
106
+ self.assertTrue(rep["passed"], rep["hits"])
107
+
108
+ def test_allow_list_from_a_measured_style(self):
109
+ data = {"width": W, "height": H, "samples": [
110
+ {"t": t, "blocks": [], "shapes": [{"id": "bar", "kind": "bar", "box": [0, 10, w * W, 18]}]}
111
+ for t, w in ((1.0, 0.1), (2.0, 0.3), (3.0, 0.5))]}
112
+ rep = lint(data, allow={"P1"})
113
+ self.assertTrue(rep["passed"])
114
+ self.assertEqual(rep["allowed"], ["P1"])
115
+
116
+ def test_text_in_flight_is_not_a_held_label(self):
117
+ # Same label at a different place 0.1 s later: it is moving through
118
+ # the corner, not parked there.
119
+ data = {"width": W, "height": H, "samples": [
120
+ {"t": 1.0, "blocks": [block("WIZZ", 0.05, 0.03, 0.15, 0.05, font=30)], "shapes": []},
121
+ {"t": 1.1, "blocks": [block("WIZZ", 0.25, 0.20, 0.35, 0.22, font=30)], "shapes": []}]}
122
+ self.assertNotIn("C1", rules_hit(lint(data)))
123
+
124
+
125
+ class TestTimeline(unittest.TestCase):
126
+ def test_remotion_text_layers_are_linted(self):
127
+ tl = {"duration": 10.0, "elements": [
128
+ {"at": 0.0, "until": 10.0, "kind": "text", "text": "02/05",
129
+ "box": [900, 40, 1040, 80], "font_px": 32},
130
+ {"at": 0.6, "kind": "text"}]} # an element without text is skipped
131
+ samples = CC.samples_from_timeline(tl, CFG.frame.width, CFG.frame.height, RULES)
132
+ rep = CC.lint(samples, RULES, set())
133
+ self.assertIn("C3", rules_hit(rep))
134
+ self.assertIn("C1", rules_hit(rep))
135
+
136
+ def test_timeline_progress_bar_from_box_end(self):
137
+ tl = {"duration": 10.0, "elements": [
138
+ {"at": 0.0, "until": 10.0, "kind": "bar", "box": [0, 1880, 100, 1890],
139
+ "box_end": [0, 1880, 1080, 1890]}]}
140
+ samples = CC.samples_from_timeline(tl, W, H, RULES)
141
+ self.assertIn("P1", rules_hit(CC.lint(samples, RULES, set())))
142
+
143
+ def test_flash_label_shorter_than_held_s_is_skipped(self):
144
+ tl = {"duration": 5.0, "elements": [
145
+ {"at": 1.0, "until": 1.1, "kind": "text", "text": "WIZZ",
146
+ "box": [40, 40, 200, 80], "font_px": 30}]}
147
+ samples = CC.samples_from_timeline(tl, W, H, RULES)
148
+ self.assertNotIn("C1", rules_hit(CC.lint(samples, RULES, set())))
149
+
150
+
151
+ class TestCli(unittest.TestCase):
152
+ def _run(self, args):
153
+ buf = io.StringIO()
154
+ with redirect_stdout(buf):
155
+ code = CC.main(["--config", str(TEMPLATE), *args])
156
+ return code, buf.getvalue()
157
+
158
+ def test_snapshot_fail_exits_1(self):
159
+ with tempfile.TemporaryDirectory() as tmp:
160
+ p = Path(tmp) / "layout.json"
161
+ p.write_text(json.dumps(snap(block("00:00:12", 0.4, 0.5, 0.6, 0.52, font=40))), encoding="utf-8")
162
+ code, out = self._run(["--snapshot", str(p)])
163
+ self.assertEqual(code, 1, out)
164
+ self.assertIn("C4", out)
165
+
166
+ def test_timeline_allow_key_is_read(self):
167
+ with tempfile.TemporaryDirectory() as tmp:
168
+ p = Path(tmp) / "t.json"
169
+ p.write_text(json.dumps({"duration": 5.0, "chrome_allow": ["C3"], "elements": [
170
+ {"at": 0.0, "until": 5.0, "kind": "text", "text": "Dia 3",
171
+ "box": [400, 900, 680, 960], "font_px": 40}]}), encoding="utf-8")
172
+ code, out = self._run(["--timeline", str(p)])
173
+ self.assertEqual(code, 0, out)
174
+
175
+ def test_missing_input_exits_2(self):
176
+ code, _ = self._run(["--snapshot", "/nonexistent.json"])
177
+ self.assertEqual(code, 2)
178
+
179
+
180
+ if __name__ == "__main__":
181
+ unittest.main()
@@ -0,0 +1,171 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Tests of check_dead_frames.py: flat-field runs (blocks), frozen picture
3
+ (warns), the motion note, and the config fallback to the template. The
4
+ ffmpeg round trip is skipped when ffmpeg is absent."""
5
+ import copy
6
+ import io
7
+ import json
8
+ import random
9
+ import shutil
10
+ import subprocess
11
+ import tempfile
12
+ import unittest
13
+ from contextlib import redirect_stdout
14
+ from pathlib import Path
15
+
16
+ import check_dead_frames as DF
17
+ from config import TEMPLATE, load
18
+
19
+ CFG = load(TEMPLATE)
20
+ RULES = DF.rules(CFG)[0]
21
+ W, H = RULES.width, RULES.height
22
+ SIZE = W * H
23
+
24
+
25
+ def flat(v: int = 0) -> bytes:
26
+ return bytes([v]) * SIZE
27
+
28
+
29
+ def textured(seed: int, amp: int = 60) -> bytes:
30
+ rnd = random.Random(seed)
31
+ return bytes(100 + rnd.randrange(amp) for _ in range(SIZE))
32
+
33
+
34
+ def jitter(base: bytes, seed: int, amp: int = 3) -> bytes:
35
+ """The same picture plus small per-pixel noise: a talking head that
36
+ barely moves (sensor noise, breathing, compression)."""
37
+ rnd = random.Random(seed)
38
+ return bytes(max(0, min(255, b + rnd.randrange(-amp, amp + 1))) for b in base)
39
+
40
+
41
+ class TestFrameMeasures(unittest.TestCase):
42
+ def test_uniform_frame_is_flat(self):
43
+ self.assertTrue(DF.is_flat(flat(0), RULES))
44
+ self.assertTrue(DF.is_flat(flat(200), RULES))
45
+
46
+ def test_textured_frame_is_not_flat(self):
47
+ self.assertFalse(DF.is_flat(textured(1), RULES))
48
+
49
+ def test_dark_frame_with_one_small_caption_is_not_flat(self):
50
+ # Range catches it even when std-dev is tiny: that frame belongs to
51
+ # the density gate, not to this one.
52
+ f = bytearray(flat(10))
53
+ f[SIZE // 2:SIZE // 2 + 6] = bytes([250]) * 6
54
+ self.assertFalse(DF.is_flat(bytes(f), RULES))
55
+
56
+ def test_mean_abs_diff(self):
57
+ self.assertEqual(DF.mean_abs_diff(flat(10), flat(10)), 0.0)
58
+ self.assertAlmostEqual(DF.mean_abs_diff(flat(10), flat(14)), 4.0)
59
+
60
+ def test_runs(self):
61
+ self.assertEqual(DF.runs([False, True, True, False, True]), [(1, 2), (4, 4)])
62
+ self.assertEqual(DF.runs([]), [])
63
+
64
+
65
+ class TestAnalyze(unittest.TestCase):
66
+ FPS = 30.0
67
+
68
+ def _rep(self, frames):
69
+ return DF.analyze(frames, self.FPS, RULES)
70
+
71
+ def test_short_black_dip_inside_a_transition_passes(self):
72
+ frames = [textured(i) for i in range(20)] + [flat()] * RULES.flat_max_frames \
73
+ + [textured(100 + i) for i in range(20)]
74
+ rep = self._rep(frames)
75
+ self.assertTrue(rep["passed"], rep)
76
+ self.assertEqual(rep["flat_fail"], [])
77
+ self.assertEqual(len(rep["flat_ignored"]), 1)
78
+
79
+ def test_black_gap_longer_than_the_limit_fails(self):
80
+ frames = [textured(i) for i in range(20)] + [flat()] * (RULES.flat_max_frames + 2) \
81
+ + [textured(100 + i) for i in range(20)]
82
+ rep = self._rep(frames)
83
+ self.assertFalse(rep["passed"])
84
+ (s, e), = rep["flat_fail"]
85
+ self.assertEqual((s, e), (20, 20 + RULES.flat_max_frames + 1))
86
+
87
+ def test_frozen_picture_warns_but_does_not_block(self):
88
+ still = textured(7)
89
+ n = int(self.FPS * (RULES.frozen_warn_s + 1.0))
90
+ frames = [textured(i) for i in range(10)] + [still] * n + [textured(50 + i) for i in range(10)]
91
+ rep = self._rep(frames)
92
+ self.assertTrue(rep["passed"])
93
+ self.assertEqual(len(rep["frozen_warn"]), 1)
94
+
95
+ def test_talking_head_micro_motion_is_not_frozen(self):
96
+ base = textured(3)
97
+ frames = [jitter(base, i) for i in range(int(self.FPS * 4))]
98
+ rep = self._rep(frames)
99
+ self.assertEqual(rep["frozen_warn"], [])
100
+ self.assertTrue(rep["passed"])
101
+
102
+ def test_motion_note_is_reported(self):
103
+ frames = [textured(i) for i in range(5)]
104
+ rep = self._rep(frames)
105
+ self.assertGreater(rep["motion_mean"], 0.0)
106
+
107
+ def test_no_frame_is_an_error(self):
108
+ with self.assertRaises(DF.ReadError):
109
+ self._rep([])
110
+
111
+
112
+ class TestConfigFallback(unittest.TestCase):
113
+ def test_config_without_block_uses_template(self):
114
+ raw = json.loads(TEMPLATE.read_text(encoding="utf-8"))
115
+ raw = {k: v for k, v in copy.deepcopy(raw).items() if k != "dead_frames"}
116
+ with tempfile.TemporaryDirectory() as tmp:
117
+ p = Path(tmp) / "effects.config.json"
118
+ p.write_text(json.dumps(raw), encoding="utf-8")
119
+ r, from_template = DF.rules(load(p))
120
+ self.assertTrue(from_template)
121
+ self.assertEqual(r.flat_max_frames, RULES.flat_max_frames)
122
+
123
+ def test_incoherent_block_is_refused(self):
124
+ raw = json.loads(TEMPLATE.read_text(encoding="utf-8"))
125
+ raw["dead_frames"]["flat_max_frames"] = -1
126
+ with tempfile.TemporaryDirectory() as tmp:
127
+ p = Path(tmp) / "effects.config.json"
128
+ p.write_text(json.dumps(raw), encoding="utf-8")
129
+ with self.assertRaises(DF.ConfigError):
130
+ DF.rules(load(p))
131
+
132
+
133
+ class TestCli(unittest.TestCase):
134
+ def test_missing_video_exits_2(self):
135
+ buf = io.StringIO()
136
+ with redirect_stdout(buf):
137
+ code = DF.main(["--config", str(TEMPLATE), "/nonexistent/final.mp4"])
138
+ self.assertEqual(code, 2)
139
+
140
+
141
+ @unittest.skipUnless(shutil.which("ffmpeg") and shutil.which("ffprobe"), "ffmpeg not installed")
142
+ class TestOnRealFile(unittest.TestCase):
143
+ def _make(self, tmp: str, black_s: float) -> str:
144
+ out = str(Path(tmp) / "piece.mp4")
145
+ src = ("testsrc2=size=270x480:rate=30:duration=1[a];"
146
+ f"color=black:size=270x480:rate=30:duration={black_s}[b];"
147
+ "testsrc2=size=270x480:rate=30:duration=1[c];[a][b][c]concat=n=3:v=1:a=0")
148
+ subprocess.run(["ffmpeg", "-v", "error", "-y", "-filter_complex", src,
149
+ "-pix_fmt", "yuv420p", out], check=True)
150
+ return out
151
+
152
+ def _run(self, video: str) -> tuple[int, str]:
153
+ buf = io.StringIO()
154
+ with redirect_stdout(buf):
155
+ code = DF.main(["--config", str(TEMPLATE), video])
156
+ return code, buf.getvalue()
157
+
158
+ def test_half_second_black_gap_fails(self):
159
+ with tempfile.TemporaryDirectory() as tmp:
160
+ code, out = self._run(self._make(tmp, 0.5))
161
+ self.assertEqual(code, 1, out)
162
+ self.assertIn("FLAT", out)
163
+
164
+ def test_one_frame_black_dip_passes(self):
165
+ with tempfile.TemporaryDirectory() as tmp:
166
+ code, out = self._run(self._make(tmp, 1 / 30))
167
+ self.assertEqual(code, 0, out)
168
+
169
+
170
+ if __name__ == "__main__":
171
+ unittest.main()
@@ -29,13 +29,14 @@ Scripts live in this skill's `scripts/`; run them from the project root (the his
29
29
  - B-roll or stock, a physical product, food, a place?
30
30
  - Niche, goal of the piece (follow, comment, save, click), duration.
31
31
  Never pick a style the material cannot feed. "Talking head" needs a person on camera; "motion editorial" needs nothing but a voice; "macro / ASMR" needs a physical product filmed close.
32
- 2. **Pick the style** with [references/style-picker.md](references/style-picker.md): material decides which styles are possible, niche and goal rank them. Mixed is allowed and common: person on camera for the hook and the CTA, another style in the middle. Before deciding, run `node scripts/references.mjs history` in the project: if the last two pieces used the same style, do not pick it a third time unless the user wants a series; choose another style the material allows, or keep it only for the hook and CTA.
33
- 3. **Open the style card** in `references/styles/` and follow it: rhythm, captions, palette, typography, motion, music, SFX, hook, CTA, and its "do not" list.
32
+ 2. **Pick the style** with [references/style-picker.md](references/style-picker.md): material decides which styles are possible, niche and goal rank them. Mixed is allowed and common: person on camera for the hook and the CTA, another style in the middle. Before deciding, run `node scripts/references.mjs history` in the project: if the last two pieces used the same style, do not pick it a third time unless the user wants a series; choose another style the material allows, or keep it only for the hook and CTA. Also run `node scripts/taste.mjs show`: what this user already approved or rejected in this project outranks the catalogue's ranking.
33
+ 3. **Open the style card** in `references/styles/` and follow it: rhythm, captions, palette, typography, motion, music, SFX, hook, CTA, and its "do not" list. Where the card is silent on how things move, use [references/motion-laws.md](references/motion-laws.md).
34
34
  4. **Get fresh references and watch two** before editing: `node scripts/references.mjs pick --style <key>` returns measured references plus, for styles generated in code, current viral videos from the awesome-opus5-5-videos list, skipping everything this project already used. Reading a card is not watching a video. For how those generated videos were briefed, see [references/generated-prompts.md](references/generated-prompts.md).
35
35
  5. **Audio:** music and SFX choices from [references/audio.md](references/audio.md); the redistributable kit is in `assets/audio/` (credits in `assets/audio/CREDITS.md`).
36
36
  6. **Hand over** to `wizz-video-effects` with the card's SFX ceiling and music level, then to Remotion.
37
37
  7. **Look at the final file**: extract frames and compare them side by side with a contact sheet of one reference.
38
38
  8. **Record the piece**: `node scripts/references.mjs record --style <key> --piece <slug> --refs <ids watched>`. It writes `_wizz-output/video-styles/historico.json`, which is what keeps the next piece from repeating the same references and look.
39
+ 9. **Record the user's verdict** after they review it (with two versions, offer `python3 scripts/blind_review.py prepare <dir> a.mp4 b.mp4` first), in their words: `node scripts/taste.mjs add --verdict approved|rejected --piece <slug> --style <key> --quote "..." --lesson "..."` (appends to `_wizz-output/video-styles/gosto.md`; the narrowest lesson the reaction supports).
39
40
 
40
41
  ## What the measurements say across every style
41
42
 
@@ -52,12 +53,15 @@ Details and the per-style table: [references/measured-patterns.md](references/me
52
53
  |---|---|
53
54
  | [references/style-picker.md](references/style-picker.md) | **start here.** Material and niche → which styles |
54
55
  | [references/styles/](references/styles/) | one card per style: recipe, references, do-not list |
56
+ | [references/motion-laws.md](references/motion-laws.md) | how things move when the card is silent: easing, scale, cuts, fades, springs, word cadence (measured on horizontal brand films) |
55
57
  | [references/measured-patterns.md](references/measured-patterns.md) | the numbers behind the cards, per style and per niche |
56
58
  | [references/audio.md](references/audio.md) | music and SFX per style, the kit, libraries and licences |
57
59
  | [references/research-method.md](references/research-method.md) | how the catalogue was measured, its limits, how to add a niche or a style |
58
60
  | `data/references.json` | every measured short: link, channel, views, niche, style, metrics |
59
61
  | [references/generated-prompts.md](references/generated-prompts.md) | prompts of viral code-generated videos, grouped by style, and how they run here |
60
62
  | `scripts/analyze.sh` | measures one short (cuts, music bed, SFX heuristics, loudness, contact sheet) |
63
+ | `scripts/blind_review.py` | blind side-by-side page for two or more versions, answer key hidden until the pick |
64
+ | `scripts/taste.mjs` | the user's approved and rejected verdicts for this project, read before choosing and appended after review |
61
65
  | `scripts/references.mjs` | fresh references per style without repeats, and the project's history of styles used |
62
66
  | `scripts/beat_grid.py` | BPM, beats and downbeats of a song, so cuts snap to the music (lyric video, kinetic typography) |
63
67
  | `assets/remotion/editorial/` | Remotion template of the motion editorial style |
@@ -0,0 +1,62 @@
1
+ # Motion laws: how things move inside a style
2
+
3
+ The style cards say **what** moves (captions, counters, cards, b-roll). This page says **how** it moves when the card is silent: durations, curves, scale, fades and word cadence.
4
+
5
+ ## Where these numbers come from (read first)
6
+
7
+ Every number here is a measurement by [motionmaxxing](https://github.com/Tejashmakwana/motionmaxxing) (Apache-2.0, credited in `THIRD-PARTY-NOTICES.md`): about 4,300 moves sampled frame by frame from 53 professional moments, plus whole films ranked by one human rater. Two limits follow from that sample:
8
+
9
+ - It is **horizontal** (16:9) **brand and product launch films**, mostly 3 to 7 s UI-forward moments. It is **not** vertical Reels, talking heads or captioned shorts. Nothing here was measured on the catalogue in `data/references.json`.
10
+ - Frames are counted at 30 fps (`f`). 1 f = 0.033 s, 6 f = 0.2 s, 10 f = 0.33 s, 15 f = 0.5 s.
11
+
12
+ So: **when a style card states a number, the card wins** (it was measured on vertical shorts). Use these laws for what the card leaves open, mostly in screens drawn in code (HyperFrames, the Remotion templates) and in motion graphics over footage.
13
+
14
+ ## The laws
15
+
16
+ ### 1. Land decelerates, leave accelerates
17
+
18
+ - An element that arrives eases **out**: most of its travel happens in the first 4 to 8 f, then a long tail, no bounce. 58% of measured landings ease out.
19
+ - An element that leaves eases **in** and exits at peak speed: 37% of leaving moves accelerate (42% of scale exits, 63% of blur exits). Not every exit: shrink-to-centre and moves swept by a camera may stay linear.
20
+ - Median move: **10 f** (0.33 s). Translate and scale entries about 12 f, opacity 6 f, blur 7 f; p25 to p75 roughly 6 to 24 f. A film where everything takes 20 to 30 f is slower than the whole sample.
21
+
22
+ ### 2. Nothing grows from zero
23
+
24
+ - An object with identity (logo, key word, card, cursor) **arrives from oversize and scales down** to rest: logos from about 3.5x, a cursor 2 to 5x, an opening word from about 0.39 of the frame height down to 0.09.
25
+ - Growth from small is the content only in a few cases: tiles in a 1-frame cascade, an object that becomes the next scene, an identity object that starts no smaller than about 0.3 to 0.6x.
26
+ - Spend scale once. Most type is small; the ending usually gets smaller. This is about arrival, not a licence for a giant cropped word in every beat.
27
+
28
+ ### 3. Cut on motion
29
+
30
+ - A cut lands mid-move, at peak speed, never on a settled frame. The next shot opens already moving.
31
+ - A piece ends in motion or on a designed stillness, not on a fade to black by default.
32
+ - In this pipeline the **instant** of a cut comes from a speech breath (`wizz-video-effects`, `fill_breaths.py`). The law adds the picture side: at that instant something should still be moving, so the cut does not land on a held frame.
33
+
34
+ ### 4. Almost nothing fades
35
+
36
+ - Elements arrive and leave by **moving** (offset, scale, blur decaying), by a hard 1-frame cut, or by unmasking. Measured: 3,392 transform entries against 584 opacity fades; exits 60% moves, 15% fades, 25% hard steps.
37
+ - Opacity is a 4 to 6 f helper under a move, not the move. A UI row or a word that should read as a state change hard-cuts on.
38
+ - Fades are for single objects, not between scenes; a slow fade is a calm-brand choice with a stated reason.
39
+
40
+ ### 5. One spring per film
41
+
42
+ - Overshoot is rare and late: 8.8% of moves overshoot by more than 3%, and 88% of those peak after 35% of the move (a slow lean past the mark and back), not a fast bounce.
43
+ - At most **one visible spring** per film, on the payoff that deserves a physical reward. A bounce on every pop is the most common generated-video tell.
44
+ - The Remotion templates use `spring()` for entries; keep their damping high enough that only one element per piece visibly bounces.
45
+
46
+ ### 6. Words move at speech rhythm
47
+
48
+ Whole words, never letters (letter-by-letter typing belongs only inside the product's own input field). Gaps between word onsets:
49
+
50
+ | Situation | Gap |
51
+ |---|---|
52
+ | Fast hook, inside a phrase | 2 to 4 f (0.07 to 0.12 s) |
53
+ | Standard headline | 4 to 6 f (0.12 to 0.20 s), tightening toward the end |
54
+ | Voice-led, thoughtful | 8 to 12 f (0.25 to 0.40 s), irregular |
55
+ | Before the payoff word | 1.5 to 2x the base gap, once |
56
+
57
+ With narration, captions follow the `wizz-ctc-align` timestamps (the voice is the clock) and on-screen words land 0 to 2 f before they are heard. The table is for type without a voice, or for building a headline word by word.
58
+
59
+ ## Related
60
+
61
+ - What breathes and what is dead on the final file: `wizz-video-effects`, `check_dead_frames.py` (no run of flat frames; frozen picture warns).
62
+ - Corner labels, counters, progress bars and slide layouts: `wizz-video-effects`, `check_chrome.py`.