wizz-method 2.22.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 (118) hide show
  1. package/THIRD-PARTY-NOTICES.md +7 -1
  2. package/package.json +1 -1
  3. package/removals.txt +3 -0
  4. package/skills-registry.yaml +93 -5
  5. package/src/bmm-skills/1-analysis/research/wizz-market-research/steps/step-06-research-completion.md +27 -0
  6. package/src/modules/lowticket/README.md +2 -1
  7. package/src/modules/lowticket/module.yaml +1 -1
  8. package/src/modules/lowticket/skills/wizz-clone-do-expert/SKILL.md +86 -0
  9. package/src/modules/lowticket/skills/wizz-clone-do-expert/references/clone-prompt-template.md +21 -0
  10. package/src/modules/wizz/_shared/video-pipeline.md +2 -0
  11. package/src/modules/wizz/agents/wizz-copy/customize.toml +1 -1
  12. package/src/modules/wizz/agents/wizz-copy/knowledge/playbook.md +2 -2
  13. package/src/modules/wizz/agents/wizz-growth/customize.toml +7 -2
  14. package/src/modules/wizz/agents/wizz-growth/knowledge/playbook.md +3 -2
  15. package/src/skills-lib/wizz-ios-build/SKILL.md +61 -0
  16. package/src/skills-lib/wizz-offer-forge/SKILL.md +2 -2
  17. package/src/skills-lib/wizz-paywall-upgrade-cro/SKILL.md +1 -0
  18. package/src/skills-lib/{wizz-pesquisa-de-publico-do-piva → wizz-pesquisa-de-publico}/README.md +13 -13
  19. package/src/skills-lib/{wizz-pesquisa-de-publico-do-piva → wizz-pesquisa-de-publico}/SKILL.md +11 -7
  20. package/src/skills-lib/{wizz-pesquisa-de-publico-do-piva → wizz-pesquisa-de-publico}/assets/template.html +8 -21
  21. package/src/skills-lib/wizz-pesquisa-de-publico/references/interview-mode.md +69 -0
  22. package/src/skills-lib/wizz-premium-landing-ui-researcher/references/component-sources.md +23 -0
  23. package/src/skills-lib/wizz-premium-landing-ui-researcher/references/design-sources.yaml +73 -0
  24. package/src/skills-lib/wizz-router/references/routing-table-flat.md +5 -1
  25. package/src/skills-lib/wizz-seo-audit/SKILL.md +4 -0
  26. package/src/skills-lib/wizz-seo-audit/references/gsc-regex-perguntas.md +64 -0
  27. package/src/skills-lib/wizz-signup-flow-cro/SKILL.md +1 -0
  28. package/src/skills-lib/wizz-signup-flow-cro/references/trial-abuse.md +40 -0
  29. package/src/skills-lib/wizz-ui-ux-pro-max/SKILL.md +1 -1
  30. package/src/skills-lib/wizz-ui-ux-pro-max/references/common-rules-checklist.md +11 -0
  31. package/src/skills-lib/wizz-video-effects/SKILL.md +3 -1
  32. package/src/skills-lib/wizz-video-effects/assets/effects.config.template.json +10 -0
  33. package/src/skills-lib/wizz-video-effects/references/data-contract.md +2 -0
  34. package/src/skills-lib/wizz-video-effects/references/gates.md +1 -0
  35. package/src/skills-lib/wizz-video-effects/references/one-take.md +41 -0
  36. package/src/skills-lib/wizz-video-effects/scripts/check_one_take.py +159 -0
  37. package/src/skills-lib/wizz-video-effects/scripts/test_one_take.py +126 -0
  38. package/src/skills-lib/wizz-video-styles/LICENSE-lemo-opuscar.txt +21 -0
  39. package/src/skills-lib/wizz-video-styles/SKILL.md +12 -7
  40. package/src/skills-lib/wizz-video-styles/assets/film-styles/dark-keynote.html +78 -0
  41. package/src/skills-lib/wizz-video-styles/assets/film-styles/remotion/Riso.tsx +63 -0
  42. package/src/skills-lib/wizz-video-styles/assets/film-styles/risograph.html +69 -0
  43. package/src/skills-lib/wizz-video-styles/assets/film-styles/swiss-motion.html +84 -0
  44. package/src/skills-lib/wizz-video-styles/references/check-ins.md +25 -0
  45. package/src/skills-lib/wizz-video-styles/references/film-styles/DIRECTOR.md +139 -0
  46. package/src/skills-lib/wizz-video-styles/references/film-styles/archive-lighttable.md +109 -0
  47. package/src/skills-lib/wizz-video-styles/references/film-styles/art-deco.md +112 -0
  48. package/src/skills-lib/wizz-video-styles/references/film-styles/ascii-crt.md +115 -0
  49. package/src/skills-lib/wizz-video-styles/references/film-styles/backrooms.md +125 -0
  50. package/src/skills-lib/wizz-video-styles/references/film-styles/blueprint.md +120 -0
  51. package/src/skills-lib/wizz-video-styles/references/film-styles/brick-toy.md +114 -0
  52. package/src/skills-lib/wizz-video-styles/references/film-styles/briefing-board.md +122 -0
  53. package/src/skills-lib/wizz-video-styles/references/film-styles/caption-news.md +137 -0
  54. package/src/skills-lib/wizz-video-styles/references/film-styles/cel-anime-80s.md +119 -0
  55. package/src/skills-lib/wizz-video-styles/references/film-styles/city-symphony.md +142 -0
  56. package/src/skills-lib/wizz-video-styles/references/film-styles/countdown.md +132 -0
  57. package/src/skills-lib/wizz-video-styles/references/film-styles/crayon-book.md +109 -0
  58. package/src/skills-lib/wizz-video-styles/references/film-styles/dark-keynote.md +117 -0
  59. package/src/skills-lib/wizz-video-styles/references/film-styles/data-physical.md +119 -0
  60. package/src/skills-lib/wizz-video-styles/references/film-styles/dataviz.md +123 -0
  61. package/src/skills-lib/wizz-video-styles/references/film-styles/engraving.md +123 -0
  62. package/src/skills-lib/wizz-video-styles/references/film-styles/game-show.md +118 -0
  63. package/src/skills-lib/wizz-video-styles/references/film-styles/glass-product.md +110 -0
  64. package/src/skills-lib/wizz-video-styles/references/film-styles/halftone-dossier.md +122 -0
  65. package/src/skills-lib/wizz-video-styles/references/film-styles/hd-2d.md +111 -0
  66. package/src/skills-lib/wizz-video-styles/references/film-styles/hologram-hud.md +129 -0
  67. package/src/skills-lib/wizz-video-styles/references/film-styles/impasto.md +124 -0
  68. package/src/skills-lib/wizz-video-styles/references/film-styles/ink-wash.md +120 -0
  69. package/src/skills-lib/wizz-video-styles/references/film-styles/iso-infographic.md +117 -0
  70. package/src/skills-lib/wizz-video-styles/references/film-styles/kino-montage.md +151 -0
  71. package/src/skills-lib/wizz-video-styles/references/film-styles/lidar-survey.md +118 -0
  72. package/src/skills-lib/wizz-video-styles/references/film-styles/living-screencast.md +124 -0
  73. package/src/skills-lib/wizz-video-styles/references/film-styles/lowpoly-island.md +112 -0
  74. package/src/skills-lib/wizz-video-styles/references/film-styles/microgame.md +122 -0
  75. package/src/skills-lib/wizz-video-styles/references/film-styles/microscope.md +108 -0
  76. package/src/skills-lib/wizz-video-styles/references/film-styles/midcentury-toon.md +124 -0
  77. package/src/skills-lib/wizz-video-styles/references/film-styles/news-package.md +158 -0
  78. package/src/skills-lib/wizz-video-styles/references/film-styles/newsreel.md +136 -0
  79. package/src/skills-lib/wizz-video-styles/references/film-styles/one-line.md +114 -0
  80. package/src/skills-lib/wizz-video-styles/references/film-styles/paper-lantern.md +116 -0
  81. package/src/skills-lib/wizz-video-styles/references/film-styles/paper-popup.md +119 -0
  82. package/src/skills-lib/wizz-video-styles/references/film-styles/papercut-red.md +110 -0
  83. package/src/skills-lib/wizz-video-styles/references/film-styles/pictogram-motion.md +119 -0
  84. package/src/skills-lib/wizz-video-styles/references/film-styles/pixel-rpg.md +117 -0
  85. package/src/skills-lib/wizz-video-styles/references/film-styles/risograph.md +125 -0
  86. package/src/skills-lib/wizz-video-styles/references/film-styles/rubber-hose.md +112 -0
  87. package/src/skills-lib/wizz-video-styles/references/film-styles/satellite-briefing.md +116 -0
  88. package/src/skills-lib/wizz-video-styles/references/film-styles/scifi-toon.md +121 -0
  89. package/src/skills-lib/wizz-video-styles/references/film-styles/shadow-puppet.md +124 -0
  90. package/src/skills-lib/wizz-video-styles/references/film-styles/silent-film.md +109 -0
  91. package/src/skills-lib/wizz-video-styles/references/film-styles/silkscreen-poster.md +140 -0
  92. package/src/skills-lib/wizz-video-styles/references/film-styles/spy-titles.md +116 -0
  93. package/src/skills-lib/wizz-video-styles/references/film-styles/stained-glass.md +123 -0
  94. package/src/skills-lib/wizz-video-styles/references/film-styles/swiss-motion.md +115 -0
  95. package/src/skills-lib/wizz-video-styles/references/film-styles/teardown.md +127 -0
  96. package/src/skills-lib/wizz-video-styles/references/film-styles/telestrator.md +137 -0
  97. package/src/skills-lib/wizz-video-styles/references/film-styles/tilt-shift.md +107 -0
  98. package/src/skills-lib/wizz-video-styles/references/film-styles/ukiyoe.md +112 -0
  99. package/src/skills-lib/wizz-video-styles/references/film-styles/urban-sketch.md +126 -0
  100. package/src/skills-lib/wizz-video-styles/references/film-styles/visual-investigation.md +140 -0
  101. package/src/skills-lib/wizz-video-styles/references/film-styles/watercolor.md +113 -0
  102. package/src/skills-lib/wizz-video-styles/references/film-styles/white-model.md +115 -0
  103. package/src/skills-lib/wizz-video-styles/references/film-styles/whiteboard.md +118 -0
  104. package/src/skills-lib/wizz-video-styles/references/film-styles/woodcut.md +122 -0
  105. package/src/skills-lib/wizz-video-styles/references/film-styles.md +140 -0
  106. package/src/skills-lib/wizz-video-styles/references/insert-recipes.md +33 -0
  107. package/src/skills-lib/wizz-video-styles/scripts/beat_grid.py +38 -6
  108. package/src/skills-lib/wizz-vsl/SKILL.md +1 -1
  109. package/src/skills-lib/wizz-vsl/references/assembly-qa.md +1 -1
  110. package/src/skills-lib/wizz-vsl/references/blocks-and-leads.md +1 -1
  111. package/src/skills-lib/wizz-vsl/references/diagnosis.md +2 -2
  112. package/src/skills-lib/wizz-vsl/references/intake.md +5 -5
  113. package/src/skills-lib/wizz-web-security/SKILL.md +2 -0
  114. package/tools/installer/guided/labels.js +16 -0
  115. package/tools/installer/install-text.js +2 -0
  116. package/tools/installer/modules/cli-config.js +3 -0
  117. package/tools/installer/ui.js +6 -0
  118. /package/src/skills-lib/{wizz-pesquisa-de-publico-do-piva → wizz-pesquisa-de-publico}/prompts.md +0 -0
@@ -42,6 +42,7 @@ Only `start` and `end` are required. Breaths are the gaps between words at least
42
42
 
43
43
  - `transition`: `cut`, `whip`, `zoom` or any name the renderer knows. Anything other than `""` or `cut` counts as a `transition` event in the budget. `fill_breaths.py` only fills scenes without a real transition (absent, `""` or `cut`); a transition the author declared is kept, and its `sfx` too when already set.
44
44
  - `sfx` (optional): the cut sound, played at `start`. Same shape as an `sfx[]` entry without `at`.
45
+ - `carry` (optional, required on a one-take piece): one line saying what on screen survives the passage into this scene and what it becomes. Read by `check_one_take.py` ([one-take.md](one-take.md)).
45
46
 
46
47
  ### `sfx[]`
47
48
 
@@ -165,6 +166,7 @@ Written by `assets/chrome/collect_layout.js` from the opened HTML composition (i
165
166
  | --- | --- | --- | --- |
166
167
  | `fill_breaths.py TIMELINE [--narration F@OFF] [--visual MP4] [--write\|--out P]` | `words`, `scenes`, effects, `sfx`, `duration` | `scenes[].transition`, `scenes[].sfx`, `punches`, `flashes`, `sfx` | 0, 2 |
167
168
  | `sound.py duck TIMELINE [--write]` | `sfx`, `scenes[].sfx`, `words`, `fps` | `ducking` | 0, 2 |
169
+ | `check_one_take.py FINAL.mp4 [--timeline T]` | `scenes[].carry`, `scenes[].transition` | nothing | 0 pass, 1 fail, 2 error |
168
170
  | `check_excess.py TIMELINE... [--warn]` | effects, `scenes`, `duration` | nothing | 0 pass, 1 fail, 2 error |
169
171
  | `check_rhythm.py TIMELINE...` | `scenes`, effects, `elements`, `duration` | nothing | always 0 (warn only) |
170
172
  | `check_chrome.py --timeline T \| --snapshot LAYOUT [--allow R]` | `elements[]` text layers and bars, `chrome_allow` (or the snapshot) | nothing | 0, 1, 2 |
@@ -17,6 +17,7 @@ Thresholds are cited by config key. Every script exits `0` pass, `1` fail, `2` i
17
17
  | `check_safe_zone.py` | **final MP4** | per row, local contrast (std-dev, `safe_zone_gate.dp_min`) on every frame; the highest and lowest content rows must sit inside the piece's profile (`safe_zones.<profile>` top and bottom) | yes | whether what invades is text, a card or background glow; left and right edges. Full-screen footage fails by construction: frame it inside the zone. On a fail, extract the frame and look |
18
18
  | `measure_density.py` | **final MP4** | how many of `density.n_bands` bands across the active safe zone have content (`density.dp_min`), sampled every `density.step_s`; fails under `density.min_filled` | yes | whether the content makes sense there: a band full of noise counts like a band full of information |
19
19
  | `check_dead_frames.py` | **final MP4**, every frame | runs of flat frames (one uniform field: `dead_frames.flat_std` and `dead_frames.flat_range` on a small luma copy); more than `dead_frames.flat_max_frames` in a row fails. Frozen picture (frame difference under `dead_frames.frozen_diff`) longer than `dead_frames.frozen_warn_s` only warns. Prints the mean motion against `dead_frames.motion_band_reference` as a question | yes for flat runs; frozen is a warning | a talking head is never flat and almost never frozen (noise, breathing, compression), so a dead shot on camera passes; grain or a looping background keeps a dead slide "moving"; a dark frame with one small line of text is not flat (that is density). Thresholds come from horizontal launch films (motionmaxxing G2), not from vertical Reels |
20
+ | `check_one_take.py` | timeline JSON (`--timeline`) + **final MP4**, every frame | only on a piece meant as one take: every scene after the first declares `carry` and is not a plain `cut`; a frame whose change is above `one_take.cut_min_diff` and `one_take.cut_ratio` times the median of its `one_take.neighbour_frames` neighbours is a hard cut ([one-take.md](one-take.md)) | yes | a dissolve between unrelated scenes (no spike); whether the declared carry is really on screen; a cut hidden under a flash or a blur. Thresholds are a house rule, not measured on real one-take pieces |
20
21
  | `check_loudness.py` | **final MP4 or audio** | integrated loudness and true peak (ffmpeg `loudnorm`) against `loudness.*`; prints the two-pass fix and warns when the fix will switch loudnorm to dynamic compression | yes | the balance inside the mix (voice vs SFX); that is the level contract and the ear |
21
22
 
22
23
  Order that matches the pipeline: config and coverage once per project; excess, rhythm and chrome on the timeline before the expensive render; safe zone, density, dead frames and loudness on the final file.
@@ -0,0 +1,41 @@
1
+ # One take: a piece with no change of scene
2
+
3
+ A one-take piece is one continuous shot. Nothing replaces anything: every beat grows out of something already on screen, and a single camera carries the viewer from the hook to the CTA. It fits pieces drawn in code (HyperFrames): a product film, a UI walkthrough, an explainer. It does not fit footage that was shot in separate takes. Idea seen in onetake (github.com/feitangyuan/onetake, PolyForm Noncommercial); nothing here is copied from it. The onetake skill itself is installable as the optional CLI `onetake` (designer area), free only for personal or noncommercial work; client work or content that sells needs onetake Pro. This technique needs neither.
4
+
5
+ ## The rule
6
+
7
+ **No passage is only a change of scene.** At every boundary, something on screen survives and becomes the next beat. Write it in the timeline as the scene's `carry` ([data-contract.md](data-contract.md)), in one line: what it is and what it turns into. If that line cannot be written, the passage is a cut, and the piece is not one take.
8
+
9
+ ## Ways to carry
10
+
11
+ - **Grow into:** an element of this beat becomes the frame of the next one (a price chip opens into the checkout card; a search bar opens into the app window).
12
+ - **Push through:** the camera flies into an element (a card, a phone screen, a letter of the headline) until its inside is the next scene.
13
+ - **Pull back:** the camera opens from a detail to reveal it was part of a bigger picture (one cell of a table, one post in a feed).
14
+ - **Travel:** a line, a dot or a cursor leaves this beat and becomes the first stroke of the next one.
15
+ - **Gather:** the closing lockup collects the pieces seen along the way.
16
+
17
+ ## Camera
18
+
19
+ - Lead the subject: the camera arrives where the next element will land a moment before it lands.
20
+ - Rest between moves. A hold of about a beat after each move is what makes the move read; a camera that never stops reads as a screensaver.
21
+ - Vary the length of the moves; equal lengths read as a template.
22
+ - Fast moves get motion blur; a sharp whip looks like a glitch.
23
+ - Build every move as a function of time (seek(t)), so every frame renders the same.
24
+
25
+ ## Passages to build first (agency Reels)
26
+
27
+ From the pauta of effect names collected on 2026-10-25, the ones that are carries and serve agency Reels:
28
+
29
+ | Passage | Carry |
30
+ |---|---|
31
+ | Zoom Through Cut | push through an element into the next scene |
32
+ | Pullback Reveal | pull back from a detail to the whole |
33
+ | Phone Mockup | the screen of a phone grows out of the frame or the frame flies into it |
34
+ | Chart Grow | a number on screen becomes the bar or line of a chart |
35
+ | Headline Wall | a headline pulls back to reveal a wall of headlines |
36
+
37
+ Still only names, no recipe yet: Orbit, Couch Wall, UI World, Frame Break, Digital Zoom, Checklist, Greenscreen, Paper Cut, Doodle Boy, Vertigo, Cover Story, Glass Type.
38
+
39
+ ## Gate
40
+
41
+ `scripts/check_one_take.py FINAL.mp4 --timeline TIMELINE` blocks a scene without `carry`, a `transition` that is a plain cut, and any hard cut measured in the file (`one_take.*` in the config). A dissolve between unrelated scenes passes the measurement: the `carry` line and the eye are what catch it. Extract the frames around each passage and look.
@@ -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())
@@ -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.
@@ -30,13 +30,14 @@ Scripts live in this skill's `scripts/`; run them from the project root (the his
30
30
  - Niche, goal of the piece (follow, comment, save, click), duration.
31
31
  Never pick a style the material cannot feed. "Talking head" needs a person on camera; "motion editorial" needs nothing but a voice; "macro / ASMR" needs a physical product filmed close.
32
32
  2. **Pick the style** with [references/style-picker.md](references/style-picker.md): material decides which styles are possible, niche and goal rank them. Mixed is allowed and common: person on camera for the hook and the CTA, another style in the middle. Before deciding, run `node scripts/references.mjs history` in the project: if the last two pieces used the same style, do not pick it a third time unless the user wants a series; choose another style the material allows, or keep it only for the hook and CTA. Also run `node scripts/taste.mjs show`: what this user already approved or rejected in this project outranks the catalogue's ranking.
33
- 3. **Open the style card** in `references/styles/` and follow it: rhythm, captions, palette, typography, motion, music, SFX, hook, CTA, and its "do not" list. Where the card is silent on how things move, use [references/motion-laws.md](references/motion-laws.md).
33
+ 3. **Open the style card** in `references/styles/` (or, for an art-directed film rather than a reach format, a film style from [references/film-styles.md](references/film-styles.md)) and follow it: rhythm, captions, palette, typography, motion, music, SFX, hook, CTA, and its "do not" list. Where the card is silent on how things move, use [references/motion-laws.md](references/motion-laws.md).
34
34
  4. **Get fresh references and watch two** before editing: `node scripts/references.mjs pick --style <key>` returns measured references plus, for styles generated in code, current viral videos from the awesome-opus5-5-videos list, skipping everything this project already used. Reading a card is not watching a video. For how those generated videos were briefed, see [references/generated-prompts.md](references/generated-prompts.md).
35
- 5. **Audio:** music and SFX choices from [references/audio.md](references/audio.md); the redistributable kit is in `assets/audio/` (credits in `assets/audio/CREDITS.md`).
36
- 6. **Hand over** to `wizz-video-effects` with the card's SFX ceiling and music level, then to Remotion.
37
- 7. **Look at the final file**: extract frames and compare them side by side with a contact sheet of one reference.
38
- 8. **Record the piece**: `node scripts/references.mjs record --style <key> --piece <slug> --refs <ids watched>`. It writes `_wizz-output/video-styles/historico.json`, which is what keeps the next piece from repeating the same references and look.
39
- 9. **Record the user's verdict** after they review it (with two versions, offer `python3 scripts/blind_review.py prepare <dir> a.mp4 b.mp4` first), in their words: `node scripts/taste.mjs add --verdict approved|rejected --piece <slug> --style <key> --quote "..." --lesson "..."` (appends to `_wizz-output/video-styles/gosto.md`; the narrowest lesson the reaction supports).
35
+ 5. **Pieces drawn in code** (no footage): run the story, look and storyboard check-ins of [references/check-ins.md](references/check-ins.md) before building, with the panels timed on `scripts/beat_grid.py`.
36
+ 6. **Audio:** music and SFX choices from [references/audio.md](references/audio.md); the redistributable kit is in `assets/audio/` (credits in `assets/audio/CREDITS.md`).
37
+ 7. **Hand over** to `wizz-video-effects` with the card's SFX ceiling and music level, then to Remotion.
38
+ 8. **Look at the final file**: extract frames and compare them side by side with a contact sheet of one reference.
39
+ 9. **Record the piece**: `node scripts/references.mjs record --style <key> --piece <slug> --refs <ids watched>`. It writes `_wizz-output/video-styles/historico.json`, which is what keeps the next piece from repeating the same references and look.
40
+ 10. **Record the user's verdict** after they review it (with two versions, offer `python3 scripts/blind_review.py prepare <dir> a.mp4 b.mp4` first), in their words: `node scripts/taste.mjs add --verdict approved|rejected --piece <slug> --style <key> --quote "..." --lesson "..."` (appends to `_wizz-output/video-styles/gosto.md`; the narrowest lesson the reaction supports).
40
41
 
41
42
  ## What the measurements say across every style
42
43
 
@@ -58,13 +59,17 @@ Details and the per-style table: [references/measured-patterns.md](references/me
58
59
  | [references/audio.md](references/audio.md) | music and SFX per style, the kit, libraries and licences |
59
60
  | [references/research-method.md](references/research-method.md) | how the catalogue was measured, its limits, how to add a niche or a style |
60
61
  | `data/references.json` | every measured short: link, channel, views, niche, style, metrics |
62
+ | [references/film-styles.md](references/film-styles.md) | 59 art-directed film styles drawn in code (lemo-opuscar, not measured): index, how to build them in HyperFrames for 9:16, what the test renders taught |
63
+ | [references/insert-recipes.md](references/insert-recipes.md) | b-roll treatments cut into a style (collage b-roll), drawn in code, not measured |
61
64
  | [references/generated-prompts.md](references/generated-prompts.md) | prompts of viral code-generated videos, grouped by style, and how they run here |
62
65
  | `scripts/analyze.sh` | measures one short (cuts, music bed, SFX heuristics, loudness, contact sheet) |
63
66
  | `scripts/blind_review.py` | blind side-by-side page for two or more versions, answer key hidden until the pick |
64
67
  | `scripts/taste.mjs` | the user's approved and rejected verdicts for this project, read before choosing and appended after review |
65
68
  | `scripts/references.mjs` | fresh references per style without repeats, and the project's history of styles used |
66
- | `scripts/beat_grid.py` | BPM, beats and downbeats of a song, so cuts snap to the music (lyric video, kinetic typography) |
69
+ | [references/check-ins.md](references/check-ins.md) | story, look and storyboard approvals before building a piece drawn in code, plus the beat map |
70
+ | `scripts/beat_grid.py` | BPM, beats, downbeats, loudest hits and drops of a song, so cuts and the payoff snap to the music |
67
71
  | `assets/remotion/editorial/` | Remotion template of the motion editorial style |
72
+ | `assets/film-styles/` | three film styles rebuilt as 9:16 HyperFrames compositions (swiss-motion, risograph, dark-keynote), plus the risograph as a Remotion component, all gated |
68
73
  | `assets/audio/` | licensed music beds and SFX that may ship with a project |
69
74
 
70
75
  ## Out of scope
@@ -0,0 +1,78 @@
1
+ <!doctype html>
2
+ <html lang="pt-BR">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=1080, height=1920" />
6
+ <title>dark-keynote · 9:16 example</title>
7
+ <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
8
+ <style>
9
+ body { margin: 0; background: #07090D; }
10
+ #root { position: relative; width: 1080px; height: 1920px; overflow: hidden; font-family: "Inter", "Helvetica Neue", sans-serif; color: #E8ECF2; }
11
+ .stage { position: absolute; inset: 0;
12
+ background: radial-gradient(ellipse 80% 40% at 50% 8%, #151A24 0%, transparent 70%),
13
+ radial-gradient(circle at 78% 62%, rgba(110,140,255,0.07) 0%, transparent 45%),
14
+ radial-gradient(ellipse at 50% 50%, transparent 50%, rgba(0,0,0,0.55) 100%), #07090D; }
15
+ .grid { position: absolute; inset: 0;
16
+ background-image: linear-gradient(rgba(255,255,255,0.03) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,0.03) 1px, transparent 1px);
17
+ background-size: 48px 48px; }
18
+ .card { position: absolute; width: 560px; height: 116px; border-radius: 22px; background: #141922;
19
+ border: 1px solid rgba(255,255,255,0.08); box-shadow: inset 0 1px 0 rgba(255,255,255,0.06), 0 20px 40px rgba(0,0,0,0.45); }
20
+ .card .ico { position: absolute; left: 24px; top: 28px; width: 60px; height: 60px; border-radius: 14px; background: #1E2531; }
21
+ .card .l1 { position: absolute; left: 104px; top: 30px; width: 300px; height: 18px; border-radius: 9px; background: #3A4352; }
22
+ .card .l2 { position: absolute; left: 104px; top: 64px; width: 200px; height: 14px; border-radius: 7px; background: #262D39; }
23
+ .card .badge { position: absolute; right: 22px; top: 22px; width: 40px; height: 40px; border-radius: 20px; background: #E5484D;
24
+ font: 600 20px/40px "JetBrains Mono", Menlo, monospace; text-align: center; color: #fff; }
25
+ #btn { position: absolute; left: 340px; top: 1180px; width: 400px; height: 120px; border-radius: 60px; background: #1A2029;
26
+ border: 1px solid rgba(255,255,255,0.1); font: 600 40px/120px Inter, "Helvetica Neue", sans-serif; text-align: center; color: #9AA4B2; }
27
+ #cursor { position: absolute; left: 0; top: 0; width: 44px; height: 56px; }
28
+ #big { position: absolute; left: 0; top: 560px; width: 1080px; text-align: center; font: 700 520px/1 "Inter Tight", "Helvetica Neue", sans-serif;
29
+ letter-spacing: -0.045em; color: #B7F34A; text-shadow: 0 0 80px rgba(183,243,74,0.35); }
30
+ #label { position: absolute; left: 0; top: 1120px; width: 1080px; text-align: center; font: 500 30px "JetBrains Mono", Menlo, monospace; letter-spacing: 0.18em; color: #8A94A3; }
31
+ #toast { position: absolute; left: 240px; top: 1400px; width: 600px; height: 92px; border-radius: 46px; background: rgba(20,25,34,0.9);
32
+ border: 1px solid rgba(255,255,255,0.12); font: 500 38px/92px Inter, "Helvetica Neue", sans-serif; text-align: center; }
33
+ #toast i { display: inline-block; width: 14px; height: 14px; border-radius: 7px; background: #B7F34A; margin-right: 16px; vertical-align: middle; }
34
+ </style>
35
+ </head>
36
+ <body>
37
+ <div id="root" data-composition-id="main" data-width="1080" data-height="1920" data-duration="6" data-fps="24">
38
+ <section id="scene" class="clip" data-start="0" data-duration="6" data-track-index="1" style="position:absolute;inset:0">
39
+ <div class="stage"></div><div class="grid"></div>
40
+ <div id="cards"></div>
41
+ <div id="btn">unificar</div>
42
+ <div id="big">1</div>
43
+ <div id="label">PAINEL · NO LUGAR DE 9 ABAS</div>
44
+ <div id="toast"><i></i>tudo num lugar só</div>
45
+ <svg id="cursor" viewBox="0 0 22 28"><path d="M1 1 L1 22 L7 16.5 L11 26 L14.5 24.5 L10.5 15 L18 15 Z" fill="#fff" stroke="#000" stroke-width="1.2"/></svg>
46
+ </section>
47
+ </div>
48
+ <script>
49
+ // 9 cards on the 48 px grid, fixed positions (no random)
50
+ const pos = [[96,288],[432,416],[144,544],[480,672],[96,800],[432,928],[192,1056],[480,1184],[96,1312]];
51
+ const holder = document.getElementById("cards");
52
+ pos.forEach(([x, y], i) => {
53
+ const c = document.createElement("div"); c.className = "card"; c.style.left = x + "px"; c.style.top = y + "px";
54
+ c.innerHTML = '<div class="ico"></div><div class="l1"></div><div class="l2"></div><div class="badge">' + (i + 2) + '</div>';
55
+ holder.appendChild(c);
56
+ });
57
+ window.__timelines = window.__timelines || {};
58
+ const tl = gsap.timeline({ paused: true });
59
+ const beat = 0.5;
60
+ // spawns: drop + small overshoot, one per half beat (problem state, red badges)
61
+ tl.from(".card", { y: -60, opacity: 0, duration: 0.25, ease: "back.out(1.3)", stagger: beat / 2 }, 0.1);
62
+ tl.to(".card", { x: (i) => (i % 2 ? 4 : -4), duration: 0.05, ease: "none", repeat: 5, yoyo: true }, 2.4); // tremble
63
+ tl.from("#btn", { scale: 0.6, opacity: 0, duration: 0.25, ease: "back.out(1.3)" }, 2.5);
64
+ // cursor flies on a curve to the button, lands on the beat at 3.0
65
+ tl.fromTo("#cursor", { x: 900, y: 1480 }, { x: 560, y: 1250, duration: 0.5, ease: "power3.out" }, 2.5);
66
+ tl.to("#cursor", { scale: 0.85, duration: 0.06, ease: "none", yoyo: true, repeat: 1 }, 3.0);
67
+ tl.to("#btn", { backgroundColor: "#B7F34A", color: "#0A0D10", duration: 0.08, ease: "none" }, 3.0);
68
+ // turn: cards retract into the button, one per eighth note
69
+ tl.to(".card", { x: (i) => 540 - 280 - pos[i][0], y: (i) => 1240 - 58 - pos[i][1], scale: 0.2, opacity: 0, duration: 0.3, ease: "power3.in", stagger: 0.06 }, 3.15);
70
+ tl.to(["#btn", "#cursor"], { scaleY: 0.02, scaleX: 0.4, opacity: 0, duration: 0.2, ease: "power3.in" }, 3.85);
71
+ // the number gets its own screen, no caption over it
72
+ tl.from("#big", { scale: 0.7, opacity: 0, duration: 0.3, ease: "back.out(1.3)" }, 3.9);
73
+ tl.from("#label", { y: 20, opacity: 0, duration: 0.2, ease: "power2.out" }, 4.0);
74
+ tl.from("#toast", { y: 24, opacity: 0, duration: 0.16, ease: "power2.out" }, 4.25);
75
+ window.__timelines["main"] = tl;
76
+ </script>
77
+ </body>
78
+ </html>
@@ -0,0 +1,63 @@
1
+ import React from "react";
2
+ import { AbsoluteFill, Easing, interpolate, useCurrentFrame, useVideoConfig } from "remotion";
3
+
4
+ // Risograph Print (lemo-opuscar card), rebuilt for Remotion: each plate is one ink
5
+ // printed with multiply; tints are a dotted mask; registration is fixed per shot,
6
+ // with one kick on the accent and a drift that snaps back on the downbeat.
7
+ const PAPER = "#F4EEE2";
8
+ const TEAL = "#00838A";
9
+ const ORANGE = "#FF7477";
10
+ const clamp = { extrapolateLeft: "clamp", extrapolateRight: "clamp" } as const;
11
+
12
+ const Tint: React.FC<{ ink: string; dot: number; style: React.CSSProperties }> = ({ ink, dot, style }) => {
13
+ const mask = `radial-gradient(circle, #000 ${dot}px, transparent ${dot + 0.6}px)`;
14
+ return <div style={{ position: "absolute", background: ink, WebkitMaskImage: mask, maskImage: mask, WebkitMaskSize: "7px 7px", maskSize: "7px 7px", ...style }} />;
15
+ };
16
+
17
+ const Title: React.FC<{ ink: string; style?: React.CSSProperties }> = ({ ink, style }) => (
18
+ <div style={{ position: "absolute", left: 110, top: 310, width: 900, fontFamily: "Futura, 'Avenir Next', sans-serif", fontWeight: 800, fontSize: 150, lineHeight: 0.92, letterSpacing: "-0.02em", color: ink, ...style }}>
19
+ <div>anúncio</div>
20
+ <div>que vende</div>
21
+ </div>
22
+ );
23
+
24
+ export const Riso: React.FC = () => {
25
+ const frame = useCurrentFrame();
26
+ const { fps } = useVideoConfig();
27
+ const t = frame / fps;
28
+
29
+ const hillY = interpolate(t, [0, 0.6], [100, 0], { ...clamp, easing: Easing.out(Easing.cubic) });
30
+ const titleClip = interpolate(t, [0.25, 0.75], [100, 0], { ...clamp, easing: Easing.out(Easing.quad) });
31
+ const roller = interpolate(t, [1, 2], [0, 1], clamp);
32
+ // sun rises on twos: 18 steps over 1.5 s
33
+ const sunStep = Math.floor(interpolate(t, [1.4, 2.9], [0, 18], clamp));
34
+ const sunY = 320 - (320 * sunStep) / 18;
35
+ // kick on the accent at 3.0, ringing back
36
+ const kick = t < 3 ? 0 : t < 3.04 ? 1 : interpolate(t, [3.04, 3.34], [1, 0], { ...clamp, easing: Easing.out(Easing.elastic(1.5)) });
37
+ const p2x = -2 - 12 * kick;
38
+ const p2y = 2 + 8 * kick;
39
+ // drift through the hold, snap back on the downbeat
40
+ const p1x = t < 3.4 ? 2 : t < 4.6 ? interpolate(t, [3.4, 4.6], [2, 22]) : 2;
41
+ const p2on = t < 5;
42
+
43
+ const plate: React.CSSProperties = { position: "absolute", inset: -40, mixBlendMode: "multiply" };
44
+ return (
45
+ <AbsoluteFill style={{ background: PAPER }}>
46
+ <div style={{ ...plate, transform: `translate(${p1x}px, -1px)` }}>
47
+ <Tint ink={TEAL} dot={2.2} style={{ left: 0, top: 290, width: 1160, height: 570 }} />
48
+ <div style={{ position: "absolute", left: -100, top: 1180, width: 1400, height: 900, borderRadius: "50% 50% 0 0", background: TEAL, transform: `translateY(${hillY}px)` }} />
49
+ <Title ink={TEAL} style={{ clipPath: `inset(0 0 ${titleClip}% 0)` }} />
50
+ </div>
51
+ {p2on && (
52
+ <div style={{ ...plate, transform: `translate(${p2x}px, ${p2y}px)`, clipPath: `inset(0 0 ${100 - roller * 100}% 0)` }}>
53
+ <div style={{ position: "absolute", left: 380, top: 820, width: 520, height: 520, borderRadius: "50%", background: ORANGE, transform: `translateY(${sunY}px)` }} />
54
+ <Title ink={ORANGE} style={{ transform: "translate(9px, 7px)", opacity: 0.9 }} />
55
+ <Tint ink={ORANGE} dot={2} style={{ left: 0, top: 1350, width: 1160, height: 250 }} />
56
+ </div>
57
+ )}
58
+ {roller > 0 && roller < 1 && (
59
+ <div style={{ position: "absolute", left: 0, top: -40 + roller * 2000, width: 1080, height: 30, background: "rgba(255,116,119,0.35)", mixBlendMode: "multiply" }} />
60
+ )}
61
+ </AbsoluteFill>
62
+ );
63
+ };
@@ -0,0 +1,69 @@
1
+ <!doctype html>
2
+ <html lang="pt-BR">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=1080, height=1920" />
6
+ <title>risograph · 9:16 example</title>
7
+ <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
8
+ <style>
9
+ body { margin: 0; background: #F4EEE2; }
10
+ #root { position: relative; width: 1080px; height: 1920px; overflow: hidden; font-family: "Futura", "Avenir Next", sans-serif; }
11
+ .paper { position: absolute; inset: 0; background: #F4EEE2; }
12
+ /* each plate is one ink, printed with multiply so overlaps mix */
13
+ .plate { position: absolute; inset: -40px; mix-blend-mode: multiply; }
14
+ .teal { --ink: #00838A; }
15
+ .orange { --ink: #FF7477; }
16
+ .solid { position: absolute; background: var(--ink); }
17
+ .tint { position: absolute; background: var(--ink);
18
+ -webkit-mask-image: radial-gradient(circle, #000 var(--dot), transparent calc(var(--dot) + 0.6px));
19
+ -webkit-mask-size: 7px 7px; mask-image: radial-gradient(circle, #000 var(--dot), transparent calc(var(--dot) + 0.6px)); mask-size: 7px 7px; }
20
+ .title { position: absolute; left: 110px; width: 900px; font-weight: 800; font-size: 150px; line-height: 0.92; letter-spacing: -0.02em; color: var(--ink); }
21
+ .grain { position: absolute; inset: 0; mix-blend-mode: multiply; opacity: 0.35; }
22
+ #roller { position: absolute; left: 0; width: 1080px; height: 30px; background: rgba(255,116,119,0.35); mix-blend-mode: multiply; }
23
+ </style>
24
+ </head>
25
+ <body>
26
+ <div id="root" data-composition-id="main" data-width="1080" data-height="1920" data-duration="6" data-fps="24">
27
+ <section id="scene" class="clip" data-start="0" data-duration="6" data-track-index="1" style="position:absolute;inset:0">
28
+ <div class="paper"></div>
29
+ <!-- plate 1: teal, offset +2px/-1px -->
30
+ <div id="p1" class="plate teal">
31
+ <div class="tint" style="--dot:2.2px; left:0; top:290px; width:1160px; height:570px"></div>
32
+ <div id="hill" class="solid" style="left:-100px; top:1180px; width:1400px; height:900px; border-radius:50% 50% 0 0"></div>
33
+ <div id="t1" class="title" style="top:310px"><span style="display:block">anúncio</span><span style="display:block">que vende</span></div>
34
+ </div>
35
+ <!-- plate 2: orange, offset -2px/+2px, arrives by roller sweep -->
36
+ <div id="p2" class="plate orange">
37
+ <div id="sun" class="solid" style="left:380px; top:820px; width:520px; height:520px; border-radius:50%"></div>
38
+ <div id="t2" class="title" style="top:310px; transform: translate(9px,7px); opacity:0.9"><span style="display:block">anúncio</span><span style="display:block">que vende</span></div>
39
+ <div class="tint" style="--dot:2px; left:0; top:1350px; width:1160px; height:250px"></div>
40
+ </div>
41
+ <svg class="grain" width="1080" height="1920"><filter id="n"><feTurbulence type="fractalNoise" baseFrequency="0.9" numOctaves="2" seed="7"/><feColorMatrix values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 -1.2 1.1"/></filter><rect width="1080" height="1920" filter="url(#n)"/></svg>
42
+ <div id="roller"></div>
43
+ </section>
44
+ </div>
45
+ <script>
46
+ window.__timelines = window.__timelines || {};
47
+ const tl = gsap.timeline({ paused: true });
48
+ tl.set("#p1", { x: 2, y: -1 }, 0);
49
+ tl.set("#p2", { x: -2, y: 2 }, 0);
50
+ // one-plate version first: teal alone is a complete frame
51
+ tl.from("#t1", { clipPath: "inset(0 0 100% 0)", duration: 0.5, ease: "power2.out" }, 0.25);
52
+ tl.from("#hill", { y: 100, duration: 0.6, ease: "power3.out" }, 0.0);
53
+ // roller sweep prints the orange plate top to bottom (1.0 - 2.0)
54
+ tl.fromTo("#p2", { clipPath: "inset(0 0 100% 0)" }, { clipPath: "inset(0 0 0% 0)", duration: 1.0, ease: "none" }, 1.0);
55
+ tl.fromTo("#roller", { y: -40 }, { y: 1960, duration: 1.0, ease: "none" }, 1.0);
56
+ // sun rises on twos (12 fps steps)
57
+ tl.fromTo("#sun", { y: 320 }, { y: 0, duration: 1.5, ease: "steps(18)" }, 1.4);
58
+ // kick: registration jumps on the accent at 3.0 and rings back
59
+ tl.to("#p2", { x: -14, y: 10, duration: 0.04, ease: "none" }, 3.0);
60
+ tl.to("#p2", { x: -2, y: 2, duration: 0.3, ease: "elastic.out(1, 0.35)" }, 3.04);
61
+ // drift through the hold, snap back on the downbeat
62
+ tl.to("#p1", { x: 22, duration: 1.2, ease: "none" }, 3.4);
63
+ tl.to("#p1", { x: 2, duration: 0.05, ease: "none" }, 4.6);
64
+ // ending: one ink left (the orange plate lifts away)
65
+ tl.to("#p2", { opacity: 0, duration: 0.05, ease: "none" }, 5.0);
66
+ window.__timelines["main"] = tl;
67
+ </script>
68
+ </body>
69
+ </html>