wizz-method 2.11.2 → 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 (66) hide show
  1. package/package.json +1 -1
  2. package/skills-registry.yaml +19 -5
  3. package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/04-criativos.md +1 -1
  4. package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/11-checklist-roi.md +1 -1
  5. package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/12-mineracao-ofertas.md +1 -1
  6. package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/pontos-em-aberto.md +2 -2
  7. package/src/modules/wizz/_shared/video-pipeline.md +11 -3
  8. package/src/modules/wizz/agents/wizz-designer/SKILL.md +1 -1
  9. package/src/modules/wizz/agents/wizz-social/SKILL.md +1 -0
  10. package/src/skills-lib/wizz-remotion-best-practices/rules/sfx.md +3 -1
  11. package/src/skills-lib/wizz-router/references/routing-table-flat.md +1 -0
  12. package/src/skills-lib/wizz-video-effects/SKILL.md +85 -0
  13. package/src/skills-lib/wizz-video-effects/assets/effects.config.template.json +178 -0
  14. package/src/skills-lib/wizz-video-effects/assets/remotion/EffectsLayer.tsx +87 -0
  15. package/src/skills-lib/wizz-video-effects/assets/remotion/README.md +37 -0
  16. package/src/skills-lib/wizz-video-effects/assets/remotion/useEffects.ts +120 -0
  17. package/src/skills-lib/wizz-video-effects/assets/sfx/CREDITS.md +56 -0
  18. package/src/skills-lib/wizz-video-effects/assets/sfx/cash.mp3 +0 -0
  19. package/src/skills-lib/wizz-video-effects/assets/sfx/chime.mp3 +0 -0
  20. package/src/skills-lib/wizz-video-effects/assets/sfx/click-soft.mp3 +0 -0
  21. package/src/skills-lib/wizz-video-effects/assets/sfx/click.mp3 +0 -0
  22. package/src/skills-lib/wizz-video-effects/assets/sfx/coin.mp3 +0 -0
  23. package/src/skills-lib/wizz-video-effects/assets/sfx/error.mp3 +0 -0
  24. package/src/skills-lib/wizz-video-effects/assets/sfx/impact-bass-1.mp3 +0 -0
  25. package/src/skills-lib/wizz-video-effects/assets/sfx/impact-bass-2.mp3 +0 -0
  26. package/src/skills-lib/wizz-video-effects/assets/sfx/key-press.mp3 +0 -0
  27. package/src/skills-lib/wizz-video-effects/assets/sfx/notif.mp3 +0 -0
  28. package/src/skills-lib/wizz-video-effects/assets/sfx/notification.mp3 +0 -0
  29. package/src/skills-lib/wizz-video-effects/assets/sfx/paste.mp3 +0 -0
  30. package/src/skills-lib/wizz-video-effects/assets/sfx/ping.mp3 +0 -0
  31. package/src/skills-lib/wizz-video-effects/assets/sfx/pop.mp3 +0 -0
  32. package/src/skills-lib/wizz-video-effects/assets/sfx/riser-metal.mp3 +0 -0
  33. package/src/skills-lib/wizz-video-effects/assets/sfx/riser.mp3 +0 -0
  34. package/src/skills-lib/wizz-video-effects/assets/sfx/send.mp3 +0 -0
  35. package/src/skills-lib/wizz-video-effects/assets/sfx/sparkle.mp3 +0 -0
  36. package/src/skills-lib/wizz-video-effects/assets/sfx/stamp.mp3 +0 -0
  37. package/src/skills-lib/wizz-video-effects/assets/sfx/success.mp3 +0 -0
  38. package/src/skills-lib/wizz-video-effects/assets/sfx/type.mp3 +0 -0
  39. package/src/skills-lib/wizz-video-effects/assets/sfx/typing.mp3 +0 -0
  40. package/src/skills-lib/wizz-video-effects/assets/sfx/whoosh-cinematic.mp3 +0 -0
  41. package/src/skills-lib/wizz-video-effects/assets/sfx/whoosh-short.mp3 +0 -0
  42. package/src/skills-lib/wizz-video-effects/assets/sfx/whoosh.mp3 +0 -0
  43. package/src/skills-lib/wizz-video-effects/references/audio.md +87 -0
  44. package/src/skills-lib/wizz-video-effects/references/data-contract.md +145 -0
  45. package/src/skills-lib/wizz-video-effects/references/do-not-use.md +54 -0
  46. package/src/skills-lib/wizz-video-effects/references/editing-patterns.md +58 -0
  47. package/src/skills-lib/wizz-video-effects/references/gates.md +37 -0
  48. package/src/skills-lib/wizz-video-effects/references/sfx-licensing.md +40 -0
  49. package/src/skills-lib/wizz-video-effects/references/when-to-use.md +129 -0
  50. package/src/skills-lib/wizz-video-effects/scripts/budget.py +211 -0
  51. package/src/skills-lib/wizz-video-effects/scripts/check_excess.py +218 -0
  52. package/src/skills-lib/wizz-video-effects/scripts/check_loudness.py +175 -0
  53. package/src/skills-lib/wizz-video-effects/scripts/check_rhythm.py +137 -0
  54. package/src/skills-lib/wizz-video-effects/scripts/check_safe_zone.py +221 -0
  55. package/src/skills-lib/wizz-video-effects/scripts/config.py +583 -0
  56. package/src/skills-lib/wizz-video-effects/scripts/fill_breaths.py +355 -0
  57. package/src/skills-lib/wizz-video-effects/scripts/measure_density.py +122 -0
  58. package/src/skills-lib/wizz-video-effects/scripts/normalize_audio.py +189 -0
  59. package/src/skills-lib/wizz-video-effects/scripts/sound.py +342 -0
  60. package/src/skills-lib/wizz-video-effects/scripts/test_budget.py +131 -0
  61. package/src/skills-lib/wizz-video-effects/scripts/test_check_loudness.py +156 -0
  62. package/src/skills-lib/wizz-video-effects/scripts/test_checks.py +190 -0
  63. package/src/skills-lib/wizz-video-effects/scripts/test_config.py +135 -0
  64. package/src/skills-lib/wizz-video-effects/scripts/test_fill_breaths.py +179 -0
  65. package/src/skills-lib/wizz-video-effects/scripts/test_sound.py +173 -0
  66. package/src/skills-lib/wizz-video-effects/scripts/timeline.py +85 -0
@@ -0,0 +1,583 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Loader for `effects.config.json`: the single home of every canonical number.
3
+
4
+ Why it exists. Numbers copied into a second place drift into two versions (the
5
+ source project once had six quantities with two values each). The rule this
6
+ module enforces: the number lives in the config and whoever needs it imports
7
+ it. No doc rewrites a value; it cites the key.
8
+
9
+ This file knows nothing about any offer, piece or project path. Everything
10
+ that changes per project lives in the JSON.
11
+
12
+ Lookup order for the config file:
13
+ 1. explicit path (argument or --config on any CLI)
14
+ 2. environment variable VIDEO_EFFECTS_CONFIG
15
+ 3. ./effects.config.json in the current working directory
16
+ Nothing found is an error: copy assets/effects.config.template.json first.
17
+
18
+ CLI, to print what is in force:
19
+ python3 config.py [--config PATH]
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import argparse
24
+ import json
25
+ import os
26
+ from dataclasses import dataclass, field
27
+ from pathlib import Path
28
+
29
+ ENV_CONFIG = "VIDEO_EFFECTS_CONFIG"
30
+ DEFAULT_NAME = "effects.config.json"
31
+ TEMPLATE = Path(__file__).resolve().parent.parent / "assets" / "effects.config.template.json"
32
+
33
+
34
+ class ConfigError(Exception):
35
+ """Invalid config. Better to fail here than to render something wrong."""
36
+
37
+
38
+ # ------------------------------------------------------------------ structures
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class Frame:
43
+ width: int
44
+ height: int
45
+ fps: int
46
+
47
+
48
+ @dataclass(frozen=True)
49
+ class SafeZone:
50
+ name: str
51
+ top: int
52
+ bottom: int
53
+ left: int
54
+ right: int
55
+ recommended_margin: int = 0
56
+
57
+ def inside(self, x: float, y: float) -> bool:
58
+ return self.left <= x <= self.right and self.top <= y <= self.bottom
59
+
60
+
61
+ @dataclass(frozen=True)
62
+ class SafeZoneGate:
63
+ dp_min: float
64
+ edge_artifact_rows: int
65
+ min_rows_to_fail: int
66
+ sample_width: int
67
+
68
+
69
+ @dataclass(frozen=True)
70
+ class Density:
71
+ n_bands: int
72
+ min_filled: int
73
+ dp_min: float
74
+ step_s: float
75
+ sample_width: int
76
+
77
+ def bands(self, top: int, bottom: int) -> list[tuple[int, int]]:
78
+ """N uniform bands between top and bottom, to measure occupancy."""
79
+ h = (bottom - top) // self.n_bands
80
+ return [(top + i * h, top + (i + 1) * h) for i in range(self.n_bands)]
81
+
82
+
83
+ @dataclass(frozen=True)
84
+ class EffectRule:
85
+ max_per_min: float
86
+ gap_min: float
87
+ max_in_row: int
88
+ dominant: bool
89
+ max_dur_s: float | None = None
90
+
91
+
92
+ @dataclass(frozen=True)
93
+ class CurveSection:
94
+ name: str
95
+ until_frac: float
96
+ weight: float
97
+
98
+
99
+ @dataclass(frozen=True)
100
+ class Budget:
101
+ gap_min_global: float
102
+ burst_gap_s: float
103
+ per_effect: dict[str, EffectRule]
104
+ curve: tuple[CurveSection, ...]
105
+
106
+ def rule(self, kind: str) -> EffectRule | None:
107
+ return self.per_effect.get(kind)
108
+
109
+ def section(self, t: float, total: float) -> CurveSection:
110
+ """Which part of the piece the instant t falls in."""
111
+ frac = (t / total) if total > 0 else 0.0
112
+ for s in self.curve:
113
+ if frac <= s.until_frac:
114
+ return s
115
+ return self.curve[-1]
116
+
117
+ def ceiling(self, kind: str, total: float) -> int:
118
+ """How many events of this kind fit in the whole piece (floor 1)."""
119
+ r = self.rule(kind)
120
+ if r is None:
121
+ return 0
122
+ return max(1, int(round(r.max_per_min * total / 60.0)))
123
+
124
+
125
+ @dataclass(frozen=True)
126
+ class SoundEvent:
127
+ key: str
128
+ sound: str
129
+ vol: float
130
+ when: str
131
+ triggers: tuple[str, ...] = ()
132
+ # Narrative weight (0..1) of the visual effect this event fires. Volume is
133
+ # mix, strength is weight. None falls back to vol, and says so.
134
+ strength: float | None = None
135
+
136
+
137
+ @dataclass(frozen=True)
138
+ class BedLayer:
139
+ """Optional continuous layer (ambience or music), relative to media_root."""
140
+ file: str
141
+ vol: float
142
+
143
+
144
+ @dataclass(frozen=True)
145
+ class Sound:
146
+ sfx_subdir: str
147
+ narration_volume: float
148
+ relative_ceiling: float
149
+ max_same_sound_in_row: int
150
+ collision_window_s: float
151
+ events: dict[str, SoundEvent]
152
+ reference_level_db: float = -20.0
153
+ max_peak_db: float = -3.0
154
+ bed: tuple[BedLayer, ...] = ()
155
+
156
+ def event(self, key: str) -> SoundEvent:
157
+ if key not in self.events:
158
+ known = ", ".join(sorted(self.events))
159
+ raise ConfigError(
160
+ f"unknown sound event: {key!r}. Events are semantic, not file "
161
+ f"names. Known: {known}")
162
+ return self.events[key]
163
+
164
+ def src(self, key: str) -> str:
165
+ """Path of the event's file relative to media_root (timeline `src`)."""
166
+ return f"{self.sfx_subdir}/{self.event(key).sound}.mp3"
167
+
168
+ def sounds_used(self) -> set[str]:
169
+ return {e.sound for e in self.events.values()}
170
+
171
+
172
+ @dataclass(frozen=True)
173
+ class Ducking:
174
+ depth_db: float
175
+ attack_s: float
176
+ release_s: float
177
+ assumed_sfx_duration_s: float
178
+
179
+
180
+ @dataclass(frozen=True)
181
+ class Loudness:
182
+ target_lufs: float
183
+ tolerance_lu: float
184
+ max_true_peak_db: float
185
+ lra: float
186
+ # Headroom the printed fix leaves under max_true_peak_db when it writes a
187
+ # lossy file (AAC/MP3): the encoder overshoots the peak loudnorm set.
188
+ encode_tp_margin_db: float = 1.0
189
+
190
+
191
+ @dataclass(frozen=True)
192
+ class Rhythm:
193
+ max_static_s: float
194
+ hook_s: float
195
+ hook_cut_every_s: float
196
+ body_cut_every_s: tuple[float, float]
197
+ cta_tail_s: float
198
+ counts_as_change: tuple[str, ...]
199
+
200
+
201
+ @dataclass(frozen=True)
202
+ class PaceProfile:
203
+ transition: str
204
+ sound_event: str
205
+
206
+
207
+ @dataclass(frozen=True)
208
+ class Breaths:
209
+ min_silence_s: float
210
+ silence_db: float
211
+ edge_guard_s: float
212
+ fast_speech_wps: float
213
+ punch_peak_scale: float
214
+ pace_profiles: dict[str, PaceProfile]
215
+ punch_sound_event: str
216
+
217
+
218
+ @dataclass(frozen=True)
219
+ class Avatar:
220
+ max_full_shot_s: float
221
+ max_occlusion_s: float
222
+ max_take_s: float
223
+ variation_step_s: float
224
+ min_variation_s: float
225
+
226
+
227
+ @dataclass(frozen=True)
228
+ class Config:
229
+ version: int
230
+ offer: str
231
+ source: Path
232
+ media_root: Path
233
+ frame: Frame
234
+ safe_zone_profile: str
235
+ safe_zones: dict[str, SafeZone]
236
+ safe_zone_gate: SafeZoneGate
237
+ density: Density
238
+ budget: Budget
239
+ sound: Sound
240
+ ducking: Ducking
241
+ loudness: Loudness
242
+ rhythm: Rhythm
243
+ breaths: Breaths
244
+ avatar: Avatar | None = None
245
+ extra: dict = field(default_factory=dict)
246
+
247
+ @property
248
+ def sfx_dir(self) -> Path:
249
+ return self.media_root / self.sound.sfx_subdir
250
+
251
+ def safe_zone(self, name: str | None = None) -> SafeZone:
252
+ """The named profile, or the default one when name is None."""
253
+ key = name or self.safe_zone_profile
254
+ if key not in self.safe_zones:
255
+ raise ConfigError(
256
+ f"unknown safe zone profile: {key!r}. "
257
+ f"Known: {', '.join(sorted(self.safe_zones))}")
258
+ return self.safe_zones[key]
259
+
260
+
261
+ # ---------------------------------------------------------------------- reading
262
+
263
+
264
+ def _need(d: dict, key: str, where: str):
265
+ if not isinstance(d, dict) or key not in d:
266
+ raise ConfigError(f"missing {where}.{key} in config")
267
+ return d[key]
268
+
269
+
270
+ def _notes_out(d: dict) -> dict:
271
+ """Drop `_note` style keys: they document, they never configure."""
272
+ return {k: v for k, v in d.items() if not k.startswith("_")}
273
+
274
+
275
+ def resolve_path(path: str | os.PathLike | None = None) -> Path:
276
+ """Where the config comes from, by the documented lookup order."""
277
+ if path:
278
+ return Path(path)
279
+ env = os.environ.get(ENV_CONFIG)
280
+ if env:
281
+ return Path(env)
282
+ return Path.cwd() / DEFAULT_NAME
283
+
284
+
285
+ def _read_json(p: Path) -> dict:
286
+ if not p.exists():
287
+ raise ConfigError(
288
+ f"config not found: {p}. Copy {TEMPLATE.name} from the skill's "
289
+ f"assets/ to your project as {DEFAULT_NAME}, or pass --config, or "
290
+ f"set {ENV_CONFIG}.")
291
+ try:
292
+ return json.loads(p.read_text(encoding="utf-8"))
293
+ except json.JSONDecodeError as e:
294
+ raise ConfigError(f"{p} is not valid JSON: {e}") from e
295
+
296
+
297
+ def _read_safe_zones(d: dict) -> dict[str, SafeZone]:
298
+ zones = {}
299
+ for name, z in _notes_out(_need(d, "safe_zones", "root")).items():
300
+ zones[name] = SafeZone(
301
+ name=name, top=int(_need(z, "top", name)),
302
+ bottom=int(_need(z, "bottom", name)),
303
+ left=int(_need(z, "left", name)), right=int(_need(z, "right", name)),
304
+ recommended_margin=int(z.get("recommended_margin", 0)))
305
+ if not zones:
306
+ raise ConfigError("safe_zones has no profile")
307
+ return zones
308
+
309
+
310
+ def _read_budget(d: dict) -> Budget:
311
+ b = _need(d, "budget", "root")
312
+ per_effect = {}
313
+ for kind, r in _notes_out(_need(b, "per_effect", "budget")).items():
314
+ per_effect[kind] = EffectRule(
315
+ max_per_min=float(_need(r, "max_per_min", kind)),
316
+ gap_min=float(_need(r, "gap_min", kind)),
317
+ max_in_row=int(_need(r, "max_in_row", kind)),
318
+ dominant=bool(r.get("dominant", False)),
319
+ max_dur_s=(float(r["max_dur_s"]) if "max_dur_s" in r else None))
320
+ curve = tuple(
321
+ CurveSection(name=c["name"], until_frac=float(c["until_frac"]),
322
+ weight=float(c["weight"]))
323
+ for c in _need(b, "curve", "budget"))
324
+ return Budget(gap_min_global=float(_need(b, "gap_min_global", "budget")),
325
+ burst_gap_s=float(_need(b, "burst_gap_s", "budget")),
326
+ per_effect=per_effect, curve=curve)
327
+
328
+
329
+ def _read_sound(d: dict, sfx_subdir: str) -> Sound:
330
+ s = _need(d, "sound", "root")
331
+ events = {}
332
+ for key, e in _notes_out(_need(s, "events", "sound")).items():
333
+ events[key] = SoundEvent(
334
+ key=key, sound=str(_need(e, "sound", key)),
335
+ vol=float(_need(e, "vol", key)), when=str(e.get("when", "")),
336
+ triggers=tuple(e.get("triggers", ())),
337
+ strength=(float(e["strength"]) if "strength" in e else None))
338
+ bed = tuple(
339
+ BedLayer(file=str(v["file"]), vol=float(v.get("vol", 0.0)))
340
+ for k, v in _notes_out(s.get("bed", {})).items()
341
+ if isinstance(v, dict) and "file" in v)
342
+ return Sound(
343
+ sfx_subdir=sfx_subdir,
344
+ narration_volume=float(s.get("narration_volume", 1.0)),
345
+ relative_ceiling=float(_need(s, "relative_ceiling", "sound")),
346
+ max_same_sound_in_row=int(_need(s, "max_same_sound_in_row", "sound")),
347
+ collision_window_s=float(_need(s, "collision_window_s", "sound")),
348
+ events=events,
349
+ reference_level_db=float(s.get("reference_level_db", -20.0)),
350
+ max_peak_db=float(s.get("max_peak_db", -3.0)), bed=bed)
351
+
352
+
353
+ def _read_rhythm(d: dict) -> Rhythm:
354
+ r = _need(d, "rhythm", "root")
355
+ body = _need(r, "body_cut_every_s", "rhythm")
356
+ if not (isinstance(body, list) and len(body) == 2):
357
+ raise ConfigError("rhythm.body_cut_every_s must be [min, max]")
358
+ return Rhythm(
359
+ max_static_s=float(_need(r, "max_static_s", "rhythm")),
360
+ hook_s=float(_need(r, "hook_s", "rhythm")),
361
+ hook_cut_every_s=float(_need(r, "hook_cut_every_s", "rhythm")),
362
+ body_cut_every_s=(float(body[0]), float(body[1])),
363
+ cta_tail_s=float(r.get("cta_tail_s", 0.0)),
364
+ counts_as_change=tuple(_need(r, "counts_as_change", "rhythm")))
365
+
366
+
367
+ def _read_breaths(d: dict) -> Breaths:
368
+ b = _need(d, "breaths", "root")
369
+ profiles = {
370
+ k: PaceProfile(transition=str(v["transition"]), sound_event=str(v["sound_event"]))
371
+ for k, v in _notes_out(_need(b, "pace_profiles", "breaths")).items()}
372
+ for k in ("first", "fast", "slow"):
373
+ if k not in profiles:
374
+ raise ConfigError(f"missing breaths.pace_profiles.{k} in config")
375
+ return Breaths(
376
+ min_silence_s=float(_need(b, "min_silence_s", "breaths")),
377
+ silence_db=float(_need(b, "silence_db", "breaths")),
378
+ edge_guard_s=float(_need(b, "edge_guard_s", "breaths")),
379
+ fast_speech_wps=float(_need(b, "fast_speech_wps", "breaths")),
380
+ punch_peak_scale=float(_need(b, "punch_peak_scale", "breaths")),
381
+ pace_profiles=profiles,
382
+ punch_sound_event=str(_need(b, "punch_sound_event", "breaths")))
383
+
384
+
385
+ def _read_avatar(d: dict) -> Avatar | None:
386
+ a = d.get("avatar")
387
+ if not isinstance(a, dict) or not _notes_out(a):
388
+ return None
389
+ return Avatar(
390
+ max_full_shot_s=float(_need(a, "max_full_shot_s", "avatar")),
391
+ max_occlusion_s=float(_need(a, "max_occlusion_s", "avatar")),
392
+ max_take_s=float(_need(a, "max_take_s", "avatar")),
393
+ variation_step_s=float(_need(a, "variation_step_s", "avatar")),
394
+ min_variation_s=float(_need(a, "min_variation_s", "avatar")))
395
+
396
+
397
+ def _read_small_blocks(d: dict) -> dict:
398
+ g = _need(d, "safe_zone_gate", "root")
399
+ den = _need(d, "density", "root")
400
+ du = _need(d, "ducking", "root")
401
+ lo = _need(d, "loudness", "root")
402
+ return {
403
+ "safe_zone_gate": SafeZoneGate(
404
+ dp_min=float(_need(g, "dp_min", "safe_zone_gate")),
405
+ edge_artifact_rows=int(g.get("edge_artifact_rows", 0)),
406
+ min_rows_to_fail=int(_need(g, "min_rows_to_fail", "safe_zone_gate")),
407
+ sample_width=int(_need(g, "sample_width", "safe_zone_gate"))),
408
+ "density": Density(
409
+ n_bands=int(_need(den, "n_bands", "density")),
410
+ min_filled=int(_need(den, "min_filled", "density")),
411
+ dp_min=float(_need(den, "dp_min", "density")),
412
+ step_s=float(_need(den, "step_s", "density")),
413
+ sample_width=int(_need(den, "sample_width", "density"))),
414
+ "ducking": Ducking(
415
+ depth_db=float(_need(du, "depth_db", "ducking")),
416
+ attack_s=float(_need(du, "attack_ms", "ducking")) / 1000.0,
417
+ release_s=float(_need(du, "release_ms", "ducking")) / 1000.0,
418
+ assumed_sfx_duration_s=float(_need(du, "assumed_sfx_duration_s", "ducking"))),
419
+ "loudness": Loudness(
420
+ target_lufs=float(_need(lo, "target_lufs", "loudness")),
421
+ tolerance_lu=float(_need(lo, "tolerance_lu", "loudness")),
422
+ max_true_peak_db=float(_need(lo, "max_true_peak_db", "loudness")),
423
+ lra=float(lo.get("lra", 11.0)),
424
+ encode_tp_margin_db=float(lo.get("encode_tp_margin_db", 1.0))),
425
+ }
426
+
427
+
428
+ def load(path: str | os.PathLike | None = None) -> Config:
429
+ """Read and validate the config. Fails instead of assuming a default."""
430
+ p = resolve_path(path)
431
+ d = _read_json(p)
432
+ paths = d.get("paths", {})
433
+ fr = _need(d, "frame", "root")
434
+ cfg = Config(
435
+ version=int(d.get("version", 1)),
436
+ offer=str(d.get("offer", "")),
437
+ source=p.resolve(),
438
+ media_root=(p.resolve().parent / paths.get("media_root", ".")),
439
+ frame=Frame(int(_need(fr, "width", "frame")), int(_need(fr, "height", "frame")),
440
+ int(_need(fr, "fps", "frame"))),
441
+ safe_zone_profile=str(_need(d, "safe_zone_profile", "root")),
442
+ safe_zones=_read_safe_zones(d),
443
+ budget=_read_budget(d),
444
+ sound=_read_sound(d, str(paths.get("sfx_subdir", "sfx"))),
445
+ rhythm=_read_rhythm(d),
446
+ breaths=_read_breaths(d),
447
+ avatar=_read_avatar(d),
448
+ **_read_small_blocks(d),
449
+ )
450
+ validate(cfg)
451
+ return cfg
452
+
453
+
454
+ # ------------------------------------------------------------------- validation
455
+
456
+
457
+ def _validate_zones(c: Config) -> None:
458
+ if c.safe_zone_profile not in c.safe_zones:
459
+ raise ConfigError(
460
+ f"safe_zone_profile {c.safe_zone_profile!r} is not in safe_zones "
461
+ f"({', '.join(sorted(c.safe_zones))})")
462
+ for z in c.safe_zones.values():
463
+ if z.top >= z.bottom:
464
+ raise ConfigError(f"safe_zones.{z.name}.top ({z.top}) >= bottom ({z.bottom})")
465
+ if z.left >= z.right:
466
+ raise ConfigError(f"safe_zones.{z.name}.left ({z.left}) >= right ({z.right})")
467
+ if z.top < 0 or z.left < 0 or z.bottom > c.frame.height or z.right > c.frame.width:
468
+ raise ConfigError(f"safe_zones.{z.name} leaves the frame")
469
+ if c.density.min_filled > c.density.n_bands:
470
+ raise ConfigError("density.min_filled is larger than density.n_bands")
471
+
472
+
473
+ def _validate_budget(c: Config) -> None:
474
+ fracs = [s.until_frac for s in c.budget.curve]
475
+ if not fracs:
476
+ raise ConfigError("budget.curve is empty")
477
+ if fracs != sorted(fracs):
478
+ raise ConfigError("budget.curve is not sorted by until_frac")
479
+ if abs(fracs[-1] - 1.0) > 1e-9:
480
+ raise ConfigError("the last budget.curve section must end at until_frac 1.0")
481
+ for kind, r in c.budget.per_effect.items():
482
+ # A per-effect gap below the global gap is a rule that contradicts
483
+ # itself: the engine would have to pick which one to ignore.
484
+ if r.dominant and r.gap_min < c.budget.gap_min_global:
485
+ raise ConfigError(
486
+ f"budget.per_effect.{kind}.gap_min ({r.gap_min}) is below "
487
+ f"gap_min_global ({c.budget.gap_min_global}) on a dominant effect")
488
+ if r.max_in_row < 1:
489
+ raise ConfigError(f"budget.per_effect.{kind}.max_in_row must be >= 1")
490
+
491
+
492
+ def _validate_sound(c: Config) -> None:
493
+ s = c.sound
494
+ ceiling = s.relative_ceiling * s.narration_volume
495
+ for key, e in s.events.items():
496
+ if not 0.0 <= e.vol <= 1.0:
497
+ raise ConfigError(f"sound.events.{key}.vol outside 0..1: {e.vol}")
498
+ if e.vol > ceiling:
499
+ raise ConfigError(
500
+ f"sound.events.{key}.vol ({e.vol}) exceeds the ceiling relative "
501
+ f"to narration ({ceiling}). A SFX that competes with the voice "
502
+ f"steals attention instead of marking it.")
503
+ if e.strength is not None and not 0.0 <= e.strength <= 1.0:
504
+ raise ConfigError(f"sound.events.{key}.strength outside 0..1: {e.strength}")
505
+ for t in e.triggers:
506
+ if t not in c.budget.per_effect:
507
+ raise ConfigError(
508
+ f"sound.events.{key}.triggers names effect {t!r}, which has "
509
+ f"no rule in budget.per_effect")
510
+ used = [p.sound_event for p in c.breaths.pace_profiles.values()]
511
+ for ev in used + [c.breaths.punch_sound_event]:
512
+ if ev not in s.events:
513
+ raise ConfigError(f"breaths names sound event {ev!r}, not in sound.events")
514
+
515
+
516
+ def _validate_audio_blocks(c: Config) -> None:
517
+ d, lo, r = c.ducking, c.loudness, c.rhythm
518
+ if d.depth_db <= 0 or d.attack_s <= 0 or d.release_s <= 0:
519
+ raise ConfigError("ducking.depth_db, attack_ms and release_ms must be > 0")
520
+ if lo.tolerance_lu <= 0:
521
+ raise ConfigError("loudness.tolerance_lu must be > 0")
522
+ if lo.encode_tp_margin_db < 0:
523
+ raise ConfigError("loudness.encode_tp_margin_db must be >= 0")
524
+ if lo.max_true_peak_db > 0:
525
+ raise ConfigError("loudness.max_true_peak_db must be <= 0 dBTP")
526
+ lo_b, hi_b = r.body_cut_every_s
527
+ if not 0 < lo_b <= hi_b:
528
+ raise ConfigError("rhythm.body_cut_every_s must be 0 < min <= max")
529
+ if r.hook_s <= 0 or r.max_static_s <= 0 or r.hook_cut_every_s <= 0:
530
+ raise ConfigError("rhythm.hook_s, max_static_s and hook_cut_every_s must be > 0")
531
+ if c.breaths.punch_peak_scale < 1.0:
532
+ raise ConfigError("breaths.punch_peak_scale must be >= 1.0")
533
+
534
+
535
+ def validate(c: Config) -> None:
536
+ """The incoherences that already cost a wrong render."""
537
+ _validate_zones(c)
538
+ _validate_budget(c)
539
+ _validate_sound(c)
540
+ _validate_audio_blocks(c)
541
+
542
+
543
+ # -------------------------------------------------------------------- CLI glue
544
+
545
+
546
+ def add_config_arg(parser: argparse.ArgumentParser) -> None:
547
+ parser.add_argument(
548
+ "--config", metavar="PATH",
549
+ help=f"effects config JSON (default: ${ENV_CONFIG}, then ./{DEFAULT_NAME})")
550
+
551
+
552
+ def _print_summary(c: Config) -> None:
553
+ print(f"config: {c.source}")
554
+ print(f"offer: {c.offer or '(empty)'} version {c.version}")
555
+ print(f"frame: {c.frame.width}x{c.frame.height} @ {c.frame.fps}fps")
556
+ for z in c.safe_zones.values():
557
+ mark = " [default]" if z.name == c.safe_zone_profile else ""
558
+ print(f"safe zone {z.name:8} x={z.left}..{z.right} y={z.top}..{z.bottom}{mark}")
559
+ print("budget (55s piece):")
560
+ for kind, r in c.budget.per_effect.items():
561
+ print(f" {kind:10} ceiling {c.budget.ceiling(kind, 55.0):3} gap {r.gap_min:.1f}s"
562
+ f" max {r.max_in_row} in a row{' [dominant]' if r.dominant else ''}")
563
+ print(f"sfx dir: {c.sfx_dir}")
564
+ for key, e in c.sound.events.items():
565
+ print(f" {key:12} -> {e.sound:12} @{e.vol:.2f} {e.when}")
566
+
567
+
568
+ def main(argv: list[str] | None = None) -> int:
569
+ ap = argparse.ArgumentParser(description="Validate and print the effects config in force.")
570
+ add_config_arg(ap)
571
+ args = ap.parse_args(argv)
572
+ try:
573
+ c = load(args.config)
574
+ except ConfigError as e:
575
+ print(f"INVALID CONFIG: {e}")
576
+ return 1
577
+ _print_summary(c)
578
+ print("ok: config is valid.")
579
+ return 0
580
+
581
+
582
+ if __name__ == "__main__":
583
+ raise SystemExit(main())