wizz-method 2.11.2 → 2.13.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 (83) hide show
  1. package/README.md +2 -1
  2. package/package.json +1 -1
  3. package/skills-registry.yaml +38 -29
  4. package/src/bmm-skills/module.yaml +1 -1
  5. package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/04-criativos.md +1 -1
  6. package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/11-checklist-roi.md +1 -1
  7. package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/12-mineracao-ofertas.md +1 -1
  8. package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/pontos-em-aberto.md +2 -2
  9. package/src/modules/wizz/_shared/video-pipeline.md +12 -4
  10. package/src/modules/wizz/agents/wizz-designer/SKILL.md +1 -1
  11. package/src/modules/wizz/agents/wizz-maestro/SKILL.md +1 -1
  12. package/src/modules/wizz/agents/wizz-social/SKILL.md +1 -0
  13. package/src/skills-lib/wizz-remotion-best-practices/rules/sfx.md +3 -1
  14. package/src/skills-lib/wizz-router/references/routing-table-flat.md +3 -2
  15. package/src/skills-lib/wizz-video-effects/SKILL.md +85 -0
  16. package/src/skills-lib/wizz-video-effects/assets/effects.config.template.json +178 -0
  17. package/src/skills-lib/wizz-video-effects/assets/remotion/EffectsLayer.tsx +87 -0
  18. package/src/skills-lib/wizz-video-effects/assets/remotion/README.md +37 -0
  19. package/src/skills-lib/wizz-video-effects/assets/remotion/useEffects.ts +120 -0
  20. package/src/skills-lib/wizz-video-effects/assets/sfx/CREDITS.md +56 -0
  21. package/src/skills-lib/wizz-video-effects/assets/sfx/cash.mp3 +0 -0
  22. package/src/skills-lib/wizz-video-effects/assets/sfx/chime.mp3 +0 -0
  23. package/src/skills-lib/wizz-video-effects/assets/sfx/click-soft.mp3 +0 -0
  24. package/src/skills-lib/wizz-video-effects/assets/sfx/click.mp3 +0 -0
  25. package/src/skills-lib/wizz-video-effects/assets/sfx/coin.mp3 +0 -0
  26. package/src/skills-lib/wizz-video-effects/assets/sfx/error.mp3 +0 -0
  27. package/src/skills-lib/wizz-video-effects/assets/sfx/impact-bass-1.mp3 +0 -0
  28. package/src/skills-lib/wizz-video-effects/assets/sfx/impact-bass-2.mp3 +0 -0
  29. package/src/skills-lib/wizz-video-effects/assets/sfx/key-press.mp3 +0 -0
  30. package/src/skills-lib/wizz-video-effects/assets/sfx/notif.mp3 +0 -0
  31. package/src/skills-lib/wizz-video-effects/assets/sfx/notification.mp3 +0 -0
  32. package/src/skills-lib/wizz-video-effects/assets/sfx/paste.mp3 +0 -0
  33. package/src/skills-lib/wizz-video-effects/assets/sfx/ping.mp3 +0 -0
  34. package/src/skills-lib/wizz-video-effects/assets/sfx/pop.mp3 +0 -0
  35. package/src/skills-lib/wizz-video-effects/assets/sfx/riser-metal.mp3 +0 -0
  36. package/src/skills-lib/wizz-video-effects/assets/sfx/riser.mp3 +0 -0
  37. package/src/skills-lib/wizz-video-effects/assets/sfx/send.mp3 +0 -0
  38. package/src/skills-lib/wizz-video-effects/assets/sfx/sparkle.mp3 +0 -0
  39. package/src/skills-lib/wizz-video-effects/assets/sfx/stamp.mp3 +0 -0
  40. package/src/skills-lib/wizz-video-effects/assets/sfx/success.mp3 +0 -0
  41. package/src/skills-lib/wizz-video-effects/assets/sfx/type.mp3 +0 -0
  42. package/src/skills-lib/wizz-video-effects/assets/sfx/typing.mp3 +0 -0
  43. package/src/skills-lib/wizz-video-effects/assets/sfx/whoosh-cinematic.mp3 +0 -0
  44. package/src/skills-lib/wizz-video-effects/assets/sfx/whoosh-short.mp3 +0 -0
  45. package/src/skills-lib/wizz-video-effects/assets/sfx/whoosh.mp3 +0 -0
  46. package/src/skills-lib/wizz-video-effects/references/audio.md +87 -0
  47. package/src/skills-lib/wizz-video-effects/references/data-contract.md +145 -0
  48. package/src/skills-lib/wizz-video-effects/references/do-not-use.md +54 -0
  49. package/src/skills-lib/wizz-video-effects/references/editing-patterns.md +58 -0
  50. package/src/skills-lib/wizz-video-effects/references/gates.md +37 -0
  51. package/src/skills-lib/wizz-video-effects/references/sfx-licensing.md +40 -0
  52. package/src/skills-lib/wizz-video-effects/references/when-to-use.md +129 -0
  53. package/src/skills-lib/wizz-video-effects/scripts/budget.py +211 -0
  54. package/src/skills-lib/wizz-video-effects/scripts/check_excess.py +218 -0
  55. package/src/skills-lib/wizz-video-effects/scripts/check_loudness.py +175 -0
  56. package/src/skills-lib/wizz-video-effects/scripts/check_rhythm.py +137 -0
  57. package/src/skills-lib/wizz-video-effects/scripts/check_safe_zone.py +221 -0
  58. package/src/skills-lib/wizz-video-effects/scripts/config.py +583 -0
  59. package/src/skills-lib/wizz-video-effects/scripts/fill_breaths.py +355 -0
  60. package/src/skills-lib/wizz-video-effects/scripts/measure_density.py +122 -0
  61. package/src/skills-lib/wizz-video-effects/scripts/normalize_audio.py +189 -0
  62. package/src/skills-lib/wizz-video-effects/scripts/sound.py +342 -0
  63. package/src/skills-lib/wizz-video-effects/scripts/test_budget.py +131 -0
  64. package/src/skills-lib/wizz-video-effects/scripts/test_check_loudness.py +156 -0
  65. package/src/skills-lib/wizz-video-effects/scripts/test_checks.py +190 -0
  66. package/src/skills-lib/wizz-video-effects/scripts/test_config.py +135 -0
  67. package/src/skills-lib/wizz-video-effects/scripts/test_fill_breaths.py +179 -0
  68. package/src/skills-lib/wizz-video-effects/scripts/test_sound.py +173 -0
  69. package/src/skills-lib/wizz-video-effects/scripts/timeline.py +85 -0
  70. package/tools/installer/banner.js +80 -29
  71. package/tools/installer/commands/catalog.js +87 -0
  72. package/tools/installer/commands/install.js +9 -0
  73. package/tools/installer/guided/detect-tools.js +53 -0
  74. package/tools/installer/guided/flow.js +203 -0
  75. package/tools/installer/guided/labels.js +185 -0
  76. package/tools/installer/guided/messages.js +132 -0
  77. package/tools/installer/guided/outro.js +32 -0
  78. package/tools/installer/guided/profiles.js +95 -0
  79. package/tools/installer/guided/steps.js +154 -0
  80. package/tools/installer/install-messages.yaml +3 -3
  81. package/tools/installer/modules/external-manager.js +4 -0
  82. package/tools/installer/ui.js +57 -22
  83. package/wizz-modules.yaml +1 -0
@@ -0,0 +1,355 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Anchor effects in the real breaths of the speech.
3
+
4
+ Why it exists. A generator that picks transitions by position left pieces
5
+ with 6s+ holes without any event and identical transition sequences. The
6
+ right rhythm is already in the audio: in the worst measured hole there were
7
+ six breaths spaced 2.4 to 2.9s apart, exactly the cadence vertical video asks
8
+ for. This script reads those breaths and uses them as anchors.
9
+
10
+ The breath says WHEN, the screen says WHAT: where the frame has room at the
11
+ edges a `punch` (scale, the strongest effect) fits; where content is glued to
12
+ the safe zone edge the punch would push it out at its peak
13
+ (breaths.punch_peak_scale), so a `flash` (opacity only) takes the breath.
14
+ Refusing the breath would waste the one place where an effect fits without
15
+ stepping on speech. Without --visual every breath is treated as a flash.
16
+
17
+ Holes are closed from the widest down, never past the budget of the chosen
18
+ kind (budget.ceiling) nor its gap_min, and never wider than budget.burst_gap_s.
19
+ Every new punch asks for a sound (breaths.punch_sound_event), evaluated
20
+ TOGETHER with the sounds already in the timeline. Scene transitions and cut
21
+ sounds follow the pace of speech in each scene (breaths.pace_profiles).
22
+
23
+ Breath source: --narration FILE[@OFFSET] (ffmpeg silencedetect with
24
+ breaths.silence_db / breaths.min_silence_s), otherwise the gaps between
25
+ words[] (ctc-align output, in the timeline or via --words).
26
+
27
+ Deterministic: same input, same output. Runs after the timeline is generated
28
+ and before render; it reports only unless --write or --out is given.
29
+ """
30
+ from __future__ import annotations
31
+
32
+ import argparse
33
+ import json
34
+ import math
35
+ import re
36
+ import subprocess
37
+ import sys
38
+ from pathlib import Path
39
+
40
+ import sound
41
+ import timeline as tl
42
+ from check_safe_zone import ReadError, content_rows, read_frame
43
+ from config import Config, ConfigError, SafeZone, add_config_arg, load
44
+
45
+ # ---------------------------------------------------------------- breath sources
46
+
47
+
48
+ def breaths_from_audio(path: Path, cfg: Config) -> list[tuple[float, float]]:
49
+ """(start, end) silence windows in an audio file."""
50
+ b = cfg.breaths
51
+ out = subprocess.run(
52
+ ["ffmpeg", "-hide_banner", "-i", str(path), "-af",
53
+ f"silencedetect=n={b.silence_db:g}dB:d={b.min_silence_s:g}", "-f", "null", "-"],
54
+ capture_output=True, text=True).stderr
55
+ starts = [float(x) for x in re.findall(r"silence_start: (-?[\d.]+)", out)]
56
+ ends = [float(x) for x in re.findall(r"silence_end: (-?[\d.]+)", out)]
57
+ return list(zip(starts, ends))
58
+
59
+
60
+ def breaths_from_words(words: list[dict], cfg: Config) -> list[tuple[float, float]]:
61
+ """Gaps between consecutive words at least breaths.min_silence_s long."""
62
+ ws = sorted(words, key=lambda w: float(w["start"]))
63
+ return [(float(a["end"]), float(b["start"])) for a, b in zip(ws, ws[1:])
64
+ if float(b["start"]) - float(a["end"]) >= cfg.breaths.min_silence_s]
65
+
66
+
67
+ def candidates_from(breaths: list[tuple[float, float]], offset: float, total: float,
68
+ cfg: Config) -> list[float]:
69
+ """Middle of each breath (never glued to a word), inside the edge guard."""
70
+ g = cfg.breaths.edge_guard_s
71
+ mids = (round(offset + (a + b) / 2, 3) for a, b in breaths)
72
+ return sorted(t for t in mids if g < t < total - g)
73
+
74
+
75
+ # ------------------------------------------------------------ punch or flash
76
+
77
+
78
+ def punch_band(zone: SafeZone, cfg: Config) -> tuple[int, int]:
79
+ """(min top row, max bottom row) content may occupy so that a punch at
80
+ breaths.punch_peak_scale, scaling around the frame centre, stays inside."""
81
+ c, k = cfg.frame.height / 2.0, cfg.breaths.punch_peak_scale
82
+ return math.ceil(c - (c - zone.top) / k), math.floor(c + (zone.bottom - c) / k)
83
+
84
+
85
+ def effect_for_frame(frame: bytes, zone: SafeZone, cfg: Config) -> str:
86
+ """'punch' when the content fits the punch band, else 'flash'. Measured
87
+ exactly like the safe-zone gate: measuring differently would approve what
88
+ the gate fails."""
89
+ g = cfg.safe_zone_gate
90
+ rows = content_rows(frame, g.sample_width, cfg.frame.height, g.dp_min, g.edge_artifact_rows)
91
+ if not rows:
92
+ return "punch"
93
+ top, bottom = punch_band(zone, cfg)
94
+ return "punch" if rows[0][0] >= top and rows[-1][0] <= bottom else "flash"
95
+
96
+
97
+ # -------------------------------------------------------------- hole filling
98
+
99
+
100
+ def _holes(line: list[float], burst: float, skip: set) -> list[tuple[float, float, float]]:
101
+ return sorted(((b - a, a, b) for a, b in zip(line, line[1:])
102
+ if b - a > burst and (a, b) not in skip), reverse=True)
103
+
104
+
105
+ def _has_budget(t, effect_of, used, ceilings) -> bool:
106
+ kind = "punch" if effect_of.get(t) == "punch" else "flash"
107
+ return used[kind] < ceilings[kind]
108
+
109
+
110
+ def fill_holes(candidates: list[float], existing: list[float], effect_of: dict[float, str],
111
+ total: float, punches: list[float], flashes: list[float], cfg: Config):
112
+ """Close holes wider than budget.burst_gap_s with breaths, widest first,
113
+ re-evaluated after each pick (a split hole may still be too wide).
114
+
115
+ Returns (new, no_breath, no_budget, stopped_at_punch_ceiling):
116
+ new chosen instants, in decision order
117
+ no_breath holes (a, b) where speech does not breathe
118
+ no_budget holes that breathe but whose kinds ran out of budget
119
+ stopped... (n, ceiling) the first time a punch was left out by the
120
+ ceiling (not by gap), or None
121
+ """
122
+ rule = cfg.budget.rule("punch")
123
+ if rule is None:
124
+ raise ConfigError("budget.per_effect has no rule for 'punch'")
125
+ gap, burst = rule.gap_min, cfg.budget.burst_gap_s
126
+ ceilings = {"punch": cfg.budget.ceiling("punch", total),
127
+ "flash": cfg.budget.ceiling("flash", total)}
128
+ used = {"punch": len(punches), "flash": len(flashes)}
129
+ new: list[float] = []
130
+ no_breath, no_budget, stopped = set(), set(), None
131
+ while True:
132
+ holes = _holes(sorted(set(existing + new + [0.0, total])), burst, no_breath | no_budget)
133
+ if not holes:
134
+ break
135
+ _, a, b = holes[0]
136
+ inside = [t for t in candidates if t not in new and a + gap <= t <= b - gap]
137
+ if not inside:
138
+ no_breath.add((a, b))
139
+ continue
140
+ funded = [t for t in inside if _has_budget(t, effect_of, used, ceilings)]
141
+ if not funded:
142
+ no_budget.add((a, b))
143
+ if stopped is None and used["punch"] >= ceilings["punch"] \
144
+ and any(effect_of.get(t) == "punch" for t in inside):
145
+ stopped = (used["punch"], ceilings["punch"])
146
+ continue
147
+ pick = min(funded, key=lambda t: abs(t - (a + b) / 2))
148
+ new.append(pick)
149
+ used["punch" if effect_of.get(pick) == "punch" else "flash"] += 1
150
+ return new, no_breath, no_budget, stopped
151
+
152
+
153
+ # -------------------------------------------------------------------- sounds
154
+
155
+
156
+ event_of = sound.event_of # kept here for callers of the old location
157
+
158
+
159
+ def request_punch_sounds(new_punches: list[float], existing: list[dict], cfg: Config):
160
+ """One `breaths.punch_sound_event` request per new punch (a mute punch is
161
+ half a punch), mapped TOGETHER with the sounds already there, so a new
162
+ request cannot slip past a collision or a same-sound run.
163
+
164
+ Returns (new_shots, new_refusals, unrecoverable, displaced):
165
+ unrecoverable existing entries whose event cannot be rebuilt (left out
166
+ of the joint evaluation, reported)
167
+ displaced existing instants the joint evaluation would no longer
168
+ accept; they are reported, never rewritten silently
169
+ """
170
+ old, unrecoverable = [], []
171
+ for e in existing:
172
+ ev = event_of(e, cfg)
173
+ if ev:
174
+ old.append((float(e["at"]), ev))
175
+ else:
176
+ unrecoverable.append(e)
177
+ req = [(t, cfg.breaths.punch_sound_event) for t in new_punches]
178
+ res = sound.map_requests(old + req, cfg)
179
+ by_t = {s.t: s for s in res.shots}
180
+ displaced = sorted({t for t, _ in old} - set(by_t))
181
+ shots = [by_t[t] for t in new_punches if t in by_t]
182
+ news = set(new_punches)
183
+ return shots, [r for r in res.refusals if r.t in news], unrecoverable, displaced
184
+
185
+
186
+ # ---------------------------------------------------------------- scene pace
187
+
188
+
189
+ def words_per_second(words: list[dict], start: float, end: float) -> float:
190
+ n = sum(1 for w in words if start <= float(w["start"]) < end)
191
+ return n / (end - start) if end > start else 0.0
192
+
193
+
194
+ def pace_profile(index: int, wps: float, cfg: Config) -> str:
195
+ """'first' for the opening scene (a direct entry, nothing competing with
196
+ the hook), then 'fast' for burst speech, 'slow' for dragging speech."""
197
+ if index == 0:
198
+ return "first"
199
+ return "fast" if wps >= cfg.breaths.fast_speech_wps else "slow"
200
+
201
+
202
+ def scene_updates(scenes: list[dict], words: list[dict], cfg: Config) -> list[tuple[dict, float]]:
203
+ """(new scene dict, words per second) with transition and cut sound by pace."""
204
+ out = []
205
+ for i, sc in enumerate(scenes):
206
+ start = float(sc["start"])
207
+ wps = words_per_second(words, start, start + float(sc.get("dur", 0.0)))
208
+ prof = cfg.breaths.pace_profiles[pace_profile(i, wps, cfg)]
209
+ ev = cfg.sound.event(prof.sound_event)
210
+ out.append(({**sc, "transition": prof.transition,
211
+ "sfx": {"event": prof.sound_event, "src": cfg.sound.src(prof.sound_event),
212
+ "vol": ev.vol}}, round(wps, 2)))
213
+ return out
214
+
215
+
216
+ # ------------------------------------------------------------------ pipeline
217
+
218
+
219
+ def _narration_breaths(specs: list[str], cfg: Config, total: float) -> list[float]:
220
+ """Candidates from --narration FILE[@OFFSET] entries."""
221
+ out: list[float] = []
222
+ for spec in specs:
223
+ path, _, off = spec.partition("@")
224
+ p = Path(path)
225
+ if not p.exists():
226
+ raise ReadError(f"narration not found: {p}")
227
+ out += candidates_from(breaths_from_audio(p, cfg), float(off or 0.0), total, cfg)
228
+ return sorted(set(out))
229
+
230
+
231
+ def _effects_by_frame(cands: list[float], visual: str | None, zone: SafeZone,
232
+ cfg: Config) -> dict[float, str]:
233
+ if not visual:
234
+ return {t: "flash" for t in cands}
235
+ w, h = cfg.safe_zone_gate.sample_width, cfg.frame.height
236
+ return {t: effect_for_frame(read_frame(visual, t, w, h), zone, cfg) for t in cands}
237
+
238
+
239
+ def plan(data: dict, cfg: Config, cands: list[float], effect_of: dict[float, str]) -> dict:
240
+ """Everything the script would change, as a report dict (pure)."""
241
+ total, _ = tl.duration(data)
242
+ words = data.get("words") or []
243
+ scenes = scene_updates(data.get("scenes") or [], words, cfg)
244
+ punches = tl.instants(data.get("punches"))
245
+ flashes = tl.instants(data.get("flashes"))
246
+ existing = sorted(punches + flashes + tl.instants(data.get("shakes"))
247
+ + tl.instants(data.get("interrupts"))
248
+ + [float(sc["start"]) for sc, _ in scenes])
249
+ new, no_breath, no_budget, stopped = fill_holes(
250
+ cands, existing, effect_of, total, punches, flashes, cfg)
251
+ new_p = sorted(t for t in new if effect_of.get(t) == "punch")
252
+ new_f = sorted(t for t in new if effect_of.get(t) != "punch")
253
+ after = {**data, "scenes": [sc for sc, _ in scenes]}
254
+ shots, refused, unrec, displaced = request_punch_sounds(new_p, tl.sounds(after), cfg)
255
+ return {"total": total, "candidates": cands, "existing": existing, "scenes": scenes,
256
+ "new_punches": new_p, "new_flashes": new_f, "shots": shots, "refused": refused,
257
+ "unrecoverable": unrec, "displaced": displaced, "stopped": stopped,
258
+ "no_breath": sorted(no_breath), "no_budget": sorted(no_budget)}
259
+
260
+
261
+ def apply(data: dict, p: dict) -> dict:
262
+ """The new timeline (a new dict; the input is not mutated)."""
263
+ extra = [{"at": round(s.t, 3), "event": s.event, "src": s.src, "vol": s.vol}
264
+ for s in p["shots"]]
265
+ return {**data,
266
+ "scenes": [sc for sc, _ in p["scenes"]],
267
+ "punches": sorted(set(tl.instants(data.get("punches")) + p["new_punches"])),
268
+ "flashes": sorted(set(tl.instants(data.get("flashes")) + p["new_flashes"])),
269
+ "sfx": sorted(list(data.get("sfx") or []) + extra, key=lambda s: float(s["at"]))}
270
+
271
+
272
+ def _largest_hole(events: list[float], total: float) -> float:
273
+ ev = sorted(set(events + [0.0, total]))
274
+ return max((b - a for a, b in zip(ev, ev[1:])), default=0.0)
275
+
276
+
277
+ def print_plan(name: str, p: dict) -> None:
278
+ total, ex = p["total"], p["existing"]
279
+ new = p["new_punches"] + p["new_flashes"]
280
+ print(f"== {name} == {total:.1f}s, {len(p['scenes'])} scenes")
281
+ print(f" breaths found : {len(p['candidates'])}")
282
+ print(f" new events : {len(new)} punch={p['new_punches']} flash={p['new_flashes']}")
283
+ print(f" largest hole : {_largest_hole(ex, total):.1f}s -> "
284
+ f"{_largest_hole(ex + new, total):.1f}s")
285
+ print(f" punch sounds : asked={len(p['new_punches'])} accepted={len(p['shots'])} "
286
+ f"refused={len(p['refused'])}")
287
+ for r in p["refused"]:
288
+ print(f" t={r.t:.2f}s [{r.rule}] {r.detail}")
289
+ if p["unrecoverable"]:
290
+ print(f" warning: {len(p['unrecoverable'])} existing sound(s) without a rebuildable "
291
+ f"event were left out of the joint sound evaluation")
292
+ if p["displaced"]:
293
+ print(f" warning: {len(p['displaced'])} existing sound(s) would no longer be accepted "
294
+ f"jointly; they were not touched: {p['displaced']}")
295
+ if p["stopped"]:
296
+ print(f" stopped at {p['stopped'][0]} punches, ceiling is {p['stopped'][1]}")
297
+ if p["no_breath"]:
298
+ print(" holes without breath : " + ", ".join(f"{a:.1f}-{b:.1f}s" for a, b in p["no_breath"])
299
+ + " <- look at the script, not the edit")
300
+ if p["no_budget"]:
301
+ print(" holes without budget : " + ", ".join(f"{a:.1f}-{b:.1f}s" for a, b in p["no_budget"]))
302
+ for sc, wps in p["scenes"]:
303
+ print(f" scene at {float(sc['start']):6.2f}s: {sc['transition']:5} + "
304
+ f"{sc['sfx']['event']} ({wps} words/s)")
305
+
306
+
307
+ def _parser() -> argparse.ArgumentParser:
308
+ ap = argparse.ArgumentParser(description="Anchor punches and flashes in the breaths of speech.")
309
+ add_config_arg(ap)
310
+ ap.add_argument("timeline", help="timeline JSON (see references/data-contract.md)")
311
+ ap.add_argument("--narration", action="append", default=[], metavar="FILE[@OFFSET]",
312
+ help="narration audio, optionally with its start in the piece (s); repeatable")
313
+ ap.add_argument("--words", help="ctc-align words JSON (default: timeline words[])")
314
+ ap.add_argument("--visual", help="mute visual render, to choose punch vs flash per breath")
315
+ ap.add_argument("--profile", help="safe zone profile (default: timeline, then config)")
316
+ out = ap.add_mutually_exclusive_group()
317
+ out.add_argument("--write", action="store_true", help="rewrite the timeline in place")
318
+ out.add_argument("--out", help="write the new timeline to this path")
319
+ return ap
320
+
321
+
322
+ def main(argv: list[str] | None = None) -> int:
323
+ args = _parser().parse_args(argv)
324
+ try:
325
+ cfg = load(args.config)
326
+ data = tl.load(args.timeline)
327
+ if args.words:
328
+ data = {**data, "words": json.loads(Path(args.words).read_text(encoding="utf-8"))}
329
+ total, _ = tl.duration(data)
330
+ if args.narration:
331
+ cands = _narration_breaths(args.narration, cfg, total)
332
+ elif data.get("words"):
333
+ cands = candidates_from(breaths_from_words(data["words"], cfg), 0.0, total, cfg)
334
+ else:
335
+ print("ERROR: no breath source: pass --narration, --words or put words[] in the timeline")
336
+ return 2
337
+ zone = cfg.safe_zone(args.profile or data.get("safe_zone_profile"))
338
+ if not args.visual:
339
+ print(" note: no --visual, every breath is treated as a flash")
340
+ p = plan(data, cfg, cands, _effects_by_frame(cands, args.visual, zone, cfg))
341
+ except (ConfigError, tl.TimelineError, ReadError) as e:
342
+ print(f"ERROR: {e}")
343
+ return 2
344
+ print_plan(Path(args.timeline).stem, p)
345
+ target = args.timeline if args.write else args.out
346
+ if target:
347
+ tl.save(target, apply(data, p))
348
+ print(f" written: {target}")
349
+ else:
350
+ print(" (report only; use --write or --out to apply)")
351
+ return 0
352
+
353
+
354
+ if __name__ == "__main__":
355
+ sys.exit(main())
@@ -0,0 +1,122 @@
1
+ # -*- coding: utf-8 -*-
2
+ """DENSITY gate: is the frame full enough, measured on the FINAL MP4.
3
+
4
+ The area between the active safe zone top and bottom is split into
5
+ density.n_bands uniform bands; at every density.step_s a band counts as
6
+ filled when its local contrast (std-dev of luma, sampled at
7
+ density.sample_width columns) reaches density.dp_min. The piece fails where
8
+ fewer than density.min_filled bands are filled.
9
+
10
+ Traps already paid for:
11
+ - Measuring max brightness instead of occupancy: one line of white text
12
+ passes a max with the screen 90% black.
13
+ - A brightness threshold instead of contrast: a dark card (luma 21) and
14
+ the background glow (luma 22) overlap, no absolute threshold separates
15
+ them. Contrast does.
16
+ - The sample at t=0.0 is skipped: it is the first frame, before any entry
17
+ animation began. A piece that really opens black still fails at t=step.
18
+ - Samples that cannot be read are an error, never skipped silently (a
19
+ file measured while the render was still writing it once returned a
20
+ plausible number over a third of the film).
21
+
22
+ Exit code: 0 pass, 1 fail, 2 file unreadable.
23
+ """
24
+ from __future__ import annotations
25
+
26
+ import argparse
27
+ import os
28
+ import subprocess
29
+ import sys
30
+
31
+ from check_safe_zone import ReadError, read_frame, resolve_profile, row_std
32
+ from config import Config, ConfigError, SafeZone, add_config_arg, load
33
+ import timeline as tl
34
+
35
+
36
+ def duration(video: str) -> float:
37
+ o = subprocess.run(["ffprobe", "-v", "error", "-show_entries", "format=duration",
38
+ "-of", "default=nw=1:nk=1", video], capture_output=True, text=True)
39
+ try:
40
+ return float(o.stdout.strip())
41
+ except ValueError as e:
42
+ raise ReadError(f"ffprobe could not read the duration of {video}") from e
43
+
44
+
45
+ def filled_bands(frame: bytes, bands: list[tuple[int, int]], width: int, dp_min: float) -> int:
46
+ return sum(1 for y0, y1 in bands if row_std(frame[y0 * width:y1 * width]) >= dp_min)
47
+
48
+
49
+ def group_runs(poor: list[tuple[float, int]], step: float) -> list[tuple[float, float]]:
50
+ """Contiguous poor samples as (first, last) runs: what the viewer sees."""
51
+ if not poor:
52
+ return []
53
+ runs, start, prev = [], poor[0][0], poor[0][0]
54
+ for t, _ in poor[1:]:
55
+ if t - prev > step + 0.01:
56
+ runs.append((start, prev))
57
+ start = t
58
+ prev = t
59
+ runs.append((start, prev))
60
+ return runs
61
+
62
+
63
+ def sample(video: str, cfg: Config, zone: SafeZone):
64
+ """(history [(t, filled)], unread count) over the whole video."""
65
+ d = cfg.density
66
+ total = duration(video)
67
+ bands = d.bands(zone.top, zone.bottom)
68
+ hist, unread, t = [], 0, d.step_s # t=0.0 skipped on purpose (see docstring)
69
+ while t < total:
70
+ frame = read_frame(video, t, d.sample_width, cfg.frame.height)
71
+ if len(frame) >= d.sample_width * cfg.frame.height:
72
+ hist.append((round(t, 2), filled_bands(frame, bands, d.sample_width, d.dp_min)))
73
+ elif t < total - d.step_s:
74
+ # The last sample can land a few ms before the end with no frame to
75
+ # give: rounding, not a broken file. Only earlier gaps count.
76
+ unread += 1
77
+ t = round(t + d.step_s, 2)
78
+ return total, hist, unread
79
+
80
+
81
+ def report(video: str, cfg: Config, zone: SafeZone) -> int:
82
+ d = cfg.density
83
+ total, hist, unread = sample(video, cfg, zone)
84
+ name = os.path.basename(video)
85
+ if unread:
86
+ print(f"{name}: {unread} of {unread + len(hist)} samples could NOT be read.")
87
+ print(" The file may still be written by the render, or truncated. Measure again.")
88
+ return 2
89
+ mean = sum(c for _, c in hist) / len(hist) if hist else 0.0
90
+ print(f"{name}: {total:.1f}s, {len(hist)} samples, profile '{zone.name}'")
91
+ print(f" mean {mean:.1f} of {d.n_bands} bands filled (minimum density.min_filled={d.min_filled})")
92
+ poor = [(t, c) for t, c in hist if c < d.min_filled]
93
+ if not poor:
94
+ print(f" ok: no instant below {d.min_filled}/{d.n_bands}")
95
+ return 0
96
+ runs = group_runs(poor, d.step_s)
97
+ secs = sum(b - a + d.step_s for a, b in runs)
98
+ print(f" BELOW {d.min_filled}/{d.n_bands} in {len(runs)} run(s), {secs:.1f}s "
99
+ f"({100.0 * secs / total:.0f}% of the piece):")
100
+ for a, b in runs:
101
+ worst = min(c for t, c in poor if a <= t <= b)
102
+ print(f" {a:5.1f}s -> {b + d.step_s:5.1f}s worst: {worst}/{d.n_bands}")
103
+ return 1
104
+
105
+
106
+ def main(argv: list[str] | None = None) -> int:
107
+ ap = argparse.ArgumentParser(description="Density gate: bands with content on the final MP4.")
108
+ add_config_arg(ap)
109
+ ap.add_argument("video", help="final MP4 (with actor and captions, not the mute render)")
110
+ ap.add_argument("--profile", help="safe zone profile the bands span (default: config)")
111
+ ap.add_argument("--timeline", help="timeline JSON whose safe_zone_profile applies")
112
+ args = ap.parse_args(argv)
113
+ try:
114
+ cfg = load(args.config)
115
+ return report(args.video, cfg, resolve_profile(cfg, args.profile, args.timeline))
116
+ except (ConfigError, tl.TimelineError, ReadError) as e:
117
+ print(f"ERROR: {e}")
118
+ return 2
119
+
120
+
121
+ if __name__ == "__main__":
122
+ sys.exit(main())
@@ -0,0 +1,189 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Bring every SFX (and the optional bed layers) to the same level contract,
3
+ so the semantic `vol` of the config means something.
4
+
5
+ Why it exists. Measured on a real piece: narration at -20.6 dB mean, an
6
+ un-normalized SFX library from -19.6 to -30.9 dB. The whoosh that marks block
7
+ changes ended at -36.1 dB after its vol, 15.5 dB under the voice: inaudible.
8
+ The `vol` table only means something if every file starts from the same place.
9
+
10
+ Per file:
11
+ 1. Measure mean_volume and max_volume with ffmpeg volumedetect.
12
+ 2. gain = sound.reference_level_db - mean. The PEAK ALWAYS WINS: if that
13
+ gain would push the peak past sound.max_peak_db, it is cut so the peak
14
+ lands exactly on the ceiling.
15
+ 3. Apply with ffmpeg volume=<gain>dB, re-encode mp3 at a CBR bitrate at
16
+ or above the original (sources are VBR; round up to a standard step).
17
+
18
+ The original is always kept: before the first write, the file goes to
19
+ `_originals/` next to it and is never touched again.
20
+
21
+ Idempotent: a file within APPLY_TOLERANCE_DB of its target is left alone.
22
+
23
+ Known limit: the mean is taken over the WHOLE file. A SFX with a long silent
24
+ tail gets its audible part pushed toward the ceiling. Trim silence first,
25
+ then normalize.
26
+
27
+ Scope: the loudness of the FINAL piece (LUFS) is check_loudness.py, not this.
28
+
29
+ Exit code: 0 done/pass, 1 --check found files off target, 2 error.
30
+ """
31
+ from __future__ import annotations
32
+
33
+ import argparse
34
+ import re
35
+ import shutil
36
+ import subprocess
37
+ import sys
38
+ from pathlib import Path
39
+
40
+ from config import Config, ConfigError, add_config_arg, load
41
+
42
+ # Tolerances of this tool (not creative numbers): writing is stricter than
43
+ # failing on purpose. Rewriting just to erase 0.4 dB of re-encode drift is not
44
+ # worth the generation loss; failing only happens on real drift.
45
+ APPLY_TOLERANCE_DB = 0.3
46
+ CHECK_TOLERANCE_DB = 1.0
47
+
48
+ # Standard MP3 CBR steps (a codec fact).
49
+ MP3_BITRATES_KBPS = (32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320)
50
+
51
+ RE_MEAN = re.compile(r"mean_volume:\s*(-?[\d.]+)\s*dB")
52
+ RE_MAX = re.compile(r"max_volume:\s*(-?[\d.]+)\s*dB")
53
+ TMP_SUFFIX = ".normalizing.mp3"
54
+ ORIGINALS = "_originals"
55
+
56
+
57
+ class AudioError(Exception):
58
+ """Missing file or ffmpeg/ffprobe failure."""
59
+
60
+
61
+ def parse_volumedetect(stderr: str) -> tuple[float, float]:
62
+ m, p = RE_MEAN.search(stderr), RE_MAX.search(stderr)
63
+ if not m or not p:
64
+ raise AudioError(f"no volumedetect output: {stderr.strip()[-300:]}")
65
+ return float(m.group(1)), float(p.group(1))
66
+
67
+
68
+ def measure(path: Path) -> tuple[float, float]:
69
+ """(mean_volume, max_volume) in dB."""
70
+ r = subprocess.run(["ffmpeg", "-hide_banner", "-i", str(path), "-af", "volumedetect",
71
+ "-f", "null", "-"], capture_output=True, text=True)
72
+ try:
73
+ return parse_volumedetect(r.stderr)
74
+ except AudioError as e:
75
+ raise AudioError(f"{path}: {e}") from e
76
+
77
+
78
+ def target_bitrate_kbps(bps: int) -> int:
79
+ kbps = bps / 1000.0
80
+ return next((s for s in MP3_BITRATES_KBPS if s >= kbps), MP3_BITRATES_KBPS[-1])
81
+
82
+
83
+ def compute_gain(mean_db: float, peak_db: float, cfg: Config) -> tuple[float, str]:
84
+ """Gain in dB to land the mean on the reference, unless the peak would
85
+ pass the ceiling: then the peak wins."""
86
+ gain = cfg.sound.reference_level_db - mean_db
87
+ if peak_db + gain > cfg.sound.max_peak_db:
88
+ return cfg.sound.max_peak_db - peak_db, "peak"
89
+ return gain, "mean"
90
+
91
+
92
+ def _bitrate(path: Path) -> int:
93
+ r = subprocess.run(["ffprobe", "-v", "error", "-show_entries", "format=bit_rate",
94
+ "-of", "csv=p=0", str(path)], capture_output=True, text=True)
95
+ s = r.stdout.strip()
96
+ if not s.isdigit():
97
+ raise AudioError(f"ffprobe could not read the bitrate of {path}: {r.stderr.strip()}")
98
+ return int(s)
99
+
100
+
101
+ def apply_gain(path: Path, gain: float) -> None:
102
+ """Write the normalized file in place, keeping the original once."""
103
+ backup = path.parent / ORIGINALS / path.name
104
+ tmp = path.with_name(path.stem + TMP_SUFFIX)
105
+ kbps = target_bitrate_kbps(_bitrate(path))
106
+ r = subprocess.run(["ffmpeg", "-hide_banner", "-y", "-i", str(path),
107
+ "-af", "volume=%.3fdB" % gain, "-c:a", "libmp3lame",
108
+ "-b:a", "%dk" % kbps, str(tmp)], capture_output=True, text=True)
109
+ if r.returncode != 0 or not tmp.exists():
110
+ tmp.unlink(missing_ok=True)
111
+ raise AudioError(f"ffmpeg failed applying gain on {path}: {r.stderr.strip()[-300:]}")
112
+ backup.parent.mkdir(exist_ok=True)
113
+ if backup.exists():
114
+ path.unlink()
115
+ else:
116
+ shutil.move(str(path), str(backup))
117
+ shutil.move(str(tmp), str(path))
118
+
119
+
120
+ def targets(cfg: Config, sfx_dir: Path) -> tuple[list[Path], list[str]]:
121
+ """Files to process, and optional bed layers missing on disk (report only)."""
122
+ if not sfx_dir.is_dir():
123
+ raise AudioError(f"sfx folder not found: {sfx_dir}")
124
+ files = [f for f in sorted(sfx_dir.glob("*.mp3")) if not f.name.endswith(TMP_SUFFIX)]
125
+ missing = []
126
+ for layer in cfg.sound.bed:
127
+ p = cfg.media_root / layer.file
128
+ if p.exists():
129
+ files.append(p)
130
+ else:
131
+ missing.append(layer.file)
132
+ return files, missing
133
+
134
+
135
+ def process(path: Path, cfg: Config, write: bool) -> dict:
136
+ mean0, peak0 = measure(path)
137
+ gain, by = compute_gain(mean0, peak0, cfg)
138
+ done = abs(gain) <= APPLY_TOLERANCE_DB
139
+ mean1, peak1, written = mean0, peak0, False
140
+ if write and not done:
141
+ apply_gain(path, gain)
142
+ mean1, peak1 = measure(path)
143
+ written = True
144
+ return {"file": path.name, "by": by, "gain": gain, "done": done, "written": written,
145
+ "mean_before": mean0, "peak_before": peak0, "mean_after": mean1, "peak_after": peak1}
146
+
147
+
148
+ def print_table(rows: list[dict]) -> None:
149
+ print("%-24s %8s %8s %9s %-5s %8s %8s state"
150
+ % ("file", "mean-", "peak-", "gain", "by", "mean+", "peak+"))
151
+ for r in rows:
152
+ state = "already normalized" if r["done"] else "applied" if r["written"] else "pending"
153
+ print("%-24s %8.1f %8.1f %+9.2f %-5s %8.1f %8.1f %s"
154
+ % (r["file"], r["mean_before"], r["peak_before"], r["gain"], r["by"],
155
+ r["mean_after"], r["peak_after"], state))
156
+
157
+
158
+ def main(argv: list[str] | None = None) -> int:
159
+ ap = argparse.ArgumentParser(
160
+ description="Normalize every SFX to sound.reference_level_db (peak locked at "
161
+ "sound.max_peak_db).")
162
+ add_config_arg(ap)
163
+ ap.add_argument("--sfx-dir", help="SFX folder (default: from the config paths)")
164
+ ap.add_argument("--check", action="store_true",
165
+ help=f"measure only; exit 1 if a file is more than {CHECK_TOLERANCE_DB} dB off")
166
+ args = ap.parse_args(argv)
167
+ try:
168
+ cfg = load(args.config)
169
+ files, missing = targets(cfg, Path(args.sfx_dir) if args.sfx_dir else cfg.sfx_dir)
170
+ rows = [process(f, cfg, write=not args.check) for f in files]
171
+ except (ConfigError, AudioError) as e:
172
+ print(f"ERROR: {e}")
173
+ return 2
174
+ print_table(rows)
175
+ for m in missing:
176
+ print(f"(optional bed layer missing, skipped: {m})")
177
+ if not args.check:
178
+ return 0
179
+ off = [r["file"] for r in rows if abs(r["gain"]) > CHECK_TOLERANCE_DB]
180
+ if off:
181
+ print(f"\nFAILED: {len(off)} file(s) more than {CHECK_TOLERANCE_DB} dB off target: "
182
+ f"{', '.join(off)}")
183
+ return 1
184
+ print(f"\nPASSED: all {len(rows)} file(s) within {CHECK_TOLERANCE_DB} dB of target.")
185
+ return 0
186
+
187
+
188
+ if __name__ == "__main__":
189
+ sys.exit(main())