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.
Files changed (83) hide show
  1. package/README.md +2 -1
  2. package/package.json +1 -1
  3. package/skills-registry.yaml +38 -29
  4. package/src/bmm-skills/module.yaml +1 -1
  5. package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/04-criativos.md +1 -1
  6. package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/11-checklist-roi.md +1 -1
  7. package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/12-mineracao-ofertas.md +1 -1
  8. package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/pontos-em-aberto.md +2 -2
  9. package/src/modules/wizz/_shared/video-pipeline.md +12 -4
  10. package/src/modules/wizz/agents/wizz-designer/SKILL.md +1 -1
  11. package/src/modules/wizz/agents/wizz-maestro/SKILL.md +1 -1
  12. package/src/modules/wizz/agents/wizz-social/SKILL.md +1 -0
  13. package/src/skills-lib/wizz-remotion-best-practices/rules/sfx.md +3 -1
  14. package/src/skills-lib/wizz-router/references/routing-table-flat.md +3 -2
  15. package/src/skills-lib/wizz-video-effects/SKILL.md +85 -0
  16. package/src/skills-lib/wizz-video-effects/assets/effects.config.template.json +178 -0
  17. package/src/skills-lib/wizz-video-effects/assets/remotion/EffectsLayer.tsx +87 -0
  18. package/src/skills-lib/wizz-video-effects/assets/remotion/README.md +37 -0
  19. package/src/skills-lib/wizz-video-effects/assets/remotion/useEffects.ts +120 -0
  20. package/src/skills-lib/wizz-video-effects/assets/sfx/CREDITS.md +56 -0
  21. package/src/skills-lib/wizz-video-effects/assets/sfx/cash.mp3 +0 -0
  22. package/src/skills-lib/wizz-video-effects/assets/sfx/chime.mp3 +0 -0
  23. package/src/skills-lib/wizz-video-effects/assets/sfx/click-soft.mp3 +0 -0
  24. package/src/skills-lib/wizz-video-effects/assets/sfx/click.mp3 +0 -0
  25. package/src/skills-lib/wizz-video-effects/assets/sfx/coin.mp3 +0 -0
  26. package/src/skills-lib/wizz-video-effects/assets/sfx/error.mp3 +0 -0
  27. package/src/skills-lib/wizz-video-effects/assets/sfx/impact-bass-1.mp3 +0 -0
  28. package/src/skills-lib/wizz-video-effects/assets/sfx/impact-bass-2.mp3 +0 -0
  29. package/src/skills-lib/wizz-video-effects/assets/sfx/key-press.mp3 +0 -0
  30. package/src/skills-lib/wizz-video-effects/assets/sfx/notif.mp3 +0 -0
  31. package/src/skills-lib/wizz-video-effects/assets/sfx/notification.mp3 +0 -0
  32. package/src/skills-lib/wizz-video-effects/assets/sfx/paste.mp3 +0 -0
  33. package/src/skills-lib/wizz-video-effects/assets/sfx/ping.mp3 +0 -0
  34. package/src/skills-lib/wizz-video-effects/assets/sfx/pop.mp3 +0 -0
  35. package/src/skills-lib/wizz-video-effects/assets/sfx/riser-metal.mp3 +0 -0
  36. package/src/skills-lib/wizz-video-effects/assets/sfx/riser.mp3 +0 -0
  37. package/src/skills-lib/wizz-video-effects/assets/sfx/send.mp3 +0 -0
  38. package/src/skills-lib/wizz-video-effects/assets/sfx/sparkle.mp3 +0 -0
  39. package/src/skills-lib/wizz-video-effects/assets/sfx/stamp.mp3 +0 -0
  40. package/src/skills-lib/wizz-video-effects/assets/sfx/success.mp3 +0 -0
  41. package/src/skills-lib/wizz-video-effects/assets/sfx/type.mp3 +0 -0
  42. package/src/skills-lib/wizz-video-effects/assets/sfx/typing.mp3 +0 -0
  43. package/src/skills-lib/wizz-video-effects/assets/sfx/whoosh-cinematic.mp3 +0 -0
  44. package/src/skills-lib/wizz-video-effects/assets/sfx/whoosh-short.mp3 +0 -0
  45. package/src/skills-lib/wizz-video-effects/assets/sfx/whoosh.mp3 +0 -0
  46. package/src/skills-lib/wizz-video-effects/references/audio.md +87 -0
  47. package/src/skills-lib/wizz-video-effects/references/data-contract.md +145 -0
  48. package/src/skills-lib/wizz-video-effects/references/do-not-use.md +54 -0
  49. package/src/skills-lib/wizz-video-effects/references/editing-patterns.md +58 -0
  50. package/src/skills-lib/wizz-video-effects/references/gates.md +37 -0
  51. package/src/skills-lib/wizz-video-effects/references/sfx-licensing.md +40 -0
  52. package/src/skills-lib/wizz-video-effects/references/when-to-use.md +129 -0
  53. package/src/skills-lib/wizz-video-effects/scripts/budget.py +211 -0
  54. package/src/skills-lib/wizz-video-effects/scripts/check_excess.py +218 -0
  55. package/src/skills-lib/wizz-video-effects/scripts/check_loudness.py +175 -0
  56. package/src/skills-lib/wizz-video-effects/scripts/check_rhythm.py +137 -0
  57. package/src/skills-lib/wizz-video-effects/scripts/check_safe_zone.py +221 -0
  58. package/src/skills-lib/wizz-video-effects/scripts/config.py +583 -0
  59. package/src/skills-lib/wizz-video-effects/scripts/fill_breaths.py +355 -0
  60. package/src/skills-lib/wizz-video-effects/scripts/measure_density.py +122 -0
  61. package/src/skills-lib/wizz-video-effects/scripts/normalize_audio.py +189 -0
  62. package/src/skills-lib/wizz-video-effects/scripts/sound.py +342 -0
  63. package/src/skills-lib/wizz-video-effects/scripts/test_budget.py +131 -0
  64. package/src/skills-lib/wizz-video-effects/scripts/test_check_loudness.py +156 -0
  65. package/src/skills-lib/wizz-video-effects/scripts/test_checks.py +190 -0
  66. package/src/skills-lib/wizz-video-effects/scripts/test_config.py +135 -0
  67. package/src/skills-lib/wizz-video-effects/scripts/test_fill_breaths.py +179 -0
  68. package/src/skills-lib/wizz-video-effects/scripts/test_sound.py +173 -0
  69. package/src/skills-lib/wizz-video-effects/scripts/timeline.py +85 -0
  70. package/tools/installer/banner.js +80 -29
  71. package/tools/installer/commands/catalog.js +87 -0
  72. package/tools/installer/commands/install.js +9 -0
  73. package/tools/installer/guided/detect-tools.js +53 -0
  74. package/tools/installer/guided/flow.js +203 -0
  75. package/tools/installer/guided/labels.js +185 -0
  76. package/tools/installer/guided/messages.js +132 -0
  77. package/tools/installer/guided/outro.js +32 -0
  78. package/tools/installer/guided/profiles.js +95 -0
  79. package/tools/installer/guided/steps.js +154 -0
  80. package/tools/installer/install-messages.yaml +3 -3
  81. package/tools/installer/modules/external-manager.js +4 -0
  82. package/tools/installer/ui.js +57 -22
  83. 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())