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,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())
|