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,211 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Anti-excess engine: effects compete for a budget instead of all entering.
3
+
4
+ Why it exists. A rule like "zoom punch on every SFX with vol >= 0.5" fired on
5
+ almost every SFX of the source project (one zoom every 2.5s on the busiest
6
+ pieces). An effect that always happens stops marking what matters.
7
+
8
+ This module does not decide IF a SFX asks for an effect (that is `triggers`
9
+ in the sound config) nor HOW the effect is drawn. It only decides WHICH
10
+ candidates, from a list that already arrived too long, earn the right to
11
+ appear: by narrative strength first, then a ceiling per curve section, a
12
+ minimum distance between repeats of the same kind, a minimum global distance
13
+ between dominant effects, and a limit on same-kind effects in a burst.
14
+
15
+ Usage:
16
+ from budget import Candidate, select
17
+ res = select([Candidate(t=3.2, kind="punch", strength=0.8, reason="verdict")],
18
+ total=55.0, cfg=cfg)
19
+ print(res.report())
20
+ """
21
+ from __future__ import annotations
22
+
23
+ from bisect import bisect_left, insort
24
+ from collections.abc import Iterable
25
+ from dataclasses import dataclass
26
+
27
+ from config import Config, CurveSection
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class Candidate:
32
+ """An effect asking for a place in the timeline."""
33
+ t: float
34
+ kind: str # key of budget.per_effect: punch, flash, shake, interrupt, transition
35
+ strength: float # 0..1, narrative claim
36
+ reason: str # short human-readable why
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class Refusal:
41
+ candidate: Candidate
42
+ rule: str # "gap_min" | "gap_min_global" | "max_in_row" | "ceiling" | "unknown_kind"
43
+ detail: str
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class Result:
48
+ accepted: tuple[Candidate, ...] # always sorted by t
49
+ refusals: tuple[Refusal, ...]
50
+
51
+ def by_kind(self) -> dict[str, int]:
52
+ count: dict[str, int] = {}
53
+ for c in self.accepted:
54
+ count[c.kind] = count.get(c.kind, 0) + 1
55
+ return count
56
+
57
+ def report(self) -> str:
58
+ lines = [f"accepted: {len(self.accepted)} refused: {len(self.refusals)}"]
59
+ if self.accepted:
60
+ lines.append("by kind:")
61
+ for kind, n in self.by_kind().items():
62
+ lines.append(f" {kind:<10} {n}")
63
+ if self.refusals:
64
+ lines.append("refusals:")
65
+ for r in self.refusals:
66
+ lines.append(f" t={r.candidate.t:6.1f}s {r.candidate.kind:<10} "
67
+ f"[{r.rule}] {r.detail}")
68
+ return "\n".join(lines)
69
+
70
+
71
+ def _durations_and_weights(cfg: Config, total: float) -> list[tuple[str, float, float]]:
72
+ """Seconds of each curve section with its weight. The curve stores
73
+ CUMULATIVE fractions: a section runs from the previous until_frac to its own."""
74
+ out = []
75
+ prev = 0.0
76
+ for s in cfg.budget.curve:
77
+ out.append((s.name, (s.until_frac - prev) * total, s.weight))
78
+ prev = s.until_frac
79
+ return out
80
+
81
+
82
+ def section_ceiling(cfg: Config, kind: str, section: CurveSection, total: float) -> int:
83
+ """How many `kind` events fit in this section.
84
+
85
+ The whole-piece ceiling (budget.ceiling, from max_per_min) is SPLIT across
86
+ the sections in proportion to duration * weight. A heavier section gets a
87
+ bigger slice of the SAME total; it never invents a new ceiling:
88
+
89
+ slice = total_ceiling * (dur * weight) / sum(dur_i * weight_i)
90
+
91
+ Rounded to the nearest integer. A short section may round to 0: there is
92
+ no per-section floor, only the whole-piece floor of 1.
93
+ """
94
+ total_ceiling = cfg.budget.ceiling(kind, total)
95
+ parts = _durations_and_weights(cfg, total)
96
+ weighted = sum(d * w for _, d, w in parts)
97
+ if weighted <= 0:
98
+ return total_ceiling
99
+ dur, weight = next((d, w) for name, d, w in parts if name == section.name)
100
+ return round(total_ceiling * (dur * weight) / weighted)
101
+
102
+
103
+ def _nearest_gap(times: list[float], t: float) -> float | None:
104
+ """Distance to the nearest neighbour in a sorted list; None when empty."""
105
+ if not times:
106
+ return None
107
+ i = bisect_left(times, t)
108
+ near = []
109
+ if i > 0:
110
+ near.append(t - times[i - 1])
111
+ if i < len(times):
112
+ near.append(times[i] - t)
113
+ return min(near)
114
+
115
+
116
+ def _in_row_if_inserted(accepted: list[Candidate], pos: int, kind: str,
117
+ t: float, burst_gap: float) -> int:
118
+ """How many same-kind events would sit in the SAME BURST if a candidate
119
+ entered at `pos` of a list sorted by t.
120
+
121
+ Counted on the FINAL ORDER BY TIME, never on processing order (which is by
122
+ strength): a candidate accepted early for its strength can end up
123
+ surrounded, in time, by same-kind neighbours accepted later.
124
+
125
+ `burst_gap` separates bursts. Without it, "in a row" would span the whole
126
+ piece and a third punch 20s after the second would fail max_in_row=2,
127
+ cancelling the per-piece ceiling. A rule that cancels another rule of the
128
+ same module is wrong.
129
+ """
130
+ back, i, prev = 0, pos - 1, t
131
+ while i >= 0 and accepted[i].kind == kind and prev - accepted[i].t <= burst_gap:
132
+ back, prev, i = back + 1, accepted[i].t, i - 1
133
+ fwd, i, nxt = 0, pos, t
134
+ while i < len(accepted) and accepted[i].kind == kind and accepted[i].t - nxt <= burst_gap:
135
+ fwd, nxt, i = fwd + 1, accepted[i].t, i + 1
136
+ return back + fwd + 1
137
+
138
+
139
+ def as_candidate(c) -> Candidate:
140
+ """Accept a Candidate or a dict with the same keys (sound.py emits dicts
141
+ on purpose, so the two modules stay independent). A missing key fails
142
+ here with its name, not later mid-selection."""
143
+ if isinstance(c, Candidate):
144
+ return c
145
+ try:
146
+ return Candidate(t=float(c["t"]), kind=str(c["kind"]),
147
+ strength=float(c["strength"]), reason=str(c.get("reason", "")))
148
+ except (KeyError, TypeError) as e:
149
+ raise TypeError(
150
+ f"invalid candidate: {c!r}. Expected a Candidate or a dict with keys "
151
+ f"t, kind, strength (reason optional). Missing or wrong: {e}") from e
152
+
153
+
154
+ def _check(cand: Candidate, cfg: Config, total: float, state: dict) -> tuple[str, str] | None:
155
+ """The first rule the candidate breaks, as (rule, detail), or None."""
156
+ rule = cfg.budget.rule(cand.kind)
157
+ if rule is None:
158
+ return "unknown_kind", (f"kind {cand.kind!r} is not in budget.per_effect. "
159
+ f"Known: {', '.join(sorted(cfg.budget.per_effect))}")
160
+ sec = cfg.budget.section(cand.t, total)
161
+ cap = section_ceiling(cfg, cand.kind, sec, total)
162
+ used = state["per_section"].get((cand.kind, sec.name), 0)
163
+ if used >= cap:
164
+ return "ceiling", f"section {sec.name!r} of {cand.kind} already has {used} of {cap} allowed"
165
+ gap = _nearest_gap(state["times"].get(cand.kind, []), cand.t)
166
+ if gap is not None and gap < rule.gap_min:
167
+ return "gap_min", f"gap {gap:.1f}s < {rule.gap_min:.1f}s required for {cand.kind}"
168
+ if rule.dominant:
169
+ g = _nearest_gap(state["dominant"], cand.t)
170
+ if g is not None and g < cfg.budget.gap_min_global:
171
+ return "gap_min_global", (f"gap {g:.1f}s < {cfg.budget.gap_min_global:.1f}s "
172
+ f"required between dominant effects")
173
+ pos = bisect_left([c.t for c in state["accepted"]], cand.t)
174
+ row = _in_row_if_inserted(state["accepted"], pos, cand.kind, cand.t, cfg.budget.burst_gap_s)
175
+ if row > rule.max_in_row:
176
+ return "max_in_row", (f"{row} {cand.kind} in one burst (burst gap "
177
+ f"{cfg.budget.burst_gap_s}s) > {rule.max_in_row} allowed")
178
+ return None
179
+
180
+
181
+ def select(candidates: Iterable[Candidate | dict], total: float, cfg: Config) -> Result:
182
+ """Decide which candidates enter, by these rules in order:
183
+
184
+ 1. Process by decreasing strength (tie: increasing t): the strongest
185
+ narrative claim picks its place first. PRIORITY is by strength;
186
+ final ACCEPTANCE is read in time order.
187
+ 2. Unknown kind is never accepted silently.
188
+ 3. Ceiling per curve section (see `section_ceiling`).
189
+ 4. gap_min of the kind against accepted events of the SAME kind.
190
+ 5. gap_min_global against accepted DOMINANT events of any kind (a
191
+ non-dominant candidate is not held by this rule).
192
+ 6. max_in_row, measured on the final timeline sorted by t.
193
+
194
+ Deterministic: same input, same Result (no sets, no hash order, no random).
195
+ """
196
+ ordered = sorted((as_candidate(c) for c in candidates), key=lambda c: (-c.strength, c.t))
197
+ state = {"accepted": [], "per_section": {}, "times": {}, "dominant": []}
198
+ refusals: list[Refusal] = []
199
+ for cand in ordered:
200
+ broken = _check(cand, cfg, total, state)
201
+ if broken:
202
+ refusals.append(Refusal(cand, *broken))
203
+ continue
204
+ pos = bisect_left([c.t for c in state["accepted"]], cand.t)
205
+ state["accepted"].insert(pos, cand)
206
+ key = (cand.kind, cfg.budget.section(cand.t, total).name)
207
+ state["per_section"][key] = state["per_section"].get(key, 0) + 1
208
+ insort(state["times"].setdefault(cand.kind, []), cand.t)
209
+ if cfg.budget.rule(cand.kind).dominant:
210
+ insort(state["dominant"], cand.t)
211
+ return Result(accepted=tuple(state["accepted"]), refusals=tuple(refusals))
@@ -0,0 +1,218 @@
1
+ # -*- coding: utf-8 -*-
2
+ """EXCESS gate: measures what the other gates do not.
3
+
4
+ Why it exists. Every other gate measures a LACK (of density, of anchor, of
5
+ room inside the safe zone). None measured the opposite: an audit counted one
6
+ zoom punch every 2.5s on the busiest pieces and no gate said anything. This
7
+ script counts the real effects of a timeline JSON and fails when they exceed
8
+ the budget in the config (`budget`).
9
+
10
+ Timeline keys read (see references/data-contract.md; all may be absent):
11
+ punches, shakes, flashes numbers or objects {"at": ...}
12
+ interrupts objects {"at": ..., "kind": ...}
13
+ scenes a scene with `transition` other than "" or
14
+ "cut" counts as a transition event
15
+ duration if absent, derived from the scenes (and said)
16
+ sfx, scenes[].sfx every sound, checked TOGETHER against the three
17
+ refusals of sound.map_requests (unknown event,
18
+ collision, same sound too many times in a row)
19
+
20
+ Per effect kind it reports: count and per minute against the piece ceiling
21
+ (budget.ceiling), smallest gap against gap_min, longest run of same-kind
22
+ events closer than gap_min against max_in_row, and pairs of DIFFERENT dominant
23
+ kinds closer than gap_min_global. It always reports the largest cluster of
24
+ dominant effects in a CLUSTER_WINDOW_S window: it is what the viewer sees.
25
+
26
+ Exit code: 0 pass, 1 fail, 2 usage error or missing file. --warn measures and
27
+ always exits 0 (use it only while calibrating; the gate is meant to block).
28
+ """
29
+ from __future__ import annotations
30
+
31
+ import argparse
32
+ import json
33
+ import sys
34
+ from pathlib import Path
35
+
36
+ from config import Budget, Config, ConfigError, add_config_arg, load
37
+ import sound
38
+ import timeline as tl
39
+
40
+ # Report window for the largest cluster. A display choice, not a rule: the
41
+ # span a viewer perceives at once.
42
+ CLUSTER_WINDOW_S = 10.0
43
+
44
+
45
+ def smallest_gap(times: list[float]) -> tuple[float | None, float | None]:
46
+ """(gap, instant of the first of the pair), or (None, None) with < 2 events."""
47
+ if len(times) < 2:
48
+ return None, None
49
+ a, b = min(zip(times, times[1:]), key=lambda p: p[1] - p[0])
50
+ return b - a, a
51
+
52
+
53
+ def longest_run(times: list[float], gap_min: float) -> tuple[int, float | None]:
54
+ """Longest run where each consecutive pair is closer than gap_min. Reuses
55
+ gap_min as the only threshold the config gives for 'glued'."""
56
+ if not times:
57
+ return 0, None
58
+ best = cur = 1
59
+ cur_start = best_start = times[0]
60
+ for a, b in zip(times, times[1:]):
61
+ if b - a < gap_min:
62
+ cur += 1
63
+ else:
64
+ cur, cur_start = 1, b
65
+ if cur > best:
66
+ best, best_start = cur, cur_start
67
+ return best, best_start
68
+
69
+
70
+ def _dominant(events: dict[str, list[float]], budget: Budget) -> list[tuple[float, str]]:
71
+ return sorted((t, kind) for kind, lst in events.items() for t in lst
72
+ if (r := budget.rule(kind)) and r.dominant)
73
+
74
+
75
+ def dominant_crossings(events: dict[str, list[float]], budget: Budget) -> list[tuple]:
76
+ """Pairs of DIFFERENT dominant kinds closer than gap_min_global. The
77
+ per-kind gap only guards repeats of the SAME kind; a punch glued to a
78
+ shake passes both and still overloads the same instant."""
79
+ marked = _dominant(events, budget)
80
+ found = []
81
+ for i, (ta, ka) in enumerate(marked):
82
+ for tb, kb in marked[i + 1:]:
83
+ dt = tb - ta
84
+ if dt >= budget.gap_min_global:
85
+ break
86
+ if ka != kb:
87
+ found.append((ta, ka, tb, kb, dt))
88
+ return found
89
+
90
+
91
+ def largest_cluster(events: dict[str, list[float]], budget: Budget,
92
+ window: float = CLUSTER_WINDOW_S) -> tuple[int, float | None]:
93
+ """Window of `window` seconds with the most dominant effects (two pointers)."""
94
+ dom = [t for t, _ in _dominant(events, budget)]
95
+ if not dom:
96
+ return 0, None
97
+ best_n, best_t, j = 1, dom[0], 0
98
+ for i in range(len(dom)):
99
+ while dom[i] - dom[j] >= window:
100
+ j += 1
101
+ if i - j + 1 > best_n:
102
+ best_n, best_t = i - j + 1, dom[j]
103
+ return best_n, best_t
104
+
105
+
106
+ def _kind_row(kind: str, times: list[float], total: float, cfg: Config) -> tuple[dict, list[str]]:
107
+ rule = cfg.budget.rule(kind)
108
+ n = len(times)
109
+ per_min = (n / total * 60.0) if total > 0 else 0.0
110
+ ceiling = cfg.budget.ceiling(kind, total)
111
+ gap, gap_at = smallest_gap(times)
112
+ run, run_at = longest_run(times, rule.gap_min)
113
+ row = {"kind": kind, "n": n, "per_min": per_min, "ceiling": ceiling,
114
+ "gap_measured": gap, "gap_required": rule.gap_min,
115
+ "run_measured": run, "run_allowed": rule.max_in_row}
116
+ errs = []
117
+ if n > ceiling:
118
+ errs.append(f"{kind}: {n} event(s) > ceiling of {ceiling} for {total:.1f}s "
119
+ f"({per_min:.1f}/min, limit budget.per_effect.{kind}.max_per_min="
120
+ f"{rule.max_per_min:g})")
121
+ if gap is not None and gap < rule.gap_min:
122
+ errs.append(f"{kind}: gap of {gap:.2f}s at {gap_at:.2f}s < gap_min {rule.gap_min:.1f}s")
123
+ if run > rule.max_in_row:
124
+ errs.append(f"{kind}: {run} in a row from {run_at:.2f}s > max_in_row {rule.max_in_row}")
125
+ return row, errs
126
+
127
+
128
+ def sound_violations(data: dict, cfg: Config) -> list[str]:
129
+ """The three sound refusals of sound.map_requests, applied to EVERY sound
130
+ in the timeline at once (sfx[] plus scene cut sounds). Sounds mapped in
131
+ separate passes can each be fine alone and still collide together."""
132
+ requests, errs = [], []
133
+ for s in tl.sounds(data):
134
+ ev = sound.event_of(s, cfg)
135
+ if ev is None:
136
+ errs.append(f"sound {s.get('src', '?')}@{float(s['at']):.2f}s refused "
137
+ f"(unknown_event): no `event` and no unique (file, vol) match in "
138
+ f"sound.events")
139
+ continue
140
+ requests.append((float(s["at"]), ev))
141
+ for r in sound.map_requests(requests, cfg).refusals:
142
+ errs.append(f"sound {r.event}@{r.t:.2f}s refused ({r.rule}): {r.detail}")
143
+ return errs
144
+
145
+
146
+ def analyze(data: dict, name: str, cfg: Config) -> dict:
147
+ """Measure one timeline and return a structured report. Never prints."""
148
+ total, derived = tl.duration(data)
149
+ events = tl.effect_events(data)
150
+ rows, violations = [], []
151
+ for kind in cfg.budget.per_effect:
152
+ row, errs = _kind_row(kind, events.get(kind, []), total, cfg)
153
+ rows.append(row)
154
+ violations.extend(errs)
155
+ for ta, ka, tb, kb, dt in dominant_crossings(events, cfg.budget):
156
+ violations.append(f"{ka}@{ta:.2f}s glued to {kb}@{tb:.2f}s ({dt:.2f}s) "
157
+ f"< gap_min_global {cfg.budget.gap_min_global:.1f}s")
158
+ violations.extend(sound_violations(data, cfg))
159
+ n_dom, t_dom = largest_cluster(events, cfg.budget)
160
+ return {"name": name, "duration": total, "duration_derived": derived, "rows": rows,
161
+ "cluster": {"n": n_dom, "start": t_dom}, "violations": violations,
162
+ "passed": not violations}
163
+
164
+
165
+ def print_report(rep: dict) -> None:
166
+ print(f"== excess of {rep['name']} ==")
167
+ if rep["duration_derived"]:
168
+ print(f" warning: no 'duration' in the JSON; derived from scenes: {rep['duration']:.2f}s")
169
+ print(f" {'kind':<10} | {'n':>3} | {'per min':>7} | {'cap':>4} | "
170
+ f"{'min gap':>8} | {'required':>8} | {'in a row':>8}")
171
+ for r in rep["rows"]:
172
+ gap = f"{r['gap_measured']:.2f}s" if r["gap_measured"] is not None else "-"
173
+ print(f" {r['kind']:<10} | {r['n']:>3} | {r['per_min']:>7.1f} | {r['ceiling']:>4} | "
174
+ f"{gap:>8} | {r['gap_required']:>7.1f}s | "
175
+ f"{str(r['run_measured']) + '/' + str(r['run_allowed']):>8}")
176
+ c = rep["cluster"]
177
+ if c["start"] is not None:
178
+ print(f" largest cluster: {c['n']} dominant effect(s) in {CLUSTER_WINDOW_S:.0f}s "
179
+ f"from {c['start']:.2f}s")
180
+ else:
181
+ print(" largest cluster: no dominant effect in the piece")
182
+ for v in rep["violations"]:
183
+ print(f" ERROR: {v}")
184
+ print(f" FAILED: {len(rep['violations'])} excess violation(s)" if rep["violations"]
185
+ else " ok: no excess measured")
186
+ print()
187
+
188
+
189
+ def main(argv: list[str] | None = None) -> int:
190
+ ap = argparse.ArgumentParser(
191
+ description="Excess gate: count effects of timeline JSON(s) against the budget.")
192
+ add_config_arg(ap)
193
+ ap.add_argument("timelines", nargs="+", help="timeline JSON path(s)")
194
+ ap.add_argument("--warn", action="store_true", help="measure but always exit 0")
195
+ ap.add_argument("--json", action="store_true", help="machine-readable output")
196
+ args = ap.parse_args(argv)
197
+ try:
198
+ cfg = load(args.config)
199
+ reports = [analyze(tl.load(p), Path(p).stem, cfg) for p in args.timelines]
200
+ except (ConfigError, tl.TimelineError) as e:
201
+ print(f"ERROR: {e}")
202
+ return 2
203
+ if args.json:
204
+ print(json.dumps({r["name"]: r for r in reports}, ensure_ascii=False, indent=2))
205
+ else:
206
+ for r in reports:
207
+ print_report(r)
208
+ if len(reports) > 1:
209
+ failed = [r["name"] for r in reports if not r["passed"]]
210
+ print(f"== summary: {len(failed)}/{len(reports)} failed: {', '.join(failed)} =="
211
+ if failed else f"== summary: {len(reports)}/{len(reports)} passed ==")
212
+ if args.warn:
213
+ return 0
214
+ return 0 if all(r["passed"] for r in reports) else 1
215
+
216
+
217
+ if __name__ == "__main__":
218
+ sys.exit(main())
@@ -0,0 +1,175 @@
1
+ # -*- coding: utf-8 -*-
2
+ """LOUDNESS gate on the final piece: integrated LUFS and true peak.
3
+
4
+ Measures the final MP4 (or audio) with ffmpeg `loudnorm` in analysis mode
5
+ (print_format=json) and BLOCKS when:
6
+ - integrated loudness is outside loudness.target_lufs +/- loudness.tolerance_lu
7
+ - true peak is above loudness.max_true_peak_db
8
+ On failure it prints the ready two-pass ffmpeg command that fixes the file
9
+ (pass 1 = this measurement, pass 2 = loudnorm with the measured values and
10
+ linear=true, video stream copied).
11
+
12
+ Why a gate and not a habit: masterizing louder than the target does not help
13
+ (the platform turns it down) and a true peak near 0 dBTP distorts on the
14
+ platform re-encode. The numbers and their weak source live in the config.
15
+
16
+ Exit code: 0 pass, 1 fail, 2 error (missing file, ffmpeg without loudnorm
17
+ output).
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import argparse
22
+ import json
23
+ import math
24
+ import shlex
25
+ import subprocess
26
+ import sys
27
+ from pathlib import Path
28
+
29
+ from config import ConfigError, Loudness, add_config_arg, load
30
+
31
+ FIELDS = ("input_i", "input_tp", "input_lra", "input_thresh", "target_offset")
32
+ VIDEO_EXT = {".mp4", ".mov", ".m4v", ".mkv"}
33
+
34
+
35
+ class LoudnessError(Exception):
36
+ """File missing or loudnorm output not found."""
37
+
38
+
39
+ LOSSLESS_EXT = {".wav", ".flac", ".aiff", ".aif"}
40
+
41
+
42
+ def loudnorm_filter(lo: Loudness, measured: dict | None = None, tp_margin: float = 0.0) -> str:
43
+ """tp_margin: dB kept under max_true_peak_db in pass 2, for lossy output."""
44
+ base = (f"loudnorm=I={lo.target_lufs:g}:TP={lo.max_true_peak_db - tp_margin:g}"
45
+ f":LRA={lo.lra:g}")
46
+ if measured is None:
47
+ return base + ":print_format=json"
48
+ return (base + f":measured_I={measured['input_i']:g}:measured_TP={measured['input_tp']:g}"
49
+ f":measured_LRA={measured['input_lra']:g}"
50
+ f":measured_thresh={measured['input_thresh']:g}"
51
+ f":offset={measured['target_offset']:g}:linear=true:print_format=summary")
52
+
53
+
54
+ def parse_loudnorm(stderr: str) -> dict:
55
+ """The LAST JSON object in ffmpeg's stderr, numeric fields as floats.
56
+ Silence gives "-inf", which becomes -math.inf (and fails the gate)."""
57
+ end = stderr.rfind("}")
58
+ start = stderr.rfind("{", 0, end + 1)
59
+ if start < 0 or end < 0:
60
+ raise LoudnessError("no loudnorm JSON in ffmpeg output")
61
+ try:
62
+ raw = json.loads(stderr[start:end + 1])
63
+ except json.JSONDecodeError as e:
64
+ raise LoudnessError(f"loudnorm JSON unreadable: {e}") from e
65
+ out = {}
66
+ for k in FIELDS:
67
+ if k not in raw:
68
+ raise LoudnessError(f"loudnorm JSON has no {k!r}")
69
+ out[k] = float(str(raw[k]).strip()) # float() accepts "-inf"
70
+ return out
71
+
72
+
73
+ def measure(path: Path, lo: Loudness) -> dict:
74
+ if not path.exists():
75
+ raise LoudnessError(f"file not found: {path}")
76
+ r = subprocess.run(["ffmpeg", "-hide_banner", "-nostats", "-i", str(path),
77
+ "-af", loudnorm_filter(lo), "-f", "null", "-"],
78
+ capture_output=True, text=True)
79
+ try:
80
+ return parse_loudnorm(r.stderr)
81
+ except LoudnessError as e:
82
+ if "audio:0KiB" in r.stderr or "does not contain any stream" in r.stderr:
83
+ raise LoudnessError(f"{path}: has no audio stream") from e
84
+ raise LoudnessError(f"{path}: {e}: {r.stderr.strip()[-300:]}") from e
85
+
86
+
87
+ def evaluate(m: dict, lo: Loudness) -> list[str]:
88
+ """Violations, empty when the measurement passes."""
89
+ errs = []
90
+ i, tp = m["input_i"], m["input_tp"]
91
+ if not math.isfinite(i):
92
+ errs.append("integrated loudness is -inf: the audio is silent or missing")
93
+ elif abs(i - lo.target_lufs) > lo.tolerance_lu:
94
+ errs.append(f"integrated {i:.1f} LUFS outside loudness.target_lufs "
95
+ f"{lo.target_lufs:g} +/- loudness.tolerance_lu {lo.tolerance_lu:g}")
96
+ if tp > lo.max_true_peak_db:
97
+ errs.append(f"true peak {tp:.1f} dBTP above loudness.max_true_peak_db "
98
+ f"{lo.max_true_peak_db:g}")
99
+ return errs
100
+
101
+
102
+ def fix_command(path: Path, m: dict, lo: Loudness) -> str:
103
+ """Pass 2 of the two-pass loudnorm (pass 1 is the measurement above).
104
+
105
+ A lossy output (AAC in the MP4, MP3...) overshoots the true peak loudnorm
106
+ set, so its TP target sits loudness.encode_tp_margin_db lower: a fix that
107
+ fails the gate it came from is not a fix."""
108
+ out = path.with_name(path.stem + ".loudnorm" + path.suffix)
109
+ # loudnorm upsamples to 192 kHz internally: set the output rate back.
110
+ codec = "-c:v copy -c:a aac -b:a 192k -ar 48000" if path.suffix.lower() in VIDEO_EXT \
111
+ else "-ar 48000"
112
+ margin = 0.0 if path.suffix.lower() in LOSSLESS_EXT else lo.encode_tp_margin_db
113
+ return " ".join([
114
+ "ffmpeg -hide_banner -i", shlex.quote(str(path)),
115
+ "-af", shlex.quote(loudnorm_filter(lo, m, margin)), codec, shlex.quote(str(out))])
116
+
117
+
118
+ def dynamic_warning(m: dict, path: Path, lo: Loudness) -> str | None:
119
+ """loudnorm only keeps linear=true when the gain it needs fits under the
120
+ true-peak target; otherwise it silently falls back to dynamic compression
121
+ (measured: +12.8 dB on a quiet narration flattened LRA from 3.1 to 1.8).
122
+ Say so before the fix runs, so the fix is a choice and not a surprise."""
123
+ margin = 0.0 if path.suffix.lower() in LOSSLESS_EXT else lo.encode_tp_margin_db
124
+ peak_after = m["input_tp"] + (lo.target_lufs - m["input_i"])
125
+ ceiling = lo.max_true_peak_db - margin
126
+ if not (math.isfinite(peak_after) and peak_after > ceiling):
127
+ return None
128
+ return (f"the fix needs {lo.target_lufs - m['input_i']:+.1f} dB, which puts the true peak "
129
+ f"at {peak_after:.1f} dBTP over the {ceiling:.1f} target: loudnorm will switch to "
130
+ f"dynamic compression. Prefer raising the narration before the render (it is "
131
+ f"usually the quiet layer) and measuring again.")
132
+
133
+
134
+ def check(path: Path, lo: Loudness) -> tuple[dict, list[str]]:
135
+ m = measure(path, lo)
136
+ return m, evaluate(m, lo)
137
+
138
+
139
+ def main(argv: list[str] | None = None) -> int:
140
+ ap = argparse.ArgumentParser(
141
+ description="Loudness gate: integrated LUFS and true peak of the final piece.")
142
+ add_config_arg(ap)
143
+ ap.add_argument("files", nargs="+", help="final MP4 or audio file(s)")
144
+ ap.add_argument("--json", action="store_true", help="machine-readable output")
145
+ args = ap.parse_args(argv)
146
+ try:
147
+ lo = load(args.config).loudness
148
+ results = {p: check(Path(p), lo) for p in args.files}
149
+ except (ConfigError, LoudnessError) as e:
150
+ print(f"ERROR: {e}")
151
+ return 2
152
+ failed = [p for p, (_, errs) in results.items() if errs]
153
+ if args.json:
154
+ finite = {p: {**{k: (v if math.isfinite(v) else None) for k, v in m.items()},
155
+ "violations": e, "passed": not e} for p, (m, e) in results.items()}
156
+ print(json.dumps(finite, indent=2))
157
+ return 1 if failed else 0
158
+ for p, (m, errs) in results.items():
159
+ print(f"== loudness of {p} ==")
160
+ print(f" integrated {m['input_i']:.1f} LUFS true peak {m['input_tp']:.1f} dBTP"
161
+ f" LRA {m['input_lra']:.1f} LU")
162
+ for e in errs:
163
+ print(f" ERROR: {e}")
164
+ if errs and math.isfinite(m["input_i"]):
165
+ warning = dynamic_warning(m, Path(p), lo)
166
+ if warning:
167
+ print(f" warning: {warning}")
168
+ print(" fix (pass 2 of 2):")
169
+ print(f" {fix_command(Path(p), m, lo)}")
170
+ print(" FAILED" if errs else " ok")
171
+ return 1 if failed else 0
172
+
173
+
174
+ if __name__ == "__main__":
175
+ sys.exit(main())