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