wizz-method 2.21.0 → 2.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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-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-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,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`.
|
|
@@ -27,6 +27,7 @@ Food, money, products, everyday economics. The highest single reach in the set (
|
|
|
27
27
|
**Look**
|
|
28
28
|
|
|
29
29
|
- A visible progress device fixed on screen: time icons (1 s / 1 min / 1 h) changing colour per stage, emoji checklist, price tags on each ingredient.
|
|
30
|
+
It is measured for this style, so the chrome lint of `wizz-video-effects` takes `chrome_allow: ["P1", "C3"]` in the timeline (progress and stage counter).
|
|
30
31
|
- Close hands, real places (market, street), a reaction at the end.
|
|
31
32
|
|
|
32
33
|
**Palette:** Warm natural colours; tags in bright yellow with black outline; red/green icons for stages.
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""Blind A/B review of two or more versions of a piece.
|
|
3
|
+
|
|
4
|
+
python3 blind_review.py prepare OUT_DIR a.mp4 b.mp4 [c.mp4 ...]
|
|
5
|
+
python3 blind_review.py reveal OUT_DIR
|
|
6
|
+
|
|
7
|
+
prepare: links (or copies) each film to OUT_DIR/A.mp4, B.mp4 ... in a random
|
|
8
|
+
order, writes OUT_DIR/index.html (the films side by side at phone size, one
|
|
9
|
+
play button for all, sound per film) and the answer key in
|
|
10
|
+
OUT_DIR/.answer-key.json (mode 600). Running it again on the same folder and
|
|
11
|
+
films keeps the earlier shuffle, so a verdict already given stays valid.
|
|
12
|
+
reveal: prints which letter is which file. Only after the user picked.
|
|
13
|
+
|
|
14
|
+
Do not open the key or compare file sizes before judging: both give the
|
|
15
|
+
films away. Judge the picture silent first, then each soundtrack alone. The
|
|
16
|
+
user's pick goes to taste.mjs add.
|
|
17
|
+
|
|
18
|
+
Idea from motionmaxxing's blind_review.py (Apache-2.0,
|
|
19
|
+
github.com/Tejashmakwana/motionmaxxing); see THIRD-PARTY-NOTICES.md.
|
|
20
|
+
|
|
21
|
+
Exit code: 0 ok, 2 bad usage (fewer than 2 films, missing file, no key).
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import argparse
|
|
26
|
+
import html
|
|
27
|
+
import json
|
|
28
|
+
import os
|
|
29
|
+
import secrets
|
|
30
|
+
import shutil
|
|
31
|
+
import string
|
|
32
|
+
import sys
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
|
|
35
|
+
KEY_NAME = ".answer-key.json"
|
|
36
|
+
|
|
37
|
+
PAGE = """<!doctype html>
|
|
38
|
+
<html lang="en">
|
|
39
|
+
<head>
|
|
40
|
+
<meta charset="utf-8">
|
|
41
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
42
|
+
<title>Blind review</title>
|
|
43
|
+
<style>
|
|
44
|
+
:root {{ color-scheme: light dark; --bg: #f6f5f2; --ink: #1b1b1b; }}
|
|
45
|
+
@media (prefers-color-scheme: dark) {{ :root {{ --bg: #141414; --ink: #ececec; }} }}
|
|
46
|
+
body {{ margin: 0; padding: 16px; background: var(--bg); color: var(--ink); font: 16px/1.4 system-ui, sans-serif; }}
|
|
47
|
+
.row {{ display: flex; gap: 16px; flex-wrap: wrap; justify-content: center; }}
|
|
48
|
+
figure {{ margin: 0; text-align: center; }}
|
|
49
|
+
video {{ width: min(42vw, 360px); aspect-ratio: 9 / 16; background: #000; }}
|
|
50
|
+
button {{ font: inherit; padding: 6px 14px; margin: 4px; }}
|
|
51
|
+
p {{ max-width: 720px; margin: 8px auto 16px; text-align: center; }}
|
|
52
|
+
</style>
|
|
53
|
+
</head>
|
|
54
|
+
<body>
|
|
55
|
+
<p>Watch silent first, then each sound alone. Pick before revealing anything.</p>
|
|
56
|
+
<p><button id="play">Play all</button><button id="restart">Restart</button></p>
|
|
57
|
+
<div class="row">
|
|
58
|
+
{figures}
|
|
59
|
+
</div>
|
|
60
|
+
<script>
|
|
61
|
+
const vids = [...document.querySelectorAll("video")];
|
|
62
|
+
document.getElementById("play").onclick = () => {{
|
|
63
|
+
const go = vids[0].paused;
|
|
64
|
+
vids.forEach((v) => (go ? v.play() : v.pause()));
|
|
65
|
+
}};
|
|
66
|
+
document.getElementById("restart").onclick = () => vids.forEach((v) => {{ v.currentTime = 0; }});
|
|
67
|
+
document.querySelectorAll("[data-solo]").forEach((b) => {{
|
|
68
|
+
b.onclick = () => vids.forEach((v) => {{ v.muted = v.id !== b.dataset.solo; }});
|
|
69
|
+
}});
|
|
70
|
+
</script>
|
|
71
|
+
</body>
|
|
72
|
+
</html>
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
FIGURE = """<figure>
|
|
76
|
+
<video id="{letter}" src="{letter}.mp4" muted playsinline preload="auto"></video>
|
|
77
|
+
<figcaption><strong>{letter}</strong> <button data-solo="{letter}">Sound of {letter} only</button></figcaption>
|
|
78
|
+
</figure>"""
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def assign(films: list[str]) -> dict[str, str]:
|
|
82
|
+
"""Letter -> film, in a random order from the OS random source."""
|
|
83
|
+
pool = list(films)
|
|
84
|
+
order = []
|
|
85
|
+
while pool:
|
|
86
|
+
order.append(pool.pop(secrets.randbelow(len(pool))))
|
|
87
|
+
return dict(zip(string.ascii_uppercase, order))
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _place(src: str, dst: Path) -> None:
|
|
91
|
+
if dst.exists():
|
|
92
|
+
dst.unlink()
|
|
93
|
+
try:
|
|
94
|
+
os.link(src, dst)
|
|
95
|
+
except OSError:
|
|
96
|
+
shutil.copyfile(src, dst)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def prepare(out: Path, films: list[str]) -> int:
|
|
100
|
+
films = [os.path.abspath(f) for f in films]
|
|
101
|
+
if len(films) < 2 or len(set(films)) != len(films):
|
|
102
|
+
print("ERROR: give at least two different films")
|
|
103
|
+
return 2
|
|
104
|
+
missing = [f for f in films if not os.path.isfile(f)]
|
|
105
|
+
if missing:
|
|
106
|
+
print(f"ERROR: not found: {', '.join(missing)}")
|
|
107
|
+
return 2
|
|
108
|
+
if len(films) > len(string.ascii_uppercase):
|
|
109
|
+
print("ERROR: too many films")
|
|
110
|
+
return 2
|
|
111
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
112
|
+
key_file = out / KEY_NAME
|
|
113
|
+
key = None
|
|
114
|
+
if key_file.exists():
|
|
115
|
+
old = json.loads(key_file.read_text(encoding="utf-8"))
|
|
116
|
+
if sorted(old.values()) == sorted(films):
|
|
117
|
+
key = old
|
|
118
|
+
key = key or assign(films)
|
|
119
|
+
for letter, film in key.items():
|
|
120
|
+
_place(film, out / f"{letter}.mp4")
|
|
121
|
+
key_file.write_text(json.dumps(key, indent=1) + "\n", encoding="utf-8")
|
|
122
|
+
os.chmod(key_file, 0o600)
|
|
123
|
+
figures = "\n".join(FIGURE.format(letter=html.escape(letter)) for letter in key)
|
|
124
|
+
(out / "index.html").write_text(PAGE.format(figures=figures), encoding="utf-8")
|
|
125
|
+
print(f"open {out / 'index.html'} ({len(key)} films, letters {', '.join(key)})")
|
|
126
|
+
print(f"after the pick: python3 blind_review.py reveal {out}")
|
|
127
|
+
return 0
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def reveal(out: Path) -> int:
|
|
131
|
+
key_file = out / KEY_NAME
|
|
132
|
+
if not key_file.exists():
|
|
133
|
+
print(f"ERROR: no answer key in {out}")
|
|
134
|
+
return 2
|
|
135
|
+
for letter, film in json.loads(key_file.read_text(encoding="utf-8")).items():
|
|
136
|
+
print(f"{letter}: {film}")
|
|
137
|
+
return 0
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def main(argv: list[str] | None = None) -> int:
|
|
141
|
+
ap = argparse.ArgumentParser(description="Blind A/B review page for versions of a piece.")
|
|
142
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
143
|
+
p = sub.add_parser("prepare")
|
|
144
|
+
p.add_argument("out")
|
|
145
|
+
p.add_argument("films", nargs="+")
|
|
146
|
+
r = sub.add_parser("reveal")
|
|
147
|
+
r.add_argument("out")
|
|
148
|
+
args = ap.parse_args(argv)
|
|
149
|
+
try:
|
|
150
|
+
if args.cmd == "prepare":
|
|
151
|
+
return prepare(Path(args.out), args.films)
|
|
152
|
+
return reveal(Path(args.out))
|
|
153
|
+
except (OSError, json.JSONDecodeError) as e:
|
|
154
|
+
print(f"ERROR: {e}")
|
|
155
|
+
return 2
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
if __name__ == "__main__":
|
|
159
|
+
sys.exit(main())
|