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.
- package/README.md +2 -1
- package/package.json +1 -1
- package/skills-registry.yaml +38 -29
- package/src/bmm-skills/module.yaml +1 -1
- package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/04-criativos.md +1 -1
- package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/11-checklist-roi.md +1 -1
- package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/12-mineracao-ofertas.md +1 -1
- package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/pontos-em-aberto.md +2 -2
- package/src/modules/wizz/_shared/video-pipeline.md +12 -4
- package/src/modules/wizz/agents/wizz-designer/SKILL.md +1 -1
- package/src/modules/wizz/agents/wizz-maestro/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 +3 -2
- 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
- package/tools/installer/banner.js +80 -29
- package/tools/installer/commands/catalog.js +87 -0
- package/tools/installer/commands/install.js +9 -0
- package/tools/installer/guided/detect-tools.js +53 -0
- package/tools/installer/guided/flow.js +203 -0
- package/tools/installer/guided/labels.js +185 -0
- package/tools/installer/guided/messages.js +132 -0
- package/tools/installer/guided/outro.js +32 -0
- package/tools/installer/guided/profiles.js +95 -0
- package/tools/installer/guided/steps.js +154 -0
- package/tools/installer/install-messages.yaml +3 -3
- package/tools/installer/modules/external-manager.js +4 -0
- package/tools/installer/ui.js +57 -22
- 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())
|