wizz-method 2.11.1 → 2.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/package.json +1 -1
  2. package/skills-registry.yaml +19 -5
  3. package/src/modules/lowticket/README.md +1 -1
  4. package/src/modules/lowticket/skills/wizz-lowticket-guia/SKILL.md +1 -1
  5. package/src/modules/lowticket/skills/wizz-lowticket-metodologia/SKILL.md +1 -1
  6. package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/02-diagnostico-funil.md +7 -5
  7. package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/03-campanha-bidcap.md +9 -10
  8. package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/04-criativos.md +3 -3
  9. package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/04a-criativos-ia.md +6 -6
  10. package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/04b-criativos-formatos.md +1 -1
  11. package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/04c-criativos-roteiro.md +3 -3
  12. package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/06-pagina-vendas.md +2 -2
  13. package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/06a-instagram.md +2 -2
  14. package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/07-funil-upsell.md +2 -2
  15. package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/11-checklist-roi.md +5 -5
  16. package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/12-mineracao-ofertas.md +4 -4
  17. package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/12a-mineracao-veredito.md +3 -3
  18. package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/12b-fabrica-de-ofertas.md +11 -11
  19. package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/12c-anatomia-low-ticket.md +2 -2
  20. package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/14-vocabulario.md +1 -1
  21. package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/15-como-estender.md +1 -1
  22. package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/18-copy-desejo-oferta.md +2 -2
  23. package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/INDEX.md +12 -12
  24. package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/guardrails.md +41 -55
  25. package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/pontos-em-aberto.md +24 -24
  26. package/src/modules/lowticket/skills/wizz-lowticket-minerador/SKILL.md +7 -7
  27. package/src/modules/wizz/_shared/video-pipeline.md +11 -3
  28. package/src/modules/wizz/agents/wizz-designer/SKILL.md +1 -1
  29. package/src/modules/wizz/agents/wizz-social/SKILL.md +1 -0
  30. package/src/skills-lib/wizz-remotion-best-practices/rules/sfx.md +3 -1
  31. package/src/skills-lib/wizz-router/references/routing-table-flat.md +1 -0
  32. package/src/skills-lib/wizz-video-effects/SKILL.md +85 -0
  33. package/src/skills-lib/wizz-video-effects/assets/effects.config.template.json +178 -0
  34. package/src/skills-lib/wizz-video-effects/assets/remotion/EffectsLayer.tsx +87 -0
  35. package/src/skills-lib/wizz-video-effects/assets/remotion/README.md +37 -0
  36. package/src/skills-lib/wizz-video-effects/assets/remotion/useEffects.ts +120 -0
  37. package/src/skills-lib/wizz-video-effects/assets/sfx/CREDITS.md +56 -0
  38. package/src/skills-lib/wizz-video-effects/assets/sfx/cash.mp3 +0 -0
  39. package/src/skills-lib/wizz-video-effects/assets/sfx/chime.mp3 +0 -0
  40. package/src/skills-lib/wizz-video-effects/assets/sfx/click-soft.mp3 +0 -0
  41. package/src/skills-lib/wizz-video-effects/assets/sfx/click.mp3 +0 -0
  42. package/src/skills-lib/wizz-video-effects/assets/sfx/coin.mp3 +0 -0
  43. package/src/skills-lib/wizz-video-effects/assets/sfx/error.mp3 +0 -0
  44. package/src/skills-lib/wizz-video-effects/assets/sfx/impact-bass-1.mp3 +0 -0
  45. package/src/skills-lib/wizz-video-effects/assets/sfx/impact-bass-2.mp3 +0 -0
  46. package/src/skills-lib/wizz-video-effects/assets/sfx/key-press.mp3 +0 -0
  47. package/src/skills-lib/wizz-video-effects/assets/sfx/notif.mp3 +0 -0
  48. package/src/skills-lib/wizz-video-effects/assets/sfx/notification.mp3 +0 -0
  49. package/src/skills-lib/wizz-video-effects/assets/sfx/paste.mp3 +0 -0
  50. package/src/skills-lib/wizz-video-effects/assets/sfx/ping.mp3 +0 -0
  51. package/src/skills-lib/wizz-video-effects/assets/sfx/pop.mp3 +0 -0
  52. package/src/skills-lib/wizz-video-effects/assets/sfx/riser-metal.mp3 +0 -0
  53. package/src/skills-lib/wizz-video-effects/assets/sfx/riser.mp3 +0 -0
  54. package/src/skills-lib/wizz-video-effects/assets/sfx/send.mp3 +0 -0
  55. package/src/skills-lib/wizz-video-effects/assets/sfx/sparkle.mp3 +0 -0
  56. package/src/skills-lib/wizz-video-effects/assets/sfx/stamp.mp3 +0 -0
  57. package/src/skills-lib/wizz-video-effects/assets/sfx/success.mp3 +0 -0
  58. package/src/skills-lib/wizz-video-effects/assets/sfx/type.mp3 +0 -0
  59. package/src/skills-lib/wizz-video-effects/assets/sfx/typing.mp3 +0 -0
  60. package/src/skills-lib/wizz-video-effects/assets/sfx/whoosh-cinematic.mp3 +0 -0
  61. package/src/skills-lib/wizz-video-effects/assets/sfx/whoosh-short.mp3 +0 -0
  62. package/src/skills-lib/wizz-video-effects/assets/sfx/whoosh.mp3 +0 -0
  63. package/src/skills-lib/wizz-video-effects/references/audio.md +87 -0
  64. package/src/skills-lib/wizz-video-effects/references/data-contract.md +145 -0
  65. package/src/skills-lib/wizz-video-effects/references/do-not-use.md +54 -0
  66. package/src/skills-lib/wizz-video-effects/references/editing-patterns.md +58 -0
  67. package/src/skills-lib/wizz-video-effects/references/gates.md +37 -0
  68. package/src/skills-lib/wizz-video-effects/references/sfx-licensing.md +40 -0
  69. package/src/skills-lib/wizz-video-effects/references/when-to-use.md +129 -0
  70. package/src/skills-lib/wizz-video-effects/scripts/budget.py +211 -0
  71. package/src/skills-lib/wizz-video-effects/scripts/check_excess.py +218 -0
  72. package/src/skills-lib/wizz-video-effects/scripts/check_loudness.py +175 -0
  73. package/src/skills-lib/wizz-video-effects/scripts/check_rhythm.py +137 -0
  74. package/src/skills-lib/wizz-video-effects/scripts/check_safe_zone.py +221 -0
  75. package/src/skills-lib/wizz-video-effects/scripts/config.py +583 -0
  76. package/src/skills-lib/wizz-video-effects/scripts/fill_breaths.py +355 -0
  77. package/src/skills-lib/wizz-video-effects/scripts/measure_density.py +122 -0
  78. package/src/skills-lib/wizz-video-effects/scripts/normalize_audio.py +189 -0
  79. package/src/skills-lib/wizz-video-effects/scripts/sound.py +342 -0
  80. package/src/skills-lib/wizz-video-effects/scripts/test_budget.py +131 -0
  81. package/src/skills-lib/wizz-video-effects/scripts/test_check_loudness.py +156 -0
  82. package/src/skills-lib/wizz-video-effects/scripts/test_checks.py +190 -0
  83. package/src/skills-lib/wizz-video-effects/scripts/test_config.py +135 -0
  84. package/src/skills-lib/wizz-video-effects/scripts/test_fill_breaths.py +179 -0
  85. package/src/skills-lib/wizz-video-effects/scripts/test_sound.py +173 -0
  86. package/src/skills-lib/wizz-video-effects/scripts/timeline.py +85 -0
@@ -0,0 +1,190 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Tests of the timeline gates: check_excess.py (blocks), check_rhythm.py
3
+ (warns only) and the frame evaluation of check_safe_zone.py."""
4
+ import io
5
+ import json
6
+ import math
7
+ import tempfile
8
+ import unittest
9
+ from contextlib import redirect_stdout
10
+ from pathlib import Path
11
+
12
+ import check_excess as CE
13
+ import check_rhythm as CR
14
+ import check_safe_zone as CS
15
+ from config import TEMPLATE, load
16
+
17
+ CFG = load(TEMPLATE)
18
+
19
+
20
+ def _run(main, args) -> tuple[int, str]:
21
+ buf = io.StringIO()
22
+ with redirect_stdout(buf):
23
+ code = main(["--config", str(TEMPLATE), *args])
24
+ return code, buf.getvalue()
25
+
26
+
27
+ def _write(tmp: str, name: str, data: dict) -> str:
28
+ p = Path(tmp) / f"{name}.json"
29
+ p.write_text(json.dumps(data), encoding="utf-8")
30
+ return str(p)
31
+
32
+
33
+ class TestExcess(unittest.TestCase):
34
+ CLEAN = {"duration": 30.0, "punches": [2.0, 12.0, 24.0], "flashes": [6.0, 18.0],
35
+ "scenes": [{"start": 0.0, "dur": 15.0, "transition": "cut"},
36
+ {"start": 15.0, "dur": 15.0, "transition": "whip"}]}
37
+
38
+ def test_clean_timeline_passes(self):
39
+ rep = CE.analyze(self.CLEAN, "clean", CFG)
40
+ self.assertTrue(rep["passed"], rep["violations"])
41
+
42
+ def test_glued_punches_fail(self):
43
+ rep = CE.analyze({**self.CLEAN, "punches": [2.0, 3.0, 12.0]}, "glued", CFG)
44
+ self.assertFalse(rep["passed"])
45
+ self.assertTrue(any("gap" in v for v in rep["violations"]))
46
+
47
+ def test_over_ceiling_fails(self):
48
+ many = [float(i * 4) for i in range(8)] # 8 punches in 30s, ceiling 5
49
+ rep = CE.analyze({**self.CLEAN, "punches": many}, "busy", CFG)
50
+ self.assertTrue(any("ceiling" in v for v in rep["violations"]))
51
+
52
+ def test_dominant_crossing_fails(self):
53
+ rep = CE.analyze({**self.CLEAN, "shakes": [12.5]}, "cross", CFG)
54
+ self.assertTrue(any("gap_min_global" in v for v in rep["violations"]))
55
+
56
+ def test_scene_cut_sound_colliding_with_sfx_fails(self):
57
+ # The cut sound fill_breaths writes on a scene and an sfx[] entry
58
+ # mapped earlier, 60 ms apart: heard as one dirty sound.
59
+ scenes = [{"start": 0.0, "dur": 15.0, "transition": "cut"},
60
+ {"start": 15.0, "dur": 15.0, "transition": "whip",
61
+ "sfx": {"event": "block_cut", "src": "sfx/whoosh.mp3", "vol": 0.55}}]
62
+ sfx = [{"at": 15.06, "event": "price", "src": "sfx/cash.mp3", "vol": 0.55}]
63
+ rep = CE.analyze({**self.CLEAN, "scenes": scenes, "sfx": sfx}, "clash", CFG)
64
+ self.assertFalse(rep["passed"])
65
+ self.assertTrue(any("collision" in v for v in rep["violations"]), rep["violations"])
66
+
67
+ def test_same_sound_too_many_times_fails(self):
68
+ sfx = [{"at": float(t), "event": "element_in", "src": "sfx/pop.mp3", "vol": 0.5}
69
+ for t in (1, 3, 5, 7)]
70
+ rep = CE.analyze({**self.CLEAN, "sfx": sfx}, "pops", CFG)
71
+ self.assertTrue(any("same_sound_in_row" in v for v in rep["violations"]), rep["violations"])
72
+
73
+ def test_sound_without_known_event_fails(self):
74
+ sfx = [{"at": 4.0, "src": "sfx/vine-boom.mp3", "vol": 0.5}]
75
+ rep = CE.analyze({**self.CLEAN, "sfx": sfx}, "meme", CFG)
76
+ self.assertTrue(any("unknown_event" in v for v in rep["violations"]), rep["violations"])
77
+
78
+ def test_duration_derived_from_scenes(self):
79
+ data = {k: v for k, v in self.CLEAN.items() if k != "duration"}
80
+ rep = CE.analyze(data, "x", CFG)
81
+ self.assertEqual((rep["duration"], rep["duration_derived"]), (30.0, True))
82
+
83
+ def test_cli_blocks_and_warn_mode(self):
84
+ with tempfile.TemporaryDirectory() as tmp:
85
+ bad = _write(tmp, "bad", {**self.CLEAN, "punches": [2.0, 2.5]})
86
+ good = _write(tmp, "good", self.CLEAN)
87
+ self.assertEqual(_run(CE.main, [bad])[0], 1)
88
+ self.assertEqual(_run(CE.main, [bad, "--warn"])[0], 0)
89
+ self.assertEqual(_run(CE.main, [good])[0], 0)
90
+ self.assertEqual(_run(CE.main, [str(Path(tmp) / "missing.json")])[0], 2)
91
+
92
+ def test_json_output(self):
93
+ with tempfile.TemporaryDirectory() as tmp:
94
+ code, out = _run(CE.main, [_write(tmp, "p", self.CLEAN), "--json"])
95
+ self.assertTrue(json.loads(out)["p"]["passed"])
96
+
97
+
98
+ class TestRhythm(unittest.TestCase):
99
+ R = CFG.rhythm
100
+
101
+ def test_longest_static(self):
102
+ self.assertEqual(CR.longest_static([2.0, 4.0], 12.0), (8.0, 4.0))
103
+ self.assertEqual(CR.longest_static([], 3.0), (3.0, 0.0))
104
+
105
+ def test_cadence(self):
106
+ self.assertEqual(CR.cadence([1.0, 2.0, 3.0], 0.0, 3.0), 1.0)
107
+ self.assertTrue(math.isinf(CR.cadence([5.0], 0.0, 3.0)))
108
+ self.assertEqual(CR.cadence([0.0, 1.5], 0.0, 3.0), 3.0) # t=0 is not a change
109
+
110
+ def test_good_rhythm_has_no_warning(self):
111
+ pts = [1.5, 3.0] + [3.0 + 3.0 * i for i in range(1, 9)]
112
+ data = {"duration": 30.0, "scenes": [{"start": 0.0, "dur": 30.0}],
113
+ "elements": [{"at": t} for t in pts]}
114
+ rep = CR.analyze(data, "ok", self.R)
115
+ self.assertEqual(rep["warnings"], [])
116
+
117
+ def test_static_hook_and_slow_body_warn(self):
118
+ data = {"duration": 30.0, "scenes": [{"start": 0.0, "dur": 20.0},
119
+ {"start": 20.0, "dur": 10.0}]}
120
+ rep = CR.analyze(data, "slow", self.R)
121
+ text = " ".join(rep["warnings"])
122
+ self.assertIn("max_static_s", text)
123
+ self.assertIn("hook_cut_every_s", text)
124
+ self.assertIn("body_cut_every_s", text)
125
+
126
+ def test_frantic_body_warns(self):
127
+ pts = [0.5 * i for i in range(1, 60)]
128
+ rep = CR.analyze({"duration": 30.0, "punches": pts}, "frantic", self.R)
129
+ self.assertTrue(any("body" in w for w in rep["warnings"]))
130
+
131
+ def test_sfx_is_not_a_visual_change(self):
132
+ data = {"duration": 10.0, "sfx": [{"at": t} for t in range(1, 10)]}
133
+ self.assertEqual(CR.analyze(data, "audio-only", self.R)["changes"], 0)
134
+
135
+ def test_cli_never_blocks(self):
136
+ with tempfile.TemporaryDirectory() as tmp:
137
+ p = _write(tmp, "slow", {"duration": 30.0, "scenes": [{"start": 0.0, "dur": 30.0}]})
138
+ code, out = _run(CR.main, [p])
139
+ self.assertEqual(code, 0)
140
+ self.assertIn("WARNING", out)
141
+ code, out = _run(CR.main, [p, "--json"])
142
+ self.assertIsNone(json.loads(out)["slow"]["hook_every_s"])
143
+
144
+
145
+ class TestSafeZoneFrame(unittest.TestCase):
146
+ G, H = CFG.safe_zone_gate, CFG.frame.height
147
+ ROW = bytes([0, 255] * (CFG.safe_zone_gate.sample_width // 2))
148
+
149
+ def _frame(self, rows: range) -> bytes:
150
+ f = bytearray(self.G.sample_width * self.H)
151
+ for y in rows:
152
+ f[y * self.G.sample_width:(y + 1) * self.G.sample_width] = self.ROW
153
+ return bytes(f)
154
+
155
+ def test_row_std(self):
156
+ self.assertEqual(CS.row_std(bytes(10)), 0.0)
157
+ self.assertAlmostEqual(CS.row_std(bytes([0, 255] * 5)), 127.5)
158
+
159
+ def test_empty_and_inside(self):
160
+ zone = CFG.safe_zone("organic")
161
+ self.assertEqual(CS.evaluate(self._frame(range(0)), CFG, zone)[0], "empty")
162
+ state, top, bottom, _ = CS.evaluate(self._frame(range(600, 700)), CFG, zone)
163
+ self.assertEqual((state, top, bottom), ("ok", 600, 699))
164
+
165
+ def test_graze_warns_and_invasion_fails(self):
166
+ zone = CFG.safe_zone("organic")
167
+ few = range(zone.top - self.G.min_rows_to_fail + 1, zone.top + 50)
168
+ many = range(zone.top - self.G.min_rows_to_fail - 5, zone.top + 50)
169
+ self.assertEqual(CS.evaluate(self._frame(few), CFG, zone)[0], "warn")
170
+ self.assertEqual(CS.evaluate(self._frame(many), CFG, zone)[0], "fail")
171
+
172
+ def test_same_frame_passes_organic_and_fails_ad(self):
173
+ frame = self._frame(range(1300, 1400)) # below the ad bottom (1248)
174
+ self.assertEqual(CS.evaluate(frame, CFG, CFG.safe_zone("organic"))[0], "ok")
175
+ self.assertEqual(CS.evaluate(frame, CFG, CFG.safe_zone("ad"))[0], "fail")
176
+
177
+ def test_edge_artifact_rows_ignored(self):
178
+ edge = range(0, self.G.edge_artifact_rows)
179
+ self.assertEqual(CS.evaluate(self._frame(edge), CFG, CFG.safe_zone())[0], "empty")
180
+
181
+ def test_profile_resolution(self):
182
+ with tempfile.TemporaryDirectory() as tmp:
183
+ p = _write(tmp, "t", {"safe_zone_profile": "ad"})
184
+ self.assertEqual(CS.resolve_profile(CFG, None, p).name, "ad")
185
+ self.assertEqual(CS.resolve_profile(CFG, "organic", p).name, "organic")
186
+ self.assertEqual(CS.resolve_profile(CFG, None, None).name, CFG.safe_zone_profile)
187
+
188
+
189
+ if __name__ == "__main__":
190
+ unittest.main()
@@ -0,0 +1,135 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Tests of config.py against the shipped template and broken variants."""
3
+ import copy
4
+ import json
5
+ import os
6
+ import tempfile
7
+ import unittest
8
+ from pathlib import Path
9
+
10
+ import config
11
+ from config import TEMPLATE, ConfigError, load
12
+
13
+ RAW = json.loads(TEMPLATE.read_text(encoding="utf-8"))
14
+
15
+
16
+ def _load_variant(mutate) -> config.Config:
17
+ data = copy.deepcopy(RAW)
18
+ mutate(data)
19
+ with tempfile.TemporaryDirectory() as tmp:
20
+ p = Path(tmp) / "effects.config.json"
21
+ p.write_text(json.dumps(data), encoding="utf-8")
22
+ return load(p)
23
+
24
+
25
+ class TestTemplate(unittest.TestCase):
26
+ def test_template_is_valid(self):
27
+ cfg = load(TEMPLATE)
28
+ self.assertEqual(cfg.offer, "")
29
+ self.assertEqual((cfg.frame.width, cfg.frame.height, cfg.frame.fps), (1080, 1920, 30))
30
+
31
+ def test_two_safe_zone_profiles(self):
32
+ cfg = load(TEMPLATE)
33
+ self.assertEqual(set(cfg.safe_zones), {"organic", "ad"})
34
+ self.assertEqual(cfg.safe_zone().name, cfg.safe_zone_profile)
35
+ org = cfg.safe_zone("organic")
36
+ self.assertEqual((org.top, org.bottom, org.left, org.right), (250, 1570, 60, 920))
37
+
38
+ def test_ad_profile_matches_meta_percentages(self):
39
+ cfg = load(TEMPLATE)
40
+ ad = cfg.safe_zone("ad")
41
+ h, w = cfg.frame.height, cfg.frame.width
42
+ self.assertGreaterEqual(ad.top, 0.14 * h)
43
+ self.assertLessEqual(ad.bottom, h - 0.35 * h)
44
+ self.assertGreaterEqual(ad.left, 0.06 * w)
45
+ self.assertLessEqual(ad.right, w - 0.06 * w)
46
+
47
+ def test_weak_numbers_carry_a_source(self):
48
+ for block in ("ducking", "loudness", "rhythm"):
49
+ self.assertIn("_source", RAW[block], block)
50
+ self.assertIn("no primary source", RAW["ducking"]["_source"])
51
+ for name, zone in RAW["safe_zones"].items():
52
+ self.assertIn("_source", zone, name)
53
+
54
+ def test_no_glitch_event(self):
55
+ cfg = load(TEMPLATE)
56
+ self.assertFalse(any("glitch" in e.sound for e in cfg.sound.events.values()))
57
+
58
+ def test_unknown_profile_fails_loudly(self):
59
+ with self.assertRaises(ConfigError):
60
+ load(TEMPLATE).safe_zone("tiktok-dark-mode")
61
+
62
+
63
+ class TestLookup(unittest.TestCase):
64
+ def test_env_variable_is_used(self):
65
+ old = os.environ.get(config.ENV_CONFIG)
66
+ os.environ[config.ENV_CONFIG] = str(TEMPLATE)
67
+ try:
68
+ self.assertEqual(load().source, TEMPLATE.resolve())
69
+ finally:
70
+ if old is None:
71
+ os.environ.pop(config.ENV_CONFIG, None)
72
+ else:
73
+ os.environ[config.ENV_CONFIG] = old
74
+
75
+ def test_missing_file_says_how_to_fix(self):
76
+ with self.assertRaises(ConfigError) as ctx:
77
+ load("/no/such/effects.config.json")
78
+ self.assertIn("template", str(ctx.exception))
79
+
80
+
81
+ class TestValidation(unittest.TestCase):
82
+ def test_event_louder_than_voice_ceiling_is_refused(self):
83
+ def m(d):
84
+ d["sound"]["events"]["verdict"]["vol"] = 0.9
85
+ with self.assertRaises(ConfigError):
86
+ _load_variant(m)
87
+
88
+ def test_trigger_without_budget_rule_is_refused(self):
89
+ def m(d):
90
+ d["sound"]["events"]["verdict"]["triggers"] = ["confetti"]
91
+ with self.assertRaises(ConfigError):
92
+ _load_variant(m)
93
+
94
+ def test_profile_outside_frame_is_refused(self):
95
+ def m(d):
96
+ d["safe_zones"]["ad"]["bottom"] = 5000
97
+ with self.assertRaises(ConfigError):
98
+ _load_variant(m)
99
+
100
+ def test_default_profile_must_exist(self):
101
+ def m(d):
102
+ d["safe_zone_profile"] = "stories"
103
+ with self.assertRaises(ConfigError):
104
+ _load_variant(m)
105
+
106
+ def test_curve_must_end_at_one(self):
107
+ def m(d):
108
+ d["budget"]["curve"][-1]["until_frac"] = 0.9
109
+ with self.assertRaises(ConfigError):
110
+ _load_variant(m)
111
+
112
+ def test_positive_true_peak_is_refused(self):
113
+ def m(d):
114
+ d["loudness"]["max_true_peak_db"] = 0.5
115
+ with self.assertRaises(ConfigError):
116
+ _load_variant(m)
117
+
118
+ def test_body_range_must_be_ordered(self):
119
+ def m(d):
120
+ d["rhythm"]["body_cut_every_s"] = [4.0, 2.5]
121
+ with self.assertRaises(ConfigError):
122
+ _load_variant(m)
123
+
124
+ def test_avatar_block_is_optional(self):
125
+ cfg = _load_variant(lambda d: d.pop("avatar"))
126
+ self.assertIsNone(cfg.avatar)
127
+
128
+ def test_missing_block_names_the_key(self):
129
+ with self.assertRaises(ConfigError) as ctx:
130
+ _load_variant(lambda d: d.pop("loudness"))
131
+ self.assertIn("loudness", str(ctx.exception))
132
+
133
+
134
+ if __name__ == "__main__":
135
+ unittest.main()
@@ -0,0 +1,179 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Tests of fill_breaths.py with the shipped template config.
3
+
4
+ Covers the regressions of the source project: hand-written gaps (2.5/5.0)
5
+ instead of the config ones, the per-kind ceiling ignored, every new punch
6
+ asking for a sound evaluated TOGETHER with the existing sounds, plus the new
7
+ breath source from ctc-align words and the punch band per safe zone profile.
8
+ """
9
+ import unittest
10
+
11
+ import fill_breaths as FB
12
+ from config import TEMPLATE, load
13
+
14
+ CFG = load(TEMPLATE)
15
+ PUNCH = CFG.budget.rule("punch")
16
+ DENSE = [round(x * 0.5, 1) for x in range(2, 158)] # a breath every 0.5s over 80s
17
+
18
+
19
+ def _all_punch(cands):
20
+ return {t: "punch" for t in cands}
21
+
22
+
23
+ class TestGapFromConfig(unittest.TestCase):
24
+ def test_candidate_closer_than_gap_min_is_refused(self):
25
+ near = 10.0 + PUNCH.gap_min - 0.9
26
+ new, *_ = FB.fill_holes([near, 20.0], [10.0], _all_punch([near, 20.0]), 60.0,
27
+ [10.0], [], CFG)
28
+ self.assertNotIn(near, new)
29
+ self.assertIn(20.0, new)
30
+
31
+
32
+ class TestCeiling(unittest.TestCase):
33
+ def test_stops_at_ceiling_and_reports(self):
34
+ cap = CFG.budget.ceiling("punch", 80.0)
35
+ new, no_breath, no_budget, stopped = FB.fill_holes(
36
+ DENSE, [], _all_punch(DENSE), 80.0, [], [], CFG)
37
+ self.assertEqual(len(new), cap)
38
+ self.assertEqual(stopped, (cap, cap))
39
+ self.assertTrue(no_budget)
40
+
41
+ def test_exhausted_budget_adds_nothing(self):
42
+ cap = CFG.budget.ceiling("punch", 80.0)
43
+ existing = [float(i * 5) for i in range(cap)]
44
+ new, *_ = FB.fill_holes(DENSE, existing, _all_punch(DENSE), 80.0, existing, [], CFG)
45
+ self.assertEqual(new, [])
46
+
47
+ def test_no_breath_hole_is_marked_not_aborted(self):
48
+ cands = [30.0]
49
+ new, no_breath, _, _ = FB.fill_holes(cands, [], _all_punch(cands), 60.0, [], [], CFG)
50
+ self.assertEqual(new, [30.0])
51
+ self.assertTrue(no_breath)
52
+
53
+
54
+ class TestIdempotent(unittest.TestCase):
55
+ def test_fixed_point(self):
56
+ eff = _all_punch(DENSE)
57
+ n1, *_ = FB.fill_holes(DENSE, [], eff, 80.0, [], [], CFG)
58
+ p1 = sorted(n1)
59
+ n2, *_ = FB.fill_holes(DENSE, p1, eff, 80.0, p1, [], CFG)
60
+ self.assertEqual(n2, [])
61
+
62
+
63
+ class TestPunchSounds(unittest.TestCase):
64
+ EV = CFG.breaths.punch_sound_event
65
+
66
+ def _entry(self, t, event):
67
+ return {"at": t, "event": event, "src": CFG.sound.src(event),
68
+ "vol": CFG.sound.events[event].vol}
69
+
70
+ def test_every_new_punch_is_accounted_for(self):
71
+ shots, refused, _, _ = FB.request_punch_sounds([2.0, 6.0, 10.0], [], CFG)
72
+ self.assertEqual({s.t for s in shots} | {r.t for r in refused}, {2.0, 6.0, 10.0})
73
+
74
+ def test_isolated_punch_gets_the_configured_event(self):
75
+ shots, refused, _, _ = FB.request_punch_sounds([5.0], [], CFG)
76
+ self.assertEqual((shots[0].event, refused), (self.EV, []))
77
+
78
+ def test_collision_with_existing_sound(self):
79
+ w = CFG.sound.collision_window_s
80
+ shots, refused, _, _ = FB.request_punch_sounds(
81
+ [5.0 + w / 2], [self._entry(5.0, "block_cut")], CFG)
82
+ self.assertEqual(shots, [])
83
+ self.assertEqual(refused[0].rule, "collision")
84
+
85
+ def test_joint_evaluation_catches_same_sound_run(self):
86
+ n = CFG.sound.max_same_sound_in_row
87
+ existing = [self._entry(float(i + 1), self.EV) for i in range(n)]
88
+ shots, refused, _, _ = FB.request_punch_sounds([float(n + 1)], existing, CFG)
89
+ self.assertEqual(shots, [])
90
+ self.assertEqual(refused[0].rule, "same_sound_in_row")
91
+
92
+ def test_existing_sounds_are_not_displaced(self):
93
+ existing = [self._entry(1.0, "element_in"), self._entry(20.0, "block_cut")]
94
+ *_, displaced = FB.request_punch_sounds([10.0], existing, CFG)
95
+ self.assertEqual(displaced, [])
96
+
97
+ def test_unrecoverable_entry_is_reported(self):
98
+ _, _, unrec, _ = FB.request_punch_sounds(
99
+ [5.0], [{"at": 1.0, "src": "sfx/nope.mp3", "vol": 0.99}], CFG)
100
+ self.assertEqual(unrec[0]["src"], "sfx/nope.mp3")
101
+
102
+
103
+ class TestEventOf(unittest.TestCase):
104
+ def test_explicit_event_wins(self):
105
+ self.assertEqual(FB.event_of({"event": "price", "src": "x", "vol": 0}, CFG), "price")
106
+
107
+ def test_rebuilt_from_file_and_volume(self):
108
+ e = CFG.sound.events
109
+ a = {"src": "sfx/whoosh.mp3", "vol": e["block_cut"].vol}
110
+ b = {"src": "sfx/whoosh.mp3", "vol": e["scene_cut"].vol}
111
+ self.assertEqual((FB.event_of(a, CFG), FB.event_of(b, CFG)), ("block_cut", "scene_cut"))
112
+
113
+ def test_unknown_pair_is_none(self):
114
+ self.assertIsNone(FB.event_of({"src": "sfx/none.mp3", "vol": 0.5}, CFG))
115
+
116
+
117
+ class TestBreathsFromWords(unittest.TestCase):
118
+ def test_only_gaps_long_enough(self):
119
+ m = CFG.breaths.min_silence_s
120
+ words = [{"start": 0.0, "end": 1.0}, {"start": 1.0 + m / 2, "end": 2.0},
121
+ {"start": 2.0 + m * 2, "end": 3.0}]
122
+ self.assertEqual(FB.breaths_from_words(words, CFG), [(2.0, 2.0 + m * 2)])
123
+
124
+ def test_candidates_in_the_middle_and_inside_guard(self):
125
+ g = CFG.breaths.edge_guard_s
126
+ c = FB.candidates_from([(0.0, g), (4.0, 5.0), (9.8, 10.0)], 0.0, 10.0, CFG)
127
+ self.assertEqual(c, [4.5])
128
+
129
+
130
+ class TestPunchBand(unittest.TestCase):
131
+ def test_band_is_inside_the_zone_and_matches_source_number(self):
132
+ top, bottom = FB.punch_band(CFG.safe_zone("organic"), CFG)
133
+ self.assertEqual(top, 294) # source project: 294, rounded up to 295 by hand
134
+ self.assertLess(bottom, CFG.safe_zone("organic").bottom)
135
+
136
+ def test_ad_profile_band_is_tighter(self):
137
+ org, ad = FB.punch_band(CFG.safe_zone("organic"), CFG), FB.punch_band(CFG.safe_zone("ad"), CFG)
138
+ self.assertGreater(ad[0], org[0])
139
+ self.assertLess(ad[1], org[1])
140
+
141
+ def test_effect_for_frame(self):
142
+ g, h = CFG.safe_zone_gate, CFG.frame.height
143
+ blank = bytes(g.sample_width * h)
144
+ zone = CFG.safe_zone("organic")
145
+ self.assertEqual(FB.effect_for_frame(blank, zone, CFG), "punch")
146
+ row = bytes([0, 255] * (g.sample_width // 2))
147
+ at_edge = bytearray(blank)
148
+ at_edge[zone.top * g.sample_width:(zone.top + 1) * g.sample_width] = row
149
+ self.assertEqual(FB.effect_for_frame(bytes(at_edge), zone, CFG), "flash")
150
+
151
+
152
+ class TestPlan(unittest.TestCase):
153
+ DATA = {"duration": 20.0,
154
+ "words": [{"text": f"w{i}", "start": i * 0.3, "end": i * 0.3 + 0.25}
155
+ for i in range(20)],
156
+ "scenes": [{"start": 0.0, "dur": 10.0, "transition": "cut"},
157
+ {"start": 10.0, "dur": 10.0, "transition": "cut"}],
158
+ "punches": [], "flashes": [], "sfx": []}
159
+
160
+ def test_plan_and_apply_are_pure(self):
161
+ cands = [13.0, 15.0, 17.0]
162
+ before = repr(self.DATA)
163
+ p = FB.plan(self.DATA, CFG, cands, _all_punch(cands))
164
+ out = FB.apply(self.DATA, p)
165
+ self.assertEqual(repr(self.DATA), before)
166
+ self.assertEqual(out["scenes"][0]["transition"], CFG.breaths.pace_profiles["first"].transition)
167
+ self.assertIn("sfx", out["scenes"][1])
168
+ self.assertTrue(out["punches"])
169
+ self.assertTrue(all("event" in s for s in out["sfx"]))
170
+
171
+ def test_pace_profile(self):
172
+ f = CFG.breaths.fast_speech_wps
173
+ self.assertEqual(FB.pace_profile(0, 99.0, CFG), "first")
174
+ self.assertEqual(FB.pace_profile(1, f, CFG), "fast")
175
+ self.assertEqual(FB.pace_profile(1, f - 0.1, CFG), "slow")
176
+
177
+
178
+ if __name__ == "__main__":
179
+ unittest.main()
@@ -0,0 +1,173 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Tests of sound.py: the three refusals, the bridge to the budget, coverage
3
+ and narration ducking. Uses the shipped template config: if someone changes
4
+ max_same_sound_in_row or an event vol without adjusting here, tests say so."""
5
+ import tempfile
6
+ import unittest
7
+ from pathlib import Path
8
+
9
+ import sound
10
+ from config import TEMPLATE, SoundEvent, load
11
+ from sound import coverage, ducking_envelope, effect_candidates, gain_db_at, map_requests
12
+
13
+ CFG = load(TEMPLATE)
14
+ SFX_DIR = TEMPLATE.parent / "sfx"
15
+
16
+
17
+ class TestMap(unittest.TestCase):
18
+ def test_maps_event_to_file_and_config_volume(self):
19
+ res = map_requests([(1.0, "verdict")], CFG)
20
+ self.assertEqual(res.refusals, ())
21
+ s = res.shots[0]
22
+ self.assertEqual(s.src, "sfx/stamp.mp3")
23
+ self.assertEqual(s.vol, CFG.sound.events["verdict"].vol)
24
+ self.assertEqual(s.triggers, ("shake",))
25
+
26
+ def test_unknown_event_is_refused_and_lists_valid_ones(self):
27
+ res = map_requests([(1.0, "no_such_event")], CFG)
28
+ self.assertEqual(res.shots, ())
29
+ self.assertEqual(res.refusals[0].rule, "unknown_event")
30
+ self.assertIn("verdict", res.refusals[0].detail)
31
+
32
+ def test_collision_refuses_the_second(self):
33
+ w = CFG.sound.collision_window_s
34
+ res = map_requests([(1.0, "click"), (1.0 + w / 2, "click")], CFG)
35
+ self.assertEqual([s.t for s in res.shots], [1.0])
36
+ self.assertEqual(res.refusals[0].rule, "collision")
37
+
38
+ def test_exactly_the_window_is_not_a_collision(self):
39
+ w = CFG.sound.collision_window_s
40
+ res = map_requests([(1.0, "click"), (1.0 + w, "click")], CFG)
41
+ self.assertEqual(len(res.shots), 2)
42
+
43
+ def test_same_sound_in_row_limit(self):
44
+ n = CFG.sound.max_same_sound_in_row
45
+ res = map_requests([(float(i), "click") for i in range(n + 2)], CFG)
46
+ self.assertEqual(len(res.shots), n)
47
+ self.assertTrue(all(r.rule == "same_sound_in_row" for r in res.refusals))
48
+
49
+ def test_other_sound_resets_the_run(self):
50
+ n = CFG.sound.max_same_sound_in_row
51
+ req = [(float(i), "click") for i in range(n)] + [(float(n), "send"), (float(n + 1), "click")]
52
+ res = map_requests(req, CFG)
53
+ self.assertEqual(res.refusals, ())
54
+
55
+ def test_order_by_time_not_arrival(self):
56
+ res = map_requests([(3.0, "send"), (1.0, "click"), (2.0, "paste")], CFG)
57
+ self.assertEqual([s.t for s in res.shots], [1.0, 2.0, 3.0])
58
+
59
+ def test_deterministic(self):
60
+ req = [(5.0, "block_cut"), (5.5, "element_in"), (5.55, "list_item"), (99.0, "x")]
61
+ self.assertEqual(map_requests(req, CFG), map_requests(list(reversed(req)), CFG))
62
+
63
+
64
+ class TestEffectCandidates(unittest.TestCase):
65
+ def test_strength_not_volume_decides(self):
66
+ c = effect_candidates(map_requests([(2.5, "verdict")], CFG))[0]
67
+ self.assertEqual((c["t"], c["kind"]), (2.5, "shake"))
68
+ self.assertEqual(c["strength"], CFG.sound.events["verdict"].strength)
69
+ self.assertGreater(c["strength"], CFG.sound.events["verdict"].vol)
70
+
71
+ def test_volume_fallback_is_declared(self):
72
+ ev = SoundEvent(key="x", sound="pop", vol=0.42, when="", triggers=("punch",))
73
+ shot = sound.Shot(t=1.0, event="x", src="sfx/pop.mp3", vol=ev.vol, triggers=ev.triggers)
74
+ c = effect_candidates(sound.SoundResult(shots=(shot,), refusals=()))[0]
75
+ self.assertEqual(c["strength"], 0.42)
76
+ self.assertIn("strength from volume", c["reason"])
77
+
78
+ def test_no_trigger_no_candidate(self):
79
+ self.assertEqual(effect_candidates(map_requests([(1.0, "click")], CFG)), [])
80
+
81
+ def test_plain_dicts(self):
82
+ c = effect_candidates(map_requests([(1.0, "verdict")], CFG))[0]
83
+ self.assertEqual(set(c), {"t", "kind", "strength", "reason"})
84
+
85
+
86
+ class TestCoverage(unittest.TestCase):
87
+ def test_template_events_all_have_a_shipped_file(self):
88
+ cov = coverage(CFG, SFX_DIR)
89
+ self.assertTrue(cov["sfx_dir_exists"], cov["sfx_dir"])
90
+ self.assertEqual(cov["events_without_file"], [])
91
+
92
+ def test_no_glitch_shipped(self):
93
+ self.assertFalse(any(n.startswith("glitch") for n in coverage(CFG, SFX_DIR)["on_disk"]))
94
+
95
+ def test_fabricated_folder(self):
96
+ with tempfile.TemporaryDirectory() as tmp:
97
+ (Path(tmp) / "pop.mp3").touch()
98
+ (Path(tmp) / "unused.mp3").touch()
99
+ cov = coverage(CFG, tmp)
100
+ self.assertEqual(cov["sounds_without_event"], ["unused"])
101
+ self.assertIn("verdict", cov["events_without_file"])
102
+ self.assertNotIn("element_in", cov["events_without_file"])
103
+
104
+ def test_missing_folder_does_not_crash(self):
105
+ cov = coverage(CFG, "/no/such/sfx")
106
+ self.assertFalse(cov["sfx_dir_exists"])
107
+ self.assertEqual(len(cov["events_without_file"]), len(CFG.sound.events))
108
+
109
+
110
+ WORDS = [{"text": "stop", "start": 1.00, "end": 1.30},
111
+ {"text": "now", "start": 1.35, "end": 1.60},
112
+ {"text": "price", "start": 5.50, "end": 5.90}]
113
+
114
+
115
+ class TestDucking(unittest.TestCase):
116
+ def test_no_window_when_sfx_sits_in_silence(self):
117
+ sfx = [{"at": 3.0, "event": "block_cut", "dur": 0.5}]
118
+ self.assertEqual(ducking_envelope(sfx, WORDS, CFG), [])
119
+
120
+ def test_window_when_sfx_covers_a_word_onset(self):
121
+ d = CFG.ducking
122
+ sfx = [{"at": 5.40, "event": "price", "dur": 0.45}]
123
+ [w] = ducking_envelope(sfx, WORDS, CFG, fps=30)
124
+ self.assertAlmostEqual(w["start"], 5.40 - d.attack_s)
125
+ self.assertAlmostEqual(w["end"], 5.85 + d.release_s)
126
+ self.assertEqual(w["depth_db"], d.depth_db)
127
+ self.assertIn("price", w["cause"])
128
+ self.assertEqual(w["from_frame"], int(w["start"] * 30))
129
+ self.assertGreaterEqual(w["to_frame"] / 30, w["end"])
130
+
131
+ def test_onset_just_before_the_hit_counts_within_attack(self):
132
+ d = CFG.ducking
133
+ sfx = [{"at": 5.50 + d.attack_s / 2, "dur": 0.2, "src": "sfx/pop.mp3"}]
134
+ self.assertEqual(len(ducking_envelope(sfx, WORDS, CFG)), 1)
135
+
136
+ def test_word_already_started_is_not_an_onset(self):
137
+ # SFX in the middle of "price" (onset 5.50, well before the SFX).
138
+ sfx = [{"at": 5.70, "event": "click", "dur": 0.1}]
139
+ self.assertEqual(ducking_envelope(sfx, WORDS, CFG), [])
140
+
141
+ def test_duration_fallbacks(self):
142
+ d = CFG.ducking
143
+ sfx = [{"at": 0.9, "src": "sfx/pop.mp3"}]
144
+ [w] = ducking_envelope(sfx, WORDS, CFG)
145
+ self.assertAlmostEqual(w["end"], 0.9 + d.assumed_sfx_duration_s + d.release_s)
146
+ [w2] = ducking_envelope(sfx, WORDS, CFG, durations={"sfx/pop.mp3": 0.3})
147
+ self.assertAlmostEqual(w2["end"], 0.9 + 0.3 + d.release_s)
148
+
149
+ def test_overlapping_windows_merge(self):
150
+ sfx = [{"at": 0.95, "dur": 0.3, "event": "a"}, {"at": 1.30, "dur": 0.3, "event": "b"}]
151
+ windows = ducking_envelope(sfx, WORDS, CFG)
152
+ self.assertEqual(len(windows), 1)
153
+ self.assertIn(";", windows[0]["cause"])
154
+
155
+ def test_gain_shape(self):
156
+ d = CFG.ducking
157
+ [w] = ducking_envelope([{"at": 5.40, "dur": 0.45}], WORDS, CFG)
158
+ self.assertEqual(gain_db_at(w["start"] - 0.01, [w]), 0.0)
159
+ self.assertAlmostEqual(gain_db_at(w["start"], [w]), 0.0)
160
+ self.assertAlmostEqual(gain_db_at(5.40, [w]), -d.depth_db)
161
+ self.assertAlmostEqual(gain_db_at(5.60, [w]), -d.depth_db)
162
+ mid_release = w["end"] - d.release_s / 2
163
+ self.assertAlmostEqual(gain_db_at(mid_release, [w]), -d.depth_db / 2)
164
+ self.assertEqual(gain_db_at(w["end"] + 0.01, [w]), 0.0)
165
+
166
+ def test_deterministic_and_order_free(self):
167
+ sfx = [{"at": 5.4, "dur": 0.45}, {"at": 0.95, "dur": 0.2}]
168
+ self.assertEqual(ducking_envelope(sfx, WORDS, CFG),
169
+ ducking_envelope(list(reversed(sfx)), list(reversed(WORDS)), CFG))
170
+
171
+
172
+ if __name__ == "__main__":
173
+ unittest.main()