wizz-method 2.21.0 → 2.23.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 (179) hide show
  1. package/THIRD-PARTY-NOTICES.md +24 -0
  2. package/package.json +4 -2
  3. package/removals.txt +3 -0
  4. package/skills-registry.yaml +133 -4
  5. package/src/bmm-skills/1-analysis/research/wizz-market-research/steps/step-06-research-completion.md +27 -0
  6. package/src/bmm-skills/1-analysis/wizz-set-feature/SKILL.md +1 -1
  7. package/src/bmm-skills/4-implementation/wizz-code-review/SKILL.md +1 -1
  8. package/src/bmm-skills/4-implementation/wizz-create-story/SKILL.md +1 -1
  9. package/src/bmm-skills/4-implementation/wizz-dev-story/SKILL.md +8 -1
  10. package/src/bmm-skills/4-implementation/wizz-quick-dev/SKILL.md +2 -2
  11. package/src/bmm-skills/4-implementation/wizz-quick-dev/step-03-implement.md +2 -0
  12. package/src/bmm-skills/4-implementation/wizz-sprint-status/SKILL.md +1 -1
  13. package/src/core-skills/wizz-party-mode/customize.toml +3 -3
  14. package/src/modules/lowticket/README.md +2 -1
  15. package/src/modules/lowticket/module.yaml +1 -1
  16. package/src/modules/lowticket/skills/wizz-clone-do-expert/SKILL.md +86 -0
  17. package/src/modules/lowticket/skills/wizz-clone-do-expert/references/clone-prompt-template.md +21 -0
  18. package/src/modules/lowticket/skills/wizz-lowticket-trafego/references/reguas.md +2 -0
  19. package/src/modules/wizz/_shared/cerebro.md +1 -1
  20. package/src/modules/wizz/_shared/organizacao.md +2 -2
  21. package/src/modules/wizz/_shared/video-pipeline.md +2 -0
  22. package/src/modules/wizz/agents/wizz-copy/SKILL.md +1 -0
  23. package/src/modules/wizz/agents/wizz-copy/customize.toml +5 -0
  24. package/src/modules/wizz/agents/wizz-copy/knowledge/playbook.md +3 -2
  25. package/src/modules/wizz/agents/wizz-growth/customize.toml +7 -2
  26. package/src/modules/wizz/agents/wizz-growth/knowledge/playbook.md +3 -2
  27. package/src/modules/wizz/agents/wizz-social/knowledge/blueprint-roteiro-viral.md +14 -0
  28. package/src/modules/wizz/agents/wizz-social/knowledge/legenda-instagram.md +12 -0
  29. package/src/skills-lib/wizz-cerebro/SKILL.md +18 -5
  30. package/src/skills-lib/wizz-design-needs/SKILL.md +7 -0
  31. package/src/skills-lib/wizz-design-needs/references/design-state.md +34 -2
  32. package/src/skills-lib/wizz-design-needs/references/gap-analysis.md +1 -1
  33. package/src/skills-lib/wizz-design-needs/scripts/design_state.py +305 -0
  34. package/src/skills-lib/wizz-design-needs/scripts/tests/test_design_state.py +143 -0
  35. package/src/skills-lib/wizz-impeccable/references/design-rules.md +18 -0
  36. package/src/skills-lib/wizz-ios-build/SKILL.md +61 -0
  37. package/src/skills-lib/wizz-motion-3d-director/references/motion-paths.md +2 -0
  38. package/src/skills-lib/wizz-offer-forge/SKILL.md +2 -2
  39. package/src/skills-lib/wizz-paid-ads/SKILL.md +2 -1
  40. package/src/skills-lib/wizz-paywall-upgrade-cro/SKILL.md +1 -0
  41. package/src/skills-lib/{wizz-pesquisa-de-publico-do-piva → wizz-pesquisa-de-publico}/README.md +13 -13
  42. package/src/skills-lib/{wizz-pesquisa-de-publico-do-piva → wizz-pesquisa-de-publico}/SKILL.md +11 -7
  43. package/src/skills-lib/{wizz-pesquisa-de-publico-do-piva → wizz-pesquisa-de-publico}/assets/template.html +8 -21
  44. package/src/skills-lib/wizz-pesquisa-de-publico/references/interview-mode.md +69 -0
  45. package/src/skills-lib/wizz-premium-landing-ui-researcher/SKILL.md +6 -2
  46. package/src/skills-lib/wizz-premium-landing-ui-researcher/references/audit-protocol.md +7 -34
  47. package/src/skills-lib/wizz-premium-landing-ui-researcher/references/component-sources.md +25 -0
  48. package/src/skills-lib/wizz-premium-landing-ui-researcher/references/design-sources.yaml +325 -0
  49. package/src/skills-lib/wizz-premium-landing-ui-researcher/references/mandatory-process.md +1 -1
  50. package/src/skills-lib/wizz-premium-landing-ui-researcher/references/mining-rubric.md +123 -0
  51. package/src/skills-lib/wizz-premium-landing-ui-researcher/references/source-first-protocol.md +10 -4
  52. package/src/skills-lib/wizz-premium-landing-ui-researcher/references/web-access-and-evidence.md +1 -1
  53. package/src/skills-lib/wizz-premium-landing-ui-researcher/scripts/design_sources.py +221 -0
  54. package/src/skills-lib/wizz-premium-landing-ui-researcher/scripts/tests/test_design_sources.py +130 -0
  55. package/src/skills-lib/wizz-router/references/routing-table-flat.md +7 -1
  56. package/src/skills-lib/wizz-seo-audit/SKILL.md +4 -0
  57. package/src/skills-lib/wizz-seo-audit/references/gsc-regex-perguntas.md +64 -0
  58. package/src/skills-lib/wizz-signup-flow-cro/SKILL.md +1 -0
  59. package/src/skills-lib/wizz-signup-flow-cro/references/trial-abuse.md +40 -0
  60. package/src/skills-lib/wizz-taste-skill/references/anti-slop-tells.md +8 -0
  61. package/src/skills-lib/wizz-ui-component-curator/SKILL.md +8 -7
  62. package/src/skills-lib/wizz-ui-ux-pro-max/SKILL.md +1 -1
  63. package/src/skills-lib/wizz-ui-ux-pro-max/references/common-rules-checklist.md +11 -0
  64. package/src/skills-lib/wizz-video-effects/LICENSE-motionmaxxing.txt +202 -0
  65. package/src/skills-lib/wizz-video-effects/SKILL.md +7 -2
  66. package/src/skills-lib/wizz-video-effects/assets/chrome/collect_layout.js +113 -0
  67. package/src/skills-lib/wizz-video-effects/assets/effects.config.template.json +45 -0
  68. package/src/skills-lib/wizz-video-effects/references/data-contract.md +32 -1
  69. package/src/skills-lib/wizz-video-effects/references/gates.md +4 -1
  70. package/src/skills-lib/wizz-video-effects/references/one-take.md +41 -0
  71. package/src/skills-lib/wizz-video-effects/scripts/check_chrome.py +376 -0
  72. package/src/skills-lib/wizz-video-effects/scripts/check_dead_frames.py +193 -0
  73. package/src/skills-lib/wizz-video-effects/scripts/check_one_take.py +159 -0
  74. package/src/skills-lib/wizz-video-effects/scripts/config.py +13 -0
  75. package/src/skills-lib/wizz-video-effects/scripts/test_chrome.py +181 -0
  76. package/src/skills-lib/wizz-video-effects/scripts/test_dead_frames.py +171 -0
  77. package/src/skills-lib/wizz-video-effects/scripts/test_one_take.py +126 -0
  78. package/src/skills-lib/wizz-video-styles/LICENSE-lemo-opuscar.txt +21 -0
  79. package/src/skills-lib/wizz-video-styles/SKILL.md +16 -7
  80. package/src/skills-lib/wizz-video-styles/assets/film-styles/dark-keynote.html +78 -0
  81. package/src/skills-lib/wizz-video-styles/assets/film-styles/remotion/Riso.tsx +63 -0
  82. package/src/skills-lib/wizz-video-styles/assets/film-styles/risograph.html +69 -0
  83. package/src/skills-lib/wizz-video-styles/assets/film-styles/swiss-motion.html +84 -0
  84. package/src/skills-lib/wizz-video-styles/references/check-ins.md +25 -0
  85. package/src/skills-lib/wizz-video-styles/references/film-styles/DIRECTOR.md +139 -0
  86. package/src/skills-lib/wizz-video-styles/references/film-styles/archive-lighttable.md +109 -0
  87. package/src/skills-lib/wizz-video-styles/references/film-styles/art-deco.md +112 -0
  88. package/src/skills-lib/wizz-video-styles/references/film-styles/ascii-crt.md +115 -0
  89. package/src/skills-lib/wizz-video-styles/references/film-styles/backrooms.md +125 -0
  90. package/src/skills-lib/wizz-video-styles/references/film-styles/blueprint.md +120 -0
  91. package/src/skills-lib/wizz-video-styles/references/film-styles/brick-toy.md +114 -0
  92. package/src/skills-lib/wizz-video-styles/references/film-styles/briefing-board.md +122 -0
  93. package/src/skills-lib/wizz-video-styles/references/film-styles/caption-news.md +137 -0
  94. package/src/skills-lib/wizz-video-styles/references/film-styles/cel-anime-80s.md +119 -0
  95. package/src/skills-lib/wizz-video-styles/references/film-styles/city-symphony.md +142 -0
  96. package/src/skills-lib/wizz-video-styles/references/film-styles/countdown.md +132 -0
  97. package/src/skills-lib/wizz-video-styles/references/film-styles/crayon-book.md +109 -0
  98. package/src/skills-lib/wizz-video-styles/references/film-styles/dark-keynote.md +117 -0
  99. package/src/skills-lib/wizz-video-styles/references/film-styles/data-physical.md +119 -0
  100. package/src/skills-lib/wizz-video-styles/references/film-styles/dataviz.md +123 -0
  101. package/src/skills-lib/wizz-video-styles/references/film-styles/engraving.md +123 -0
  102. package/src/skills-lib/wizz-video-styles/references/film-styles/game-show.md +118 -0
  103. package/src/skills-lib/wizz-video-styles/references/film-styles/glass-product.md +110 -0
  104. package/src/skills-lib/wizz-video-styles/references/film-styles/halftone-dossier.md +122 -0
  105. package/src/skills-lib/wizz-video-styles/references/film-styles/hd-2d.md +111 -0
  106. package/src/skills-lib/wizz-video-styles/references/film-styles/hologram-hud.md +129 -0
  107. package/src/skills-lib/wizz-video-styles/references/film-styles/impasto.md +124 -0
  108. package/src/skills-lib/wizz-video-styles/references/film-styles/ink-wash.md +120 -0
  109. package/src/skills-lib/wizz-video-styles/references/film-styles/iso-infographic.md +117 -0
  110. package/src/skills-lib/wizz-video-styles/references/film-styles/kino-montage.md +151 -0
  111. package/src/skills-lib/wizz-video-styles/references/film-styles/lidar-survey.md +118 -0
  112. package/src/skills-lib/wizz-video-styles/references/film-styles/living-screencast.md +124 -0
  113. package/src/skills-lib/wizz-video-styles/references/film-styles/lowpoly-island.md +112 -0
  114. package/src/skills-lib/wizz-video-styles/references/film-styles/microgame.md +122 -0
  115. package/src/skills-lib/wizz-video-styles/references/film-styles/microscope.md +108 -0
  116. package/src/skills-lib/wizz-video-styles/references/film-styles/midcentury-toon.md +124 -0
  117. package/src/skills-lib/wizz-video-styles/references/film-styles/news-package.md +158 -0
  118. package/src/skills-lib/wizz-video-styles/references/film-styles/newsreel.md +136 -0
  119. package/src/skills-lib/wizz-video-styles/references/film-styles/one-line.md +114 -0
  120. package/src/skills-lib/wizz-video-styles/references/film-styles/paper-lantern.md +116 -0
  121. package/src/skills-lib/wizz-video-styles/references/film-styles/paper-popup.md +119 -0
  122. package/src/skills-lib/wizz-video-styles/references/film-styles/papercut-red.md +110 -0
  123. package/src/skills-lib/wizz-video-styles/references/film-styles/pictogram-motion.md +119 -0
  124. package/src/skills-lib/wizz-video-styles/references/film-styles/pixel-rpg.md +117 -0
  125. package/src/skills-lib/wizz-video-styles/references/film-styles/risograph.md +125 -0
  126. package/src/skills-lib/wizz-video-styles/references/film-styles/rubber-hose.md +112 -0
  127. package/src/skills-lib/wizz-video-styles/references/film-styles/satellite-briefing.md +116 -0
  128. package/src/skills-lib/wizz-video-styles/references/film-styles/scifi-toon.md +121 -0
  129. package/src/skills-lib/wizz-video-styles/references/film-styles/shadow-puppet.md +124 -0
  130. package/src/skills-lib/wizz-video-styles/references/film-styles/silent-film.md +109 -0
  131. package/src/skills-lib/wizz-video-styles/references/film-styles/silkscreen-poster.md +140 -0
  132. package/src/skills-lib/wizz-video-styles/references/film-styles/spy-titles.md +116 -0
  133. package/src/skills-lib/wizz-video-styles/references/film-styles/stained-glass.md +123 -0
  134. package/src/skills-lib/wizz-video-styles/references/film-styles/swiss-motion.md +115 -0
  135. package/src/skills-lib/wizz-video-styles/references/film-styles/teardown.md +127 -0
  136. package/src/skills-lib/wizz-video-styles/references/film-styles/telestrator.md +137 -0
  137. package/src/skills-lib/wizz-video-styles/references/film-styles/tilt-shift.md +107 -0
  138. package/src/skills-lib/wizz-video-styles/references/film-styles/ukiyoe.md +112 -0
  139. package/src/skills-lib/wizz-video-styles/references/film-styles/urban-sketch.md +126 -0
  140. package/src/skills-lib/wizz-video-styles/references/film-styles/visual-investigation.md +140 -0
  141. package/src/skills-lib/wizz-video-styles/references/film-styles/watercolor.md +113 -0
  142. package/src/skills-lib/wizz-video-styles/references/film-styles/white-model.md +115 -0
  143. package/src/skills-lib/wizz-video-styles/references/film-styles/whiteboard.md +118 -0
  144. package/src/skills-lib/wizz-video-styles/references/film-styles/woodcut.md +122 -0
  145. package/src/skills-lib/wizz-video-styles/references/film-styles.md +140 -0
  146. package/src/skills-lib/wizz-video-styles/references/insert-recipes.md +33 -0
  147. package/src/skills-lib/wizz-video-styles/references/motion-laws.md +62 -0
  148. package/src/skills-lib/wizz-video-styles/references/styles/desafio-numero.md +1 -0
  149. package/src/skills-lib/wizz-video-styles/scripts/beat_grid.py +38 -6
  150. package/src/skills-lib/wizz-video-styles/scripts/blind_review.py +159 -0
  151. package/src/skills-lib/wizz-video-styles/scripts/taste.mjs +110 -0
  152. package/src/skills-lib/wizz-video-styles/scripts/test_blind_review.py +82 -0
  153. package/src/skills-lib/wizz-vsl/LICENSE +49 -0
  154. package/src/skills-lib/wizz-vsl/SKILL.md +122 -0
  155. package/src/skills-lib/wizz-vsl/references/assembly-qa.md +123 -0
  156. package/src/skills-lib/wizz-vsl/references/blocks-and-leads.md +149 -0
  157. package/src/skills-lib/wizz-vsl/references/diagnosis.md +95 -0
  158. package/src/skills-lib/wizz-vsl/references/intake.md +200 -0
  159. package/src/skills-lib/wizz-vsl/references/mechanisms.md +138 -0
  160. package/src/skills-lib/wizz-vsl/references/niches.md +40 -0
  161. package/src/skills-lib/wizz-vsl/references/offer.md +91 -0
  162. package/src/skills-lib/wizz-vsl/references/production.md +75 -0
  163. package/src/skills-lib/wizz-web-security/SKILL.md +2 -0
  164. package/tools/hooks/README.md +10 -9
  165. package/tools/hooks/wizz-arrumar.js +1 -0
  166. package/tools/hooks/wizz-artifact-guard.js +31 -5
  167. package/tools/hooks/wizz-cerebro-lib.js +36 -13
  168. package/tools/hooks/wizz-feature-lib.js +4 -1
  169. package/tools/hooks/wizz-feature.js +66 -76
  170. package/tools/hooks/wizz-sprint-archive-lib.js +311 -0
  171. package/tools/installer/core/hooks-settings.js +1 -0
  172. package/tools/installer/core/installer.js +6 -0
  173. package/tools/installer/guided/labels.js +16 -0
  174. package/tools/installer/install-text.js +2 -0
  175. package/tools/installer/modules/cli-config.js +3 -0
  176. package/tools/installer/modules/core-seed.js +72 -0
  177. package/tools/installer/modules/mcp-config.js +3 -0
  178. package/tools/installer/ui.js +22 -26
  179. /package/src/skills-lib/{wizz-pesquisa-de-publico-do-piva → wizz-pesquisa-de-publico}/prompts.md +0 -0
@@ -0,0 +1,159 @@
1
+ # -*- coding: utf-8 -*-
2
+ """ONE-TAKE gate: no passage is only a change of scene.
3
+
4
+ For a piece meant as one continuous shot (a camera that flies from one beat
5
+ into the next, an element that becomes the next screen). Two checks:
6
+
7
+ CARRY (blocks, timeline). Every scene after the first declares in
8
+ `carry` what on screen survives the passage and becomes the next beat
9
+ ("the price chip grows into the checkout card"). A scene without it,
10
+ or whose `transition` is a plain `cut`, is a swap, not a passage.
11
+ CUTS (blocks, final MP4). On a small luma copy of every frame
12
+ (one_take.analysis_width x one_take.analysis_height), a frame whose
13
+ mean absolute difference to the previous one is above
14
+ one_take.cut_min_diff AND one_take.cut_ratio times the median of its
15
+ neighbours (one_take.neighbour_frames on each side) is a hard cut. A
16
+ whip or a fast push changes many frames in a row, so it is not an
17
+ isolated spike; a cut is one.
18
+
19
+ What it cannot see:
20
+ - A dissolve between two unrelated scenes has no spike: it passes CUTS.
21
+ Only the `carry` line and the eye catch it.
22
+ - Whether the declared carry is really on screen. Extract the frames
23
+ around each passage and look.
24
+ - A cut hidden under a flash or a whip blur spreads its change over
25
+ several frames and passes.
26
+
27
+ Exit code: 0 pass, 1 fail, 2 file or config unreadable.
28
+ """
29
+ from __future__ import annotations
30
+
31
+ import argparse
32
+ import os
33
+ import statistics
34
+ import subprocess
35
+ import sys
36
+ from dataclasses import dataclass
37
+ from typing import Iterable
38
+
39
+ from check_dead_frames import mean_abs_diff
40
+ from check_safe_zone import ReadError, all_frames, video_fps
41
+ from config import Config, ConfigError, add_config_arg, load, optional_block
42
+ from timeline import TimelineError, load as load_timeline
43
+
44
+ BLOCK = "one_take"
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class OneTakeRules:
49
+ width: int
50
+ height: int
51
+ cut_min_diff: float
52
+ cut_ratio: float
53
+ neighbour_frames: int
54
+
55
+
56
+ def rules(cfg: Config) -> tuple[OneTakeRules, bool]:
57
+ """(rules, from_template). Refuses an incoherent block."""
58
+ b, from_template = optional_block(cfg, BLOCK)
59
+ try:
60
+ r = OneTakeRules(
61
+ width=int(b["analysis_width"]), height=int(b["analysis_height"]),
62
+ cut_min_diff=float(b["cut_min_diff"]), cut_ratio=float(b["cut_ratio"]),
63
+ neighbour_frames=int(b["neighbour_frames"]))
64
+ except (KeyError, TypeError, ValueError) as e:
65
+ raise ConfigError(f"{BLOCK}: missing or invalid key ({e})") from e
66
+ if r.width <= 0 or r.height <= 0:
67
+ raise ConfigError(f"{BLOCK}.analysis_width and analysis_height must be > 0")
68
+ if r.cut_min_diff <= 0 or r.cut_ratio <= 1:
69
+ raise ConfigError(f"{BLOCK}.cut_min_diff must be > 0 and cut_ratio > 1")
70
+ if r.neighbour_frames < 1:
71
+ raise ConfigError(f"{BLOCK}.neighbour_frames must be >= 1")
72
+ return r, from_template
73
+
74
+
75
+ def missing_carry(scenes) -> list[tuple[int, float, str]]:
76
+ """(index, start, reason) of every scene after the first that is a swap."""
77
+ out = []
78
+ for i, sc in enumerate(scenes or []):
79
+ if i == 0:
80
+ continue
81
+ start = float(sc.get("start", 0.0))
82
+ if not str(sc.get("carry") or "").strip():
83
+ out.append((i, start, "no `carry`"))
84
+ elif sc.get("transition") == "cut":
85
+ out.append((i, start, "`transition` is a plain cut"))
86
+ return out
87
+
88
+
89
+ def diffs(frames: Iterable[bytes]) -> list[float]:
90
+ """diffs[i] = change from frame i to frame i + 1."""
91
+ out, prev = [], None
92
+ for fr in frames:
93
+ if prev is not None:
94
+ out.append(mean_abs_diff(fr, prev))
95
+ prev = fr
96
+ return out
97
+
98
+
99
+ def cut_frames(d: list[float], r: OneTakeRules) -> list[int]:
100
+ """Frame index (the first frame after the jump) of every isolated spike."""
101
+ out = []
102
+ k = r.neighbour_frames
103
+ for i, v in enumerate(d):
104
+ if v <= r.cut_min_diff:
105
+ continue
106
+ around = d[max(0, i - k):i] + d[i + 1:i + 1 + k]
107
+ base = statistics.median(around) if around else 0.0
108
+ if v > r.cut_ratio * base:
109
+ out.append(i + 1)
110
+ return out
111
+
112
+
113
+ def report(name: str, cuts: list[int], fps: float, swaps, r: OneTakeRules,
114
+ from_template: bool) -> int:
115
+ print(f"{name}: one-take gate")
116
+ if from_template:
117
+ print(f" note: no '{BLOCK}' block in the project config; using the template's")
118
+ if swaps is None:
119
+ print(" note: no --timeline; the `carry` of each passage was not checked")
120
+ elif swaps:
121
+ print(f" SWAP: {len(swaps)} passage(s) with nothing carried into the next beat:")
122
+ for i, start, why in swaps[:10]:
123
+ print(f" scene {i} at {start:6.2f}s: {why}")
124
+ else:
125
+ print(" ok: every passage declares what it carries")
126
+ if cuts:
127
+ print(f" CUT: {len(cuts)} hard cut(s) (frame change above one_take.cut_min_diff="
128
+ f"{r.cut_min_diff} and {r.cut_ratio}x its neighbours):")
129
+ for f in cuts[:10]:
130
+ print(f" {f / fps:6.2f}s (frame {f})")
131
+ else:
132
+ print(" ok: no hard cut in the file")
133
+ return 1 if cuts or swaps else 0
134
+
135
+
136
+ def main(argv: list[str] | None = None) -> int:
137
+ ap = argparse.ArgumentParser(description="One-take gate: every passage carries something (timeline) and the final MP4 has no hard cut.")
138
+ add_config_arg(ap)
139
+ ap.add_argument("video", help="final MP4")
140
+ ap.add_argument("--timeline", help="timeline JSON; checks the `carry` of every scene")
141
+ args = ap.parse_args(argv)
142
+ try:
143
+ cfg = load(args.config)
144
+ r, from_template = rules(cfg)
145
+ swaps = missing_carry(load_timeline(args.timeline).get("scenes")) if args.timeline else None
146
+ if not os.path.exists(args.video):
147
+ raise ReadError(f"video not found: {args.video}")
148
+ d = diffs(f for _, f in all_frames(args.video, r.width, r.height))
149
+ if not d:
150
+ raise ReadError("fewer than two frames decoded")
151
+ return report(os.path.basename(args.video), cut_frames(d, r), video_fps(args.video),
152
+ swaps, r, from_template)
153
+ except (ConfigError, ReadError, TimelineError, subprocess.SubprocessError, OSError) as e:
154
+ print(f"ERROR: {e}")
155
+ return 2
156
+
157
+
158
+ if __name__ == "__main__":
159
+ sys.exit(main())
@@ -540,6 +540,19 @@ def validate(c: Config) -> None:
540
540
  _validate_audio_blocks(c)
541
541
 
542
542
 
543
+ def optional_block(c: Config, key: str) -> tuple[dict, bool]:
544
+ """(block, from_template) for a block added after projects already had a
545
+ config. A project config without it uses the TEMPLATE's block, so the
546
+ number still lives in one file; the caller says so in its report."""
547
+ own = _read_json(c.source).get(key)
548
+ if isinstance(own, dict):
549
+ return _notes_out(own), False
550
+ tpl = _read_json(TEMPLATE).get(key)
551
+ if not isinstance(tpl, dict):
552
+ raise ConfigError(f"block {key!r} is missing from the config and from the template")
553
+ return _notes_out(tpl), True
554
+
555
+
543
556
  # -------------------------------------------------------------------- CLI glue
544
557
 
545
558
 
@@ -0,0 +1,181 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Tests of check_chrome.py: the page-chrome / slide-look rules on a layout
3
+ snapshot (DOM of a HyperFrames composition) and on the text layers of a
4
+ Remotion timeline."""
5
+ import io
6
+ import json
7
+ import tempfile
8
+ import unittest
9
+ from contextlib import redirect_stdout
10
+ from pathlib import Path
11
+
12
+ import check_chrome as CC
13
+ from config import TEMPLATE, load
14
+
15
+ CFG = load(TEMPLATE)
16
+ RULES = CC.rules(CFG)[0]
17
+ W, H = 1080, 1920
18
+
19
+
20
+ def block(text, x0, y0, x1, y1, font=60, **kw):
21
+ return {"text": text, "box": [x0 * W, y0 * H, x1 * W, y1 * H], "font_px": font, **kw}
22
+
23
+
24
+ def snap(*blocks, shapes=(), t=1.0):
25
+ return {"width": W, "height": H, "samples": [{"t": t, "blocks": list(blocks), "shapes": list(shapes)}]}
26
+
27
+
28
+ def lint(data, allow=()):
29
+ samples = CC.samples_from_snapshot(data)
30
+ return CC.lint(samples, RULES, set(allow))
31
+
32
+
33
+ def rules_hit(rep):
34
+ return {h["rule"] for h in rep["hits"]}
35
+
36
+
37
+ class TestPageChrome(unittest.TestCase):
38
+ def test_clean_reel_passes(self):
39
+ rep = lint(snap(block("O erro que custa caro", 0.1, 0.40, 0.9, 0.48, font=96, align="center"),
40
+ block("custa", 0.35, 0.70, 0.65, 0.74, font=72, role="caption")))
41
+ self.assertTrue(rep["passed"], rep["hits"])
42
+
43
+ def test_corner_brand_label_fails(self):
44
+ rep = lint(snap(block("WIZZ", 0.05, 0.03, 0.15, 0.05, font=30)))
45
+ self.assertIn("C1", rules_hit(rep))
46
+ self.assertFalse(rep["passed"])
47
+
48
+ def test_tracked_caps_label_fails(self):
49
+ rep = lint(snap(block("NOVO MÉTODO", 0.3, 0.30, 0.7, 0.32, font=36, caps=True, tracking_em=0.2)))
50
+ self.assertIn("C2", rules_hit(rep))
51
+
52
+ def test_counter_fails(self):
53
+ for text in ("02 / 05", "01 - THE WAIT", "Parte 3", "Step 2"):
54
+ with self.subTest(text=text):
55
+ rep = lint(snap(block(text, 0.4, 0.5, 0.6, 0.52, font=40)))
56
+ self.assertIn("C3", rules_hit(rep))
57
+
58
+ def test_twenty_four_seven_is_not_a_counter(self):
59
+ rep = lint(snap(block("24/7 suporte", 0.4, 0.5, 0.6, 0.52, font=40)))
60
+ self.assertNotIn("C3", rules_hit(rep))
61
+
62
+ def test_timecode_and_metadata_fail(self):
63
+ for text in ("00:00:12", "30 FPS", "1080x1920", "REC"):
64
+ with self.subTest(text=text):
65
+ rep = lint(snap(block(text, 0.4, 0.5, 0.6, 0.52, font=40)))
66
+ self.assertIn("C4", rules_hit(rep))
67
+
68
+ def test_progress_bar_growing_fails(self):
69
+ data = {"width": W, "height": H, "samples": [
70
+ {"t": t, "blocks": [], "shapes": [{"id": "bar", "kind": "bar", "box": [0, 10, w * W, 18]}]}
71
+ for t, w in ((1.0, 0.1), (2.0, 0.3), (3.0, 0.5))]}
72
+ rep = lint(data)
73
+ self.assertIn("P1", rules_hit(rep))
74
+
75
+ def test_header_bar_fails(self):
76
+ rep = lint(snap(shapes=[{"kind": "bar", "box": [0, 0, W, 0.06 * H], "filled": True}]))
77
+ self.assertIn("C6", rules_hit(rep))
78
+
79
+ def test_full_frame_background_is_not_a_bar(self):
80
+ rep = lint(snap(shapes=[{"kind": "bar", "box": [0, 0, W, H], "filled": True}]))
81
+ self.assertNotIn("C6", rules_hit(rep))
82
+
83
+ def test_kicker_over_headline_fails(self):
84
+ rep = lint(snap(block("o segredo", 0.1, 0.38, 0.4, 0.40, font=30),
85
+ block("Pare de perder dinheiro", 0.1, 0.41, 0.9, 0.47, font=90)))
86
+ self.assertIn("C7", rules_hit(rep))
87
+
88
+ def test_left_headline_beside_right_media_fails(self):
89
+ rep = lint(snap(block("Seu app", 0.05, 0.40, 0.45, 0.46, font=90, align="left"),
90
+ shapes=[{"kind": "media", "box": [0.55 * W, 0.30 * H, 0.95 * W, 0.70 * H]}]))
91
+ hits = [h for h in rep["hits"] if h["rule"] == "D1"]
92
+ self.assertTrue(hits)
93
+ self.assertEqual(hits[0]["sev"], "FAIL")
94
+
95
+ def test_left_headline_alone_only_warns(self):
96
+ rep = lint(snap(block("Seu app", 0.05, 0.40, 0.45, 0.46, font=90, align="left")))
97
+ hits = [h for h in rep["hits"] if h["rule"] == "D1"]
98
+ self.assertEqual([h["sev"] for h in hits], ["WARN"])
99
+ self.assertTrue(rep["passed"])
100
+
101
+
102
+ class TestExemptions(unittest.TestCase):
103
+ def test_caption_and_legal_roles_are_not_chrome(self):
104
+ rep = lint(snap(block("PARTE 2", 0.4, 0.5, 0.6, 0.52, font=40, role="caption"),
105
+ block("Resultados variam. CNPJ 00.000", 0.05, 0.95, 0.6, 0.97, font=24, role="legal")))
106
+ self.assertTrue(rep["passed"], rep["hits"])
107
+
108
+ def test_allow_list_from_a_measured_style(self):
109
+ data = {"width": W, "height": H, "samples": [
110
+ {"t": t, "blocks": [], "shapes": [{"id": "bar", "kind": "bar", "box": [0, 10, w * W, 18]}]}
111
+ for t, w in ((1.0, 0.1), (2.0, 0.3), (3.0, 0.5))]}
112
+ rep = lint(data, allow={"P1"})
113
+ self.assertTrue(rep["passed"])
114
+ self.assertEqual(rep["allowed"], ["P1"])
115
+
116
+ def test_text_in_flight_is_not_a_held_label(self):
117
+ # Same label at a different place 0.1 s later: it is moving through
118
+ # the corner, not parked there.
119
+ data = {"width": W, "height": H, "samples": [
120
+ {"t": 1.0, "blocks": [block("WIZZ", 0.05, 0.03, 0.15, 0.05, font=30)], "shapes": []},
121
+ {"t": 1.1, "blocks": [block("WIZZ", 0.25, 0.20, 0.35, 0.22, font=30)], "shapes": []}]}
122
+ self.assertNotIn("C1", rules_hit(lint(data)))
123
+
124
+
125
+ class TestTimeline(unittest.TestCase):
126
+ def test_remotion_text_layers_are_linted(self):
127
+ tl = {"duration": 10.0, "elements": [
128
+ {"at": 0.0, "until": 10.0, "kind": "text", "text": "02/05",
129
+ "box": [900, 40, 1040, 80], "font_px": 32},
130
+ {"at": 0.6, "kind": "text"}]} # an element without text is skipped
131
+ samples = CC.samples_from_timeline(tl, CFG.frame.width, CFG.frame.height, RULES)
132
+ rep = CC.lint(samples, RULES, set())
133
+ self.assertIn("C3", rules_hit(rep))
134
+ self.assertIn("C1", rules_hit(rep))
135
+
136
+ def test_timeline_progress_bar_from_box_end(self):
137
+ tl = {"duration": 10.0, "elements": [
138
+ {"at": 0.0, "until": 10.0, "kind": "bar", "box": [0, 1880, 100, 1890],
139
+ "box_end": [0, 1880, 1080, 1890]}]}
140
+ samples = CC.samples_from_timeline(tl, W, H, RULES)
141
+ self.assertIn("P1", rules_hit(CC.lint(samples, RULES, set())))
142
+
143
+ def test_flash_label_shorter_than_held_s_is_skipped(self):
144
+ tl = {"duration": 5.0, "elements": [
145
+ {"at": 1.0, "until": 1.1, "kind": "text", "text": "WIZZ",
146
+ "box": [40, 40, 200, 80], "font_px": 30}]}
147
+ samples = CC.samples_from_timeline(tl, W, H, RULES)
148
+ self.assertNotIn("C1", rules_hit(CC.lint(samples, RULES, set())))
149
+
150
+
151
+ class TestCli(unittest.TestCase):
152
+ def _run(self, args):
153
+ buf = io.StringIO()
154
+ with redirect_stdout(buf):
155
+ code = CC.main(["--config", str(TEMPLATE), *args])
156
+ return code, buf.getvalue()
157
+
158
+ def test_snapshot_fail_exits_1(self):
159
+ with tempfile.TemporaryDirectory() as tmp:
160
+ p = Path(tmp) / "layout.json"
161
+ p.write_text(json.dumps(snap(block("00:00:12", 0.4, 0.5, 0.6, 0.52, font=40))), encoding="utf-8")
162
+ code, out = self._run(["--snapshot", str(p)])
163
+ self.assertEqual(code, 1, out)
164
+ self.assertIn("C4", out)
165
+
166
+ def test_timeline_allow_key_is_read(self):
167
+ with tempfile.TemporaryDirectory() as tmp:
168
+ p = Path(tmp) / "t.json"
169
+ p.write_text(json.dumps({"duration": 5.0, "chrome_allow": ["C3"], "elements": [
170
+ {"at": 0.0, "until": 5.0, "kind": "text", "text": "Dia 3",
171
+ "box": [400, 900, 680, 960], "font_px": 40}]}), encoding="utf-8")
172
+ code, out = self._run(["--timeline", str(p)])
173
+ self.assertEqual(code, 0, out)
174
+
175
+ def test_missing_input_exits_2(self):
176
+ code, _ = self._run(["--snapshot", "/nonexistent.json"])
177
+ self.assertEqual(code, 2)
178
+
179
+
180
+ if __name__ == "__main__":
181
+ unittest.main()
@@ -0,0 +1,171 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Tests of check_dead_frames.py: flat-field runs (blocks), frozen picture
3
+ (warns), the motion note, and the config fallback to the template. The
4
+ ffmpeg round trip is skipped when ffmpeg is absent."""
5
+ import copy
6
+ import io
7
+ import json
8
+ import random
9
+ import shutil
10
+ import subprocess
11
+ import tempfile
12
+ import unittest
13
+ from contextlib import redirect_stdout
14
+ from pathlib import Path
15
+
16
+ import check_dead_frames as DF
17
+ from config import TEMPLATE, load
18
+
19
+ CFG = load(TEMPLATE)
20
+ RULES = DF.rules(CFG)[0]
21
+ W, H = RULES.width, RULES.height
22
+ SIZE = W * H
23
+
24
+
25
+ def flat(v: int = 0) -> bytes:
26
+ return bytes([v]) * SIZE
27
+
28
+
29
+ def textured(seed: int, amp: int = 60) -> bytes:
30
+ rnd = random.Random(seed)
31
+ return bytes(100 + rnd.randrange(amp) for _ in range(SIZE))
32
+
33
+
34
+ def jitter(base: bytes, seed: int, amp: int = 3) -> bytes:
35
+ """The same picture plus small per-pixel noise: a talking head that
36
+ barely moves (sensor noise, breathing, compression)."""
37
+ rnd = random.Random(seed)
38
+ return bytes(max(0, min(255, b + rnd.randrange(-amp, amp + 1))) for b in base)
39
+
40
+
41
+ class TestFrameMeasures(unittest.TestCase):
42
+ def test_uniform_frame_is_flat(self):
43
+ self.assertTrue(DF.is_flat(flat(0), RULES))
44
+ self.assertTrue(DF.is_flat(flat(200), RULES))
45
+
46
+ def test_textured_frame_is_not_flat(self):
47
+ self.assertFalse(DF.is_flat(textured(1), RULES))
48
+
49
+ def test_dark_frame_with_one_small_caption_is_not_flat(self):
50
+ # Range catches it even when std-dev is tiny: that frame belongs to
51
+ # the density gate, not to this one.
52
+ f = bytearray(flat(10))
53
+ f[SIZE // 2:SIZE // 2 + 6] = bytes([250]) * 6
54
+ self.assertFalse(DF.is_flat(bytes(f), RULES))
55
+
56
+ def test_mean_abs_diff(self):
57
+ self.assertEqual(DF.mean_abs_diff(flat(10), flat(10)), 0.0)
58
+ self.assertAlmostEqual(DF.mean_abs_diff(flat(10), flat(14)), 4.0)
59
+
60
+ def test_runs(self):
61
+ self.assertEqual(DF.runs([False, True, True, False, True]), [(1, 2), (4, 4)])
62
+ self.assertEqual(DF.runs([]), [])
63
+
64
+
65
+ class TestAnalyze(unittest.TestCase):
66
+ FPS = 30.0
67
+
68
+ def _rep(self, frames):
69
+ return DF.analyze(frames, self.FPS, RULES)
70
+
71
+ def test_short_black_dip_inside_a_transition_passes(self):
72
+ frames = [textured(i) for i in range(20)] + [flat()] * RULES.flat_max_frames \
73
+ + [textured(100 + i) for i in range(20)]
74
+ rep = self._rep(frames)
75
+ self.assertTrue(rep["passed"], rep)
76
+ self.assertEqual(rep["flat_fail"], [])
77
+ self.assertEqual(len(rep["flat_ignored"]), 1)
78
+
79
+ def test_black_gap_longer_than_the_limit_fails(self):
80
+ frames = [textured(i) for i in range(20)] + [flat()] * (RULES.flat_max_frames + 2) \
81
+ + [textured(100 + i) for i in range(20)]
82
+ rep = self._rep(frames)
83
+ self.assertFalse(rep["passed"])
84
+ (s, e), = rep["flat_fail"]
85
+ self.assertEqual((s, e), (20, 20 + RULES.flat_max_frames + 1))
86
+
87
+ def test_frozen_picture_warns_but_does_not_block(self):
88
+ still = textured(7)
89
+ n = int(self.FPS * (RULES.frozen_warn_s + 1.0))
90
+ frames = [textured(i) for i in range(10)] + [still] * n + [textured(50 + i) for i in range(10)]
91
+ rep = self._rep(frames)
92
+ self.assertTrue(rep["passed"])
93
+ self.assertEqual(len(rep["frozen_warn"]), 1)
94
+
95
+ def test_talking_head_micro_motion_is_not_frozen(self):
96
+ base = textured(3)
97
+ frames = [jitter(base, i) for i in range(int(self.FPS * 4))]
98
+ rep = self._rep(frames)
99
+ self.assertEqual(rep["frozen_warn"], [])
100
+ self.assertTrue(rep["passed"])
101
+
102
+ def test_motion_note_is_reported(self):
103
+ frames = [textured(i) for i in range(5)]
104
+ rep = self._rep(frames)
105
+ self.assertGreater(rep["motion_mean"], 0.0)
106
+
107
+ def test_no_frame_is_an_error(self):
108
+ with self.assertRaises(DF.ReadError):
109
+ self._rep([])
110
+
111
+
112
+ class TestConfigFallback(unittest.TestCase):
113
+ def test_config_without_block_uses_template(self):
114
+ raw = json.loads(TEMPLATE.read_text(encoding="utf-8"))
115
+ raw = {k: v for k, v in copy.deepcopy(raw).items() if k != "dead_frames"}
116
+ with tempfile.TemporaryDirectory() as tmp:
117
+ p = Path(tmp) / "effects.config.json"
118
+ p.write_text(json.dumps(raw), encoding="utf-8")
119
+ r, from_template = DF.rules(load(p))
120
+ self.assertTrue(from_template)
121
+ self.assertEqual(r.flat_max_frames, RULES.flat_max_frames)
122
+
123
+ def test_incoherent_block_is_refused(self):
124
+ raw = json.loads(TEMPLATE.read_text(encoding="utf-8"))
125
+ raw["dead_frames"]["flat_max_frames"] = -1
126
+ with tempfile.TemporaryDirectory() as tmp:
127
+ p = Path(tmp) / "effects.config.json"
128
+ p.write_text(json.dumps(raw), encoding="utf-8")
129
+ with self.assertRaises(DF.ConfigError):
130
+ DF.rules(load(p))
131
+
132
+
133
+ class TestCli(unittest.TestCase):
134
+ def test_missing_video_exits_2(self):
135
+ buf = io.StringIO()
136
+ with redirect_stdout(buf):
137
+ code = DF.main(["--config", str(TEMPLATE), "/nonexistent/final.mp4"])
138
+ self.assertEqual(code, 2)
139
+
140
+
141
+ @unittest.skipUnless(shutil.which("ffmpeg") and shutil.which("ffprobe"), "ffmpeg not installed")
142
+ class TestOnRealFile(unittest.TestCase):
143
+ def _make(self, tmp: str, black_s: float) -> str:
144
+ out = str(Path(tmp) / "piece.mp4")
145
+ src = ("testsrc2=size=270x480:rate=30:duration=1[a];"
146
+ f"color=black:size=270x480:rate=30:duration={black_s}[b];"
147
+ "testsrc2=size=270x480:rate=30:duration=1[c];[a][b][c]concat=n=3:v=1:a=0")
148
+ subprocess.run(["ffmpeg", "-v", "error", "-y", "-filter_complex", src,
149
+ "-pix_fmt", "yuv420p", out], check=True)
150
+ return out
151
+
152
+ def _run(self, video: str) -> tuple[int, str]:
153
+ buf = io.StringIO()
154
+ with redirect_stdout(buf):
155
+ code = DF.main(["--config", str(TEMPLATE), video])
156
+ return code, buf.getvalue()
157
+
158
+ def test_half_second_black_gap_fails(self):
159
+ with tempfile.TemporaryDirectory() as tmp:
160
+ code, out = self._run(self._make(tmp, 0.5))
161
+ self.assertEqual(code, 1, out)
162
+ self.assertIn("FLAT", out)
163
+
164
+ def test_one_frame_black_dip_passes(self):
165
+ with tempfile.TemporaryDirectory() as tmp:
166
+ code, out = self._run(self._make(tmp, 1 / 30))
167
+ self.assertEqual(code, 0, out)
168
+
169
+
170
+ if __name__ == "__main__":
171
+ unittest.main()
@@ -0,0 +1,126 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Tests of check_one_take.py: the `carry` of every passage (timeline), hard
3
+ cuts as isolated spikes of frame change (a whip is not one), and the config
4
+ fallback to the template. The ffmpeg round trip is skipped when ffmpeg is
5
+ absent."""
6
+ import io
7
+ import json
8
+ import shutil
9
+ import subprocess
10
+ import tempfile
11
+ import unittest
12
+ from contextlib import redirect_stdout
13
+ from pathlib import Path
14
+
15
+ import check_one_take as OT
16
+ from config import TEMPLATE, load
17
+
18
+ CFG = load(TEMPLATE)
19
+ RULES = OT.rules(CFG)[0]
20
+ W, H = RULES.width, RULES.height
21
+ SIZE = W * H
22
+
23
+
24
+ def gradient(offset: int) -> bytes:
25
+ """A horizontal ramp shifted by `offset` columns: a camera that pans."""
26
+ return bytes(((x + offset) * 4) % 256 for _ in range(H) for x in range(W))
27
+
28
+
29
+ def flat(v: int) -> bytes:
30
+ return bytes([v]) * SIZE
31
+
32
+
33
+ class TestCarry(unittest.TestCase):
34
+ def test_every_passage_with_carry_passes(self):
35
+ scenes = [{"start": 0.0}, {"start": 3.0, "transition": "push", "carry": "price chip grows into the card"}]
36
+ self.assertEqual(OT.missing_carry(scenes), [])
37
+
38
+ def test_scene_without_carry_is_a_swap(self):
39
+ scenes = [{"start": 0.0}, {"start": 3.0, "transition": "push"}, {"start": 6.0, "carry": " "}]
40
+ self.assertEqual([i for i, _, _ in OT.missing_carry(scenes)], [1, 2])
41
+
42
+ def test_plain_cut_with_carry_is_still_a_swap(self):
43
+ scenes = [{"start": 0.0}, {"start": 3.0, "transition": "cut", "carry": "the logo"}]
44
+ self.assertEqual(OT.missing_carry(scenes)[0][2], "`transition` is a plain cut")
45
+
46
+ def test_first_scene_needs_nothing(self):
47
+ self.assertEqual(OT.missing_carry([{"start": 0.0}]), [])
48
+ self.assertEqual(OT.missing_carry(None), [])
49
+
50
+
51
+ class TestCuts(unittest.TestCase):
52
+ def test_slow_pan_has_no_cut(self):
53
+ d = OT.diffs(gradient(i) for i in range(40))
54
+ self.assertEqual(OT.cut_frames(d, RULES), [])
55
+
56
+ def test_hard_cut_is_found_on_the_first_frame_after_it(self):
57
+ frames = [gradient(i) for i in range(20)] + [flat(240 - i) for i in range(20)]
58
+ d = OT.diffs(frames)
59
+ self.assertEqual(OT.cut_frames(d, RULES), [20])
60
+
61
+ def test_fast_whip_over_several_frames_is_not_a_cut(self):
62
+ # Large change on every frame for a while: a move, not a cut.
63
+ frames = [gradient(0)] * 10 + [gradient(i * 9) for i in range(1, 9)] + [gradient(72)] * 10
64
+ d = OT.diffs(frames)
65
+ self.assertGreater(max(d), RULES.cut_min_diff)
66
+ self.assertEqual(OT.cut_frames(d, RULES), [])
67
+
68
+ def test_small_jump_under_the_floor_is_ignored(self):
69
+ frames = [flat(100)] * 10 + [flat(100 + int(RULES.cut_min_diff) - 2)] * 10
70
+ self.assertEqual(OT.cut_frames(OT.diffs(frames), RULES), [])
71
+
72
+
73
+ class TestRules(unittest.TestCase):
74
+ def test_template_block_is_coherent(self):
75
+ r, _ = OT.rules(CFG)
76
+ self.assertGreater(r.cut_ratio, 1)
77
+
78
+ def test_project_config_without_block_falls_back_to_template(self):
79
+ with tempfile.TemporaryDirectory() as tmp:
80
+ data = json.loads(Path(TEMPLATE).read_text(encoding="utf-8"))
81
+ data.pop(OT.BLOCK)
82
+ p = Path(tmp) / "effects.config.json"
83
+ p.write_text(json.dumps(data), encoding="utf-8")
84
+ _, from_template = OT.rules(load(str(p)))
85
+ self.assertTrue(from_template)
86
+
87
+
88
+ @unittest.skipUnless(shutil.which("ffmpeg") and shutil.which("ffprobe"), "ffmpeg not installed")
89
+ class TestRoundTrip(unittest.TestCase):
90
+ def _render(self, tmp: str, filters: str) -> str:
91
+ out = str(Path(tmp) / "piece.mp4")
92
+ subprocess.run(["ffmpeg", "-v", "error", "-y", "-f", "lavfi", "-i", filters,
93
+ "-pix_fmt", "yuv420p", out], check=True)
94
+ return out
95
+
96
+ def _run(self, *args) -> tuple[int, str]:
97
+ buf = io.StringIO()
98
+ with redirect_stdout(buf):
99
+ code = OT.main(["--config", str(TEMPLATE), *args])
100
+ return code, buf.getvalue()
101
+
102
+ def test_two_colours_spliced_fail_and_a_timeline_without_carry_too(self):
103
+ with tempfile.TemporaryDirectory() as tmp:
104
+ video = self._render(tmp, "color=c=0x202020:s=270x480:d=1:r=24,format=gray[a];"
105
+ "color=c=0xe0e0e0:s=270x480:d=1:r=24,format=gray[b];"
106
+ "[a][b]concat=n=2:v=1")
107
+ tl = Path(tmp) / "t.json"
108
+ tl.write_text(json.dumps({"scenes": [{"start": 0}, {"start": 1, "transition": "cut"}]}))
109
+ code, out = self._run(video, "--timeline", str(tl))
110
+ self.assertEqual(code, 1, out)
111
+ self.assertIn("CUT", out)
112
+ self.assertIn("SWAP", out)
113
+
114
+ def test_continuous_gradient_passes(self):
115
+ with tempfile.TemporaryDirectory() as tmp:
116
+ video = self._render(tmp, "gradients=s=270x480:d=2:r=24:speed=0.02")
117
+ code, out = self._run(video)
118
+ self.assertEqual(code, 0, out)
119
+
120
+ def test_missing_video_is_an_input_error(self):
121
+ code, _ = self._run("/nonexistent.mp4")
122
+ self.assertEqual(code, 2)
123
+
124
+
125
+ if __name__ == "__main__":
126
+ unittest.main()
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 LemoLab
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.