wizz-method 2.11.2 → 2.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/skills-registry.yaml +19 -5
- package/src/modules/lowticket/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 +11 -3
- package/src/modules/wizz/agents/wizz-designer/SKILL.md +1 -1
- package/src/modules/wizz/agents/wizz-social/SKILL.md +1 -0
- package/src/skills-lib/wizz-remotion-best-practices/rules/sfx.md +3 -1
- package/src/skills-lib/wizz-router/references/routing-table-flat.md +1 -0
- package/src/skills-lib/wizz-video-effects/SKILL.md +85 -0
- package/src/skills-lib/wizz-video-effects/assets/effects.config.template.json +178 -0
- package/src/skills-lib/wizz-video-effects/assets/remotion/EffectsLayer.tsx +87 -0
- package/src/skills-lib/wizz-video-effects/assets/remotion/README.md +37 -0
- package/src/skills-lib/wizz-video-effects/assets/remotion/useEffects.ts +120 -0
- package/src/skills-lib/wizz-video-effects/assets/sfx/CREDITS.md +56 -0
- package/src/skills-lib/wizz-video-effects/assets/sfx/cash.mp3 +0 -0
- package/src/skills-lib/wizz-video-effects/assets/sfx/chime.mp3 +0 -0
- package/src/skills-lib/wizz-video-effects/assets/sfx/click-soft.mp3 +0 -0
- package/src/skills-lib/wizz-video-effects/assets/sfx/click.mp3 +0 -0
- package/src/skills-lib/wizz-video-effects/assets/sfx/coin.mp3 +0 -0
- package/src/skills-lib/wizz-video-effects/assets/sfx/error.mp3 +0 -0
- package/src/skills-lib/wizz-video-effects/assets/sfx/impact-bass-1.mp3 +0 -0
- package/src/skills-lib/wizz-video-effects/assets/sfx/impact-bass-2.mp3 +0 -0
- package/src/skills-lib/wizz-video-effects/assets/sfx/key-press.mp3 +0 -0
- package/src/skills-lib/wizz-video-effects/assets/sfx/notif.mp3 +0 -0
- package/src/skills-lib/wizz-video-effects/assets/sfx/notification.mp3 +0 -0
- package/src/skills-lib/wizz-video-effects/assets/sfx/paste.mp3 +0 -0
- package/src/skills-lib/wizz-video-effects/assets/sfx/ping.mp3 +0 -0
- package/src/skills-lib/wizz-video-effects/assets/sfx/pop.mp3 +0 -0
- package/src/skills-lib/wizz-video-effects/assets/sfx/riser-metal.mp3 +0 -0
- package/src/skills-lib/wizz-video-effects/assets/sfx/riser.mp3 +0 -0
- package/src/skills-lib/wizz-video-effects/assets/sfx/send.mp3 +0 -0
- package/src/skills-lib/wizz-video-effects/assets/sfx/sparkle.mp3 +0 -0
- package/src/skills-lib/wizz-video-effects/assets/sfx/stamp.mp3 +0 -0
- package/src/skills-lib/wizz-video-effects/assets/sfx/success.mp3 +0 -0
- package/src/skills-lib/wizz-video-effects/assets/sfx/type.mp3 +0 -0
- package/src/skills-lib/wizz-video-effects/assets/sfx/typing.mp3 +0 -0
- package/src/skills-lib/wizz-video-effects/assets/sfx/whoosh-cinematic.mp3 +0 -0
- package/src/skills-lib/wizz-video-effects/assets/sfx/whoosh-short.mp3 +0 -0
- package/src/skills-lib/wizz-video-effects/assets/sfx/whoosh.mp3 +0 -0
- package/src/skills-lib/wizz-video-effects/references/audio.md +87 -0
- package/src/skills-lib/wizz-video-effects/references/data-contract.md +145 -0
- package/src/skills-lib/wizz-video-effects/references/do-not-use.md +54 -0
- package/src/skills-lib/wizz-video-effects/references/editing-patterns.md +58 -0
- package/src/skills-lib/wizz-video-effects/references/gates.md +37 -0
- package/src/skills-lib/wizz-video-effects/references/sfx-licensing.md +40 -0
- package/src/skills-lib/wizz-video-effects/references/when-to-use.md +129 -0
- package/src/skills-lib/wizz-video-effects/scripts/budget.py +211 -0
- package/src/skills-lib/wizz-video-effects/scripts/check_excess.py +218 -0
- package/src/skills-lib/wizz-video-effects/scripts/check_loudness.py +175 -0
- package/src/skills-lib/wizz-video-effects/scripts/check_rhythm.py +137 -0
- package/src/skills-lib/wizz-video-effects/scripts/check_safe_zone.py +221 -0
- package/src/skills-lib/wizz-video-effects/scripts/config.py +583 -0
- package/src/skills-lib/wizz-video-effects/scripts/fill_breaths.py +355 -0
- package/src/skills-lib/wizz-video-effects/scripts/measure_density.py +122 -0
- package/src/skills-lib/wizz-video-effects/scripts/normalize_audio.py +189 -0
- package/src/skills-lib/wizz-video-effects/scripts/sound.py +342 -0
- package/src/skills-lib/wizz-video-effects/scripts/test_budget.py +131 -0
- package/src/skills-lib/wizz-video-effects/scripts/test_check_loudness.py +156 -0
- package/src/skills-lib/wizz-video-effects/scripts/test_checks.py +190 -0
- package/src/skills-lib/wizz-video-effects/scripts/test_config.py +135 -0
- package/src/skills-lib/wizz-video-effects/scripts/test_fill_breaths.py +179 -0
- package/src/skills-lib/wizz-video-effects/scripts/test_sound.py +173 -0
- package/src/skills-lib/wizz-video-effects/scripts/timeline.py +85 -0
|
@@ -0,0 +1,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())
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""RHYTHM check on a timeline JSON. It only WARNS (always exit 0).
|
|
3
|
+
|
|
4
|
+
Why it only warns: the numbers behind it (change something every 3-5s,
|
|
5
|
+
Reels cuts every 2.5-4s) come from pacing blogs without a declared method.
|
|
6
|
+
The config marks them with `_source`. A number backed only by opinion
|
|
7
|
+
becomes a warning, never a gate.
|
|
8
|
+
|
|
9
|
+
What it measures, over the visual changes listed in rhythm.counts_as_change
|
|
10
|
+
(scene starts, punches, shakes, flashes, interrupts, element entries):
|
|
11
|
+
- longest static stretch: the longest span with no visual change, from
|
|
12
|
+
0 to the duration. Warns above rhythm.max_static_s.
|
|
13
|
+
- hook cadence: average interval between changes inside (0, rhythm.hook_s],
|
|
14
|
+
computed as hook_s / number of changes. Warns above
|
|
15
|
+
rhythm.hook_cut_every_s.
|
|
16
|
+
- body cadence: the same inside (hook_s, duration - rhythm.cta_tail_s].
|
|
17
|
+
Warns outside rhythm.body_cut_every_s [min, max]: below min is
|
|
18
|
+
frantic, above max is slow. The CTA tail is excluded on purpose: it
|
|
19
|
+
lowers effect density to keep price and button legible.
|
|
20
|
+
SFX are audio and never count as a visual change.
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import argparse
|
|
25
|
+
import json
|
|
26
|
+
import math
|
|
27
|
+
import sys
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
from config import ConfigError, Rhythm, add_config_arg, load
|
|
31
|
+
import timeline as tl
|
|
32
|
+
|
|
33
|
+
# timeline key -> how to read its instants
|
|
34
|
+
_READERS = {
|
|
35
|
+
"scenes": lambda d: tl.instants(d.get("scenes"), "start"),
|
|
36
|
+
"punches": lambda d: tl.instants(d.get("punches")),
|
|
37
|
+
"shakes": lambda d: tl.instants(d.get("shakes")),
|
|
38
|
+
"flashes": lambda d: tl.instants(d.get("flashes")),
|
|
39
|
+
"interrupts": lambda d: tl.instants(d.get("interrupts")),
|
|
40
|
+
"elements": lambda d: tl.instants(d.get("elements")),
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def change_points(data: dict, kinds: tuple[str, ...]) -> list[float]:
|
|
45
|
+
"""Sorted unique instants of visual change. Unknown kinds are ignored."""
|
|
46
|
+
pts = {round(t, 3) for k in kinds if k in _READERS for t in _READERS[k](data)}
|
|
47
|
+
return sorted(pts)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def longest_static(points: list[float], total: float) -> tuple[float, float]:
|
|
51
|
+
"""(length, start) of the longest span without change within [0, total]."""
|
|
52
|
+
edges = sorted({0.0, total, *(p for p in points if 0.0 <= p <= total)})
|
|
53
|
+
best, at = 0.0, 0.0
|
|
54
|
+
for a, b in zip(edges, edges[1:]):
|
|
55
|
+
if b - a > best:
|
|
56
|
+
best, at = b - a, a
|
|
57
|
+
return best, at
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def cadence(points: list[float], start: float, end: float) -> float:
|
|
61
|
+
"""Average seconds per change inside (start, end]; inf with no change."""
|
|
62
|
+
n = sum(1 for p in points if start < p <= end)
|
|
63
|
+
length = max(0.0, end - start)
|
|
64
|
+
return length / n if n else math.inf
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def analyze(data: dict, name: str, r: Rhythm) -> dict:
|
|
68
|
+
total, derived = tl.duration(data)
|
|
69
|
+
pts = change_points(data, r.counts_as_change)
|
|
70
|
+
static, static_at = longest_static(pts, total)
|
|
71
|
+
hook_end = min(r.hook_s, total)
|
|
72
|
+
body_end = max(hook_end, total - r.cta_tail_s)
|
|
73
|
+
hook = cadence(pts, 0.0, hook_end)
|
|
74
|
+
body = cadence(pts, hook_end, body_end) if body_end > hook_end else None
|
|
75
|
+
warns = []
|
|
76
|
+
if static > r.max_static_s:
|
|
77
|
+
warns.append(f"{static:.1f}s without visual change from {static_at:.2f}s "
|
|
78
|
+
f"> rhythm.max_static_s {r.max_static_s:g}s")
|
|
79
|
+
if hook > r.hook_cut_every_s:
|
|
80
|
+
shown = "no change" if math.isinf(hook) else f"one change every {hook:.1f}s"
|
|
81
|
+
warns.append(f"hook (0-{hook_end:g}s): {shown} > rhythm.hook_cut_every_s "
|
|
82
|
+
f"{r.hook_cut_every_s:g}s")
|
|
83
|
+
lo, hi = r.body_cut_every_s
|
|
84
|
+
if body is not None and not lo <= body <= hi:
|
|
85
|
+
shown = "no change" if math.isinf(body) else f"one change every {body:.1f}s"
|
|
86
|
+
warns.append(f"body ({hook_end:g}-{body_end:.1f}s): {shown} outside "
|
|
87
|
+
f"rhythm.body_cut_every_s [{lo:g}, {hi:g}]s")
|
|
88
|
+
return {"name": name, "duration": total, "duration_derived": derived,
|
|
89
|
+
"changes": len(pts), "longest_static_s": static, "longest_static_at": static_at,
|
|
90
|
+
"hook_every_s": hook, "body_every_s": body, "warnings": warns}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _finite(rep: dict) -> dict:
|
|
94
|
+
"""JSON has no infinity: an absent cadence becomes null."""
|
|
95
|
+
return {k: (None if isinstance(v, float) and math.isinf(v) else v) for k, v in rep.items()}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _fmt(v) -> str:
|
|
99
|
+
return "-" if v is None else ("none" if math.isinf(v) else f"{v:.2f}s")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def print_report(rep: dict) -> None:
|
|
103
|
+
print(f"== rhythm of {rep['name']} == {rep['duration']:.1f}s, {rep['changes']} visual change(s)")
|
|
104
|
+
if rep["duration_derived"]:
|
|
105
|
+
print(" note: no 'duration' in the JSON; derived from scenes")
|
|
106
|
+
print(f" longest static: {rep['longest_static_s']:.2f}s at {rep['longest_static_at']:.2f}s")
|
|
107
|
+
print(f" hook cadence: {_fmt(rep['hook_every_s'])} body cadence: {_fmt(rep['body_every_s'])}")
|
|
108
|
+
for w in rep["warnings"]:
|
|
109
|
+
print(f" WARNING: {w}")
|
|
110
|
+
if not rep["warnings"]:
|
|
111
|
+
print(" ok: rhythm inside the configured references")
|
|
112
|
+
print()
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def main(argv: list[str] | None = None) -> int:
|
|
116
|
+
ap = argparse.ArgumentParser(
|
|
117
|
+
description="Rhythm check (warn only): static stretches and cut cadence.")
|
|
118
|
+
add_config_arg(ap)
|
|
119
|
+
ap.add_argument("timelines", nargs="+", help="timeline JSON path(s)")
|
|
120
|
+
ap.add_argument("--json", action="store_true", help="machine-readable output")
|
|
121
|
+
args = ap.parse_args(argv)
|
|
122
|
+
try:
|
|
123
|
+
r = load(args.config).rhythm
|
|
124
|
+
reps = [analyze(tl.load(p), Path(p).stem, r) for p in args.timelines]
|
|
125
|
+
except (ConfigError, tl.TimelineError) as e:
|
|
126
|
+
print(f"ERROR: {e}")
|
|
127
|
+
return 2
|
|
128
|
+
if args.json:
|
|
129
|
+
print(json.dumps({x["name"]: _finite(x) for x in reps}, indent=2))
|
|
130
|
+
else:
|
|
131
|
+
for x in reps:
|
|
132
|
+
print_report(x)
|
|
133
|
+
return 0
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
if __name__ == "__main__":
|
|
137
|
+
sys.exit(main())
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""SAFE ZONE gate: legible content must live between the profile's top and
|
|
3
|
+
bottom, measured on the FINAL MP4.
|
|
4
|
+
|
|
5
|
+
Why it exists. Pieces were approved with screens invading the platform UI (a
|
|
6
|
+
CTA 44px from the top, 7px from the bottom). A green lint and an eye check do
|
|
7
|
+
not catch that; only measuring does.
|
|
8
|
+
|
|
9
|
+
Profiles. The config declares `safe_zones.organic` and `safe_zones.ad` (Meta
|
|
10
|
+
paid Stories/Reels). The profile is, in order: --profile, the timeline's
|
|
11
|
+
`safe_zone_profile` (--timeline), then the config default `safe_zone_profile`.
|
|
12
|
+
|
|
13
|
+
How it measures (lessons already paid for):
|
|
14
|
+
- LOCAL CONTRAST, not mean brightness: the horizontal std-dev of each row,
|
|
15
|
+
sampled at safe_zone_gate.sample_width columns. On a light background
|
|
16
|
+
the mean of every row passes any brightness threshold.
|
|
17
|
+
- Its own threshold, safe_zone_gate.dp_min, not density.dp_min: a low
|
|
18
|
+
threshold flags background glow as text.
|
|
19
|
+
- EVERY frame, in one continuous ffmpeg decode. Sampling at 1 fps missed
|
|
20
|
+
the peak of entry animations that last tenths of a second.
|
|
21
|
+
- safe_zone_gate.edge_artifact_rows ignored at each frame edge (encoder
|
|
22
|
+
border), and fewer than safe_zone_gate.min_rows_to_fail invading rows
|
|
23
|
+
is a warning, not a failure (a card corner grazing vs a whole label).
|
|
24
|
+
Only the vertical limits are measured, like the gate this was ported from;
|
|
25
|
+
left/right live in the config for layout.
|
|
26
|
+
|
|
27
|
+
Before moving a layout on FAIL, look at the frame (a low top with max dp near
|
|
28
|
+
the threshold is glow; a max dp in the tens is text):
|
|
29
|
+
ffmpeg -ss T -i VIDEO -frames:v 1 -vf "crop=1080:400:0:0" frame-top.png
|
|
30
|
+
|
|
31
|
+
Exit code: 0 pass, 1 fail, 2 file missing or unreadable.
|
|
32
|
+
"""
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import argparse
|
|
36
|
+
import subprocess
|
|
37
|
+
import sys
|
|
38
|
+
|
|
39
|
+
from config import Config, ConfigError, SafeZone, add_config_arg, load
|
|
40
|
+
import timeline as tl
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class ReadError(Exception):
|
|
44
|
+
"""Video missing or unreadable."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def row_std(px) -> float:
|
|
48
|
+
"""Std-dev of a row in ONE pass (sum and sum of squares)."""
|
|
49
|
+
n = len(px)
|
|
50
|
+
if n == 0:
|
|
51
|
+
return 0.0
|
|
52
|
+
s = s2 = 0
|
|
53
|
+
for v in px:
|
|
54
|
+
s += v
|
|
55
|
+
s2 += v * v
|
|
56
|
+
m = s / n
|
|
57
|
+
var = s2 / n - m * m
|
|
58
|
+
return var ** 0.5 if var > 0 else 0.0
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def content_rows(frame: bytes, width: int, height: int, dp_min: float,
|
|
62
|
+
margin: int) -> list[tuple[int, float]]:
|
|
63
|
+
"""(y, dp) of every row that holds legible content, edges excluded."""
|
|
64
|
+
rows = []
|
|
65
|
+
for y in range(margin, height - margin):
|
|
66
|
+
v = row_std(frame[y * width:(y + 1) * width])
|
|
67
|
+
if v >= dp_min:
|
|
68
|
+
rows.append((y, v))
|
|
69
|
+
return rows
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _scale_filter(width: int, height: int) -> str:
|
|
73
|
+
return "format=gray,scale=%d:%d:flags=area" % (width, height)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def read_frame(video: str, t: float, width: int, height: int) -> bytes:
|
|
77
|
+
"""One gray frame reduced to width x height, by seek."""
|
|
78
|
+
r = subprocess.run(
|
|
79
|
+
["ffmpeg", "-v", "error", "-ss", str(t), "-i", video, "-frames:v", "1",
|
|
80
|
+
"-vf", _scale_filter(width, height), "-f", "rawvideo", "-pix_fmt", "gray", "-"],
|
|
81
|
+
capture_output=True)
|
|
82
|
+
if r.returncode != 0:
|
|
83
|
+
raise ReadError(f"ffmpeg could not read {video} at t={t:.2f}: "
|
|
84
|
+
f"{r.stderr.decode(errors='replace').strip()}")
|
|
85
|
+
return r.stdout
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def video_fps(video: str) -> float:
|
|
89
|
+
"""Real fps of the file (the config fps is only the render target)."""
|
|
90
|
+
o = subprocess.run(["ffprobe", "-v", "error", "-select_streams", "v:0",
|
|
91
|
+
"-show_entries", "stream=r_frame_rate", "-of", "csv=p=0", video],
|
|
92
|
+
capture_output=True, text=True)
|
|
93
|
+
txt = o.stdout.strip().rstrip(",")
|
|
94
|
+
if not txt:
|
|
95
|
+
raise ReadError(f"ffprobe could not read the fps of {video}: {o.stderr.strip()}")
|
|
96
|
+
if "/" in txt:
|
|
97
|
+
num, den = txt.split("/")
|
|
98
|
+
return float(num) / float(den)
|
|
99
|
+
return float(txt)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def all_frames(video: str, width: int, height: int):
|
|
103
|
+
"""Yield (t, gray bytes) for EVERY frame, in one streaming decode."""
|
|
104
|
+
fr = video_fps(video)
|
|
105
|
+
size = width * height
|
|
106
|
+
proc = subprocess.Popen(
|
|
107
|
+
["ffmpeg", "-v", "error", "-i", video, "-vf", _scale_filter(width, height),
|
|
108
|
+
"-f", "rawvideo", "-pix_fmt", "gray", "-"],
|
|
109
|
+
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
110
|
+
i = 0
|
|
111
|
+
try:
|
|
112
|
+
while True:
|
|
113
|
+
d = proc.stdout.read(size)
|
|
114
|
+
if len(d) < size:
|
|
115
|
+
break
|
|
116
|
+
yield i / fr, d
|
|
117
|
+
i += 1
|
|
118
|
+
finally:
|
|
119
|
+
proc.stdout.close()
|
|
120
|
+
err = proc.stderr.read()
|
|
121
|
+
code = proc.wait()
|
|
122
|
+
if code != 0:
|
|
123
|
+
raise ReadError(f"ffmpeg could not decode {video}: {err.decode(errors='replace').strip()}")
|
|
124
|
+
if i == 0:
|
|
125
|
+
raise ReadError(f"ffmpeg returned no frame from {video}")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _side(rows: list[tuple[int, float]], bad, label: str, limit: int, fail_rows: int):
|
|
129
|
+
hit = [v for y, v in rows if bad(y)]
|
|
130
|
+
if not hit:
|
|
131
|
+
return None, False
|
|
132
|
+
severe = len(hit) >= fail_rows
|
|
133
|
+
edge = rows[0][0] if label == "TOP" else rows[-1][0]
|
|
134
|
+
sign = "<" if label == "TOP" else ">"
|
|
135
|
+
return (f"{label} {edge}{sign}{limit} ({len(hit)} row(s), max dp {max(hit):.1f}) "
|
|
136
|
+
f"[{'FAIL' if severe else 'warn'}]"), severe
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def evaluate(frame: bytes, cfg: Config, zone: SafeZone) -> tuple[str, int | None, int | None, list[str]]:
|
|
140
|
+
"""(state, top, bottom, problems); state in short/empty/ok/warn/fail."""
|
|
141
|
+
g, h = cfg.safe_zone_gate, cfg.frame.height
|
|
142
|
+
if len(frame) < g.sample_width * h:
|
|
143
|
+
return "short", None, None, []
|
|
144
|
+
rows = content_rows(frame, g.sample_width, h, g.dp_min, g.edge_artifact_rows)
|
|
145
|
+
if not rows:
|
|
146
|
+
return "empty", None, None, []
|
|
147
|
+
problems, severe = [], False
|
|
148
|
+
for label, bad, limit in (("TOP", lambda y: y < zone.top, zone.top),
|
|
149
|
+
("BOTTOM", lambda y: y > zone.bottom, zone.bottom)):
|
|
150
|
+
msg, sev = _side(rows, bad, label, limit, g.min_rows_to_fail)
|
|
151
|
+
if msg:
|
|
152
|
+
problems.append(msg)
|
|
153
|
+
severe = severe or sev
|
|
154
|
+
state = "ok" if not problems else ("fail" if severe else "warn")
|
|
155
|
+
return state, rows[0][0], rows[-1][0], problems
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def resolve_profile(cfg: Config, profile: str | None, timeline_path: str | None) -> SafeZone:
|
|
159
|
+
if profile:
|
|
160
|
+
return cfg.safe_zone(profile)
|
|
161
|
+
if timeline_path:
|
|
162
|
+
return cfg.safe_zone(tl.load(timeline_path).get("safe_zone_profile"))
|
|
163
|
+
return cfg.safe_zone()
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _scan(video: str, times: list[float], cfg: Config, zone: SafeZone):
|
|
167
|
+
"""Yield (t, state, top, bottom, problems) for explicit times or every frame."""
|
|
168
|
+
w, h = cfg.safe_zone_gate.sample_width, cfg.frame.height
|
|
169
|
+
source = ((t, read_frame(video, t, w, h)) for t in times) if times \
|
|
170
|
+
else all_frames(video, w, h)
|
|
171
|
+
for t, d in source:
|
|
172
|
+
yield (t, *evaluate(d, cfg, zone))
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def run(video: str, times: list[float], cfg: Config, zone: SafeZone) -> int:
|
|
176
|
+
fails, warns, total, empty = [], [], 0, 0
|
|
177
|
+
print(f"profile: {zone.name} y={zone.top}..{zone.bottom}")
|
|
178
|
+
print(f"{'t':>7} {'top':>6} {'bottom':>6} verdict")
|
|
179
|
+
for t, state, top, bottom, probs in _scan(video, times, cfg, zone):
|
|
180
|
+
total += 1
|
|
181
|
+
if state in ("empty", "short"):
|
|
182
|
+
empty += 1
|
|
183
|
+
continue
|
|
184
|
+
if state in ("fail", "warn") or times:
|
|
185
|
+
print(f"{t:7.2f} {top:6d} {bottom:6d} {' | '.join(probs) or 'ok'}")
|
|
186
|
+
if state == "fail":
|
|
187
|
+
fails.append(t)
|
|
188
|
+
elif state == "warn":
|
|
189
|
+
warns.append(t)
|
|
190
|
+
print(f" {total} frame(s): {len(fails)} failing, {len(warns)} warning, "
|
|
191
|
+
f"{empty} empty, rest ok.")
|
|
192
|
+
if warns:
|
|
193
|
+
print(f" {len(warns)} frame(s) graze the zone with fewer than "
|
|
194
|
+
f"{cfg.safe_zone_gate.min_rows_to_fail} rows. Not blocking: look first.")
|
|
195
|
+
if fails:
|
|
196
|
+
print(f"FAILED: {len(fails)} frame(s) invade the '{zone.name}' safe zone.")
|
|
197
|
+
return 1
|
|
198
|
+
print(f"PASSED: every frame inside y={zone.top}..{zone.bottom} ('{zone.name}').")
|
|
199
|
+
return 0
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def main(argv: list[str] | None = None) -> int:
|
|
203
|
+
ap = argparse.ArgumentParser(description="Safe-zone gate on the final MP4 (every frame).")
|
|
204
|
+
add_config_arg(ap)
|
|
205
|
+
ap.add_argument("video", help="final MP4")
|
|
206
|
+
ap.add_argument("times", nargs="*", type=float,
|
|
207
|
+
help="optional instants (s) to inspect instead of every frame")
|
|
208
|
+
ap.add_argument("--profile", help="safe zone profile name (e.g. organic, ad)")
|
|
209
|
+
ap.add_argument("--timeline", help="timeline JSON whose safe_zone_profile applies")
|
|
210
|
+
args = ap.parse_args(argv)
|
|
211
|
+
try:
|
|
212
|
+
cfg = load(args.config)
|
|
213
|
+
zone = resolve_profile(cfg, args.profile, args.timeline)
|
|
214
|
+
return run(args.video, args.times, cfg, zone)
|
|
215
|
+
except (ConfigError, tl.TimelineError, ReadError) as e:
|
|
216
|
+
print(f"ERROR: {e}")
|
|
217
|
+
return 2
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
if __name__ == "__main__":
|
|
221
|
+
sys.exit(main())
|