wizz-method 2.16.0 → 2.17.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 (60) hide show
  1. package/THIRD-PARTY-NOTICES.md +6 -0
  2. package/package.json +1 -1
  3. package/skills-registry.yaml +18 -6
  4. package/src/modules/wizz/_shared/video-pipeline.md +1 -0
  5. package/src/skills-lib/wizz-router/references/routing-table-flat.md +1 -0
  6. package/src/skills-lib/wizz-video-styles/SKILL.md +66 -0
  7. package/src/skills-lib/wizz-video-styles/assets/audio/CREDITS.md +54 -0
  8. package/src/skills-lib/wizz-video-styles/assets/audio/LIBRARIES.md +31 -0
  9. package/src/skills-lib/wizz-video-styles/assets/audio/kit.json +182 -0
  10. package/src/skills-lib/wizz-video-styles/assets/audio/music/acoustic-lifestyle.mp3 +0 -0
  11. package/src/skills-lib/wizz-video-styles/assets/audio/music/cinematic-tense.mp3 +0 -0
  12. package/src/skills-lib/wizz-video-styles/assets/audio/music/classical-light.mp3 +0 -0
  13. package/src/skills-lib/wizz-video-styles/assets/audio/music/corporate-light.mp3 +0 -0
  14. package/src/skills-lib/wizz-video-styles/assets/audio/music/lofi-chill.mp3 +0 -0
  15. package/src/skills-lib/wizz-video-styles/assets/audio/music/meme-beat.mp3 +0 -0
  16. package/src/skills-lib/wizz-video-styles/assets/audio/music/piano-editorial.mp3 +0 -0
  17. package/src/skills-lib/wizz-video-styles/assets/audio/music/tech-pulse.mp3 +0 -0
  18. package/src/skills-lib/wizz-video-styles/assets/audio/sfx/boom-impact.mp3 +0 -0
  19. package/src/skills-lib/wizz-video-styles/assets/audio/sfx/click-mouse.mp3 +0 -0
  20. package/src/skills-lib/wizz-video-styles/assets/audio/sfx/ding-notify.mp3 +0 -0
  21. package/src/skills-lib/wizz-video-styles/assets/audio/sfx/glitch-short.mp3 +0 -0
  22. package/src/skills-lib/wizz-video-styles/assets/audio/sfx/ka-ching.mp3 +0 -0
  23. package/src/skills-lib/wizz-video-styles/assets/audio/sfx/pop-bubble.mp3 +0 -0
  24. package/src/skills-lib/wizz-video-styles/assets/audio/sfx/record-scratch.mp3 +0 -0
  25. package/src/skills-lib/wizz-video-styles/assets/audio/sfx/riser-short.mp3 +0 -0
  26. package/src/skills-lib/wizz-video-styles/assets/audio/sfx/swoosh-text.mp3 +0 -0
  27. package/src/skills-lib/wizz-video-styles/assets/audio/sfx/typing-short.mp3 +0 -0
  28. package/src/skills-lib/wizz-video-styles/assets/audio/sfx/whoosh-fast.mp3 +0 -0
  29. package/src/skills-lib/wizz-video-styles/assets/audio/sfx/whoosh-soft.mp3 +0 -0
  30. package/src/skills-lib/wizz-video-styles/assets/remotion/editorial/EditorialReel.tsx +111 -0
  31. package/src/skills-lib/wizz-video-styles/assets/remotion/editorial/README.md +77 -0
  32. package/src/skills-lib/wizz-video-styles/assets/remotion/editorial/captions.tsx +56 -0
  33. package/src/skills-lib/wizz-video-styles/assets/remotion/editorial/example-words.json +285 -0
  34. package/src/skills-lib/wizz-video-styles/assets/remotion/editorial/primitives.tsx +241 -0
  35. package/src/skills-lib/wizz-video-styles/assets/remotion/editorial/scenes.tsx +113 -0
  36. package/src/skills-lib/wizz-video-styles/assets/remotion/editorial/theme.ts +68 -0
  37. package/src/skills-lib/wizz-video-styles/assets/remotion/editorial/timeline.ts +65 -0
  38. package/src/skills-lib/wizz-video-styles/data/recipes.json +341 -0
  39. package/src/skills-lib/wizz-video-styles/data/references.json +3397 -0
  40. package/src/skills-lib/wizz-video-styles/data/styles.json +726 -0
  41. package/src/skills-lib/wizz-video-styles/references/audio.md +54 -0
  42. package/src/skills-lib/wizz-video-styles/references/measured-patterns.md +38 -0
  43. package/src/skills-lib/wizz-video-styles/references/research-method.md +33 -0
  44. package/src/skills-lib/wizz-video-styles/references/style-picker.md +51 -0
  45. package/src/skills-lib/wizz-video-styles/references/styles/camera-no-setup.md +60 -0
  46. package/src/skills-lib/wizz-video-styles/references/styles/desafio-numero.md +60 -0
  47. package/src/skills-lib/wizz-video-styles/references/styles/esquete.md +57 -0
  48. package/src/skills-lib/wizz-video-styles/references/styles/experimento.md +56 -0
  49. package/src/skills-lib/wizz-video-styles/references/styles/explicador-animado.md +62 -0
  50. package/src/skills-lib/wizz-video-styles/references/styles/macro-asmr.md +61 -0
  51. package/src/skills-lib/wizz-video-styles/references/styles/meme-rapido.md +65 -0
  52. package/src/skills-lib/wizz-video-styles/references/styles/motion-editorial.md +69 -0
  53. package/src/skills-lib/wizz-video-styles/references/styles/podcast-clip.md +61 -0
  54. package/src/skills-lib/wizz-video-styles/references/styles/pov-vlog.md +57 -0
  55. package/src/skills-lib/wizz-video-styles/references/styles/quadro-branco.md +60 -0
  56. package/src/skills-lib/wizz-video-styles/references/styles/talking-head-cards.md +62 -0
  57. package/src/skills-lib/wizz-video-styles/references/styles/talking-head-legenda.md +62 -0
  58. package/src/skills-lib/wizz-video-styles/references/styles/tour.md +59 -0
  59. package/src/skills-lib/wizz-video-styles/scripts/analyze.sh +66 -0
  60. package/src/skills-lib/wizz-video-styles/scripts/build_catalog.py +128 -0
@@ -0,0 +1,77 @@
1
+ # remotion-editorial
2
+
3
+ Molde do estilo "motion editorial" (referência: shorts do Greg Isenberg): um objeto por vez no centro de um fundo claro com grade, cards escuros com sombra funda, serifa itálica bicolor, texto digitado em pílulas, conectores tracejados, música baixa e legenda pequena. Tudo guiado por palavras-âncora da fala (ctc-align).
4
+
5
+ Testado com Remotion 4.0.527, React 18, `@remotion/google-fonts` 4.0.527. Canvas 1080x1920, 30 fps. Sem outras dependências.
6
+
7
+ ## Arquivos
8
+
9
+ | arquivo | papel |
10
+ |---|---|
11
+ | `theme.ts` | `EditorialTheme`, `defaultTheme`, `mergeTheme`, `ThemeContext`, fontes (Playfair Display itálico, Inter, JetBrains Mono), `W/H/FPS` |
12
+ | `primitives.tsx` | `Stage`, `Backdrop`, `Headline`, `Rays`, `Ring`, `Pill`, `Chip`, `Dash`, `PhoneCard`, `Counter`, `DocCard` |
13
+ | `captions.tsx` | `Captions` (blocos de até 4 palavras, palavra falada em destaque) e `buildChunks` |
14
+ | `timeline.ts` | `at(words, texto, after)`, `resolveMarks`, `buildScenes` (âncoras + sobreposição), `totalSeconds` |
15
+ | `scenes.tsx` | tipo `SceneDef` (cenas como dados) e `SceneView` (7 tipos: hook, prompt, phone, numbers, steps, doc, cta) |
16
+ | `EditorialReel.tsx` | composição genérica + `exampleScenes`, `exampleProps`, `exampleSeconds` |
17
+ | `example-words.json` | palavras de exemplo no formato do ctc-align |
18
+
19
+ ## Como usar
20
+
21
+ 1. Copie a pasta inteira para `src/` do projeto Remotion (ex.: `src/editorial/`).
22
+ 2. Registre no `Root.tsx`:
23
+
24
+ ```tsx
25
+ import { EditorialReel, exampleProps, exampleSeconds } from "./editorial/EditorialReel";
26
+ import { FPS, H, W } from "./editorial/theme";
27
+
28
+ <Composition
29
+ id="editorial-example"
30
+ component={EditorialReel}
31
+ defaultProps={exampleProps}
32
+ durationInFrames={Math.ceil(exampleSeconds * FPS)}
33
+ fps={FPS}
34
+ width={W}
35
+ height={H}
36
+ />
37
+ ```
38
+
39
+ 3. Troque as três entradas, nesta ordem:
40
+ - **palavras**: `words` = saída do ctc-align `{ words: [{ text, start, end }], total }`. Trocou a voz, realinhe o JSON; as cenas acompanham.
41
+ - **cenas**: array de `SceneDef`. Cada cena tem `at` (palavra onde ela começa) e `marks` opcional (nome -> palavra interna, ex.: quando o contador sobe). Palavra repetida: a busca é monotônica, então basta manter as cenas na ordem da fala. Palavra inexistente lança erro claro.
42
+ - **tema**: `theme={{ bg, accent, ... }}` parcial. Campos: `bg grid glow vignette ink card cardHi line onAccent accent accentWarm accentLight accentShadow dash shadow serif sans mono`.
43
+ 4. Duração: `totalSeconds(words, tail)` = fim da fala + 1,4 s.
44
+ 5. Áudio (opcional, arquivos em `public/`): `audio={{ voice: "x/voz.wav", music: "x/cama.mp3", musicVolume: 0.2, sfx: [{ atWord: "Here", offset: -0.15, src: "x/swoosh.mp3", volume: 0.16 }] }}`. Nomes passam por `staticFile`.
45
+ 6. Screenshot: `{ kind: "phone", src: "x/tela.png", ratio: altura/largura }` (iPhone 1206x2622 = `2622/1206`, é o padrão). Sem `src`, desenha uma tela de exemplo. `src` também aceita URL `https:` ou `data:`.
46
+ 7. Fontes: Playfair Display itálico (600/700) via `@remotion/google-fonts/PlayfairDisplay`, carregada em `theme.ts`.
47
+
48
+ ## Tipos de cena
49
+
50
+ - `hook`: `a`, `b` (título bicolor) + anel girando.
51
+ - `prompt`: título + pílula digitada + raios; `typedMark` define até quando digita.
52
+ - `phone`: screenshot em card de celular.
53
+ - `numbers`: chip + até 2 contadores (`mark` = palavra em que cada um começa a subir).
54
+ - `steps`: pílula + raios + 3 chips ligados por tracejado.
55
+ - `doc`: card de documento com páginas (texto, depois código mono) e até 4 chips de revisão.
56
+ - `cta`: título + palavra-chave digitada + frase; `hideCaptions: true` some com a legenda dali em diante.
57
+
58
+ Para criar um tipo novo: componha as primitivas num componente em `scenes.tsx`, acrescente o ramo na união `SceneDef` e no `switch` de `SceneView`.
59
+
60
+ ## Regras do estilo
61
+
62
+ - Um objeto por vez no centro. Faixa útil y 180 a 1300; o terço de baixo é da legenda.
63
+ - Troca por transformação, nunca corte seco: a cena sai subindo e sumindo enquanto a próxima entra por mola (`overlap` 0,12 s).
64
+ - Música baixa e contínua, volume ~0,2, com fade de entrada e de saída.
65
+ - SFX raro: no máximo ~1 a cada 10 s, volume baixo (~0,16), só em viradas grandes.
66
+ - Legenda pequena, sem contorno, palavra falada em destaque; some quando a cena já carrega texto grande.
67
+ - Serifa itálica só nos momentos-chave (títulos, pílulas, chips); sans na legenda e no corpo do documento; mono só em código.
68
+ - Cor de destaque única; sobre a cor de destaque use `onAccent` (claro).
69
+ - Timing sempre por âncora de palavra, nunca por segundo fixo.
70
+
71
+ ## Verificar
72
+
73
+ ```bash
74
+ npx tsc --noEmit
75
+ npx remotion still src/index.ts editorial-example out/f.png --frame=330
76
+ ```
77
+
@@ -0,0 +1,56 @@
1
+ import React, { useMemo } from "react";
2
+ import { interpolate } from "remotion";
3
+ import { clamp, useSeconds } from "./primitives";
4
+ import { useTheme } from "./theme";
5
+ import type { Word } from "./timeline";
6
+
7
+ // Legenda discreta: blocos curtos de até 4 palavras, sem contorno, palavra falada em destaque.
8
+
9
+ export type Chunk = { words: Word[]; start: number; end: number };
10
+
11
+ const BREAK = /[,.:!?]$/;
12
+
13
+ // Agrupa palavras em blocos: corta em 4 palavras, pontuação ou pausa > 0.35 s.
14
+ export const buildChunks = (all: readonly Word[], maxWords = 4): Chunk[] => {
15
+ const ws = all.filter((w) => w.text.trim());
16
+ const raw = ws.reduce<{ done: Chunk[]; cur: Word[] }>(
17
+ (acc, w, i) => {
18
+ const cur = [...acc.cur, w];
19
+ const next = ws[i + 1];
20
+ const cut = cur.length === maxWords || BREAK.test(w.text) || !next || next.start - w.end > 0.35;
21
+ return cut
22
+ ? { done: [...acc.done, { words: cur, start: cur[0].start, end: w.end }], cur: [] }
23
+ : { ...acc, cur };
24
+ },
25
+ { done: [], cur: [] },
26
+ ).done;
27
+ // Cola o fim do bloco no começo do próximo quando a pausa é curta; senão segura 0,15 s.
28
+ return raw.map((c, i) => {
29
+ const next = raw[i + 1];
30
+ return { ...c, end: next && next.start - c.end < 0.5 ? next.start : c.end + 0.15 };
31
+ });
32
+ };
33
+
34
+ export const Captions: React.FC<{
35
+ words: readonly Word[];
36
+ hideFrom?: number; // s: some a partir daqui (ex.: cena final com texto próprio)
37
+ top?: number;
38
+ size?: number;
39
+ }> = ({ words, hideFrom, top = 1560, size = 50 }) => {
40
+ const c = useTheme();
41
+ const t = useSeconds();
42
+ const chunks = useMemo(() => buildChunks(words), [words]);
43
+ if (hideFrom !== undefined && t >= hideFrom) return null;
44
+ const chunk = chunks.find((x) => t >= x.start && t < x.end);
45
+ if (!chunk) return null;
46
+ const opacity = interpolate(t, [chunk.start, chunk.start + 0.1], [0, 1], clamp);
47
+ return (
48
+ <div style={{ position: "absolute", top, left: 90, right: 90, textAlign: "center", opacity, fontFamily: c.sans, fontWeight: 700, fontSize: size, lineHeight: 1.25, color: c.ink }}>
49
+ {chunk.words.map((w) => (
50
+ <span key={w.start} style={{ color: t >= w.start && t < w.end + 0.05 ? c.accent : c.ink }}>
51
+ {w.text.replace(BREAK, "")}{" "}
52
+ </span>
53
+ ))}
54
+ </div>
55
+ );
56
+ };
@@ -0,0 +1,285 @@
1
+ {
2
+ "words": [
3
+ {
4
+ "text": "I",
5
+ "start": 0.04,
6
+ "end": 0.23
7
+ },
8
+ {
9
+ "text": "shipped",
10
+ "start": 0.26,
11
+ "end": 0.63
12
+ },
13
+ {
14
+ "text": "a",
15
+ "start": 0.66,
16
+ "end": 0.85
17
+ },
18
+ {
19
+ "text": "whole",
20
+ "start": 0.88,
21
+ "end": 1.19
22
+ },
23
+ {
24
+ "text": "app",
25
+ "start": 1.22,
26
+ "end": 1.47
27
+ },
28
+ {
29
+ "text": "in",
30
+ "start": 1.5,
31
+ "end": 1.72
32
+ },
33
+ {
34
+ "text": "one",
35
+ "start": 1.75,
36
+ "end": 2.0
37
+ },
38
+ {
39
+ "text": "weekend.",
40
+ "start": 2.03,
41
+ "end": 2.4
42
+ },
43
+ {
44
+ "text": "Here",
45
+ "start": 2.78,
46
+ "end": 3.06
47
+ },
48
+ {
49
+ "text": "is",
50
+ "start": 3.09,
51
+ "end": 3.31
52
+ },
53
+ {
54
+ "text": "how.",
55
+ "start": 3.34,
56
+ "end": 3.59
57
+ },
58
+ {
59
+ "text": "I",
60
+ "start": 3.97,
61
+ "end": 4.16
62
+ },
63
+ {
64
+ "text": "only",
65
+ "start": 4.19,
66
+ "end": 4.47
67
+ },
68
+ {
69
+ "text": "tell",
70
+ "start": 4.5,
71
+ "end": 4.78
72
+ },
73
+ {
74
+ "text": "the",
75
+ "start": 4.81,
76
+ "end": 5.06
77
+ },
78
+ {
79
+ "text": "agent",
80
+ "start": 5.09,
81
+ "end": 5.4
82
+ },
83
+ {
84
+ "text": "what",
85
+ "start": 5.43,
86
+ "end": 5.71
87
+ },
88
+ {
89
+ "text": "I",
90
+ "start": 5.74,
91
+ "end": 5.93
92
+ },
93
+ {
94
+ "text": "want.",
95
+ "start": 5.96,
96
+ "end": 6.24
97
+ },
98
+ {
99
+ "text": "Look",
100
+ "start": 6.62,
101
+ "end": 6.9
102
+ },
103
+ {
104
+ "text": "at",
105
+ "start": 6.93,
106
+ "end": 7.15
107
+ },
108
+ {
109
+ "text": "the",
110
+ "start": 7.18,
111
+ "end": 7.43
112
+ },
113
+ {
114
+ "text": "result.",
115
+ "start": 7.46,
116
+ "end": 7.8
117
+ },
118
+ {
119
+ "text": "A",
120
+ "start": 8.18,
121
+ "end": 8.37
122
+ },
123
+ {
124
+ "text": "real",
125
+ "start": 8.4,
126
+ "end": 8.68
127
+ },
128
+ {
129
+ "text": "screen.",
130
+ "start": 8.71,
131
+ "end": 9.05
132
+ },
133
+ {
134
+ "text": "In",
135
+ "start": 9.43,
136
+ "end": 9.65
137
+ },
138
+ {
139
+ "text": "forty",
140
+ "start": 9.68,
141
+ "end": 9.99
142
+ },
143
+ {
144
+ "text": "eight",
145
+ "start": 10.02,
146
+ "end": 10.33
147
+ },
148
+ {
149
+ "text": "hours,",
150
+ "start": 10.36,
151
+ "end": 10.67
152
+ },
153
+ {
154
+ "text": "31",
155
+ "start": 10.85,
156
+ "end": 11.07
157
+ },
158
+ {
159
+ "text": "commits",
160
+ "start": 11.1,
161
+ "end": 11.47
162
+ },
163
+ {
164
+ "text": "and",
165
+ "start": 11.5,
166
+ "end": 11.75
167
+ },
168
+ {
169
+ "text": "12",
170
+ "start": 11.78,
171
+ "end": 12.0
172
+ },
173
+ {
174
+ "text": "tests.",
175
+ "start": 12.03,
176
+ "end": 12.34
177
+ },
178
+ {
179
+ "text": "Three",
180
+ "start": 12.72,
181
+ "end": 13.03
182
+ },
183
+ {
184
+ "text": "agents",
185
+ "start": 13.06,
186
+ "end": 13.4
187
+ },
188
+ {
189
+ "text": "write,",
190
+ "start": 13.43,
191
+ "end": 13.74
192
+ },
193
+ {
194
+ "text": "test",
195
+ "start": 13.92,
196
+ "end": 14.2
197
+ },
198
+ {
199
+ "text": "and",
200
+ "start": 14.23,
201
+ "end": 14.48
202
+ },
203
+ {
204
+ "text": "review.",
205
+ "start": 14.51,
206
+ "end": 14.85
207
+ },
208
+ {
209
+ "text": "First",
210
+ "start": 15.23,
211
+ "end": 15.54
212
+ },
213
+ {
214
+ "text": "a",
215
+ "start": 15.57,
216
+ "end": 15.76
217
+ },
218
+ {
219
+ "text": "spec,",
220
+ "start": 15.79,
221
+ "end": 16.07
222
+ },
223
+ {
224
+ "text": "then",
225
+ "start": 16.25,
226
+ "end": 16.53
227
+ },
228
+ {
229
+ "text": "the",
230
+ "start": 16.56,
231
+ "end": 16.81
232
+ },
233
+ {
234
+ "text": "code.",
235
+ "start": 16.84,
236
+ "end": 17.12
237
+ },
238
+ {
239
+ "text": "Comment",
240
+ "start": 17.5,
241
+ "end": 17.87
242
+ },
243
+ {
244
+ "text": "SHIP",
245
+ "start": 17.9,
246
+ "end": 18.18
247
+ },
248
+ {
249
+ "text": "and",
250
+ "start": 18.21,
251
+ "end": 18.46
252
+ },
253
+ {
254
+ "text": "I",
255
+ "start": 18.49,
256
+ "end": 18.68
257
+ },
258
+ {
259
+ "text": "will",
260
+ "start": 18.71,
261
+ "end": 18.99
262
+ },
263
+ {
264
+ "text": "send",
265
+ "start": 19.02,
266
+ "end": 19.3
267
+ },
268
+ {
269
+ "text": "you",
270
+ "start": 19.33,
271
+ "end": 19.58
272
+ },
273
+ {
274
+ "text": "the",
275
+ "start": 19.61,
276
+ "end": 19.86
277
+ },
278
+ {
279
+ "text": "prompt.",
280
+ "start": 19.89,
281
+ "end": 20.23
282
+ }
283
+ ],
284
+ "total": 20.33
285
+ }
@@ -0,0 +1,241 @@
1
+ import React, { useId } from "react";
2
+ import { AbsoluteFill, Easing, Img, interpolate, spring, staticFile, useCurrentFrame } from "remotion";
3
+ import { FPS, H, W, useTheme } from "./theme";
4
+
5
+ // Vocabulário do estilo: fundo claro com grade, cards escuros com sombra funda,
6
+ // serifa itálica bicolor, pílula digitada, conector tracejado, raios, contador.
7
+
8
+ export const clamp = { extrapolateLeft: "clamp", extrapolateRight: "clamp" } as const;
9
+ export const ease = Easing.bezier(0.22, 1, 0.36, 1);
10
+ export const useSeconds = (): number => useCurrentFrame() / FPS;
11
+ export const pop = (frame: number, delay = 0): number =>
12
+ spring({ frame: frame - Math.round(delay * FPS), fps: FPS, config: { damping: 15, mass: 0.7 } });
13
+
14
+ const CX = W / 2;
15
+
16
+ // Envelope de cena: entra subindo com mola, sai subindo e sumindo (sem corte seco).
17
+ export const Stage: React.FC<{ dur: number; children: React.ReactNode }> = ({ dur, children }) => {
18
+ const frame = useCurrentFrame();
19
+ const t = frame / FPS;
20
+ const s = pop(frame);
21
+ const out = interpolate(t, [dur - 0.3, dur], [0, 1], { ...clamp, easing: Easing.in(Easing.cubic) });
22
+ const cam = interpolate(t, [0, dur], [1, 1.035], clamp);
23
+ return (
24
+ <AbsoluteFill
25
+ style={{
26
+ opacity: Math.min(1, s * 1.4) * (1 - out),
27
+ transform: `translateY(${(1 - s) * 70 - out * 60}px) scale(${(0.94 + 0.06 * s) * cam})`,
28
+ }}
29
+ >
30
+ {children}
31
+ </AbsoluteFill>
32
+ );
33
+ };
34
+
35
+ export const Backdrop: React.FC = () => {
36
+ const c = useTheme();
37
+ return (
38
+ <AbsoluteFill
39
+ style={{
40
+ background: c.bg,
41
+ backgroundImage: `linear-gradient(${c.grid} 2px, transparent 2px), linear-gradient(90deg, ${c.grid} 2px, transparent 2px)`,
42
+ backgroundSize: "72px 72px",
43
+ }}
44
+ >
45
+ <AbsoluteFill style={{ background: `radial-gradient(ellipse at 50% 45%, ${c.glow}, rgba(0,0,0,0) 60%, ${c.vignette})` }} />
46
+ </AbsoluteFill>
47
+ );
48
+ };
49
+
50
+ // Título serifado em duas linhas: a primeira na cor do texto, a segunda no destaque.
51
+ export const Headline: React.FC<{ a: string; b: string; top?: number; delay?: number; size?: number }> = ({
52
+ a, b, top = 300, delay = 0, size = 104,
53
+ }) => {
54
+ const c = useTheme();
55
+ const frame = useCurrentFrame();
56
+ const s1 = pop(frame, delay);
57
+ const s2 = pop(frame, delay + 0.18);
58
+ const base = { fontFamily: c.serif, fontWeight: 700, fontSize: size, lineHeight: 1.0, letterSpacing: -2 } as const;
59
+ return (
60
+ <div style={{ position: "absolute", top, left: 0, right: 0, textAlign: "center" }}>
61
+ <div style={{ ...base, color: c.ink, opacity: s1, transform: `translateY(${(1 - s1) * 30}px)` }}>{a}</div>
62
+ <div style={{ ...base, color: c.accent, opacity: s2, transform: `translateY(${(1 - s2) * 30}px)` }}>{b}</div>
63
+ </div>
64
+ );
65
+ };
66
+
67
+ // Raios que "acendem" abaixo de uma pílula (o agente trabalhando). grow: 0..1.
68
+ export const Rays: React.FC<{ grow: number; y: number; size?: number }> = ({ grow, y, size = 300 }) => {
69
+ const c = useTheme();
70
+ const frame = useCurrentFrame();
71
+ const n = 11;
72
+ return (
73
+ <div style={{ position: "absolute", left: CX - size, top: y, width: size * 2, height: size, overflow: "hidden" }}>
74
+ {Array.from({ length: n }, (_, i) => {
75
+ const angle = -80 + (160 / (n - 1)) * i;
76
+ const wobble = Math.sin(frame / 9 + i) * 0.06;
77
+ const len = size * (0.62 + (0.3 * ((i * 7) % 5)) / 4) * grow * (1 + wobble);
78
+ return (
79
+ <div
80
+ key={i}
81
+ style={{
82
+ position: "absolute", left: size - 17, top: 0, width: 34, height: len, borderRadius: 17,
83
+ background: `linear-gradient(${c.accentWarm}, ${c.accentLight})`,
84
+ transformOrigin: "50% 0%", transform: `rotate(${angle}deg)`,
85
+ boxShadow: `0 10px 18px ${c.accentShadow}`,
86
+ }}
87
+ />
88
+ );
89
+ })}
90
+ </div>
91
+ );
92
+ };
93
+
94
+ // Anel girando: objeto-símbolo de abertura.
95
+ export const Ring: React.FC<{ y?: number; size?: number }> = ({ y = 760, size = 420 }) => {
96
+ const c = useTheme();
97
+ const frame = useCurrentFrame();
98
+ const s = pop(frame, 0.1);
99
+ return (
100
+ <div style={{ position: "absolute", left: CX - size / 2, top: y, width: size, height: size, transform: `scale(${s}) rotate(${frame * 0.8}deg)` }}>
101
+ <div style={{ position: "absolute", inset: 0, borderRadius: "50%", border: `${size * 0.186}px solid ${c.accent}`, borderRightColor: "transparent", boxShadow: `0 50px 80px ${c.accentShadow}` }} />
102
+ </div>
103
+ );
104
+ };
105
+
106
+ // Pílula escura com texto digitado (input de prompt). Tempos em segundos da cena.
107
+ export const Pill: React.FC<{
108
+ text: string; typeFrom: number; typeDur: number; y: number; w?: number; icon?: string; serif?: boolean;
109
+ }> = ({ text, typeFrom, typeDur, y, w = 820, icon = "✦", serif = true }) => {
110
+ const c = useTheme();
111
+ const t = useSeconds();
112
+ const n = Math.round(interpolate(t, [typeFrom, typeFrom + typeDur], [0, text.length], clamp));
113
+ const caret = n < text.length && Math.floor(t * 3) % 2 === 0;
114
+ return (
115
+ <div style={{ position: "absolute", left: CX - w / 2, top: y, width: w, height: 150, borderRadius: 40, background: `linear-gradient(${c.cardHi}, ${c.card})`, boxShadow: c.shadow, display: "flex", alignItems: "center", padding: "0 44px", gap: 28 }}>
116
+ <div style={{ width: 62, height: 62, borderRadius: 18, background: c.accent, color: c.onAccent, display: "flex", alignItems: "center", justifyContent: "center", fontSize: 34 }}>{icon}</div>
117
+ <div style={{ fontFamily: serif ? c.serif : c.sans, fontWeight: 600, fontSize: 56, color: c.line, whiteSpace: "nowrap" }}>
118
+ {text.slice(0, n)}
119
+ <span style={{ color: c.accentLight, opacity: caret ? 1 : 0 }}>|</span>
120
+ </div>
121
+ </div>
122
+ );
123
+ };
124
+
125
+ // Chip pequeno, centrado em (x, y). Entra com mola em `delay` (s).
126
+ export const Chip: React.FC<{
127
+ label: string; x: number; y: number; delay: number; accent?: boolean; big?: boolean; icon?: string;
128
+ }> = ({ label, x, y, delay, accent, big, icon = "▤" }) => {
129
+ const c = useTheme();
130
+ const s = pop(useCurrentFrame(), delay);
131
+ return (
132
+ <div style={{ position: "absolute", left: x, top: y, transform: `translate(-50%,-50%) scale(${s})`, opacity: s > 0.02 ? 1 : 0, background: accent ? c.accent : `linear-gradient(${c.cardHi}, ${c.card})`, color: accent ? c.onAccent : c.line, borderRadius: 26, padding: big ? "26px 40px" : "20px 32px", boxShadow: c.shadow, display: "flex", alignItems: "center", gap: 18, fontFamily: c.serif, fontWeight: 600, fontSize: big ? 52 : 44, whiteSpace: "nowrap" }}>
133
+ {icon ? <span style={{ fontFamily: c.sans, fontSize: big ? 34 : 28, opacity: 0.8 }}>{icon}</span> : null}
134
+ {label}
135
+ </div>
136
+ );
137
+ };
138
+
139
+ // Conector tracejado que se desenha de (x1,y1) a (x2,y2) a partir de `from` (s).
140
+ export const Dash: React.FC<{ x1: number; y1: number; x2: number; y2: number; from: number; dur?: number }> = ({
141
+ x1, y1, x2, y2, from, dur = 0.45,
142
+ }) => {
143
+ const c = useTheme();
144
+ const t = useSeconds();
145
+ const id = `dash${useId().replace(/[^a-zA-Z0-9]/g, "")}`;
146
+ const p = interpolate(t, [from, from + dur], [0, 1], { ...clamp, easing: ease });
147
+ const len = Math.hypot(x2 - x1, y2 - y1);
148
+ const d = `M ${x1} ${y1} C ${x1} ${(y1 + y2) / 2}, ${(x1 + x2) / 2} ${y2}, ${x2} ${y2}`;
149
+ return (
150
+ <svg style={{ position: "absolute", inset: 0 }} width={W} height={H}>
151
+ <defs>
152
+ <mask id={id}>
153
+ <path d={d} stroke="#fff" strokeWidth={10} fill="none" strokeDasharray={len * 1.5} strokeDashoffset={len * 1.5 * (1 - p)} />
154
+ </mask>
155
+ </defs>
156
+ <path d={d} stroke={c.dash} strokeWidth={4} fill="none" strokeDasharray="10 12" mask={`url(#${id})`} />
157
+ </svg>
158
+ );
159
+ };
160
+
161
+ const resolveSrc = (src: string): string => (/^(https?:|data:)/.test(src) ? src : staticFile(src));
162
+
163
+ // Celular-card com screenshot em proporção real. `ratio` = altura/largura da imagem
164
+ // (iPhone 1206x2622 = 2.174). Sem `src`, desenha uma tela de exemplo (o molde roda sem assets).
165
+ export const PhoneCard: React.FC<{
166
+ src?: string; y?: number; scale?: number; focusY?: number; maxH?: number; ratio?: number;
167
+ }> = ({ src, y = 250, scale = 1, focusY = 0, maxH = 1180, ratio = 2622 / 1206 }) => {
168
+ const c = useTheme();
169
+ const w = 600 * scale;
170
+ const h = w * ratio;
171
+ const viewH = Math.min(h, maxH);
172
+ return (
173
+ <div style={{ position: "absolute", left: CX - w / 2 - 16, top: y, width: w + 32, height: viewH + 32, borderRadius: 74 * scale, background: c.card, padding: 16, boxShadow: c.shadow }}>
174
+ <div style={{ width: w, height: viewH, borderRadius: 60 * scale, overflow: "hidden", background: c.bg }}>
175
+ {src ? (
176
+ <Img src={resolveSrc(src)} style={{ width: w, height: h, transform: `translateY(${-focusY * scale}px)` }} />
177
+ ) : (
178
+ <div style={{ padding: `${60 * scale}px ${40 * scale}px`, display: "flex", flexDirection: "column", gap: 28 * scale }}>
179
+ <div style={{ height: 70 * scale, borderRadius: 20 * scale, background: c.accent }} />
180
+ {Array.from({ length: 6 }, (_, i) => (
181
+ <div key={i} style={{ height: 120 * scale, borderRadius: 24 * scale, background: c.line, opacity: 1 - i * 0.1 }} />
182
+ ))}
183
+ </div>
184
+ )}
185
+ </div>
186
+ </div>
187
+ );
188
+ };
189
+
190
+ // Card com número que sobe até `to` a partir de `delay` (s).
191
+ export const Counter: React.FC<{ to: number; label: string; y: number; delay: number }> = ({ to, label, y, delay }) => {
192
+ const c = useTheme();
193
+ const frame = useCurrentFrame();
194
+ const s = pop(frame, delay);
195
+ const v = Math.round(interpolate(frame / FPS, [delay, delay + 1.0], [0, to], { ...clamp, easing: Easing.out(Easing.cubic) }));
196
+ return (
197
+ <div style={{ position: "absolute", left: 140, right: 140, top: y, height: 330, borderRadius: 44, background: `linear-gradient(${c.cardHi}, ${c.card})`, boxShadow: c.shadow, transform: `scale(${s})`, opacity: s > 0.02 ? 1 : 0, display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center" }}>
198
+ <div style={{ fontFamily: c.serif, fontWeight: 700, fontSize: 180, color: c.accentLight, lineHeight: 1 }}>{v}</div>
199
+ <div style={{ fontFamily: c.serif, fontWeight: 600, fontSize: 52, color: c.line, marginTop: 6 }}>{label}</div>
200
+ </div>
201
+ );
202
+ };
203
+
204
+ export type DocPage = { lines: string[]; at: number; mono?: boolean }; // `at` em s da cena
205
+ const REVIEW_SPOTS = [
206
+ { x: 175, y: 560 }, { x: 905, y: 700 }, { x: 175, y: 1020 }, { x: 905, y: 1160 },
207
+ ] as const;
208
+
209
+ // Card alto escuro (documento). Cada página troca o conteúdo e digita as linhas;
210
+ // `reviewers` pendura até 4 chips conectados por tracejado ao redor do card.
211
+ export const DocCard: React.FC<{
212
+ pages: DocPage[]; title: string; altTitle?: string; reviewers?: { labels: string[]; at: number };
213
+ }> = ({ pages, title, altTitle, reviewers }) => {
214
+ const c = useTheme();
215
+ const t = useSeconds();
216
+ const page = [...pages].reverse().find((p) => t >= p.at) ?? pages[0];
217
+ const isAlt = page !== pages[0];
218
+ const shown = page.lines.slice(0, Math.round(interpolate(t, [page.at + 0.2, page.at + 1.2], [0, page.lines.length], clamp)));
219
+ return (
220
+ <>
221
+ <div style={{ position: "absolute", left: 220, top: 380, width: 640, height: 980, borderRadius: 48, background: `linear-gradient(${c.cardHi}, ${c.card})`, boxShadow: c.shadow, padding: "130px 48px 0", overflow: "hidden" }}>
222
+ {shown.map((l, i) => (
223
+ <div key={`${page.at}-${i}`} style={{ fontFamily: page.mono ? c.mono : c.sans, fontSize: 25, lineHeight: page.mono ? "46px" : "42px", color: i === 0 && !page.mono ? c.accentLight : c.line, whiteSpace: "nowrap", overflow: "hidden", opacity: 0.92 }}>
224
+ {l}
225
+ </div>
226
+ ))}
227
+ </div>
228
+ <Chip label={isAlt && altTitle ? altTitle : title} x={CX} y={380} delay={0.05} accent={isAlt} />
229
+ {(reviewers?.labels ?? []).slice(0, 4).map((label, i) => {
230
+ const spot = REVIEW_SPOTS[i];
231
+ const from = (reviewers?.at ?? 0) + i * 0.22;
232
+ return (
233
+ <React.Fragment key={label}>
234
+ <Dash x1={spot.x < CX ? 230 : 850} y1={spot.y} x2={spot.x} y2={spot.y} from={from} dur={0.2} />
235
+ <Chip label={label} x={spot.x} y={spot.y} delay={from} accent />
236
+ </React.Fragment>
237
+ );
238
+ })}
239
+ </>
240
+ );
241
+ };