wizz-method 2.11.1 → 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.
Files changed (86) hide show
  1. package/package.json +1 -1
  2. package/skills-registry.yaml +19 -5
  3. package/src/modules/lowticket/README.md +1 -1
  4. package/src/modules/lowticket/skills/wizz-lowticket-guia/SKILL.md +1 -1
  5. package/src/modules/lowticket/skills/wizz-lowticket-metodologia/SKILL.md +1 -1
  6. package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/02-diagnostico-funil.md +7 -5
  7. package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/03-campanha-bidcap.md +9 -10
  8. package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/04-criativos.md +3 -3
  9. package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/04a-criativos-ia.md +6 -6
  10. package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/04b-criativos-formatos.md +1 -1
  11. package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/04c-criativos-roteiro.md +3 -3
  12. package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/06-pagina-vendas.md +2 -2
  13. package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/06a-instagram.md +2 -2
  14. package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/07-funil-upsell.md +2 -2
  15. package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/11-checklist-roi.md +5 -5
  16. package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/12-mineracao-ofertas.md +4 -4
  17. package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/12a-mineracao-veredito.md +3 -3
  18. package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/12b-fabrica-de-ofertas.md +11 -11
  19. package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/12c-anatomia-low-ticket.md +2 -2
  20. package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/14-vocabulario.md +1 -1
  21. package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/15-como-estender.md +1 -1
  22. package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/18-copy-desejo-oferta.md +2 -2
  23. package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/INDEX.md +12 -12
  24. package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/guardrails.md +41 -55
  25. package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/pontos-em-aberto.md +24 -24
  26. package/src/modules/lowticket/skills/wizz-lowticket-minerador/SKILL.md +7 -7
  27. package/src/modules/wizz/_shared/video-pipeline.md +11 -3
  28. package/src/modules/wizz/agents/wizz-designer/SKILL.md +1 -1
  29. package/src/modules/wizz/agents/wizz-social/SKILL.md +1 -0
  30. package/src/skills-lib/wizz-remotion-best-practices/rules/sfx.md +3 -1
  31. package/src/skills-lib/wizz-router/references/routing-table-flat.md +1 -0
  32. package/src/skills-lib/wizz-video-effects/SKILL.md +85 -0
  33. package/src/skills-lib/wizz-video-effects/assets/effects.config.template.json +178 -0
  34. package/src/skills-lib/wizz-video-effects/assets/remotion/EffectsLayer.tsx +87 -0
  35. package/src/skills-lib/wizz-video-effects/assets/remotion/README.md +37 -0
  36. package/src/skills-lib/wizz-video-effects/assets/remotion/useEffects.ts +120 -0
  37. package/src/skills-lib/wizz-video-effects/assets/sfx/CREDITS.md +56 -0
  38. package/src/skills-lib/wizz-video-effects/assets/sfx/cash.mp3 +0 -0
  39. package/src/skills-lib/wizz-video-effects/assets/sfx/chime.mp3 +0 -0
  40. package/src/skills-lib/wizz-video-effects/assets/sfx/click-soft.mp3 +0 -0
  41. package/src/skills-lib/wizz-video-effects/assets/sfx/click.mp3 +0 -0
  42. package/src/skills-lib/wizz-video-effects/assets/sfx/coin.mp3 +0 -0
  43. package/src/skills-lib/wizz-video-effects/assets/sfx/error.mp3 +0 -0
  44. package/src/skills-lib/wizz-video-effects/assets/sfx/impact-bass-1.mp3 +0 -0
  45. package/src/skills-lib/wizz-video-effects/assets/sfx/impact-bass-2.mp3 +0 -0
  46. package/src/skills-lib/wizz-video-effects/assets/sfx/key-press.mp3 +0 -0
  47. package/src/skills-lib/wizz-video-effects/assets/sfx/notif.mp3 +0 -0
  48. package/src/skills-lib/wizz-video-effects/assets/sfx/notification.mp3 +0 -0
  49. package/src/skills-lib/wizz-video-effects/assets/sfx/paste.mp3 +0 -0
  50. package/src/skills-lib/wizz-video-effects/assets/sfx/ping.mp3 +0 -0
  51. package/src/skills-lib/wizz-video-effects/assets/sfx/pop.mp3 +0 -0
  52. package/src/skills-lib/wizz-video-effects/assets/sfx/riser-metal.mp3 +0 -0
  53. package/src/skills-lib/wizz-video-effects/assets/sfx/riser.mp3 +0 -0
  54. package/src/skills-lib/wizz-video-effects/assets/sfx/send.mp3 +0 -0
  55. package/src/skills-lib/wizz-video-effects/assets/sfx/sparkle.mp3 +0 -0
  56. package/src/skills-lib/wizz-video-effects/assets/sfx/stamp.mp3 +0 -0
  57. package/src/skills-lib/wizz-video-effects/assets/sfx/success.mp3 +0 -0
  58. package/src/skills-lib/wizz-video-effects/assets/sfx/type.mp3 +0 -0
  59. package/src/skills-lib/wizz-video-effects/assets/sfx/typing.mp3 +0 -0
  60. package/src/skills-lib/wizz-video-effects/assets/sfx/whoosh-cinematic.mp3 +0 -0
  61. package/src/skills-lib/wizz-video-effects/assets/sfx/whoosh-short.mp3 +0 -0
  62. package/src/skills-lib/wizz-video-effects/assets/sfx/whoosh.mp3 +0 -0
  63. package/src/skills-lib/wizz-video-effects/references/audio.md +87 -0
  64. package/src/skills-lib/wizz-video-effects/references/data-contract.md +145 -0
  65. package/src/skills-lib/wizz-video-effects/references/do-not-use.md +54 -0
  66. package/src/skills-lib/wizz-video-effects/references/editing-patterns.md +58 -0
  67. package/src/skills-lib/wizz-video-effects/references/gates.md +37 -0
  68. package/src/skills-lib/wizz-video-effects/references/sfx-licensing.md +40 -0
  69. package/src/skills-lib/wizz-video-effects/references/when-to-use.md +129 -0
  70. package/src/skills-lib/wizz-video-effects/scripts/budget.py +211 -0
  71. package/src/skills-lib/wizz-video-effects/scripts/check_excess.py +218 -0
  72. package/src/skills-lib/wizz-video-effects/scripts/check_loudness.py +175 -0
  73. package/src/skills-lib/wizz-video-effects/scripts/check_rhythm.py +137 -0
  74. package/src/skills-lib/wizz-video-effects/scripts/check_safe_zone.py +221 -0
  75. package/src/skills-lib/wizz-video-effects/scripts/config.py +583 -0
  76. package/src/skills-lib/wizz-video-effects/scripts/fill_breaths.py +355 -0
  77. package/src/skills-lib/wizz-video-effects/scripts/measure_density.py +122 -0
  78. package/src/skills-lib/wizz-video-effects/scripts/normalize_audio.py +189 -0
  79. package/src/skills-lib/wizz-video-effects/scripts/sound.py +342 -0
  80. package/src/skills-lib/wizz-video-effects/scripts/test_budget.py +131 -0
  81. package/src/skills-lib/wizz-video-effects/scripts/test_check_loudness.py +156 -0
  82. package/src/skills-lib/wizz-video-effects/scripts/test_checks.py +190 -0
  83. package/src/skills-lib/wizz-video-effects/scripts/test_config.py +135 -0
  84. package/src/skills-lib/wizz-video-effects/scripts/test_fill_breaths.py +179 -0
  85. package/src/skills-lib/wizz-video-effects/scripts/test_sound.py +173 -0
  86. 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())