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
@@ -97,6 +97,8 @@ import {
97
97
  AbsoluteFill, Img, Video, Audio, Sequence, useCurrentFrame,
98
98
  useVideoConfig, interpolate, staticFile, continueRender, delayRender,
99
99
  } from 'remotion';
100
+ import { morfar, ehCor, misturarCor } from './morfar';
101
+ import { KIT, CURVAS_KIT, Filtros } from './kit';
100
102
 
101
103
  /* ── SUAVIZAÇÃO ──────────────────────────────────────────────────────────
102
104
  * Cada curva é uma função de 0..1 em 0..1. "suave" é o padrão porque
@@ -116,6 +118,8 @@ const CURVAS = {
116
118
  if (t < 2.5 / d) return n * (t -= 2.25 / d) * t + 0.9375;
117
119
  return n * (t -= 2.625 / d) * t + 0.984375;
118
120
  },
121
+ // editorial, camera, arrasto, acelera (kit/base.jsx): a curva certa do guia VOX.
122
+ ...CURVAS_KIT,
119
123
  };
120
124
 
121
125
  /* O valor de uma propriedade animada no segundo "t".
@@ -133,7 +137,14 @@ function valorEm(trilha, t, padrao) {
133
137
  if (t >= a.t && t <= b.t) {
134
138
  const bruto = b.t === a.t ? 1 : (t - a.t) / (b.t - a.t);
135
139
  const curva = CURVAS[b.e || a.e || 'suave'] || CURVAS.suave;
136
- return a.v + (b.v - a.v) * curva(bruto);
140
+ const k = curva(bruto);
141
+ /* Texto no lugar do número: é COR ("#ff6a00") ou CAMINHO (o "d" de um
142
+ SVG). A cor mistura; o caminho se deforma (morfar.js): a bolinha
143
+ vira foguete sem corte. */
144
+ if (typeof a.v === 'string' || typeof b.v === 'string') {
145
+ return ehCor(a.v) && ehCor(b.v) ? misturarCor(a.v, b.v, k) : morfar(a.v, b.v, k);
146
+ }
147
+ return a.v + (b.v - a.v) * k;
137
148
  }
138
149
  }
139
150
  return padrao;
@@ -153,12 +164,17 @@ function transformDe(a, base) {
153
164
  if (x != null || y != null || z != null) {
154
165
  partes.push('translate3d(' + (x || 0) + 'px,' + (y || 0) + 'px,' + (z || 0) + 'px)');
155
166
  }
156
- if (a.escala != null) partes.push('scale(' + a.escala + ')');
157
- if (a.escalaX != null) partes.push('scaleX(' + a.escalaX + ')');
158
- if (a.escalaY != null) partes.push('scaleY(' + a.escalaY + ')');
167
+ /* GIRA DEPOIS DE ENCOLHER. No CSS o último da lista age primeiro, então a
168
+ escala vai por último: o elemento encolhe e só então gira. Na ordem
169
+ inversa, uma janela de 1920 px girava no tamanho cheio e só depois
170
+ encolhia em X e Y — a profundidade não encolhe, e sobrava um risco de
171
+ 1920 px atravessando a tela. */
159
172
  if (a.rotacao != null) partes.push('rotate(' + a.rotacao + 'deg)');
160
173
  if (a.rotacaoX != null) partes.push('rotateX(' + a.rotacaoX + 'deg)');
161
174
  if (a.rotacaoY != null) partes.push('rotateY(' + a.rotacaoY + 'deg)');
175
+ if (a.escala != null) partes.push('scale(' + a.escala + ')');
176
+ if (a.escalaX != null) partes.push('scaleX(' + a.escalaX + ')');
177
+ if (a.escalaY != null) partes.push('scaleY(' + a.escalaY + ')');
162
178
  return partes.join(' ');
163
179
  }
164
180
 
@@ -204,7 +220,12 @@ function Elemento({ el, t, dados }) {
204
220
 
205
221
  const tLocal = t - (el.de || 0);
206
222
  const a = animado(el.anima, tLocal);
207
- const base = posicao(el);
223
+ /* A PEÇA DO KIT desenha nas coordenadas da cena (x, y dela são o lugar do
224
+ assunto, não o canto de uma caixa): a caixa dela é o quadro inteiro, e o
225
+ "anima" do elemento move o conjunto. */
226
+ const base = el.tipo === 'kit'
227
+ ? { position: 'absolute', left: 0, top: 0, width: '100%', height: '100%' }
228
+ : posicao(el);
208
229
  const move = transformDe(a, {});
209
230
  const estilo = {
210
231
  ...base,
@@ -222,7 +243,7 @@ function Elemento({ el, t, dados }) {
222
243
  estilo.backgroundColor = el.vidro.cor || 'rgba(255,255,255,0.12)';
223
244
  estilo.border = el.vidro.borda || '1px solid rgba(255,255,255,0.22)';
224
245
  }
225
- if (el.raio != null) estilo.borderRadius = medida(a.raio ?? el.raio);
246
+ if (el.raio != null || a.raio != null) estilo.borderRadius = medida(a.raio ?? el.raio);
226
247
  if (el.sombra) estilo.boxShadow = el.sombra;
227
248
  if (el.mascara) {
228
249
  estilo.WebkitMaskImage = el.mascara;
@@ -298,18 +319,78 @@ function Elemento({ el, t, dados }) {
298
319
 
299
320
  case 'forma': {
300
321
  const forma = el.forma || 'retangulo';
322
+ /* O TRAÇO: qualquer forma, desenhada por um caminho SVG na caixa
323
+ "vista" (0 0 100 100 por padrão). "caminho" e "cor" animam, e é
324
+ isso que deixa uma forma virar outra no mesmo plano. */
325
+ if (forma === 'traco') {
326
+ const g = el.fundo && typeof el.fundo === 'object' && el.fundo.tipo === 'gradiente' ? el.fundo : null;
327
+ const gid = 'g' + String(el.id || JSON.stringify(g || '')).replace(/[^a-z0-9]/gi, '').slice(0, 24);
328
+ const ang = ((g && g.angulo) ?? 180) * Math.PI / 180;
329
+ /* Volume: o degradê RADIAL com o centro fora do meio é a luz batendo de
330
+ um lado (a bola vira esfera), e a sombra segue o CONTORNO da forma
331
+ (drop-shadow), não a caixa dela. */
332
+ const paradas = g ? g.cores.map((c, i) => <stop key={i} offset={i / Math.max(1, g.cores.length - 1)} stopColor={c} />) : null;
333
+ const sombra = el.sombra ? 'drop-shadow(' + el.sombra + ')' : '';
334
+ return (
335
+ <div style={{ ...estilo, width: medida(el.largura, '200px'), height: medida(el.altura, '200px'),
336
+ filter: [estilo.filter, sombra].filter(Boolean).join(' ') || undefined, boxShadow: undefined }}>
337
+ <svg viewBox={el.vista || '0 0 100 100'} width="100%" height="100%" style={{ overflow: 'visible', display: 'block' }}>
338
+ {g && (
339
+ <defs>
340
+ {g.radial
341
+ ? <radialGradient id={gid} cx={(g.centro && g.centro[0]) ?? 0.35} cy={(g.centro && g.centro[1]) ?? 0.3} r={g.raio ?? 0.75}>{paradas}</radialGradient>
342
+ : <linearGradient id={gid} x1={0.5 - Math.sin(ang) / 2} y1={0.5 + Math.cos(ang) / 2} x2={0.5 + Math.sin(ang) / 2} y2={0.5 - Math.cos(ang) / 2}>{paradas}</linearGradient>}
343
+ </defs>
344
+ )}
345
+ <path d={a.caminho ?? el.caminho} fill={el.vazado ? 'none' : (g ? 'url(#' + gid + ')' : (a.cor ?? el.cor ?? '#fff'))}
346
+ stroke={el.linha || undefined} strokeWidth={el.espessura || undefined} strokeLinejoin="round" strokeLinecap="round" />
347
+ </svg>
348
+ </div>
349
+ );
350
+ }
301
351
  return (
302
352
  <div style={{
303
353
  ...estilo,
304
354
  width: medida(el.largura, '200px'),
305
355
  height: forma === 'linha' ? medida(el.espessura, '4px') : medida(el.altura, '200px'),
306
- ...(el.fundo && typeof el.fundo === 'object' ? fundoCSS(el.fundo) : { background: el.fundo || el.cor || '#fff' }),
356
+ ...(el.fundo && typeof el.fundo === 'object' ? fundoCSS(el.fundo) : { background: a.cor ?? el.fundo ?? el.cor ?? '#fff' }),
307
357
  borderRadius: forma === 'circulo' ? '50%' : medida(a.raio ?? el.raio, '0px'),
308
358
  border: el.contorno || undefined,
309
359
  }} />
310
360
  );
311
361
  }
312
362
 
363
+ /* O GRUPO: uma tela dentro da tela. Os filhos vivem no tempo e no espaço
364
+ dele; com "recorte" o que passa da borda some (a tela do celular). O
365
+ grupo anima como qualquer elemento: cresce, sai do celular, cobre tudo. */
366
+ case 'kit': {
367
+ const Peca = KIT[el.peca];
368
+ if (!Peca) return null;
369
+ return (
370
+ <div style={{ ...estilo, transformOrigin: el.origem || ((el.x || 0) + 'px ' + (el.y || 0) + 'px') }}>
371
+ <Peca el={el} t={tLocal} fps={dados.fps} />
372
+ </div>
373
+ );
374
+ }
375
+
376
+ case 'grupo':
377
+ return (
378
+ <div style={{
379
+ ...estilo,
380
+ width: medida(el.largura, '100%'), height: medida(el.altura, '100%'),
381
+ overflow: el.recorte ? 'hidden' : 'visible',
382
+ /* 3D: sem recorte, o grupo deixa os filhos no espaço da cena
383
+ (preserve-3d). Com recorte ele vira uma superfície plana, e a
384
+ "perspectiva" dele dá profundidade ao que está dentro. */
385
+ transformStyle: el.recorte ? undefined : 'preserve-3d',
386
+ perspective: el.perspectiva ? el.perspectiva + 'px' : undefined,
387
+ ...(el.fundo ? (typeof el.fundo === 'object' ? fundoCSS(el.fundo) : { background: el.fundo }) : {}),
388
+ border: el.contorno || undefined,
389
+ }}>
390
+ {(el.elementos || []).map((f, i) => <Elemento key={i} el={f} t={tLocal} dados={dados} />)}
391
+ </div>
392
+ );
393
+
313
394
  case 'imagem':
314
395
  return (
315
396
  <div style={{ ...estilo, overflow: 'hidden' }}>
@@ -445,10 +526,44 @@ export function Cena({ cena, dados, tFixo }) {
445
526
  tx || ty ? 'translate(' + tx + 'px,' + ty + 'px)' : '',
446
527
  cam.zoom != null || soco !== 1 ? 'scale(' + ((cam.zoom ?? 1) * soco) + ')' : '',
447
528
  cam.rotacao != null || tr ? 'rotate(' + ((cam.rotacao || 0) + tr) + 'deg)' : '',
529
+ /* A CÂMERA 3D: ela gira em volta do mundo (rotacaoX/rotacaoY) e avança
530
+ nele (z). O que está mais perto anda mais que o que está longe — é a
531
+ profundidade de verdade, não um zoom. */
532
+ cam.z != null ? 'translateZ(' + cam.z + 'px)' : '',
533
+ cam.rotacaoX != null ? 'rotateX(' + cam.rotacaoX + 'deg)' : '',
534
+ cam.rotacaoY != null ? 'rotateY(' + cam.rotacaoY + 'deg)' : '',
448
535
  ].filter(Boolean).join(' ');
449
536
 
537
+ /* A CÂMERA EM CAMADAS (o parallax do guia VOX): "camadas" são planos de
538
+ profundidade, e o que está longe anda menos que o que está perto. O
539
+ parallax é CONSEQUÊNCIA da câmera, não um efeito à parte: o plano do
540
+ assunto ("sujeito") recebe a câmera exata, os outros na proporção da
541
+ profundidade deles — e cada um com o desfoque da lente naquela distância.
542
+ Cada elemento diz a camada dele ("camada": índice); sem, é a do sujeito. */
543
+ const camadas = Array.isArray(cena.camadas) && cena.camadas.length ? cena.camadas : null;
544
+ const iSujeito = camadas ? Math.max(0, camadas.findIndex((c) => c.sujeito)) : 0;
545
+ const mundo = (els, f, desfoque) => (
546
+ <AbsoluteFill style={{
547
+ transform: f == null ? (camTransform || undefined) : [
548
+ 'translate(' + (-(cam.x || 0) * f + tx) + 'px,' + (-(cam.y || 0) * f + ty) + 'px)',
549
+ 'scale(' + ((1 + ((cam.zoom ?? 1) - 1) * f) * soco) + ')',
550
+ 'rotate(' + ((cam.rotacao || 0) * f + tr) + 'deg)',
551
+ ].join(' '),
552
+ transformOrigin: cena.cameraOrigem || 'center center',
553
+ // O mundo guarda o 3D dos filhos; a perspectiva vem do pai.
554
+ transformStyle: cena.perspectiva ? 'preserve-3d' : undefined,
555
+ filter: desfoque ? 'blur(' + desfoque + 'px)' : undefined,
556
+ }}>
557
+ {els.map((el, i) => <Elemento key={i} el={el} t={t} dados={dados} />)}
558
+ </AbsoluteFill>
559
+ );
560
+
450
561
  return (
451
- <AbsoluteFill style={{ ...fundoCSS(cena.fundo), overflow: 'hidden' }}>
562
+ /* A perspectiva mora AQUI, um nível acima do mundo: se ficasse no mesmo
563
+ elemento da câmera, girar a câmera sairia achatado. */
564
+ <AbsoluteFill style={{ ...fundoCSS(cena.fundo), overflow: 'hidden',
565
+ perspective: cena.perspectiva ? cena.perspectiva + 'px' : undefined }}>
566
+ <Filtros />
452
567
  {cena.fundo && cena.fundo.tipo === 'imagem' && (
453
568
  <Img src={staticFile(cena.fundo.src)} style={{
454
569
  position: 'absolute', inset: 0, width: '100%', height: '100%',
@@ -461,18 +576,13 @@ export function Cena({ cena, dados, tFixo }) {
461
576
  objectFit: cena.fundo.ajuste || 'cover',
462
577
  }} />
463
578
  )}
464
- <AbsoluteFill style={{
465
- transform: camTransform || undefined,
466
- transformOrigin: cena.cameraOrigem || 'center center',
467
- // A perspectiva é o que transforma rotateX/rotateY em 3D de verdade;
468
- // sem ela, girar em X só achata o elemento.
469
- perspective: cena.perspectiva ? cena.perspectiva + 'px' : undefined,
470
- transformStyle: cena.perspectiva ? 'preserve-3d' : undefined,
471
- }}>
472
- {(cena.elementos || []).map((el, i) => (
473
- <Elemento key={i} el={el} t={t} dados={dados} />
474
- ))}
475
- </AbsoluteFill>
579
+ {camadas
580
+ ? camadas.map((c, ci) => (
581
+ <React.Fragment key={ci}>
582
+ {mundo((cena.elementos || []).filter((el) => (el.camada ?? iSujeito) === ci), (c.p || 1) / (camadas[iSujeito].p || 1), c.desfoque)}
583
+ </React.Fragment>
584
+ ))
585
+ : mundo(cena.elementos || [], null, 0)}
476
586
  {flash > 0.01 && <AbsoluteFill style={{ backgroundColor: cena.corFlash || '#fff', opacity: flash * 0.85 }} />}
477
587
  </AbsoluteFill>
478
588
  );
@@ -482,6 +592,7 @@ export function Cena({ cena, dados, tFixo }) {
482
592
  const RAIZ_JSX = String.raw`import React from 'react';
483
593
  import { AbsoluteFill, Audio, Sequence, staticFile, useCurrentFrame, useVideoConfig, interpolate } from 'remotion';
484
594
  import { Cena } from './Cena';
595
+ import { Acabamento, Hud, bolhaCaminho } from './kit';
485
596
  import plano from './plano.json';
486
597
  import icones from './icones.json';
487
598
 
@@ -490,6 +601,7 @@ import icones from './icones.json';
490
601
  * o "piscar" de fundo que denuncia corte mal feito. */
491
602
  function Transicao({ tipo, progresso, children }) {
492
603
  const p = progresso;
604
+ const { width: W, height: H } = useVideoConfig();
493
605
  let estilo = {};
494
606
  if (tipo === 'esmaecer') estilo = { opacity: p };
495
607
  else if (tipo === 'deslizar') estilo = { transform: 'translateX(' + (1 - p) * 100 + '%)' };
@@ -502,6 +614,12 @@ function Transicao({ tipo, progresso, children }) {
502
614
  // O SOCO: a cena entra grande e bate no lugar.
503
615
  else if (tipo === 'soco') estilo = { transform: 'scale(' + (1.35 - 0.35 * p) + ')', opacity: Math.min(1, p * 3) };
504
616
  else if (tipo === 'flash') estilo = { opacity: Math.min(1, p * 2), filter: 'brightness(' + (1 + (1 - p) * 3) + ')' };
617
+ /* O LÍQUIDO: a cena nova entra como uma mancha que cresce de um canto e
618
+ engole a anterior, com a borda viva (os lóbulos respiram enquanto crescem). */
619
+ else if (tipo === 'liquido' && p < 1) {
620
+ const k = p < 0.5 ? 4 * p * p * p : 1 - Math.pow(-2 * p + 2, 3) / 2;
621
+ estilo = { clipPath: "path('" + bolhaCaminho(W * 0.78, H * 0.62, Math.hypot(W, H) * 0.95 * k, p * 3, 2) + "')" };
622
+ }
505
623
  return <AbsoluteFill style={estilo}>{children}</AbsoluteFill>;
506
624
  }
507
625
 
@@ -521,7 +639,7 @@ export const Paginas = () => {
521
639
  };
522
640
 
523
641
  export const Video = () => {
524
- const { fps } = useVideoConfig();
642
+ const { fps, width: largura, height: altura } = useVideoConfig();
525
643
  const frame = useCurrentFrame();
526
644
  const dados = { fps, icones };
527
645
 
@@ -562,31 +680,17 @@ export const Video = () => {
562
680
  );
563
681
  })}
564
682
 
565
- {/* O ACABAMENTO: granulação de filme, vinheta e a luz da marca passando.
566
- É o que tira a cara de apresentação e dá a cara de peça de 2026. */}
567
- {plano.acabamento && plano.acabamento.luz && (
568
- <AbsoluteFill style={{ pointerEvents: 'none', mixBlendMode: 'screen', opacity: plano.acabamento.luzForca ?? 0.22,
569
- background: 'radial-gradient(circle at ' + (50 + 40 * Math.sin(frame / 38)) + '% ' + (30 + 25 * Math.cos(frame / 51)) + '%, '
570
- + plano.acabamento.luz[0] + ' 0%, transparent 45%), radial-gradient(circle at ' + (50 - 40 * Math.sin(frame / 44)) + '% '
571
- + (75 - 20 * Math.sin(frame / 33)) + '%, ' + (plano.acabamento.luz[1] || plano.acabamento.luz[0]) + ' 0%, transparent 40%)' }} />
572
- )}
573
- {plano.acabamento && plano.acabamento.grade && (
574
- <AbsoluteFill style={{ pointerEvents: 'none', opacity: plano.acabamento.grade,
575
- backgroundImage: 'linear-gradient(rgba(255,255,255,0.5) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,0.5) 1px, transparent 1px)',
576
- backgroundSize: (plano.acabamento.passoGrade || 120) + 'px ' + (plano.acabamento.passoGrade || 120) + 'px',
577
- backgroundPosition: (frame * 0.2) + 'px ' + (frame * 0.1) + 'px' }} />
578
- )}
579
- {plano.acabamento && plano.acabamento.vinheta && (
580
- <AbsoluteFill style={{ pointerEvents: 'none', background: 'radial-gradient(ellipse at center, transparent 55%, rgba(0,0,0,' + plano.acabamento.vinheta + ') 100%)' }} />
581
- )}
582
- {plano.acabamento && plano.acabamento.grao && (
583
- <AbsoluteFill style={{ pointerEvents: 'none', opacity: plano.acabamento.grao, mixBlendMode: 'overlay' }}>
584
- <svg width="100%" height="100%">
585
- <filter id="grao"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" seed={frame % 60} /></filter>
586
- <rect width="100%" height="100%" filter="url(#grao)" />
587
- </svg>
588
- </AbsoluteFill>
589
- )}
683
+ {/* O ACABAMENTO: granulação de filme, vinheta e a luz da marca passando
684
+ (kit/base.jsx). É o que tira a cara de apresentação. */}
685
+ <Acabamento ac={plano.acabamento} frame={frame} />
686
+ {/* O HUD do motion designer: cantos, timecode, fps e a seção da cena. */}
687
+ {plano.hud && (() => {
688
+ const atual = pedacos.filter((q) => frame >= q.de).pop() || pedacos[0];
689
+ const total = pedacos.length ? pedacos[pedacos.length - 1].de + pedacos[pedacos.length - 1].dur : 1;
690
+ // A cena diz se é escura (o HUD claro) ou clara (o HUD escuro).
691
+ const escuro = !(atual && atual.cena.escuro === false);
692
+ return <Hud hud={plano.hud} frame={frame} fps={fps} total={total} W={largura} H={altura} secao={atual && atual.cena.secao} escuro={escuro} />;
693
+ })()}
590
694
 
591
695
  {plano.audio && plano.audio.narracao && (
592
696
  <Audio src={staticFile(plano.audio.narracao)} volume={plano.audio.volumeNarracao ?? 1} />
@@ -729,6 +833,9 @@ function montar({ pasta, titulo, plano, icones = {} }) {
729
833
  escrever(path.join(src, 'Root.jsx'), rootJsx(plano));
730
834
  escrever(path.join(src, 'Video.jsx'), RAIZ_JSX);
731
835
  escrever(path.join(src, 'Cena.jsx'), CENA_JSX);
836
+ escrever(path.join(src, 'morfar.js'), require('./morfar.js').fonteEsm());
837
+ // O kit de motion (kit.js): as peças que o elemento {"tipo":"kit"} monta.
838
+ for (const f of require('./kit.js').arquivos()) escrever(path.join(src, f.nome), f.conteudo);
732
839
  escrever(path.join(src, 'plano.json'), JSON.stringify(plano, null, 2));
733
840
  escrever(path.join(src, 'icones.json'), JSON.stringify(icones, null, 2));
734
841
  escrever(path.join(src, 'fontes.css'), fontesCSS(plano));
@@ -755,16 +862,24 @@ function montar({ pasta, titulo, plano, icones = {} }) {
755
862
  do usuário e roda com rede disponível no `npm install`; o Chromium do
756
863
  Remotion baixa a fonte no primeiro quadro e o Remotion espera por ela. */
757
864
  function fontesCSS(plano) {
865
+ /* Toda chave que começa com "fonte" (fonte, fonteMono, fonteDef…), em
866
+ qualquer profundidade: dentro de grupo, na legenda, nas peças do kit e
867
+ no HUD. Fonte que o plano usa e o CSS não declara sai em Times. */
758
868
  const usadas = new Set();
759
- for (const cena of plano.cenas || []) {
760
- for (const el of cena.elementos || []) {
761
- if (el.fonte) usadas.add(el.fonte);
762
- if (el.estilo && el.estilo.fonte) usadas.add(el.estilo.fonte);
869
+ const andar = (o) => {
870
+ if (!o || typeof o !== 'object') return;
871
+ if (Array.isArray(o)) return o.forEach(andar);
872
+ for (const [k, v] of Object.entries(o)) {
873
+ if (/^fonte/.test(k) && typeof v === 'string' && v.trim()) usadas.add(v.trim());
874
+ else if (v && typeof v === 'object') andar(v);
763
875
  }
764
- }
876
+ };
877
+ for (const cena of plano.cenas || []) andar(cena.elementos || []);
878
+ andar(plano.hud);
765
879
  if (!usadas.size) return '/* nenhuma fonte externa neste vídeo */\n';
766
880
  const familias = [...usadas]
767
- .map((f) => 'family=' + encodeURIComponent(f).replace(/%20/g, '+') + ':wght@300;400;500;600;700;800;900')
881
+ // O Google devolve o que a família tem e ignora o resto: pedir todos os pesos e o itálico não quebra.
882
+ .map((f) => 'family=' + encodeURIComponent(f).replace(/%20/g, '+') + ':ital,wght@0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,400;1,600')
768
883
  .join('&');
769
884
  return `@import url('https://fonts.googleapis.com/css2?${familias}&display=block');\n`;
770
885
  }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * ritmo.js — o tempo de um vídeo feito de partes: onde cada uma corta, quando
3
+ * cada coisa entra, e a música que abaixa quando a voz fala.
4
+ *
5
+ * "O áudio comanda o quadro." — guia MOTION VOX STYLE
6
+ *
7
+ * Os estilos de cena por parte (vox.js, lancamento.js) dividem isto: o corte
8
+ * cai no beat (um pouco antes da primeira palavra da parte), cada elemento
9
+ * entra no instante em que a palavra dele é dita, e o volume da trilha segue
10
+ * a narração. Puro: tempos entram, números saem.
11
+ */
12
+
13
+ 'use strict';
14
+
15
+ const { quando } = require('./cinema.js');
16
+
17
+ const r2 = (n) => Math.round(n * 100) / 100;
18
+
19
+ /**
20
+ * Onde cada parte começa e quanto dura.
21
+ *
22
+ * Com voz, a parte começa `antes` segundos antes da primeira palavra (o corte
23
+ * seco no beat) e vai até a próxima; a última ganha `cauda` de respiro. Sem
24
+ * voz, cada parte dura o que se leva para ler o que ela mostra.
25
+ *
26
+ * @returns {{ini:number[], dur:number[], total:number, temVoz:boolean}}
27
+ */
28
+ function tempoDasPartes(partes, tempos = [], { semVoz = () => 2.6, antes = 0.12, cauda = 1.1, minimo = 1.1 } = {}) {
29
+ const temVoz = tempos.length === partes.length && tempos.every((t) => t && t.ate > t.de);
30
+ const ini = [];
31
+ if (temVoz) partes.forEach((_, i) => ini.push(i === 0 ? 0 : r2(Math.max(ini[i - 1] + minimo, tempos[i].de - antes))));
32
+ else partes.forEach((p, i) => ini.push(i === 0 ? 0 : r2(ini[i - 1] + Math.max(minimo, semVoz(partes[i - 1])))));
33
+ const total = r2(temVoz ? Math.max(ini[ini.length - 1] + minimo, tempos[tempos.length - 1].ate + cauda) : ini[ini.length - 1] + Math.max(minimo, semVoz(partes[partes.length - 1])) + 0.4);
34
+ const dur = ini.map((t, i) => r2((i < ini.length - 1 ? ini[i + 1] : total) - t));
35
+ return { ini, dur, total, temVoz };
36
+ }
37
+
38
+ /**
39
+ * Quando cada texto entra, no tempo DA CENA: o instante em que a primeira
40
+ * palavra forte dele é dita. Sem voz (ou sem achar), espalha os textos pelo
41
+ * começo da cena, respeitando a ordem.
42
+ */
43
+ function gatilhos(textos, tempo, inicio, dur, { t0 = 0.3, ate = 0.62 } = {}) {
44
+ const palavras = (tempo && tempo.palavras) || [];
45
+ const fim = Math.max(t0 + 0.2, dur * ate);
46
+ if (!palavras.length) return textos.map((_, i) => r2(t0 + (fim - t0) * (i / Math.max(1, textos.length))));
47
+ const abs = quando(textos, palavras, inicio + fim, inicio + t0);
48
+ // Nunca antes do corte, nunca tarde demais para ser lido, sempre em ordem.
49
+ let antes = 0;
50
+ return abs.map((t) => {
51
+ const rel = Math.min(Math.max(0.08, t - inicio), Math.max(0.1, dur - 0.9));
52
+ antes = Math.max(antes + 0.1, rel);
53
+ return r2(antes);
54
+ });
55
+ }
56
+
57
+ /** As palavras faladas numa parte, no tempo da cena: [{texto, t}]. */
58
+ function palavrasNaCena(tempo, inicio) {
59
+ return ((tempo && tempo.palavras) || []).map((w) => ({ texto: w.texto, t: r2(Math.max(0.05, w.de - inicio)) }));
60
+ }
61
+
62
+ /** O volume da trilha no tempo: cheia no respiro, baixa quando a voz fala, some no fim. */
63
+ function volumesDaTrilha(tempos, total, { alto = 0.22, baixo = 0.11 } = {}) {
64
+ const k = (t, v) => ({ t: r2(t), v });
65
+ const vs = [k(0, 0.05), k(0.4, alto)];
66
+ // Um quadro-chave de cada vez, sempre depois do anterior: fala colada na outra não volta no tempo.
67
+ const poe = (t, v) => vs.push(k(Math.max(vs[vs.length - 1].t + 0.01, t), v));
68
+ for (const tp of tempos || []) {
69
+ if (!tp || !(tp.ate > tp.de)) continue;
70
+ poe(tp.de - 0.15, alto); poe(tp.de, baixo); poe(tp.ate, baixo); poe(tp.ate + 0.35, alto);
71
+ }
72
+ poe(total - 0.6, alto * 0.9);
73
+ poe(total, 0);
74
+ return vs;
75
+ }
76
+
77
+ module.exports = { tempoDasPartes, gatilhos, palavrasNaCena, volumesDaTrilha, r2 };
@@ -0,0 +1,62 @@
1
+ /**
2
+ * sons-leves.js — os efeitos do motion: leves, limpos, sintetizados.
3
+ *
4
+ * "Animações suaves, leves e limpas, coloca música, narração e efeitos
5
+ * sonoros." — Joel (28/09/2026)
6
+ *
7
+ * Os efeitos da edição (edicao.js SONS) são de corte de reels: whoosh cheio,
8
+ * estalo seco, impacto grave. Para o motion contínuo eles pesam. Estes são a
9
+ * versão leve: sopro em vez de whoosh, bolha em vez de estalo, um sino para o
10
+ * que nasce, um toque grave e macio para o que chega.
11
+ *
12
+ * Tudo sai do ffmpeg na hora (nada baixado, nada com licença de terceiros),
13
+ * uma vez por projeto.
14
+ */
15
+
16
+ 'use strict';
17
+
18
+ const fs = require('fs');
19
+ const path = require('path');
20
+
21
+ const EFEITOS = {
22
+ // Sopro: ruído rosa filtrado, entra e sai devagar. É o ar da passagem.
23
+ sopro: ['-f', 'lavfi', '-i', 'anoisesrc=d=0.9:c=pink:a=0.5', '-af',
24
+ 'highpass=f=300,lowpass=f=2600,afade=t=in:st=0:d=0.45:curve=qsin,afade=t=out:st=0.45:d=0.45:curve=qsin,volume=0.7'],
25
+ // Bolha: um seno curto que sobe, sem ataque seco.
26
+ bolha: ['-f', 'lavfi', '-i', "aevalsrc='0.6*sin(2*PI*(520+900*t)*t)*exp(-26*t)*(1-exp(-400*t))':s=44100:d=0.22"],
27
+ // Clique: o tique limpo de um trackpad.
28
+ clique: ['-f', 'lavfi', '-i', "aevalsrc='0.5*sin(2*PI*2400*t)*exp(-90*t)+0.15*(random(0)*2-1)*exp(-160*t)':s=44100:d=0.08"],
29
+ // Brilho: dois sinos em quinta, decaindo — o que nasce.
30
+ brilho: ['-f', 'lavfi', '-i', "aevalsrc='0.32*sin(2*PI*880*t)*exp(-3.2*t)+0.22*sin(2*PI*1320*t)*exp(-4*t)+0.12*sin(2*PI*1760*t)*exp(-5*t)':s=44100:d=1.4"],
31
+ // Subida: um seno que cresce devagar — anuncia a transformação.
32
+ subida: ['-f', 'lavfi', '-i', "aevalsrc='0.28*sin(2*PI*(180+260*t*t)*t)*(t/1.4)':s=44100:d=1.4", '-af', 'afade=t=out:st=1.2:d=0.2'],
33
+ // Toque grave e macio: o que chega e assenta.
34
+ toque: ['-f', 'lavfi', '-i', "aevalsrc='0.7*sin(2*PI*(110-50*t)*t)*exp(-7*t)':s=44100:d=0.6", '-af', 'lowpass=f=900'],
35
+ };
36
+
37
+ /**
38
+ * Gera os efeitos na pasta do projeto (midia/sfx-<nome>.mp3) e devolve
39
+ * { nome: caminho relativo }. O que já existe não é refeito; o que o ffmpeg
40
+ * não conseguiu gerar fica de fora (o vídeo sai sem aquele som, não quebra).
41
+ *
42
+ * `rodar(args)` é injetável: o padrão chama o ffmpeg por spawn síncrono.
43
+ */
44
+ function preparar(pasta, { rodar = null } = {}) {
45
+ const exec = rodar || ((args) => {
46
+ try { require('child_process').execFileSync('ffmpeg', args, { stdio: 'ignore', timeout: 30000 }); return true; }
47
+ catch { return false; }
48
+ });
49
+ const sons = {};
50
+ for (const [nome, args] of Object.entries(EFEITOS)) {
51
+ const rel = `midia/sfx-${nome}.mp3`;
52
+ const cheio = path.join(pasta, rel);
53
+ if (!fs.existsSync(cheio)) {
54
+ fs.mkdirSync(path.dirname(cheio), { recursive: true });
55
+ if (!exec(['-y', '-loglevel', 'error', ...args, '-q:a', '3', cheio])) continue;
56
+ }
57
+ sons[nome] = rel;
58
+ }
59
+ return sons;
60
+ }
61
+
62
+ module.exports = { EFEITOS, preparar };