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.
- package/package.json +1 -1
- package/skills-registry.yaml +19 -5
- package/src/modules/lowticket/README.md +1 -1
- package/src/modules/lowticket/skills/wizz-lowticket-guia/SKILL.md +1 -1
- package/src/modules/lowticket/skills/wizz-lowticket-metodologia/SKILL.md +1 -1
- package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/02-diagnostico-funil.md +7 -5
- package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/03-campanha-bidcap.md +9 -10
- package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/04-criativos.md +3 -3
- package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/04a-criativos-ia.md +6 -6
- package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/04b-criativos-formatos.md +1 -1
- package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/04c-criativos-roteiro.md +3 -3
- package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/06-pagina-vendas.md +2 -2
- package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/06a-instagram.md +2 -2
- package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/07-funil-upsell.md +2 -2
- package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/11-checklist-roi.md +5 -5
- package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/12-mineracao-ofertas.md +4 -4
- package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/12a-mineracao-veredito.md +3 -3
- package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/12b-fabrica-de-ofertas.md +11 -11
- package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/12c-anatomia-low-ticket.md +2 -2
- package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/14-vocabulario.md +1 -1
- package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/15-como-estender.md +1 -1
- package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/18-copy-desejo-oferta.md +2 -2
- package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/INDEX.md +12 -12
- package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/guardrails.md +41 -55
- package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/pontos-em-aberto.md +24 -24
- package/src/modules/lowticket/skills/wizz-lowticket-minerador/SKILL.md +7 -7
- package/src/modules/wizz/_shared/video-pipeline.md +11 -3
- package/src/modules/wizz/agents/wizz-designer/SKILL.md +1 -1
- package/src/modules/wizz/agents/wizz-social/SKILL.md +1 -0
- package/src/skills-lib/wizz-remotion-best-practices/rules/sfx.md +3 -1
- package/src/skills-lib/wizz-router/references/routing-table-flat.md +1 -0
- package/src/skills-lib/wizz-video-effects/SKILL.md +85 -0
- package/src/skills-lib/wizz-video-effects/assets/effects.config.template.json +178 -0
- package/src/skills-lib/wizz-video-effects/assets/remotion/EffectsLayer.tsx +87 -0
- package/src/skills-lib/wizz-video-effects/assets/remotion/README.md +37 -0
- package/src/skills-lib/wizz-video-effects/assets/remotion/useEffects.ts +120 -0
- package/src/skills-lib/wizz-video-effects/assets/sfx/CREDITS.md +56 -0
- package/src/skills-lib/wizz-video-effects/assets/sfx/cash.mp3 +0 -0
- package/src/skills-lib/wizz-video-effects/assets/sfx/chime.mp3 +0 -0
- package/src/skills-lib/wizz-video-effects/assets/sfx/click-soft.mp3 +0 -0
- package/src/skills-lib/wizz-video-effects/assets/sfx/click.mp3 +0 -0
- package/src/skills-lib/wizz-video-effects/assets/sfx/coin.mp3 +0 -0
- package/src/skills-lib/wizz-video-effects/assets/sfx/error.mp3 +0 -0
- package/src/skills-lib/wizz-video-effects/assets/sfx/impact-bass-1.mp3 +0 -0
- package/src/skills-lib/wizz-video-effects/assets/sfx/impact-bass-2.mp3 +0 -0
- package/src/skills-lib/wizz-video-effects/assets/sfx/key-press.mp3 +0 -0
- package/src/skills-lib/wizz-video-effects/assets/sfx/notif.mp3 +0 -0
- package/src/skills-lib/wizz-video-effects/assets/sfx/notification.mp3 +0 -0
- package/src/skills-lib/wizz-video-effects/assets/sfx/paste.mp3 +0 -0
- package/src/skills-lib/wizz-video-effects/assets/sfx/ping.mp3 +0 -0
- package/src/skills-lib/wizz-video-effects/assets/sfx/pop.mp3 +0 -0
- package/src/skills-lib/wizz-video-effects/assets/sfx/riser-metal.mp3 +0 -0
- package/src/skills-lib/wizz-video-effects/assets/sfx/riser.mp3 +0 -0
- package/src/skills-lib/wizz-video-effects/assets/sfx/send.mp3 +0 -0
- package/src/skills-lib/wizz-video-effects/assets/sfx/sparkle.mp3 +0 -0
- package/src/skills-lib/wizz-video-effects/assets/sfx/stamp.mp3 +0 -0
- package/src/skills-lib/wizz-video-effects/assets/sfx/success.mp3 +0 -0
- package/src/skills-lib/wizz-video-effects/assets/sfx/type.mp3 +0 -0
- package/src/skills-lib/wizz-video-effects/assets/sfx/typing.mp3 +0 -0
- package/src/skills-lib/wizz-video-effects/assets/sfx/whoosh-cinematic.mp3 +0 -0
- package/src/skills-lib/wizz-video-effects/assets/sfx/whoosh-short.mp3 +0 -0
- package/src/skills-lib/wizz-video-effects/assets/sfx/whoosh.mp3 +0 -0
- package/src/skills-lib/wizz-video-effects/references/audio.md +87 -0
- package/src/skills-lib/wizz-video-effects/references/data-contract.md +145 -0
- package/src/skills-lib/wizz-video-effects/references/do-not-use.md +54 -0
- package/src/skills-lib/wizz-video-effects/references/editing-patterns.md +58 -0
- package/src/skills-lib/wizz-video-effects/references/gates.md +37 -0
- package/src/skills-lib/wizz-video-effects/references/sfx-licensing.md +40 -0
- package/src/skills-lib/wizz-video-effects/references/when-to-use.md +129 -0
- package/src/skills-lib/wizz-video-effects/scripts/budget.py +211 -0
- package/src/skills-lib/wizz-video-effects/scripts/check_excess.py +218 -0
- package/src/skills-lib/wizz-video-effects/scripts/check_loudness.py +175 -0
- package/src/skills-lib/wizz-video-effects/scripts/check_rhythm.py +137 -0
- package/src/skills-lib/wizz-video-effects/scripts/check_safe_zone.py +221 -0
- package/src/skills-lib/wizz-video-effects/scripts/config.py +583 -0
- package/src/skills-lib/wizz-video-effects/scripts/fill_breaths.py +355 -0
- package/src/skills-lib/wizz-video-effects/scripts/measure_density.py +122 -0
- package/src/skills-lib/wizz-video-effects/scripts/normalize_audio.py +189 -0
- package/src/skills-lib/wizz-video-effects/scripts/sound.py +342 -0
- package/src/skills-lib/wizz-video-effects/scripts/test_budget.py +131 -0
- package/src/skills-lib/wizz-video-effects/scripts/test_check_loudness.py +156 -0
- package/src/skills-lib/wizz-video-effects/scripts/test_checks.py +190 -0
- package/src/skills-lib/wizz-video-effects/scripts/test_config.py +135 -0
- package/src/skills-lib/wizz-video-effects/scripts/test_fill_breaths.py +179 -0
- package/src/skills-lib/wizz-video-effects/scripts/test_sound.py +173 -0
- 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())
|