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.
- package/THIRD-PARTY-NOTICES.md +18 -0
- package/package.json +4 -2
- package/skills-registry.yaml +41 -0
- package/src/bmm-skills/1-analysis/wizz-set-feature/SKILL.md +1 -1
- package/src/bmm-skills/4-implementation/wizz-code-review/SKILL.md +1 -1
- package/src/bmm-skills/4-implementation/wizz-create-story/SKILL.md +1 -1
- package/src/bmm-skills/4-implementation/wizz-dev-story/SKILL.md +8 -1
- package/src/bmm-skills/4-implementation/wizz-quick-dev/SKILL.md +2 -2
- package/src/bmm-skills/4-implementation/wizz-quick-dev/step-03-implement.md +2 -0
- package/src/bmm-skills/4-implementation/wizz-sprint-status/SKILL.md +1 -1
- package/src/core-skills/wizz-party-mode/customize.toml +3 -3
- package/src/modules/lowticket/skills/wizz-lowticket-funil/SKILL.md +1 -1
- package/src/modules/lowticket/skills/wizz-lowticket-guia/SKILL.md +2 -2
- package/src/modules/lowticket/skills/wizz-lowticket-metodologia/SKILL.md +5 -5
- package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/01-principios.md +1 -1
- package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/02-diagnostico-funil.md +1 -1
- package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/03-campanha-bidcap.md +1 -1
- package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/04-criativos.md +2 -2
- package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/04a-criativos-ia.md +6 -6
- package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/04b-criativos-formatos.md +1 -1
- package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/04c-criativos-roteiro.md +5 -5
- package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/05-oferta.md +1 -1
- package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/06-pagina-vendas.md +3 -1
- package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/06a-instagram.md +2 -2
- package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/06b-player-vsl.md +1 -1
- package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/07-funil-upsell.md +1 -1
- package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/07a-copy-humanizacao.md +1 -1
- package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/08-pesquisa-publico.md +1 -1
- package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/09-especialista-coproducao.md +1 -1
- package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/10-levantar-caixa.md +1 -1
- package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/11-checklist-roi.md +1 -1
- package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/12-mineracao-ofertas.md +1 -1
- package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/12a-mineracao-veredito.md +1 -1
- package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/12b-fabrica-de-ofertas.md +2 -2
- package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/12c-anatomia-low-ticket.md +3 -1
- package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/13-benchmarks-vsl.md +1 -1
- package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/14-vocabulario.md +1 -1
- package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/15-como-estender.md +2 -2
- package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/17-rastreamento.md +1 -1
- package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/18-copy-desejo-oferta.md +3 -3
- package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/INDEX.md +14 -12
- package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/guardrails.md +18 -70
- package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/pontos-em-aberto.md +9 -9
- package/src/modules/lowticket/skills/wizz-lowticket-minerador/SKILL.md +1 -1
- package/src/modules/lowticket/skills/wizz-lowticket-pagina/SKILL.md +1 -1
- package/src/modules/lowticket/skills/wizz-lowticket-trafego/SKILL.md +1 -1
- package/src/modules/lowticket/skills/wizz-lowticket-trafego/references/reguas.md +2 -0
- package/src/modules/wizz/_shared/cerebro.md +1 -1
- package/src/modules/wizz/_shared/organizacao.md +2 -2
- package/src/modules/wizz/agents/wizz-copy/SKILL.md +1 -0
- package/src/modules/wizz/agents/wizz-copy/customize.toml +5 -0
- package/src/modules/wizz/agents/wizz-copy/knowledge/playbook.md +2 -1
- package/src/modules/wizz/agents/wizz-growth/knowledge/playbook.md +1 -1
- package/src/modules/wizz/agents/wizz-social/knowledge/blueprint-roteiro-viral.md +14 -0
- package/src/modules/wizz/agents/wizz-social/knowledge/legenda-instagram.md +12 -0
- package/src/skills-lib/wizz-cerebro/SKILL.md +18 -5
- package/src/skills-lib/wizz-design-needs/SKILL.md +7 -0
- package/src/skills-lib/wizz-design-needs/references/design-state.md +34 -2
- package/src/skills-lib/wizz-design-needs/references/gap-analysis.md +1 -1
- package/src/skills-lib/wizz-design-needs/scripts/design_state.py +305 -0
- package/src/skills-lib/wizz-design-needs/scripts/tests/test_design_state.py +143 -0
- package/src/skills-lib/wizz-impeccable/references/design-rules.md +18 -0
- package/src/skills-lib/wizz-motion-3d-director/references/motion-paths.md +2 -0
- package/src/skills-lib/wizz-paid-ads/SKILL.md +2 -1
- package/src/skills-lib/wizz-premium-landing-ui-researcher/SKILL.md +6 -2
- package/src/skills-lib/wizz-premium-landing-ui-researcher/references/audit-protocol.md +7 -34
- package/src/skills-lib/wizz-premium-landing-ui-researcher/references/component-sources.md +2 -0
- package/src/skills-lib/wizz-premium-landing-ui-researcher/references/design-sources.yaml +252 -0
- package/src/skills-lib/wizz-premium-landing-ui-researcher/references/mandatory-process.md +1 -1
- package/src/skills-lib/wizz-premium-landing-ui-researcher/references/mining-rubric.md +123 -0
- package/src/skills-lib/wizz-premium-landing-ui-researcher/references/source-first-protocol.md +10 -4
- package/src/skills-lib/wizz-premium-landing-ui-researcher/references/web-access-and-evidence.md +1 -1
- package/src/skills-lib/wizz-premium-landing-ui-researcher/scripts/design_sources.py +221 -0
- package/src/skills-lib/wizz-premium-landing-ui-researcher/scripts/tests/test_design_sources.py +130 -0
- package/src/skills-lib/wizz-router/references/routing-table-flat.md +2 -0
- package/src/skills-lib/wizz-taste-skill/references/anti-slop-tells.md +8 -0
- package/src/skills-lib/wizz-ui-component-curator/SKILL.md +8 -7
- package/src/skills-lib/wizz-video-effects/LICENSE-motionmaxxing.txt +202 -0
- package/src/skills-lib/wizz-video-effects/SKILL.md +5 -2
- package/src/skills-lib/wizz-video-effects/assets/chrome/collect_layout.js +113 -0
- package/src/skills-lib/wizz-video-effects/assets/effects.config.template.json +35 -0
- package/src/skills-lib/wizz-video-effects/references/data-contract.md +30 -1
- package/src/skills-lib/wizz-video-effects/references/gates.md +3 -1
- package/src/skills-lib/wizz-video-effects/scripts/check_chrome.py +376 -0
- package/src/skills-lib/wizz-video-effects/scripts/check_dead_frames.py +193 -0
- package/src/skills-lib/wizz-video-effects/scripts/config.py +13 -0
- package/src/skills-lib/wizz-video-effects/scripts/test_chrome.py +181 -0
- package/src/skills-lib/wizz-video-effects/scripts/test_dead_frames.py +171 -0
- package/src/skills-lib/wizz-video-styles/SKILL.md +6 -2
- package/src/skills-lib/wizz-video-styles/references/motion-laws.md +62 -0
- package/src/skills-lib/wizz-video-styles/references/styles/desafio-numero.md +1 -0
- package/src/skills-lib/wizz-video-styles/scripts/blind_review.py +159 -0
- package/src/skills-lib/wizz-video-styles/scripts/taste.mjs +110 -0
- package/src/skills-lib/wizz-video-styles/scripts/test_blind_review.py +82 -0
- package/src/skills-lib/wizz-vsl/LICENSE +49 -0
- package/src/skills-lib/wizz-vsl/SKILL.md +122 -0
- package/src/skills-lib/wizz-vsl/references/assembly-qa.md +123 -0
- package/src/skills-lib/wizz-vsl/references/blocks-and-leads.md +149 -0
- package/src/skills-lib/wizz-vsl/references/diagnosis.md +95 -0
- package/src/skills-lib/wizz-vsl/references/intake.md +200 -0
- package/src/skills-lib/wizz-vsl/references/mechanisms.md +138 -0
- package/src/skills-lib/wizz-vsl/references/niches.md +40 -0
- package/src/skills-lib/wizz-vsl/references/offer.md +91 -0
- package/src/skills-lib/wizz-vsl/references/production.md +75 -0
- package/tools/hooks/README.md +10 -9
- package/tools/hooks/wizz-arrumar.js +1 -0
- package/tools/hooks/wizz-artifact-guard.js +31 -5
- package/tools/hooks/wizz-cerebro-lib.js +36 -13
- package/tools/hooks/wizz-feature-lib.js +4 -1
- package/tools/hooks/wizz-feature.js +66 -76
- package/tools/hooks/wizz-sprint-archive-lib.js +311 -0
- package/tools/installer/core/hooks-settings.js +1 -0
- package/tools/installer/core/installer.js +6 -0
- package/tools/installer/modules/core-seed.js +72 -0
- package/tools/installer/modules/mcp-config.js +3 -0
- package/tools/installer/ui.js +16 -26
|
@@ -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`.
|