primocode 9.9.0 → 10.0.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 (63) hide show
  1. package/README.md +30 -6
  2. package/bin/primocode.js +100 -341
  3. package/lib/act.js +245 -23
  4. package/lib/browser.js +17 -0
  5. package/lib/casar.js +108 -0
  6. package/lib/catalogo.js +6 -2
  7. package/lib/config.js +10 -3
  8. package/lib/cursor.js +10 -1
  9. package/lib/entrada.js +4 -2
  10. package/lib/estudio/diretor.js +9 -1
  11. package/lib/estudio/ende.js +2 -0
  12. package/lib/estudio/estilo.js +51 -0
  13. package/lib/estudio/glifos.js +215 -0
  14. package/lib/estudio/index.js +63 -11
  15. package/lib/estudio/kit/base.jsx +285 -0
  16. package/lib/estudio/kit/index.jsx +11 -0
  17. package/lib/estudio/kit/marca.jsx +741 -0
  18. package/lib/estudio/kit/vox.jsx +357 -0
  19. package/lib/estudio/kit.js +72 -0
  20. package/lib/estudio/lancamento.js +413 -0
  21. package/lib/estudio/morfar.js +191 -0
  22. package/lib/estudio/motion.js +333 -0
  23. package/lib/estudio/plano.js +54 -7
  24. package/lib/estudio/remotion.js +167 -52
  25. package/lib/estudio/ritmo.js +77 -0
  26. package/lib/estudio/sons-leves.js +62 -0
  27. package/lib/estudio/vox.js +385 -0
  28. package/lib/fala.js +1 -94
  29. package/lib/pipeline.js +55 -56
  30. package/lib/repeticao.js +3 -1
  31. package/lib/skills.js +8 -2
  32. package/lib/snapshot.js +111 -0
  33. package/lib/tools.js +79 -20
  34. package/lib/ui.js +13 -6
  35. package/lib/video/analise.js +89 -0
  36. package/lib/video/cli.js +74 -0
  37. package/lib/video/delta.js +90 -0
  38. package/lib/video/edits.js +379 -0
  39. package/lib/video/filters.js +247 -0
  40. package/lib/video/ops.js +322 -0
  41. package/lib/video/projeto.js +133 -0
  42. package/lib/video/render.js +221 -0
  43. package/lib/video/texto.js +142 -0
  44. package/lib/video/timeline.js +310 -0
  45. package/lib/video/transcript.js +124 -0
  46. package/lib/voz.js +75 -223
  47. package/package.json +2 -3
  48. package/skills/motion-de-primeira/SKILL.md +21 -1
  49. package/studio/texto.py +208 -0
  50. package/voz/primo_voz/__main__.py +96 -236
  51. package/voz/primo_voz/transcrever.py +31 -1
  52. package/app/ansi.js +0 -55
  53. package/app/app.css +0 -500
  54. package/app/app.js +0 -1017
  55. package/app/icone.svg +0 -6
  56. package/app/index.html +0 -220
  57. package/app/primo-marca.js +0 -192
  58. package/lib/app.js +0 -615
  59. package/lib/icone.js +0 -263
  60. package/lib/instalar-app.js +0 -231
  61. package/lib/painel.js +0 -291
  62. package/voz/primo_voz/desperta.py +0 -195
  63. package/voz/primo_voz/orbe.py +0 -406
@@ -0,0 +1,333 @@
1
+ /**
2
+ * motion.js — o roteiro vira UM plano contínuo, em 3D. Não slide.
3
+ *
4
+ * "Isso aí tá muito bom. Eu quero agora que os motion saiam desse estilo,
5
+ * as animações assim, motion assim mesmo. Isso sim é motion." — Joel,
6
+ * depois do exemplo da bolinha (28/09/2026)
7
+ *
8
+ * A composição (composicao.js) faz uma cena por parte: título, corta, lista,
9
+ * corta. Isso é slide, por mais que cada cena se mexa. Aqui existe um
10
+ * PROTAGONISTA — uma forma na cor da marca — que vive o vídeo inteiro e se
11
+ * deforma no que cada parte pede:
12
+ *
13
+ * abertura esfera que chega do fundo do espaço
14
+ * titulo pílula que carrega o título
15
+ * numero disco com o número contando
16
+ * lista pílula do título, e os itens saem dela em leque 3D
17
+ * passos idem, numerados
18
+ * contraste o "não" riscado cai, o "sim" sobe no lugar
19
+ * citacao cartão com a frase
20
+ * imagem moldura, e a foto aparece dentro dela
21
+ * cta o botão; o cursor entra girando e clica
22
+ *
23
+ * Uma cena só, 60 quadros por segundo. Cada parte entra antes de a anterior
24
+ * sair, a câmera orbita o mundo o tempo todo, partículas em três
25
+ * profundidades dão o espaço, e o protagonista tem espessura (uma cópia mais
26
+ * escura atrás). É a mesma gramática do exemplo docs/exemplos/motion-bolinha.
27
+ *
28
+ * Puro: recebe tempos, narração, trilha e sons já prontos; devolve o plano.
29
+ */
30
+
31
+ 'use strict';
32
+
33
+ const { caber, falaDe } = require('./composicao.js');
34
+ const { misturarCor, ehCor } = require('./morfar.js');
35
+ const { volumesDaTrilha } = require('./ritmo.js');
36
+
37
+ const k = (t, v, e) => (e ? { t, v, e } : { t, v });
38
+ const r2 = (n) => Math.round(n * 100) / 100;
39
+ const FPS = 60;
40
+
41
+ // ── o protagonista: caixa de 1400 px, formas em 0..100 ───────────────────
42
+ const CAIXA = 1400;
43
+ const U = CAIXA / 100; // px por unidade
44
+ const KAPPA = 0.5523;
45
+ /** Retângulo de cantos redondos em unidades (é também círculo e pílula). */
46
+ function forma(w, h, r = Math.min(w, h) / 2) {
47
+ const x0 = 50 - w / 2, y0 = 50 - h / 2, x1 = 50 + w / 2, y1 = 50 + h / 2;
48
+ const rr = Math.min(r, w / 2, h / 2), c = rr * KAPPA;
49
+ const f = (n) => r2(n);
50
+ return [
51
+ `M ${f(x0 + rr)} ${f(y0)}`, `L ${f(x1 - rr)} ${f(y0)}`,
52
+ `C ${f(x1 - rr + c)} ${f(y0)} ${f(x1)} ${f(y0 + rr - c)} ${f(x1)} ${f(y0 + rr)}`,
53
+ `L ${f(x1)} ${f(y1 - rr)}`,
54
+ `C ${f(x1)} ${f(y1 - rr + c)} ${f(x1 - rr + c)} ${f(y1)} ${f(x1 - rr)} ${f(y1)}`,
55
+ `L ${f(x0 + rr)} ${f(y1)}`,
56
+ `C ${f(x0 + rr - c)} ${f(y1)} ${f(x0)} ${f(y1 - rr + c)} ${f(x0)} ${f(y1 - rr)}`,
57
+ `L ${f(x0)} ${f(y0 + rr)}`,
58
+ `C ${f(x0)} ${f(y0 + rr - c)} ${f(x0 + rr - c)} ${f(y0)} ${f(x0 + rr)} ${f(y0)}`, 'Z',
59
+ ].join(' ');
60
+ }
61
+ const px = (n) => n / U; // px → unidades da caixa
62
+
63
+ const numeroDe = (v) => {
64
+ const m = String(v == null ? '' : v).match(/^(\D*)([\d.,]+)(.*)$/);
65
+ if (!m) return null;
66
+ const n = Number(m[2].replace(/\./g, '').replace(',', '.'));
67
+ return Number.isFinite(n) ? { antes: m[1], ate: n, depois: m[3], casas: /,\d/.test(m[2]) ? 1 : 0 } : null;
68
+ };
69
+ const itensDe = (p) => (p.itens || []).map((i) => (typeof i === 'string' ? i : i.texto || i.titulo || '')).filter(Boolean);
70
+
71
+ /**
72
+ * @param {object} op
73
+ * @param {object} op.roteiro { marca, partes: [{tipo, titulo, sub, itens, valor, rotulo, sim, nao, link, autor, src}] }
74
+ * @param {Array} op.tempos [{de, ate, palavras}] por parte (da narração); sem ela, 2,4 s por parte
75
+ * @param {string} op.narracao o mp3 da narração inteira
76
+ * @param {object} op.sons { sopro, bolha, clique, brilho, subida, toque } (sons-leves.js)
77
+ * @param {string} op.trilha a música
78
+ * @param {object} op.imagens { indiceDaParte: {src, tipo} }
79
+ */
80
+ function montarMotion({ roteiro, largura = 1920, altura = 1080, tempos = [], narracao = null, sons = {}, trilha = null, imagens = {} }) {
81
+ const partes = (roteiro && roteiro.partes) || [];
82
+ if (!partes.length) return { ok: false, error: 'o roteiro veio sem partes.' };
83
+ const W = largura, H = altura, deitado = W >= H;
84
+ const base = Math.min(W, H) / 1080;
85
+
86
+ // ── a marca ──────────────────────────────────────────────────────────
87
+ const marca = roteiro.marca || {};
88
+ const cores = marca.cores || {};
89
+ const DEST = ehCor(cores.destaque) ? cores.destaque : (marca.gradiente && ehCor(marca.gradiente[1]) ? marca.gradiente[1] : '#7c5cff');
90
+ const DEST2 = marca.gradiente && ehCor(marca.gradiente[0]) ? marca.gradiente[0] : misturarCor(DEST, '#ffffff', 0.35);
91
+ const FUNDO = ehCor(cores.fundo) ? cores.fundo : '#0a0a12';
92
+ const TINTA = ehCor(cores.tinta) ? cores.tinta : '#ffffff';
93
+ const F = { titulo: (marca.fontes && marca.fontes.titulo) || 'Plus Jakarta Sans', texto: (marca.fontes && marca.fontes.texto) || 'Plus Jakarta Sans' };
94
+ const LUZ = { tipo: 'gradiente', radial: true, centro: [0.32, 0.26], raio: 0.9, cores: [misturarCor(DEST, '#ffffff', 0.55), DEST, misturarCor(DEST, '#000000', 0.45)] };
95
+ const LADO = { tipo: 'gradiente', radial: true, centro: [0.32, 0.26], raio: 0.9, cores: [misturarCor(DEST, '#000000', 0.35), misturarCor(DEST, '#000000', 0.6)] };
96
+
97
+ // ── o tempo de cada parte (a narração manda) ────────────────────────
98
+ const temVoz = tempos.length === partes.length && tempos.every((t) => t && t.ate > t.de);
99
+ const ini = partes.map((_, i) => (temVoz ? (i === 0 ? 0 : Math.max(0, tempos[i].de - 0.25)) : i * 2.4));
100
+ const DUR = r2(temVoz ? tempos[tempos.length - 1].ate + 1.3 : partes.length * 2.4 + 1.0);
101
+ const fim = partes.map((_, i) => (i < partes.length - 1 ? ini[i + 1] : DUR));
102
+
103
+ const elementos = [];
104
+ const efeitos = [];
105
+ const som = (t, nome, volume) => { if (sons[nome]) efeitos.push({ t: r2(Math.max(0, t)), src: sons[nome], volume }); };
106
+
107
+ // Trilhas do protagonista, construídas parte a parte.
108
+ const trilhaForma = [], trilhaX = [], trilhaY = [], trilhaRot = [], trilhaEsc = [];
109
+
110
+ // Uma peça de conteúdo: entra do 3D (z, giro em X), sai recuando e desfocando.
111
+ const peca = (el, de, ate, { atraso = 0 } = {}) => {
112
+ const d0 = r2(de + atraso), vida = r2(Math.max(0.5, ate - d0));
113
+ const a = el.anima || {};
114
+ return {
115
+ ...el, de: d0, ate: r2(d0 + vida),
116
+ anima: {
117
+ ...a,
118
+ opacidade: [k(0, 0), k(0.3, 1, 'saida'), k(Math.max(0.35, vida - 0.3), 1), k(vida, 0, 'suave')],
119
+ // 70 px à frente do protagonista: quando ele gira, a lateral
120
+ // dele avança em z e, mais perto que isso, cobria o texto.
121
+ z: [k(0, 260), k(0.5, 70, 'saida'), k(Math.max(0.55, vida - 0.3), 70), k(vida, -260, 'suave')],
122
+ rotacaoX: a.rotacaoX || [k(0, 28), k(0.5, 0, 'saida')],
123
+ desfoque: [k(Math.max(0.35, vida - 0.3), 0), k(vida, 6, 'suave')],
124
+ },
125
+ };
126
+ };
127
+ // Texto que se revela palavra a palavra.
128
+ const texto = (t, x, y, { tamanho, peso = 800, cor = TINTA, fonte = F.titulo, alinhamento = 'center', largura: lg, ...resto }) => ({
129
+ tipo: 'texto', texto: t, x, y, ancora: 'centro', tamanho: Math.round(tamanho), peso, cor, fonte, alinhamento,
130
+ largura: lg, entrelinha: 1.08, revelar: String(t).split(' ').map((_, i) => r2(0.08 + i * 0.07)), ...resto,
131
+ });
132
+ const caixaTexto = (t, maxLarg, linhas, max) => {
133
+ const fit = caber(t, { largura: maxLarg, linhas, max: max * base, min: 22 * base, fonte: F.titulo });
134
+ const chars = String(t).length / Math.max(1, fit.linhas);
135
+ const larg = Math.min(maxLarg, chars * fit.tamanho * 0.56);
136
+ return { ...fit, larg, alt: fit.linhas * fit.tamanho * 1.1 };
137
+ };
138
+
139
+ const CX = W / 2, CY = H / 2;
140
+ let lado = 0;
141
+ partes.forEach((p, i) => {
142
+ const t0 = ini[i], t1 = fim[i];
143
+ const d = t1 - t0;
144
+ const oscila = (lado++ % 2 === 0 ? -1 : 1);
145
+ // O protagonista VIAJA: cada parte fica num lugar diferente da tela.
146
+ const ox = CX + oscila * (deitado ? 200 : 40) * base, oy = CY + (i % 3 - 1) * 60 * base;
147
+ const titulo = p.titulo || p.texto || '';
148
+ let fx, fy, fr, x = ox, y = oy; // forma do protagonista (unidades) e onde ele fica
149
+ // A saída da parte vai até 0,45 s depois do fim, e a primeira peça da
150
+ // próxima entra em 0,2 s: as duas se cruzam no ar.
151
+ const add = (el, atraso) => elementos.push(peca(el, t0, t1 + 0.45, { atraso }));
152
+
153
+ // Imagem sem mídia vira título: moldura vazia é pior que nenhuma.
154
+ const semMidia = p.tipo === 'imagem' && !(imagens[i] || p.src);
155
+ switch (semMidia ? 'titulo' : p.tipo) {
156
+ case 'abertura': {
157
+ fx = fy = px(170 * base); fr = fx / 2; y = oy - 90 * base;
158
+ const tt = caixaTexto(titulo, W * 0.8, 2, 120);
159
+ add(texto(titulo, CX, y + 230 * base, { tamanho: tt.tamanho, largura: W * 0.84 }), 0.35);
160
+ if (p.sub) add(texto(p.sub, CX, y + 230 * base + tt.alt / 2 + 70 * base, { tamanho: 40 * base, peso: 500, fonte: F.texto, cor: misturarCor(TINTA, FUNDO, 0.3) }), 0.6);
161
+ break;
162
+ }
163
+ case 'numero': {
164
+ const r = 230 * base;
165
+ fx = fy = px(r * 2); fr = fx / 2;
166
+ const n = numeroDe(p.valor);
167
+ const v = String(p.valor || '');
168
+ add({ ...texto(v, x, y, { tamanho: Math.min(170, 900 / Math.max(2, v.length)) * base }), revelar: undefined,
169
+ ...(n ? { contar: { ate: n.ate, antes: n.antes, depois: n.depois, casas: n.casas, dur: Math.min(1.4, d * 0.5) } } : {}) }, 0.2);
170
+ if (p.rotulo || titulo) add(texto(p.rotulo || titulo, x, y + r + 80 * base, { tamanho: 48 * base, peso: 600, fonte: F.texto }), 0.5);
171
+ som(t0 + 0.2, 'brilho', 0.3);
172
+ break;
173
+ }
174
+ case 'lista': case 'passos': case 'grafico': {
175
+ const itens = itensDe(p).slice(0, 6);
176
+ const tt = caixaTexto(titulo || ' ', W * 0.62, 1, 76);
177
+ fx = px(tt.larg + 120 * base); fy = px(tt.alt + 70 * base); fr = fy / 2;
178
+ y = CY - (itens.length > 3 ? 250 : 190) * base;
179
+ if (titulo) add(texto(titulo, x, y, { tamanho: tt.tamanho, largura: tt.larg + 40 }), 0.2);
180
+ const porLinha = deitado ? Math.min(3, itens.length) : 1;
181
+ const cw = deitado ? Math.min(520, (W * 0.84) / porLinha - 30) * 1 : W * 0.8;
182
+ const ch = 150 * base;
183
+ itens.forEach((it, j) => {
184
+ const col = j % porLinha, lin = Math.floor(j / porLinha);
185
+ const n = Math.min(porLinha, itens.length - lin * porLinha);
186
+ const cx = CX + (col - (n - 1) / 2) * (cw + 30), cy = y + 190 * base + lin * (ch + 26 * base) + ch / 2;
187
+ const fit = caber(it, { largura: cw - 70, linhas: 2, max: 40 * base, min: 20 * base, fonte: F.texto });
188
+ const at = r2(0.55 + j * Math.min(0.22, (d - 1.2) / Math.max(1, itens.length)));
189
+ add({
190
+ tipo: 'grupo', x: cx, y: cy, largura: cw, altura: ch, ancora: 'centro', raio: 30,
191
+ fundo: misturarCor(FUNDO, '#ffffff', 0.08), contorno: `1px solid ${misturarCor(FUNDO, '#ffffff', 0.18)}`,
192
+ sombra: '0 24px 50px rgba(0,0,0,0.35)',
193
+ // Sai de dentro do protagonista e gira no eixo Y até o lugar.
194
+ anima: { x: [k(0, x - cx), k(0.55, 0, 'saida')], y: [k(0, y - cy), k(0.55, 0, 'saida')], rotacaoY: [k(0, (col - 1) * 55 || 40), k(0.6, 0, 'saida')],
195
+ escala: [k(0, 0.4), k(0.55, 1, 'saida')] },
196
+ elementos: [
197
+ ...(p.tipo === 'passos' ? [{ tipo: 'texto', texto: String(j + 1), x: 46, y: ch / 2, ancora: 'centro', tamanho: 52 * base, peso: 800, fonte: F.titulo, cor: DEST }] : []),
198
+ { tipo: 'texto', texto: it, x: p.tipo === 'passos' ? 88 : cw / 2, y: ch / 2, ancora: p.tipo === 'passos' ? 'centro-y' : 'centro',
199
+ largura: cw - (p.tipo === 'passos' ? 120 : 70), tamanho: fit.tamanho, peso: 600, fonte: F.texto, cor: TINTA,
200
+ alinhamento: p.tipo === 'passos' ? 'left' : 'center', entrelinha: 1.12 },
201
+ ],
202
+ }, at);
203
+ som(t0 + at, 'bolha', 0.22);
204
+ });
205
+ break;
206
+ }
207
+ case 'contraste': {
208
+ const a = p.nao || '', b = p.sim || titulo;
209
+ const ta = caixaTexto(a || ' ', W * 0.7, 2, 90), tb = caixaTexto(b || ' ', W * 0.7, 2, 96);
210
+ fx = px(Math.max(ta.larg, tb.larg) + 150 * base); fy = px(Math.max(ta.alt, tb.alt) + 80 * base); fr = Math.min(fy / 2, px(60));
211
+ const meio = r2(d * 0.45);
212
+ if (a) elementos.push(peca(texto(a, x, y, { tamanho: ta.tamanho, riscado: true, opacidade: 0.75, largura: ta.larg + 60 }), t0, t0 + meio + 0.3, { atraso: 0.2 }));
213
+ if (b) elementos.push(peca(texto(b, x, y, { tamanho: tb.tamanho, largura: tb.larg + 60 }), t0 + meio, t1 + 0.45, { atraso: 0.1 }));
214
+ som(t0 + meio, 'sopro', 0.25);
215
+ break;
216
+ }
217
+ case 'citacao': {
218
+ const tt = caixaTexto(`“${titulo}”`, W * 0.66, 3, 70);
219
+ fx = px(tt.larg + 180 * base); fy = px(tt.alt + 150 * base); fr = px(56);
220
+ add(texto(`“${titulo}”`, x, y - (p.autor ? 22 : 0), { tamanho: tt.tamanho, italico: true, peso: 600, largura: tt.larg + 60 }), 0.2);
221
+ if (p.autor) add(texto(`— ${p.autor}`, x, y + tt.alt / 2 + 20 * base, { tamanho: 34 * base, peso: 500, fonte: F.texto, cor: misturarCor(TINTA, DEST, 0.4) }), 0.7);
222
+ break;
223
+ }
224
+ case 'imagem': {
225
+ const iw = deitado ? W * 0.56 : W * 0.84, ih = iw * 9 / 16;
226
+ fx = px(iw); fy = px(ih); fr = px(40);
227
+ y = oy - (titulo ? 40 * base : 0);
228
+ const m = imagens[i] || (p.src ? { src: p.src, tipo: 'imagem' } : null);
229
+ if (m) {
230
+ add({ tipo: 'grupo', x, y, largura: iw, altura: ih, ancora: 'centro', raio: 40, recorte: true,
231
+ elementos: [{ tipo: m.tipo === 'video' ? 'video' : 'imagem', src: m.src, x: 0, y: 0, largura: '100%', altura: '100%', ajuste: 'cover',
232
+ ...(m.tipo === 'video' ? { volume: 0 } : {}), anima: { escala: [k(0, 1.12), k(d + 0.4, 1.0, 'suave')] } }] }, 0.2);
233
+ }
234
+ if (titulo) add(texto(titulo, x, y + ih / 2 + 70 * base, { tamanho: 50 * base, peso: 700, largura: iw }), 0.6);
235
+ break;
236
+ }
237
+ case 'cta': {
238
+ const tt = caixaTexto(titulo || ' ', W * 0.6, 1, 64);
239
+ fx = px(tt.larg + 160 * base); fy = px(tt.alt + 70 * base); fr = fy / 2;
240
+ y = oy + 40 * base;
241
+ if (marca.logo) add({ tipo: 'imagem', src: marca.logo, x, y: y - 230 * base, largura: 140 * base, altura: 140 * base, ancora: 'centro', ajuste: 'contain' }, 0.2);
242
+ if (titulo) add(texto(titulo, x, y, { tamanho: tt.tamanho, largura: tt.larg + 40 }), 0.2);
243
+ if (p.link) add(texto(p.link, x, y + fy * U / 2 + 60 * base, { tamanho: 36 * base, peso: 600, fonte: F.texto, cor: misturarCor(TINTA, DEST, 0.35) }), 0.6);
244
+ // O cursor entra girando e clica no botão.
245
+ const clique = r2(Math.min(d - 0.4, 1.5));
246
+ elementos.push({
247
+ tipo: 'forma', forma: 'traco', caminho: 'M 10 4 L 10 84 L 31 64 L 45 94 L 58 88 L 44 59 L 74 59 Z', x: 0, y: 0, largura: 56 * base, altura: 56 * base,
248
+ cor: '#ffffff', linha: '#14151c', espessura: 4, sombra: '0 10px 12px rgba(0,0,0,0.4)', origem: '10% 4%', de: r2(t0 + 0.4), ate: r2(t1),
249
+ anima: {
250
+ x: [k(0, W * 0.92), k(clique - 0.4, x + fx * U * 0.22, 'saida')], y: [k(0, H * 0.95), k(clique - 0.4, y + 6, 'saida')],
251
+ z: [k(0, 300), k(clique - 0.4, 40, 'saida')], rotacao: [k(0, 150), k(clique - 0.4, 0, 'saida')],
252
+ escala: [k(clique - 0.45, 1), k(clique - 0.4 + 0.08, 0.84, 'suave'), k(clique - 0.4 + 0.26, 1, 'suave')],
253
+ opacidade: [k(0, 0), k(0.2, 1)],
254
+ },
255
+ });
256
+ som(t0 + clique, 'clique', 0.55);
257
+ break;
258
+ }
259
+ default: { // titulo e o que mais vier
260
+ const tt = caixaTexto(titulo || ' ', W * 0.7, 2, 104);
261
+ fx = px(tt.larg + 150 * base); fy = px(tt.alt + 80 * base); fr = Math.min(fy / 2, px(90));
262
+ add(texto(titulo, x, y, { tamanho: tt.tamanho, largura: tt.larg + 60 }), 0.2);
263
+ if (p.sub) add(texto(p.sub, x, y + fy * U / 2 + 58 * base, { tamanho: 38 * base, peso: 500, fonte: F.texto, cor: misturarCor(TINTA, FUNDO, 0.3) }), 0.55);
264
+ }
265
+ }
266
+
267
+ // O protagonista: deforma no começo da parte, anda até o lugar dela.
268
+ const alvo = forma(Math.min(96, fx), Math.min(96, fy), fr);
269
+ const tIni = r2(Math.max(0, t0 - 0.05)), tFim = r2(t0 + 0.55);
270
+ if (i === 0) trilhaForma.push(k(0, alvo)); else trilhaForma.push(k(tIni, trilhaForma[trilhaForma.length - 1].v), k(tFim, alvo, 'suave'));
271
+ if (i === 0) { trilhaX.push(k(0, x)); trilhaY.push(k(0, y)); } else { trilhaX.push(k(tIni, trilhaX[trilhaX.length - 1].v), k(tFim, x, 'suave')); trilhaY.push(k(tIni, trilhaY[trilhaY.length - 1].v), k(tFim, y, 'suave')); }
272
+ trilhaRot.push(k(r2(t0 + d * 0.5), oscila * 3, 'suave'));
273
+ trilhaEsc.push(k(tIni, 1), k(r2(t0 + 0.3), 1.04, 'suave'), k(tFim, 1, 'suave'));
274
+ if (i > 0) { som(t0, 'sopro', 0.28); som(t0 + 0.05, 'subida', 0.14); }
275
+ });
276
+
277
+ // ── o protagonista, com espessura ────────────────────────────────────
278
+ const protagonista = (fundo, z, extra = {}) => ({
279
+ tipo: 'forma', forma: 'traco', caminho: trilhaForma[0].v, x: 0, y: 0, largura: CAIXA, altura: CAIXA, ancora: 'centro', fundo, ...extra,
280
+ anima: {
281
+ caminho: trilhaForma, x: trilhaX, y: trilhaY, rotacaoY: [k(0, 40), k(0.9, 0, 'saida'), ...trilhaRot.filter((q) => q.t > 0.9)],
282
+ escala: [k(0, 0.2), k(0.8, 1, 'saida'), ...trilhaEsc.filter((q) => q.t > 0.8)],
283
+ z: [k(0, -1400 + z), k(0.8, z, 'saida')],
284
+ },
285
+ });
286
+ const corpo = [protagonista(LADO, -18), protagonista(LUZ, 0, { sombra: '0 30px 40px rgba(0,0,0,0.45)' })];
287
+
288
+ // ── o espaço: partículas em três profundidades ──────────────────────
289
+ const particulas = [];
290
+ for (let i = 0; i < 40; i++) {
291
+ const z = [-1600, -900, -400][i % 3];
292
+ const s = [6, 5, 4][i % 3];
293
+ particulas.push({ tipo: 'forma', forma: 'circulo', x: -900 + (i * 541) % (W + 1800), y: -600 + (i * 313) % (H + 1200), largura: s, altura: s,
294
+ cor: i % 4 === 0 ? DEST : '#ffffff', opacidade: 0.18 + (i % 4) * 0.1,
295
+ anima: { z: [k(0, z)], y: [k(0, 0), k(DUR, -40 - (i % 3) * 30, 'linear')] } });
296
+ }
297
+
298
+ // ── a câmera orbita: um giro por parte, e nunca para ────────────────
299
+ const cam = { rotacaoY: [k(0, -14)], rotacaoX: [k(0, 7)], z: [k(0, -120)] };
300
+ partes.forEach((_, i) => {
301
+ const meio = r2((ini[i] + fim[i]) / 2);
302
+ cam.rotacaoY.push(k(meio, i % 2 ? 7 : -7, 'suave'));
303
+ cam.rotacaoX.push(k(meio, i % 2 ? -3 : 3, 'suave'));
304
+ cam.z.push(k(meio, 60, 'suave'), k(r2(fim[i]), 0, 'suave'));
305
+ });
306
+ cam.rotacaoY.push(k(DUR, 0, 'suave'));
307
+ cam.rotacaoX.push(k(DUR, 0, 'suave'));
308
+
309
+ // ── o som: a música abaixa quando a voz fala ─────────────────────────
310
+ const volumes = volumesDaTrilha(temVoz ? tempos : [], DUR);
311
+ som(0.05, 'sopro', 0.3);
312
+ som(DUR - 1.2, 'brilho', 0.35);
313
+
314
+ const plano = {
315
+ formato: { largura: W, altura: H, fps: FPS },
316
+ fundoGeral: FUNDO,
317
+ cenas: [{
318
+ duracao: DUR,
319
+ perspectiva: 1300,
320
+ fundo: { tipo: 'gradiente', radial: true, centro: '38% 42%', cores: [misturarCor(FUNDO, DEST, 0.18), FUNDO] },
321
+ camera: cam,
322
+ elementos: [...particulas, ...corpo, ...elementos],
323
+ }],
324
+ audio: {
325
+ ...(narracao ? { narracao } : {}),
326
+ ...(trilha ? { trilha: { src: trilha, volumes, loop: true } } : {}),
327
+ efeitos,
328
+ },
329
+ };
330
+ return { ok: true, plano, resumo: { cenas: 1, segundos: DUR, partes: partes.length, fps: FPS } };
331
+ }
332
+
333
+ module.exports = { montarMotion, forma, CAIXA };
@@ -47,11 +47,14 @@
47
47
 
48
48
  const fs = require('fs');
49
49
  const path = require('path');
50
+ const morfar = require('./morfar.js');
51
+ const kit = require('./kit.js');
50
52
 
51
- const TIPOS = new Set(['texto', 'forma', 'imagem', 'video', 'icone', 'legenda', 'componente']);
53
+ // "kit": uma peça pronta do kit de motion (kit.js), pelo nome — ver kit/*.jsx.
54
+ const TIPOS = new Set(['texto', 'forma', 'imagem', 'video', 'icone', 'legenda', 'componente', 'grupo', 'kit']);
52
55
  const FORMAS = new Set(['retangulo', 'circulo', 'linha', 'poligono', 'traco']);
53
56
  const AJUSTES = new Set(['cover', 'contain', 'preencher']);
54
- const TRANSICOES = new Set(['corte', 'esmaecer', 'deslizar', 'zoom', 'giro', 'mascara', 'morph', 'chicote', 'soco', 'flash']);
57
+ const TRANSICOES = new Set(['corte', 'esmaecer', 'deslizar', 'zoom', 'giro', 'mascara', 'morph', 'chicote', 'soco', 'flash', 'liquido']);
55
58
 
56
59
  /* As propriedades que a animação por quadro-chave alcança. Cada uma é um
57
60
  número no tempo; a curva entre dois quadros é a suavização declarada.
@@ -61,7 +64,11 @@ const ANIMAVEIS = new Set([
61
64
  'opacidade', 'x', 'y', 'z', 'escala', 'escalaX', 'escalaY',
62
65
  'rotacao', 'rotacaoX', 'rotacaoY', 'desfoque', 'brilho', 'saturacao',
63
66
  'corte', 'raio', 'espacamento',
67
+ // Estes dois não são número: "cor" é "#rrggbb" e "caminho" é o "d" de um
68
+ // SVG. É o que deixa a bolinha virar foguete no mesmo plano (morfar.js).
69
+ 'cor', 'caminho',
64
70
  ]);
71
+ const TEXTUAIS = new Set(['cor', 'caminho']);
65
72
 
66
73
  /* A CÂMERA tem vocabulário próprio, e é de propósito.
67
74
  *
@@ -70,9 +77,12 @@ const ANIMAVEIS = new Set([
70
77
  * agente escrever zoom de câmera dentro de um elemento — o que dá quase o
71
78
  * mesmo resultado na cena de uma coisa só, e nenhum resultado na cena com
72
79
  * cinco. Vocabulários separados tornam o erro impossível em vez de sutil. */
73
- const ANIMAVEIS_CAMERA = new Set(['zoom', 'x', 'y', 'rotacao']);
80
+ // rotacaoX/rotacaoY/z: a câmera gira em volta do mundo e avança nele (precisa de `perspectiva` na cena).
81
+ const ANIMAVEIS_CAMERA = new Set(['zoom', 'x', 'y', 'rotacao', 'rotacaoX', 'rotacaoY', 'z']);
74
82
 
75
- const SUAVIZACOES = new Set(['linear', 'suave', 'entrada', 'saida', 'elastico', 'salto']);
83
+ /* editorial (ataque rápido, freio forte), camera (bezier longa), arrasto (a
84
+ mão que desenha) e acelera (a câmera antes do corte) vêm do guia VOX. */
85
+ const SUAVIZACOES = new Set(['linear', 'suave', 'entrada', 'saida', 'elastico', 'salto', 'editorial', 'camera', 'arrasto', 'acelera']);
76
86
 
77
87
  const COR = /^(#[0-9a-f]{3,8}|rgba?\([^)]+\)|hsla?\([^)]+\)|transparent|currentColor)$/i;
78
88
 
@@ -92,7 +102,7 @@ function medida(v) {
92
102
  * maior faz o elemento voltar no tempo, e o que se vê é um tremor que
93
103
  * ninguém consegue explicar olhando o resultado.
94
104
  */
95
- function conferirTrilha(caminho, trilha, problemas) {
105
+ function conferirTrilha(caminho, trilha, problemas, prop) {
96
106
  if (!Array.isArray(trilha)) {
97
107
  problemas.push(erro(caminho, 'a animação de uma propriedade é uma lista de quadros-chave'));
98
108
  return;
@@ -104,7 +114,11 @@ function conferirTrilha(caminho, trilha, problemas) {
104
114
  if (typeof q.t !== 'number' || !Number.isFinite(q.t) || q.t < 0) {
105
115
  problemas.push(erro(onde, 't é o tempo em segundos dentro da cena, a partir de 0'));
106
116
  }
107
- if (typeof q.v !== 'number' || !Number.isFinite(q.v)) {
117
+ if (prop === 'cor') {
118
+ if (!morfar.ehCor(q.v)) problemas.push(erro(onde, 'v da cor é "#rrggbb" (ou "#rgb")'));
119
+ } else if (prop === 'caminho') {
120
+ try { morfar.pontos(q.v, 16); } catch (e) { problemas.push(erro(onde, `caminho inválido: ${e.message}`)); }
121
+ } else if (typeof q.v !== 'number' || !Number.isFinite(q.v)) {
108
122
  problemas.push(erro(onde, 'v é o valor numérico da propriedade nesse instante'));
109
123
  }
110
124
  if (q.e && !SUAVIZACOES.has(q.e)) {
@@ -126,7 +140,7 @@ function conferirAnima(caminho, anima, problemas, permitidas = ANIMAVEIS) {
126
140
  `não dá para animar "${prop}" aqui. Dá para: ${[...permitidas].join(', ')}`));
127
141
  continue;
128
142
  }
129
- conferirTrilha(`${caminho}.${prop}`, trilha, problemas);
143
+ conferirTrilha(`${caminho}.${prop}`, trilha, problemas, prop);
130
144
  }
131
145
  }
132
146
 
@@ -193,7 +207,19 @@ function conferirElemento(caminho, el, pasta, problemas, duracao) {
193
207
  if (el.forma && !FORMAS.has(el.forma)) {
194
208
  problemas.push(erro(`${caminho}.forma`, `forma "${el.forma}" não existe — ${[...FORMAS].join(', ')}`));
195
209
  }
210
+ if (el.forma === 'traco') {
211
+ const d = el.caminho || (el.anima && el.anima.caminho && el.anima.caminho[0] && el.anima.caminho[0].v);
212
+ if (!d) problemas.push(erro(`${caminho}.caminho`, 'o traço precisa de "caminho": o "d" de um SVG, na caixa 0 0 100 100'));
213
+ else { try { morfar.pontos(d, 16); } catch (e) { problemas.push(erro(`${caminho}.caminho`, e.message)); } }
214
+ }
215
+ break;
216
+ case 'grupo': {
217
+ // Os filhos vivem no tempo do grupo: o 0 deles é o "de" do grupo.
218
+ if (!Array.isArray(el.elementos)) { problemas.push(erro(`${caminho}.elementos`, 'o grupo precisa da lista de elementos dele')); break; }
219
+ const vida = el.ate != null ? Number(el.ate) - Number(el.de || 0) : duracao - Number(el.de || 0);
220
+ el.elementos.forEach((f, k) => conferirElemento(`${caminho}.elementos[${k}]`, f, pasta, problemas, vida));
196
221
  break;
222
+ }
197
223
  case 'imagem':
198
224
  case 'video':
199
225
  conferirArquivo(`${caminho}.src`, el.src, pasta, problemas);
@@ -218,6 +244,11 @@ function conferirElemento(caminho, el, pasta, problemas, duracao) {
218
244
  // A válvula de escape: um arquivo React escrito pela própria IA.
219
245
  conferirArquivo(`${caminho}.src`, el.src, pasta, problemas);
220
246
  break;
247
+ case 'kit':
248
+ for (const oque of kit.conferirPeca(el)) problemas.push(erro(`${caminho}.peca`, oque));
249
+ // A logo de uma peça é arquivo da pasta, como a imagem.
250
+ if (el.logo) conferirArquivo(`${caminho}.logo`, el.logo, pasta, problemas);
251
+ break;
221
252
  }
222
253
  }
223
254
 
@@ -276,6 +307,22 @@ function conferir(plano, { pasta = null } = {}) {
276
307
  conferirFundo(`${onde}.fundo`, cena.fundo, pasta, problemas);
277
308
  conferirAnima(`${onde}.camera`, cena.camera, problemas, ANIMAVEIS_CAMERA);
278
309
 
310
+ /* As CAMADAS de profundidade: [{p, desfoque, sujeito}]. "p" é a
311
+ profundidade (0,12 longe … 1,25 perto), e o elemento diz a dele por
312
+ índice. Índice fora da lista põe o elemento em lugar nenhum. */
313
+ const nCamadas = Array.isArray(cena.camadas) ? cena.camadas.length : 0;
314
+ if (cena.camadas != null) {
315
+ if (!nCamadas) problemas.push(erro(`${onde}.camadas`, 'camadas é uma lista de {p, desfoque, sujeito}'));
316
+ else cena.camadas.forEach((c, k) => {
317
+ if (!c || typeof c.p !== 'number' || !(c.p > 0)) problemas.push(erro(`${onde}.camadas[${k}].p`, 'p é a profundidade, um número maior que 0'));
318
+ });
319
+ }
320
+ for (const [k, el] of (Array.isArray(cena.elementos) ? cena.elementos : []).entries()) {
321
+ if (el && el.camada != null && !(Number.isInteger(el.camada) && el.camada >= 0 && el.camada < nCamadas)) {
322
+ problemas.push(erro(`${onde}.elementos[${k}].camada`, nCamadas ? `camada vai de 0 a ${nCamadas - 1}` : 'a cena não declara "camadas"'));
323
+ }
324
+ }
325
+
279
326
  if (cena.transicao) {
280
327
  const t = cena.transicao.tipo || cena.transicao;
281
328
  if (!TRANSICOES.has(t)) {