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
|
@@ -39,6 +39,7 @@ Requires Python 3 (stdlib only) and ffmpeg/ffprobe.
|
|
|
39
39
|
|
|
40
40
|
## Per piece
|
|
41
41
|
|
|
42
|
+
0. **Taste.** Read `_wizz-output/video-styles/gosto.md` if it exists: what this user rejected is refused like an unknown event. After their review, append with `taste.mjs add` (`wizz-video-styles`).
|
|
42
43
|
1. **Timeline.** Write the piece's timeline JSON ([references/data-contract.md](references/data-contract.md)): `words[]` from wizz-ctc-align, `scenes[]`, `elements[]`, and the safe-zone profile (`ad` or `organic`).
|
|
43
44
|
2. **Events to sounds.** List what happens in the narration as semantic events with their times, from the words: a price appears, a verdict lands, a message arrives. Map them with `res = sound.map_requests([(5.4, "verdict"), (7.1, "element_in")], cfg)` from `scripts/sound.py`; it returns the shots (file and `vol` from the config) plus the refusals, with the reason. Write the accepted shots into `sfx[]` with their `event`. Events that trigger an effect (a `verdict` triggers `shake`): pass `sound.effect_candidates(res)` through `budget.select(...)` from `scripts/budget.py` and write the accepted ones into `shakes[]`; no other step writes them.
|
|
44
45
|
3. **Effects on breaths.** `python3 scripts/fill_breaths.py TIMELINE --narration NARRATION.mp3 --visual LAYOUT_RENDER.mp4 --write` anchors punches and flashes on real speech breaths, runs every candidate through the budget, and sets scene transitions and cut sounds by speech pace. Without `--write` it only reports.
|
|
@@ -46,9 +47,9 @@ Requires Python 3 (stdlib only) and ffmpeg/ffprobe.
|
|
|
46
47
|
- `--visual` must be a silent render **in the piece's final layout**. It decides punch vs flash by how close content sits to the safe-zone edge; measured on full-screen footage, every breath turns into a flash. Content inside a clipped card can take a punch.
|
|
47
48
|
- Cut sounds are added here, after step 2: if the report lists sounds that would no longer be accepted together, map everything again. `check_excess.py` checks all sounds together and blocks on collisions.
|
|
48
49
|
4. **Ducking.** `python3 scripts/sound.py duck TIMELINE --write` writes `ducking[]`; the Remotion volume callback is in the data contract.
|
|
49
|
-
5. **Before rendering.** `python3 scripts/check_excess.py TIMELINE` (blocks)
|
|
50
|
+
5. **Before rendering.** `python3 scripts/check_excess.py TIMELINE` (blocks), `python3 scripts/check_rhythm.py TIMELINE` (warns) and `python3 scripts/check_chrome.py --timeline TIMELINE` (blocks the slide look; for HyperFrames screens use `--snapshot` from `assets/chrome/collect_layout.js`).
|
|
50
51
|
6. **Render** in Remotion from the timeline: SFX at their `vol`, narration with the ducking callback. `assets/remotion/` holds a reference `EffectsLayer.tsx` that consumes the timeline (copy it into the project). For the Remotion API itself, invoke `wizz-remotion-best-practices`.
|
|
51
|
-
7. **Gate the final MP4.** Footage (an actor take, b-roll) edge to edge fails the safe zone by construction: the gate measures contrast, not text. Put footage in a frame inside the zone over a plain or darkened background. `check_safe_zone.py FINAL.mp4 --timeline TIMELINE` (the piece's profile; run with `--profile` for the other one if the piece also runs there), `measure_density.py FINAL.mp4 --timeline TIMELINE`, `check_loudness.py FINAL.mp4` (prints the ffmpeg command that fixes it when it fails, and warns when that fix will compress; a quiet narration is better raised before the render).
|
|
52
|
+
7. **Gate the final MP4.** Footage (an actor take, b-roll) edge to edge fails the safe zone by construction: the gate measures contrast, not text. Put footage in a frame inside the zone over a plain or darkened background. `check_safe_zone.py FINAL.mp4 --timeline TIMELINE` (the piece's profile; run with `--profile` for the other one if the piece also runs there), `measure_density.py FINAL.mp4 --timeline TIMELINE`, `check_dead_frames.py FINAL.mp4`, `check_loudness.py FINAL.mp4` (prints the ffmpeg command that fixes it when it fails, and warns when that fix will compress; a quiet narration is better raised before the render).
|
|
52
53
|
8. **Look.** Extract frames of the final file, including one of the CTA block, and read them.
|
|
53
54
|
|
|
54
55
|
Every script takes `--config PATH`; without it, `VIDEO_EFFECTS_CONFIG`, then `./effects.config.json`.
|
|
@@ -60,7 +61,9 @@ Every script takes `--config PATH`; without it, `VIDEO_EFFECTS_CONFIG`, then `./
|
|
|
60
61
|
| `check_excess.py` | yes (unless `--warn`) | an effect that always happens stops marking anything |
|
|
61
62
|
| `check_safe_zone.py` | yes | text under the platform UI is text nobody reads |
|
|
62
63
|
| `measure_density.py` | yes | an empty frame loses the scroll |
|
|
64
|
+
| `check_dead_frames.py` | yes on blank runs, warns on frozen picture | a blank gap reads as a broken video |
|
|
63
65
|
| `check_loudness.py` | yes | platforms normalise to about -14 LUFS; louder only distorts |
|
|
66
|
+
| `check_chrome.py` | yes | a corner label or a counter makes an ad read as a slide |
|
|
64
67
|
| `check_rhythm.py` | **warns only** | its numbers come from weak sources |
|
|
65
68
|
|
|
66
69
|
Full table, what each gate cannot see, and escape hatches: [references/gates.md](references/gates.md).
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// collect_layout.js: layout snapshot of an HTML composition for check_chrome.py.
|
|
2
|
+
//
|
|
3
|
+
// Paste the whole file as the expression of a browser eval on the opened
|
|
4
|
+
// composition (HyperFrames preview, a plain HTML film, or Remotion Studio on
|
|
5
|
+
// the composition), e.g. with agent-browser:
|
|
6
|
+
// agent-browser open file:///.../index.html
|
|
7
|
+
// agent-browser eval "$(cat collect_layout.js)" > layout.json
|
|
8
|
+
// It seeks each sample time, waits two frames, and returns
|
|
9
|
+
// {width, height, samples: [{t, blocks, shapes}]} as JSON text.
|
|
10
|
+
//
|
|
11
|
+
// Times: window.__chromeTimes if set (array of seconds), else 12 evenly
|
|
12
|
+
// spaced plus each one again 0.1 s later (the pair tells a parked label from
|
|
13
|
+
// one flying through). Seek: window.__seek(t), else the paused GSAP
|
|
14
|
+
// timelines in window.__timelines (HyperFrames), else gsap.globalTimeline.
|
|
15
|
+
// Remotion Studio has none of these: set window.__seek yourself, or take one
|
|
16
|
+
// snapshot per frame with ?frame=N and merge.
|
|
17
|
+
//
|
|
18
|
+
// Roles: an element (or ancestor) with data-role="caption|legal|ui" is
|
|
19
|
+
// written with that role, so the lint skips it. Text in <canvas>, <img>,
|
|
20
|
+
// <video> or WebGL is invisible here.
|
|
21
|
+
//
|
|
22
|
+
// Rules ported from motionmaxxing (Apache-2.0); see THIRD-PARTY-NOTICES.md.
|
|
23
|
+
(async () => {
|
|
24
|
+
const root = document.querySelector('[data-composition-id="main"]') || document.body;
|
|
25
|
+
const rect = root.getBoundingClientRect();
|
|
26
|
+
const W = Number(root.dataset?.width) || rect.width || window.innerWidth;
|
|
27
|
+
const H = Number(root.dataset?.height) || rect.height || window.innerHeight;
|
|
28
|
+
const sx = W / (rect.width || W);
|
|
29
|
+
const sy = H / (rect.height || H);
|
|
30
|
+
const tls = window.__timelines && typeof window.__timelines === 'object' ? Object.values(window.__timelines) : [];
|
|
31
|
+
const duration =
|
|
32
|
+
Number(root.dataset?.duration) ||
|
|
33
|
+
(tls[0] && typeof tls[0].duration === 'function' ? tls[0].duration() : 0) ||
|
|
34
|
+
(window.gsap ? window.gsap.globalTimeline.duration() : 0);
|
|
35
|
+
|
|
36
|
+
const seek = async (t) => {
|
|
37
|
+
if (typeof window.__seek === 'function') await window.__seek(t);
|
|
38
|
+
else if (tls.length > 0) for (const tl of tls) tl.seek(t, false);
|
|
39
|
+
else if (window.gsap) window.gsap.globalTimeline.seek(t, false);
|
|
40
|
+
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const box = (r) => [
|
|
44
|
+
Math.round((r.left - rect.left) * sx),
|
|
45
|
+
Math.round((r.top - rect.top) * sy),
|
|
46
|
+
Math.round((r.right - rect.left) * sx),
|
|
47
|
+
Math.round((r.bottom - rect.top) * sy),
|
|
48
|
+
];
|
|
49
|
+
const opacity = (el) => {
|
|
50
|
+
let o = 1;
|
|
51
|
+
for (let e = el; e && e !== document.documentElement; e = e.parentElement) o *= Number(getComputedStyle(e).opacity);
|
|
52
|
+
return o;
|
|
53
|
+
};
|
|
54
|
+
const roleOf = (el) => el.closest('[data-role]')?.dataset.role || '';
|
|
55
|
+
const visible = (el, r) => r.width >= 1 && r.height >= 1 && getComputedStyle(el).visibility !== 'hidden' && opacity(el) >= 0.05;
|
|
56
|
+
|
|
57
|
+
const textBlocks = () => {
|
|
58
|
+
const out = [];
|
|
59
|
+
for (const el of root.querySelectorAll('*')) {
|
|
60
|
+
const own = [...el.childNodes].filter((n) => n.nodeType === 3 && n.textContent.trim());
|
|
61
|
+
if (own.length === 0) continue;
|
|
62
|
+
const range = document.createRange();
|
|
63
|
+
range.selectNodeContents(el);
|
|
64
|
+
const r = range.getBoundingClientRect();
|
|
65
|
+
if (!visible(el, r)) continue;
|
|
66
|
+
const cs = getComputedStyle(el);
|
|
67
|
+
const fontPx = Number.parseFloat(cs.fontSize) * sy;
|
|
68
|
+
const spacing = cs.letterSpacing === 'normal' ? 0 : Number.parseFloat(cs.letterSpacing) / Number.parseFloat(cs.fontSize);
|
|
69
|
+
const lineH = Number.parseFloat(cs.lineHeight) || Number.parseFloat(cs.fontSize) * 1.2;
|
|
70
|
+
out.push({
|
|
71
|
+
text: el.textContent.trim().replaceAll(/\s+/g, ' ').slice(0, 120),
|
|
72
|
+
box: box(r),
|
|
73
|
+
font_px: Math.round(fontPx),
|
|
74
|
+
caps: cs.textTransform === 'uppercase',
|
|
75
|
+
tracking_em: Math.round(spacing * 100) / 100,
|
|
76
|
+
align: cs.textAlign,
|
|
77
|
+
lines: Math.max(1, Math.round(r.height / lineH)),
|
|
78
|
+
role: roleOf(el),
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
return out;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const shapes = () => {
|
|
85
|
+
const out = [];
|
|
86
|
+
let i = 0;
|
|
87
|
+
for (const el of root.querySelectorAll('*')) {
|
|
88
|
+
i += 1;
|
|
89
|
+
const r = el.getBoundingClientRect();
|
|
90
|
+
if (!visible(el, r)) continue;
|
|
91
|
+
const tag = el.tagName.toLowerCase();
|
|
92
|
+
const cs = getComputedStyle(el);
|
|
93
|
+
const media = /^(img|video|canvas|svg|picture)$/.test(tag) || /url\(/.test(cs.backgroundImage);
|
|
94
|
+
const filled = !/rgba\(0, 0, 0, 0\)|transparent/.test(cs.backgroundColor);
|
|
95
|
+
const border = Number.parseFloat(cs.borderTopWidth) >= 1 || Number.parseFloat(cs.borderBottomWidth) >= 1;
|
|
96
|
+
if (!media && !filled && !border) continue;
|
|
97
|
+
const kind = media ? 'media' : roleOf(el) === 'ui' ? 'surface' : 'bar';
|
|
98
|
+
out.push({ id: el.id || `${tag}-${i}`, kind, box: box(r) });
|
|
99
|
+
}
|
|
100
|
+
return out;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const base = Array.isArray(window.__chromeTimes)
|
|
104
|
+
? window.__chromeTimes
|
|
105
|
+
: Array.from({ length: 12 }, (_, k) => (duration * (k + 0.5)) / 12);
|
|
106
|
+
const times = Array.isArray(window.__chromeTimes) ? base : base.flatMap((t) => [t, t + 0.1]);
|
|
107
|
+
const samples = [];
|
|
108
|
+
for (const t of times) {
|
|
109
|
+
await seek(t);
|
|
110
|
+
samples.push({ t: Math.round(t * 1000) / 1000, blocks: textBlocks(), shapes: shapes() });
|
|
111
|
+
}
|
|
112
|
+
return JSON.stringify({ width: W, height: H, duration, samples });
|
|
113
|
+
})();
|
|
@@ -57,6 +57,41 @@
|
|
|
57
57
|
"_source": "min_filled 3 of 7 is a house rule of the source project, not an external benchmark."
|
|
58
58
|
},
|
|
59
59
|
|
|
60
|
+
"dead_frames": {
|
|
61
|
+
"analysis_width": 54,
|
|
62
|
+
"analysis_height": 96,
|
|
63
|
+
"flat_std": 1.5,
|
|
64
|
+
"flat_range": 8,
|
|
65
|
+
"flat_max_frames": 3,
|
|
66
|
+
"frozen_diff": 0.35,
|
|
67
|
+
"frozen_warn_s": 1.5,
|
|
68
|
+
"motion_band_reference": [4.4, 6.8, 9.8],
|
|
69
|
+
"_note": "check_dead_frames.py on the FINAL MP4. A flat frame is one uniform field (luma std-dev under flat_std AND range under flat_range on the analysis frame); a run of more than flat_max_frames blocks. A frozen frame differs from the previous one by less than frozen_diff (mean absolute luma, 0-255); a frozen run longer than frozen_warn_s only warns.",
|
|
70
|
+
"_source": "flat_std, flat_range and flat_max_frames are gate G2 of motionmaxxing (Apache-2.0, github.com/Tejashmakwana/motionmaxxing), measured on horizontal pro launch films at 96x54; the analysis frame here keeps the same pixel count in 9:16. frozen_diff is their hold epsilon; frozen_warn_s is the top of their 'designed breath' (0.6-1.5 s). motion_band_reference is their Q1, median and Q3 of mean frame-to-frame luma change over human-made horizontal brand films: printed as a question, never a gate, and never measured on vertical Reels."
|
|
71
|
+
},
|
|
72
|
+
|
|
73
|
+
"chrome_lint": {
|
|
74
|
+
"small_text": 0.04,
|
|
75
|
+
"corner_x": 0.2,
|
|
76
|
+
"corner_y": 0.14,
|
|
77
|
+
"edge_band": 0.07,
|
|
78
|
+
"caps_tracking_em": 0.05,
|
|
79
|
+
"caps_max_size": 0.045,
|
|
80
|
+
"counter_max_size": 0.06,
|
|
81
|
+
"headline_min_size": 0.05,
|
|
82
|
+
"kicker_ratio": 2.0,
|
|
83
|
+
"kicker_gap": 0.06,
|
|
84
|
+
"deck_left_x": 0.2,
|
|
85
|
+
"bar_min_width": 0.6,
|
|
86
|
+
"bar_max_height": 0.14,
|
|
87
|
+
"bar_band": 0.12,
|
|
88
|
+
"progress_min_growth": 0.08,
|
|
89
|
+
"held_s": 0.3,
|
|
90
|
+
"exempt_roles": ["caption", "legal", "ui"],
|
|
91
|
+
"_note": "check_chrome.py. Positions are fractions of the frame (0-1). Text sizes (font size) are fractions of the SHORT side of the frame (1080 px in 9:16), so they keep the pixel size of the horizontal source. held_s: a timeline text layer shorter than this is in flight, not a held label.",
|
|
92
|
+
"_source": "Rules C1-C4, C6, C7, D1 and P1 of motionmaxxing's lint.mjs (gate G5, Apache-2.0, github.com/Tejashmakwana/motionmaxxing), built from one user's verdicts on horizontal launch films. exempt_roles is a house decision: captions, legally required ad text and product UI are not chrome."
|
|
93
|
+
},
|
|
94
|
+
|
|
60
95
|
"budget": {
|
|
61
96
|
"_readme": "The anti-excess engine. An effect only enters if its curve section still has budget, it respects the minimum distance and it does not repeat too much. When two candidates collide, the higher narrative strength wins.",
|
|
62
97
|
"gap_min_global": 2.0,
|
|
@@ -18,7 +18,7 @@ Numbers that decide anything (gaps, ceilings, depths) are never in the timeline:
|
|
|
18
18
|
| `shakes[]` | list | same | planner | short shakes (`budget.per_effect.shake.max_dur_s`) |
|
|
19
19
|
| `flashes[]` | list | same | planner, `fill_breaths.py` | opacity flashes |
|
|
20
20
|
| `interrupts[]` | list | same | planner | pattern interrupts |
|
|
21
|
-
| `elements[]` | list | `check_rhythm.py` | planner | element entries (cards, labels) that count as visual change |
|
|
21
|
+
| `elements[]` | list | `check_rhythm.py`, `check_chrome.py` | planner | element entries (cards, labels) that count as visual change |
|
|
22
22
|
| `ducking[]` | list | renderer | `sound.py duck --write` | narration duck windows |
|
|
23
23
|
|
|
24
24
|
### `words[]` (ctc-align output, unchanged)
|
|
@@ -64,6 +64,20 @@ Each item is a number (the instant) or an object with `at`:
|
|
|
64
64
|
"elements": [{ "at": 0.6, "kind": "text" }]
|
|
65
65
|
```
|
|
66
66
|
|
|
67
|
+
Text layers and bars that `check_chrome.py` should read carry their geometry (pixels of the final frame) and stay on screen until `until` (default: the end):
|
|
68
|
+
|
|
69
|
+
```json
|
|
70
|
+
"elements": [
|
|
71
|
+
{ "at": 0.6, "until": 4.0, "kind": "text", "text": "Pare de perder dinheiro", "box": [100, 760, 980, 860], "font_px": 96, "align": "center" },
|
|
72
|
+
{ "at": 1.0, "until": 3.0, "kind": "text", "text": "custa", "box": [400, 1300, 680, 1380], "font_px": 72, "role": "caption" },
|
|
73
|
+
{ "at": 0.0, "kind": "bar", "box": [0, 1880, 100, 1890], "box_end": [0, 1880, 1080, 1890] }
|
|
74
|
+
]
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
- `role`: `caption`, `legal` or `ui` (`chrome_lint.exempt_roles`) is not chrome. Optional `caps`, `tracking_em` (letter-spacing in em), `lines`.
|
|
78
|
+
- `box_end`: the bar's box at `until`; wider than `box` means a growing bar.
|
|
79
|
+
- Top-level `chrome_allow`: rules the chosen style measured on real videos (e.g. `["P1"]`), printed as allowed.
|
|
80
|
+
|
|
67
81
|
### `ducking[]`
|
|
68
82
|
|
|
69
83
|
Written by `python3 scripts/sound.py duck TIMELINE --write`. One window per group of SFX that sound over the **start of a spoken word**; nothing else ducks.
|
|
@@ -132,6 +146,19 @@ export const narrationVolume = (ducking: Duck[], fps: number, startFrame = 0) =>
|
|
|
132
146
|
}
|
|
133
147
|
```
|
|
134
148
|
|
|
149
|
+
## Layout snapshot (`check_chrome.py --snapshot`)
|
|
150
|
+
|
|
151
|
+
Written by `assets/chrome/collect_layout.js` from the opened HTML composition (instructions at the top of that file); not part of the timeline:
|
|
152
|
+
|
|
153
|
+
```json
|
|
154
|
+
{ "width": 1080, "height": 1920, "samples": [
|
|
155
|
+
{ "t": 1.0,
|
|
156
|
+
"blocks": [{ "text": "02 / 05", "box": [948, 40, 1040, 75], "font_px": 30, "caps": false, "tracking_em": 0, "align": "start", "lines": 1, "role": "" }],
|
|
157
|
+
"shapes": [{ "id": "bar", "kind": "bar", "box": [0, 1880, 1080, 1888] }] } ] }
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
`kind` is `bar` (any filled or bordered box), `media` (img, video, canvas, svg) or `surface` (inside `data-role="ui"`). Samples come in pairs 0.1 s apart, so a label flying through a corner is not taken for a parked one.
|
|
161
|
+
|
|
135
162
|
## Which script touches what
|
|
136
163
|
|
|
137
164
|
| Script | Reads | Writes | Exit codes |
|
|
@@ -140,6 +167,8 @@ export const narrationVolume = (ducking: Duck[], fps: number, startFrame = 0) =>
|
|
|
140
167
|
| `sound.py duck TIMELINE [--write]` | `sfx`, `scenes[].sfx`, `words`, `fps` | `ducking` | 0, 2 |
|
|
141
168
|
| `check_excess.py TIMELINE... [--warn]` | effects, `scenes`, `duration` | nothing | 0 pass, 1 fail, 2 error |
|
|
142
169
|
| `check_rhythm.py TIMELINE...` | `scenes`, effects, `elements`, `duration` | nothing | always 0 (warn only) |
|
|
170
|
+
| `check_chrome.py --timeline T \| --snapshot LAYOUT [--allow R]` | `elements[]` text layers and bars, `chrome_allow` (or the snapshot) | nothing | 0, 1, 2 |
|
|
143
171
|
| `check_safe_zone.py MP4 [--timeline T]` | `safe_zone_profile` | nothing | 0, 1, 2 |
|
|
144
172
|
| `measure_density.py MP4 [--timeline T]` | `safe_zone_profile` | nothing | 0, 1, 2 |
|
|
173
|
+
| `check_dead_frames.py MP4` | the final file only | nothing | 0, 1, 2 |
|
|
145
174
|
| `check_loudness.py MP4` | the final file only | nothing | 0, 1, 2 |
|
|
@@ -13,11 +13,13 @@ Thresholds are cited by config key. Every script exits `0` pass, `1` fail, `2` i
|
|
|
13
13
|
| `normalize_audio.py --check` | SFX folder | every file at `sound.reference_level_db`, peak under `sound.max_peak_db` | yes | silent tails: it measures the whole file, so trim first |
|
|
14
14
|
| `check_excess.py` | timeline JSON | every sound together (`sfx[]` and scene cut sounds) against the three refusals: unknown event, collision, same sound in a row; per effect kind: ceiling per minute, smallest gap, longest run in a row (`budget.per_effect.*`); dominant effects of different kinds closer than `budget.gap_min_global`; always reports the densest 10 s cluster | yes (`--warn` only while calibrating) | effects drawn inside the screens themselves (HyperFrames timelines); only what the timeline declares |
|
|
15
15
|
| `check_rhythm.py` | timeline JSON | longest stretch with no visual change (`rhythm.max_static_s`), cut cadence in the hook (`rhythm.hook_s`, `rhythm.hook_cut_every_s`) and body (`rhythm.body_cut_every_s`), CTA tail excluded (`rhythm.cta_tail_s`). SFX never count as change | **no, warns only** | motion inside a screen; its numbers come from weak sources ([editing-patterns.md](editing-patterns.md)) |
|
|
16
|
+
| `check_chrome.py` | timeline text layers (`--timeline`) or a DOM snapshot of the screens (`--snapshot`, from `assets/chrome/collect_layout.js`) | page chrome and slide look, thresholds in `chrome_lint.*`: C1 small label parked in a corner or edge band, C2 small tracked caps, C3 counter ("02 / 05", "Parte 3"), C4 timecode or metadata, C6 wide bar or hairline in the top or bottom band, C7 kicker right above a headline, D1 left headline block with media on the right, a sub-line or 2+ lines (alone: warn), P1 thin bar that grows. Roles in `chrome_lint.exempt_roles` (caption, legal, ui) are skipped; a rule the style measured goes in `chrome_allow` or `--allow` and is printed | yes | text in canvas, images, video or burned into footage; Remotion text not declared in `elements[]`; whether the flagged thing is still the right call. Rules come from one user's verdicts on horizontal launch films (motionmaxxing G5) |
|
|
16
17
|
| `check_safe_zone.py` | **final MP4** | per row, local contrast (std-dev, `safe_zone_gate.dp_min`) on every frame; the highest and lowest content rows must sit inside the piece's profile (`safe_zones.<profile>` top and bottom) | yes | whether what invades is text, a card or background glow; left and right edges. Full-screen footage fails by construction: frame it inside the zone. On a fail, extract the frame and look |
|
|
17
18
|
| `measure_density.py` | **final MP4** | how many of `density.n_bands` bands across the active safe zone have content (`density.dp_min`), sampled every `density.step_s`; fails under `density.min_filled` | yes | whether the content makes sense there: a band full of noise counts like a band full of information |
|
|
19
|
+
| `check_dead_frames.py` | **final MP4**, every frame | runs of flat frames (one uniform field: `dead_frames.flat_std` and `dead_frames.flat_range` on a small luma copy); more than `dead_frames.flat_max_frames` in a row fails. Frozen picture (frame difference under `dead_frames.frozen_diff`) longer than `dead_frames.frozen_warn_s` only warns. Prints the mean motion against `dead_frames.motion_band_reference` as a question | yes for flat runs; frozen is a warning | a talking head is never flat and almost never frozen (noise, breathing, compression), so a dead shot on camera passes; grain or a looping background keeps a dead slide "moving"; a dark frame with one small line of text is not flat (that is density). Thresholds come from horizontal launch films (motionmaxxing G2), not from vertical Reels |
|
|
18
20
|
| `check_loudness.py` | **final MP4 or audio** | integrated loudness and true peak (ffmpeg `loudnorm`) against `loudness.*`; prints the two-pass fix and warns when the fix will switch loudnorm to dynamic compression | yes | the balance inside the mix (voice vs SFX); that is the level contract and the ear |
|
|
19
21
|
|
|
20
|
-
Order that matches the pipeline: config and coverage once per project; excess and
|
|
22
|
+
Order that matches the pipeline: config and coverage once per project; excess, rhythm and chrome on the timeline before the expensive render; safe zone, density, dead frames and loudness on the final file.
|
|
21
23
|
|
|
22
24
|
## Two profiles, measured separately
|
|
23
25
|
|
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""PAGE-CHROME gate: does the piece look like a slide or a HUD?
|
|
3
|
+
|
|
4
|
+
Reads what text and shapes are on screen, never the pixels, from one of two
|
|
5
|
+
sources:
|
|
6
|
+
|
|
7
|
+
--snapshot LAYOUT.json a layout snapshot of an HTML composition
|
|
8
|
+
(HyperFrames, or Remotion Studio, which is also a DOM), written by
|
|
9
|
+
assets/chrome/collect_layout.js at a few seek times.
|
|
10
|
+
--timeline TIMELINE the Remotion timeline: the text layers declared in
|
|
11
|
+
elements[] (`text`, `box`, `font_px`, `role`, `until`).
|
|
12
|
+
|
|
13
|
+
Rules (thresholds in the config block chrome_lint):
|
|
14
|
+
C1 corner/edge label small text parked in a corner or an edge band FAIL
|
|
15
|
+
C2 tracked-caps label small uppercase text with wide letter-spacing FAIL
|
|
16
|
+
C3 counter "02 / 05", "01 - THE WAIT", "Parte 3", bare "01" FAIL
|
|
17
|
+
C4 production metadata timecode, FPS, BPM, resolution, REC, version FAIL
|
|
18
|
+
C6 header/footer bar a wide bar or hairline in the top or bottom band FAIL
|
|
19
|
+
C7 kicker over headline a small line right above a headline 2x its size FAIL
|
|
20
|
+
D1 deck layout left-anchored headline with media on the right,
|
|
21
|
+
a sub-line or 2+ lines: FAIL; alone: WARN
|
|
22
|
+
P1 progress bar a thin bar that grows over time FAIL
|
|
23
|
+
|
|
24
|
+
Not chrome: text whose `role` is in chrome_lint.exempt_roles (captions,
|
|
25
|
+
legally required ad text, product UI). A rule the chosen style measured on
|
|
26
|
+
real videos (the progress device of `desafio-numero`) is allowed with
|
|
27
|
+
`--allow P1` or `chrome_allow: ["P1"]` in the timeline; allowed rules are
|
|
28
|
+
printed, never silent.
|
|
29
|
+
|
|
30
|
+
What it cannot see: text drawn into a <canvas>, an image, a video or burned
|
|
31
|
+
into footage; text a Remotion component draws without declaring it in the
|
|
32
|
+
timeline; whether a flagged element is still the right call. A PASS is not
|
|
33
|
+
taste: look at the frames.
|
|
34
|
+
|
|
35
|
+
Rules ported from gate G5 of motionmaxxing's lint.mjs (Apache-2.0,
|
|
36
|
+
github.com/Tejashmakwana/motionmaxxing), rewritten for 9:16; see
|
|
37
|
+
THIRD-PARTY-NOTICES.md.
|
|
38
|
+
|
|
39
|
+
Exit code: 0 pass (warnings allowed), 1 fail, 2 input or config unreadable.
|
|
40
|
+
"""
|
|
41
|
+
from __future__ import annotations
|
|
42
|
+
|
|
43
|
+
import argparse
|
|
44
|
+
import json
|
|
45
|
+
import re
|
|
46
|
+
import sys
|
|
47
|
+
from dataclasses import dataclass
|
|
48
|
+
from pathlib import Path
|
|
49
|
+
|
|
50
|
+
import timeline as tl
|
|
51
|
+
from config import Config, ConfigError, add_config_arg, load, optional_block
|
|
52
|
+
|
|
53
|
+
BLOCK = "chrome_lint"
|
|
54
|
+
SEVERITY = {"C1": "FAIL", "C2": "FAIL", "C3": "FAIL", "C4": "FAIL", "C6": "FAIL",
|
|
55
|
+
"C7": "FAIL", "D1": "FAIL", "P1": "FAIL"}
|
|
56
|
+
NAMES = {"C1": "corner/edge label", "C2": "tracked-caps label", "C3": "counter",
|
|
57
|
+
"C4": "production metadata", "C6": "header/footer bar", "C7": "kicker over headline",
|
|
58
|
+
"D1": "deck layout (left headline block)", "P1": "progress bar"}
|
|
59
|
+
|
|
60
|
+
RX_COUNTER_NUM = re.compile(r"^\s*0?\d{1,2}\s*(?:[/·|]|-(?=\s*[A-Za-z]{3,}))\s*(?:0?\d{1,2}\b|[A-Za-zÀ-ú]{3,})")
|
|
61
|
+
RX_COUNTER_WORD = re.compile(
|
|
62
|
+
r"\b(chapter|scene|act|step|part|episode|shot|take|beat|"
|
|
63
|
+
r"cap[ií]tulo|cena|ato|passo|parte|etapa|epis[oó]dio)\s*0?\d", re.I)
|
|
64
|
+
RX_COUNTER_BARE = re.compile(r"^\s*0\d\s*$")
|
|
65
|
+
RX_META = re.compile(
|
|
66
|
+
r"\bBPM\b|\bFPS\b|\bFRAME\s*\d|\b(?:1920|3840|1280|1080|720)\s*[x×]\s*(?:1080|2160|720|1920|1280)\b"
|
|
67
|
+
r"|\bT\s*\+\s*\d|\bTC\b|\b\d{1,2}:\d{2}:\d{2}(?:[.:]\d+)?\b|\b\d{1,2}:\d{2}\.\d{1,3}\b|\bREC\b")
|
|
68
|
+
RX_VERSION = re.compile(r"\bv\d+\.\d+(?:\.\d+)?\b|©", re.I)
|
|
69
|
+
RX_24_7 = re.compile(r"^\s*24\s*/\s*7\b")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True)
|
|
73
|
+
class ChromeRules:
|
|
74
|
+
small_text: float
|
|
75
|
+
corner_x: float
|
|
76
|
+
corner_y: float
|
|
77
|
+
edge_band: float
|
|
78
|
+
caps_tracking_em: float
|
|
79
|
+
caps_max_size: float
|
|
80
|
+
counter_max_size: float
|
|
81
|
+
headline_min_size: float
|
|
82
|
+
kicker_ratio: float
|
|
83
|
+
kicker_gap: float
|
|
84
|
+
deck_left_x: float
|
|
85
|
+
bar_min_width: float
|
|
86
|
+
bar_max_height: float
|
|
87
|
+
bar_band: float
|
|
88
|
+
progress_min_growth: float
|
|
89
|
+
held_s: float
|
|
90
|
+
exempt_roles: frozenset
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def rules(cfg: Config) -> tuple[ChromeRules, bool]:
|
|
94
|
+
b, from_template = optional_block(cfg, BLOCK)
|
|
95
|
+
try:
|
|
96
|
+
nums = {k: float(b[k]) for k in ChromeRules.__dataclass_fields__ if k != "exempt_roles"}
|
|
97
|
+
r = ChromeRules(**nums, exempt_roles=frozenset(str(x) for x in b["exempt_roles"]))
|
|
98
|
+
except (KeyError, TypeError, ValueError) as e:
|
|
99
|
+
raise ConfigError(f"{BLOCK}: missing or invalid key ({e})") from e
|
|
100
|
+
bad = [k for k, v in nums.items() if v <= 0]
|
|
101
|
+
if bad:
|
|
102
|
+
raise ConfigError(f"{BLOCK}: must be > 0: {', '.join(bad)}")
|
|
103
|
+
return r, from_template
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# ------------------------------------------------------------------ inputs
|
|
107
|
+
|
|
108
|
+
@dataclass(frozen=True)
|
|
109
|
+
class Block:
|
|
110
|
+
text: str
|
|
111
|
+
x0: float
|
|
112
|
+
y0: float
|
|
113
|
+
x1: float
|
|
114
|
+
y1: float
|
|
115
|
+
size: float # font size / short side of the frame
|
|
116
|
+
caps: bool
|
|
117
|
+
tracking: float # letter-spacing in em
|
|
118
|
+
role: str
|
|
119
|
+
align: str
|
|
120
|
+
lines: int
|
|
121
|
+
|
|
122
|
+
@property
|
|
123
|
+
def cx(self) -> float:
|
|
124
|
+
return (self.x0 + self.x1) / 2
|
|
125
|
+
|
|
126
|
+
@property
|
|
127
|
+
def cy(self) -> float:
|
|
128
|
+
return (self.y0 + self.y1) / 2
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@dataclass(frozen=True)
|
|
132
|
+
class Shape:
|
|
133
|
+
sid: str
|
|
134
|
+
kind: str # bar | rule | media | surface
|
|
135
|
+
x0: float
|
|
136
|
+
y0: float
|
|
137
|
+
x1: float
|
|
138
|
+
y1: float
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _norm_box(box, w: float, h: float) -> tuple[float, float, float, float]:
|
|
142
|
+
x0, y0, x1, y1 = (float(v) for v in box)
|
|
143
|
+
return x0 / w, y0 / h, x1 / w, y1 / h
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _block(d: dict, w: float, h: float) -> Block | None:
|
|
147
|
+
text = str(d.get("text") or "").strip()
|
|
148
|
+
if not text or "box" not in d:
|
|
149
|
+
return None
|
|
150
|
+
x0, y0, x1, y1 = _norm_box(d["box"], w, h)
|
|
151
|
+
caps = bool(d.get("caps")) or (text.upper() == text and any(c.isalpha() for c in text))
|
|
152
|
+
return Block(text, x0, y0, x1, y1, float(d.get("font_px", 0)) / min(w, h), caps,
|
|
153
|
+
float(d.get("tracking_em", 0.0)), str(d.get("role") or ""),
|
|
154
|
+
str(d.get("align") or ""), int(d.get("lines", 1)))
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _shape(d: dict, i: int, w: float, h: float) -> Shape:
|
|
158
|
+
x0, y0, x1, y1 = _norm_box(d["box"], w, h)
|
|
159
|
+
return Shape(str(d.get("id", i)), str(d.get("kind", "bar")), x0, y0, x1, y1)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def samples_from_snapshot(data: dict) -> list[dict]:
|
|
163
|
+
"""[{t, blocks: [Block], shapes: [Shape]}] from a collect_layout.js file."""
|
|
164
|
+
w, h = float(data["width"]), float(data["height"])
|
|
165
|
+
out = []
|
|
166
|
+
for s in data.get("samples") or []:
|
|
167
|
+
blocks = [b for b in (_block(x, w, h) for x in s.get("blocks") or []) if b]
|
|
168
|
+
shapes = [_shape(x, i, w, h) for i, x in enumerate(s.get("shapes") or []) if "box" in x]
|
|
169
|
+
out.append({"t": float(s["t"]), "blocks": blocks, "shapes": shapes})
|
|
170
|
+
return sorted(out, key=lambda s: s["t"])
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def samples_from_timeline(data: dict, w: int, h: int, r: ChromeRules) -> list[dict]:
|
|
174
|
+
"""One sample per layer start, with every declared layer visible then.
|
|
175
|
+
A layer shorter than held_s is in flight and is skipped; a bar with a
|
|
176
|
+
`box_end` wider than its `box` is a growing bar (P1)."""
|
|
177
|
+
total, _ = tl.duration(data)
|
|
178
|
+
layers = []
|
|
179
|
+
for i, e in enumerate(data.get("elements") or []):
|
|
180
|
+
if not isinstance(e, dict) or "box" not in e:
|
|
181
|
+
continue
|
|
182
|
+
start = float(e.get("at", 0.0))
|
|
183
|
+
end = float(e.get("until", total))
|
|
184
|
+
if end - start < r.held_s:
|
|
185
|
+
continue
|
|
186
|
+
layers.append((start, end, i, e))
|
|
187
|
+
out = []
|
|
188
|
+
for t in sorted({start for start, _, _, _ in layers}):
|
|
189
|
+
blocks, shapes = [], []
|
|
190
|
+
for start, end, i, e in layers:
|
|
191
|
+
if not start <= t < end:
|
|
192
|
+
continue
|
|
193
|
+
if e.get("text"):
|
|
194
|
+
b = _block(e, w, h)
|
|
195
|
+
if b:
|
|
196
|
+
blocks.append(b)
|
|
197
|
+
elif e.get("kind") in ("bar", "rule", "media", "surface"):
|
|
198
|
+
shapes.append(_shape(e, i, w, h))
|
|
199
|
+
if e.get("box_end"):
|
|
200
|
+
shapes.append(_shape({**e, "box": e["box_end"]}, i, w, h))
|
|
201
|
+
out.append({"t": t, "blocks": blocks, "shapes": shapes, "timeline": True})
|
|
202
|
+
return out
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
# ------------------------------------------------------------------- rules
|
|
206
|
+
|
|
207
|
+
def _held(b: Block, t: float, samples: list[dict], r: ChromeRules) -> bool:
|
|
208
|
+
"""Parked, not flying through: the same text at the same place in the
|
|
209
|
+
neighbouring samples within held_s. No neighbour at all: held."""
|
|
210
|
+
near = [s for s in samples if s["t"] != t and abs(s["t"] - t) <= r.held_s]
|
|
211
|
+
if not near:
|
|
212
|
+
return True
|
|
213
|
+
return any(c.text == b.text and abs(c.x0 - b.x0) < 0.006 and abs(c.y0 - b.y0) < 0.006
|
|
214
|
+
for s in near for c in s["blocks"])
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _text_rules(b: Block, r: ChromeRules) -> list[str]:
|
|
218
|
+
hits = []
|
|
219
|
+
small = b.size <= r.small_text
|
|
220
|
+
corner = (b.cx < r.corner_x or b.cx > 1 - r.corner_x) and (b.cy < r.corner_y or b.cy > 1 - r.corner_y)
|
|
221
|
+
edge = b.y1 < r.edge_band or b.y0 > 1 - r.edge_band or b.x1 < r.edge_band / 2 or b.x0 > 1 - r.edge_band / 2
|
|
222
|
+
if small and (corner or edge):
|
|
223
|
+
hits.append("C1")
|
|
224
|
+
letters = sum(1 for c in b.text if c.isalpha())
|
|
225
|
+
if b.caps and b.tracking >= r.caps_tracking_em and b.size <= r.caps_max_size and letters >= 2:
|
|
226
|
+
hits.append("C2")
|
|
227
|
+
counter = RX_COUNTER_NUM.search(b.text) or RX_COUNTER_WORD.search(b.text) or RX_COUNTER_BARE.search(b.text)
|
|
228
|
+
if b.size <= r.counter_max_size and counter and not RX_24_7.search(b.text):
|
|
229
|
+
hits.append("C3")
|
|
230
|
+
if (RX_META.search(b.text) and b.size <= r.counter_max_size) or (RX_VERSION.search(b.text) and small):
|
|
231
|
+
hits.append("C4")
|
|
232
|
+
return hits
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _kickers(free: list[Block], r: ChromeRules) -> dict[int, Block]:
|
|
236
|
+
out = {}
|
|
237
|
+
for a in free:
|
|
238
|
+
for i, b in enumerate(free):
|
|
239
|
+
if a is b or a.size > r.small_text or b.size < max(a.size * r.kicker_ratio, r.headline_min_size):
|
|
240
|
+
continue
|
|
241
|
+
gap = b.y0 - a.y1
|
|
242
|
+
aligned = abs(a.x0 - b.x0) < 0.02 or abs(a.cx - b.cx) < 0.02
|
|
243
|
+
if -0.01 < gap < r.kicker_gap and aligned:
|
|
244
|
+
out[i] = a
|
|
245
|
+
return out
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _deck(free: list[Block], shapes: list[Shape], kickers: dict[int, Block], r: ChromeRules):
|
|
249
|
+
biggest = max((b.size for b in free), default=0.0)
|
|
250
|
+
for i, b in enumerate(free):
|
|
251
|
+
left = 0.02 <= b.x0 < r.deck_left_x and b.x1 < 0.78
|
|
252
|
+
if not (b.size >= r.headline_min_size and b.size >= 0.85 * biggest and left
|
|
253
|
+
and b.align != "center" and abs(b.cx - 0.5) > 0.06):
|
|
254
|
+
continue
|
|
255
|
+
why = []
|
|
256
|
+
if any(c is not b and 0.02 <= c.size <= 0.6 * b.size and -0.005 < c.y0 - b.y1 < 0.08
|
|
257
|
+
and abs(c.x0 - b.x0) < 0.03 for c in free):
|
|
258
|
+
why.append("sub-line")
|
|
259
|
+
if i in kickers:
|
|
260
|
+
why.append("kicker")
|
|
261
|
+
if any(s.kind in ("media", "surface") and 0.04 <= (s.x1 - s.x0) * (s.y1 - s.y0) <= 0.7
|
|
262
|
+
and (s.x0 + s.x1) / 2 > 0.5 and s.x0 > b.x1 - 0.05 and s.y1 > b.y0 and s.y0 < b.y1 + 0.2
|
|
263
|
+
for s in shapes):
|
|
264
|
+
why.append("media on the right")
|
|
265
|
+
if b.lines >= 2:
|
|
266
|
+
why.append(f"{b.lines} lines")
|
|
267
|
+
yield b, why
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _bars(shapes: list[Shape], r: ChromeRules) -> list[Shape]:
|
|
271
|
+
out = []
|
|
272
|
+
for s in shapes:
|
|
273
|
+
if s.kind in ("media", "surface"):
|
|
274
|
+
continue
|
|
275
|
+
w, h, cy = s.x1 - s.x0, s.y1 - s.y0, (s.y0 + s.y1) / 2
|
|
276
|
+
if w >= r.bar_min_width and h <= r.bar_max_height and (cy < r.bar_band or cy > 1 - r.bar_band):
|
|
277
|
+
out.append(s)
|
|
278
|
+
return out
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _progress(samples: list[dict], r: ChromeRules) -> list[tuple[str, float, float]]:
|
|
282
|
+
"""(id, first width, last width) of thin bars that grow over time."""
|
|
283
|
+
widths: dict[str, list[float]] = {}
|
|
284
|
+
for s in samples:
|
|
285
|
+
for sh in s["shapes"]:
|
|
286
|
+
if sh.kind not in ("media", "surface") and sh.y1 - sh.y0 <= 0.02:
|
|
287
|
+
widths.setdefault(sh.sid, []).append(sh.x1 - sh.x0)
|
|
288
|
+
out = []
|
|
289
|
+
for sid, ws in widths.items():
|
|
290
|
+
steps = [b - a for a, b in zip(ws, ws[1:])]
|
|
291
|
+
grows = ws[-1] - ws[0] >= r.progress_min_growth and all(d >= -0.005 for d in steps)
|
|
292
|
+
if len(ws) >= 2 and grows:
|
|
293
|
+
out.append((sid, ws[0], ws[-1]))
|
|
294
|
+
return out
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def lint(samples: list[dict], r: ChromeRules, allow: set[str]) -> dict:
|
|
298
|
+
hits: dict[tuple, dict] = {}
|
|
299
|
+
|
|
300
|
+
def add(rule: str, t: float, what: str, sev: str | None = None):
|
|
301
|
+
key = (rule, what)
|
|
302
|
+
h = hits.setdefault(key, {"rule": rule, "what": what, "times": [], "sev": sev or SEVERITY[rule]})
|
|
303
|
+
if t not in h["times"]:
|
|
304
|
+
h["times"].append(t)
|
|
305
|
+
|
|
306
|
+
for s in samples:
|
|
307
|
+
t = s["t"]
|
|
308
|
+
free = [b for b in s["blocks"] if b.role not in r.exempt_roles and _held(b, t, samples, r)]
|
|
309
|
+
for b in free:
|
|
310
|
+
for rule in _text_rules(b, r):
|
|
311
|
+
add(rule, t, b.text)
|
|
312
|
+
kickers = _kickers(free, r)
|
|
313
|
+
for i, a in kickers.items():
|
|
314
|
+
add("C7", t, f"{a.text!r} over {free[i].text!r}")
|
|
315
|
+
for b, why in _deck(free, s["shapes"], kickers, r):
|
|
316
|
+
add("D1", t, f"{b.text!r}" + (f" with {', '.join(why)}" if why else " alone"),
|
|
317
|
+
"FAIL" if why else "WARN")
|
|
318
|
+
for sh in _bars(s["shapes"], r):
|
|
319
|
+
add("C6", t, f"{sh.kind} {sh.sid} at y={sh.y0:.2f}-{sh.y1:.2f}")
|
|
320
|
+
for sid, w0, w1 in _progress(samples, r):
|
|
321
|
+
add("P1", samples[0]["t"], f"bar {sid} grows {w0:.2f} -> {w1:.2f} of the width")
|
|
322
|
+
used = sorted({h["rule"] for h in hits.values()} & allow)
|
|
323
|
+
kept = [h for h in hits.values() if h["rule"] not in allow]
|
|
324
|
+
kept.sort(key=lambda h: (h["rule"], h["times"][0]))
|
|
325
|
+
return {"hits": kept, "allowed": used, "passed": not any(h["sev"] == "FAIL" for h in kept)}
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
# --------------------------------------------------------------------- CLI
|
|
329
|
+
|
|
330
|
+
def report(rep: dict, source: str, n_samples: int, from_template: bool) -> int:
|
|
331
|
+
print(f"{source}: {n_samples} sample(s)")
|
|
332
|
+
if from_template:
|
|
333
|
+
print(f" note: no '{BLOCK}' block in the project config; using the template's")
|
|
334
|
+
for h in rep["hits"]:
|
|
335
|
+
times = ", ".join(f"{t:.2f}s" for t in sorted(h["times"])[:6])
|
|
336
|
+
print(f" {h['sev']:4} {h['rule']} {NAMES[h['rule']]}: {h['what']} [{times}]")
|
|
337
|
+
for rule in rep["allowed"]:
|
|
338
|
+
print(f" allowed {rule} {NAMES[rule]} (the style measured it)")
|
|
339
|
+
print(f"GATE chrome: {'PASS' if rep['passed'] else 'FAIL'}")
|
|
340
|
+
return 0 if rep["passed"] else 1
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def main(argv: list[str] | None = None) -> int:
|
|
344
|
+
ap = argparse.ArgumentParser(description="Page-chrome / slide-look lint on a layout snapshot or a timeline.")
|
|
345
|
+
add_config_arg(ap)
|
|
346
|
+
src = ap.add_mutually_exclusive_group(required=True)
|
|
347
|
+
src.add_argument("--snapshot", help="layout JSON written by assets/chrome/collect_layout.js")
|
|
348
|
+
src.add_argument("--timeline", help="timeline JSON with text layers in elements[]")
|
|
349
|
+
ap.add_argument("--allow", default="", help="comma-separated rules the chosen style measured (e.g. P1)")
|
|
350
|
+
args = ap.parse_args(argv)
|
|
351
|
+
try:
|
|
352
|
+
cfg = load(args.config)
|
|
353
|
+
r, from_template = rules(cfg)
|
|
354
|
+
allow = {a.strip() for a in args.allow.split(",") if a.strip()}
|
|
355
|
+
if args.snapshot:
|
|
356
|
+
p = Path(args.snapshot)
|
|
357
|
+
if not p.exists():
|
|
358
|
+
raise tl.TimelineError(f"snapshot not found: {p}")
|
|
359
|
+
samples = samples_from_snapshot(json.loads(p.read_text(encoding="utf-8")))
|
|
360
|
+
source = p.name
|
|
361
|
+
else:
|
|
362
|
+
data = tl.load(args.timeline)
|
|
363
|
+
allow |= set(data.get("chrome_allow") or [])
|
|
364
|
+
samples = samples_from_timeline(data, cfg.frame.width, cfg.frame.height, r)
|
|
365
|
+
source = Path(args.timeline).name
|
|
366
|
+
unknown = allow - set(SEVERITY)
|
|
367
|
+
if unknown:
|
|
368
|
+
raise ConfigError(f"unknown rule(s) in allow: {', '.join(sorted(unknown))}")
|
|
369
|
+
return report(lint(samples, r, allow), source, len(samples), from_template)
|
|
370
|
+
except (ConfigError, tl.TimelineError, json.JSONDecodeError, KeyError, ValueError, OSError) as e:
|
|
371
|
+
print(f"ERROR: {e}")
|
|
372
|
+
return 2
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
if __name__ == "__main__":
|
|
376
|
+
sys.exit(main())
|