primocode 9.10.0 → 10.0.9

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 (89) hide show
  1. package/README.md +30 -6
  2. package/bin/primocode.js +169 -342
  3. package/lib/act.js +360 -39
  4. package/lib/browser.js +105 -4
  5. package/lib/casar.js +108 -0
  6. package/lib/catalogo.js +6 -2
  7. package/lib/claude-engine.js +38 -17
  8. package/lib/codex-engine.js +27 -9
  9. package/lib/config.js +10 -3
  10. package/lib/cursor.js +10 -1
  11. package/lib/entrada.js +77 -11
  12. package/lib/estudio/cinema.js +27 -9
  13. package/lib/estudio/diretor.js +21 -1
  14. package/lib/estudio/ende.js +16 -5
  15. package/lib/estudio/estilo.js +66 -0
  16. package/lib/estudio/gerativo.js +1453 -0
  17. package/lib/estudio/glifos.js +215 -0
  18. package/lib/estudio/hibrido.js +165 -0
  19. package/lib/estudio/index.js +284 -53
  20. package/lib/estudio/kit/base.jsx +285 -0
  21. package/lib/estudio/kit/gerativo.jsx +980 -0
  22. package/lib/estudio/kit/index.jsx +13 -0
  23. package/lib/estudio/kit/marca.jsx +741 -0
  24. package/lib/estudio/kit/tres.jsx +308 -0
  25. package/lib/estudio/kit/vox.jsx +357 -0
  26. package/lib/estudio/kit.js +78 -0
  27. package/lib/estudio/lancamento.js +441 -0
  28. package/lib/estudio/motion.js +2 -5
  29. package/lib/estudio/plano.js +28 -3
  30. package/lib/estudio/referencias.js +105 -0
  31. package/lib/estudio/remotion.js +139 -51
  32. package/lib/estudio/ritmo.js +77 -0
  33. package/lib/estudio/sons-leves.js +38 -1
  34. package/lib/estudio/variedade.js +105 -0
  35. package/lib/estudio/vox.js +414 -0
  36. package/lib/fala.js +1 -94
  37. package/lib/mcp.js +156 -0
  38. package/lib/personas.js +67 -0
  39. package/lib/pipeline.js +55 -56
  40. package/lib/repeticao.js +3 -1
  41. package/lib/skills.js +44 -2
  42. package/lib/snapshot.js +111 -0
  43. package/lib/tools.js +100 -22
  44. package/lib/ui.js +50 -8
  45. package/lib/video/analise.js +89 -0
  46. package/lib/video/auto.js +329 -0
  47. package/lib/video/camadas.js +207 -0
  48. package/lib/video/cli.js +74 -0
  49. package/lib/video/delta.js +90 -0
  50. package/lib/video/edits.js +379 -0
  51. package/lib/video/filters.js +280 -0
  52. package/lib/video/fx3d/Camadas.jsx +64 -0
  53. package/lib/video/fx3d/Objetos.jsx +128 -0
  54. package/lib/video/fx3d/Root.jsx +18 -0
  55. package/lib/video/fx3d/modelos.jsx +304 -0
  56. package/lib/video/fx3d.js +108 -0
  57. package/lib/video/gerados.js +119 -0
  58. package/lib/video/objetos.js +111 -0
  59. package/lib/video/ops-fx.js +453 -0
  60. package/lib/video/ops.js +347 -0
  61. package/lib/video/paralelo.js +50 -0
  62. package/lib/video/pedido.js +191 -0
  63. package/lib/video/projeto.js +133 -0
  64. package/lib/video/rastrear.js +95 -0
  65. package/lib/video/rastro.js +276 -0
  66. package/lib/video/render.js +274 -0
  67. package/lib/video/texto.js +144 -0
  68. package/lib/video/timeline.js +318 -0
  69. package/lib/video/transcript.js +124 -0
  70. package/lib/voz.js +75 -223
  71. package/package.json +2 -3
  72. package/skills/motion-de-primeira/SKILL.md +5 -5
  73. package/studio/narrar.py +10 -7
  74. package/studio/rastrear.py +399 -0
  75. package/studio/texto.py +208 -0
  76. package/voz/primo_voz/__main__.py +96 -236
  77. package/voz/primo_voz/transcrever.py +31 -1
  78. package/app/ansi.js +0 -55
  79. package/app/app.css +0 -500
  80. package/app/app.js +0 -1017
  81. package/app/icone.svg +0 -6
  82. package/app/index.html +0 -220
  83. package/app/primo-marca.js +0 -192
  84. package/lib/app.js +0 -615
  85. package/lib/icone.js +0 -263
  86. package/lib/instalar-app.js +0 -231
  87. package/lib/painel.js +0 -291
  88. package/voz/primo_voz/desperta.py +0 -195
  89. package/voz/primo_voz/orbe.py +0 -406
@@ -74,6 +74,10 @@ function pkgJson(nome) {
74
74
  remotion: VERSAO_REMOTION,
75
75
  react: '19.0.0',
76
76
  'react-dom': '19.0.0',
77
+ // O produto em 3D (kit/tres.jsx). Instalado uma vez na base compartilhada.
78
+ '@remotion/three': VERSAO_REMOTION,
79
+ three: '0.186.1',
80
+ '@react-three/fiber': '9.8.1',
77
81
  },
78
82
  }, null, 2) + '\n';
79
83
  }
@@ -98,6 +102,7 @@ import {
98
102
  useVideoConfig, interpolate, staticFile, continueRender, delayRender,
99
103
  } from 'remotion';
100
104
  import { morfar, ehCor, misturarCor } from './morfar';
105
+ import { KIT, CURVAS_KIT, Filtros } from './kit';
101
106
 
102
107
  /* ── SUAVIZAÇÃO ──────────────────────────────────────────────────────────
103
108
  * Cada curva é uma função de 0..1 em 0..1. "suave" é o padrão porque
@@ -117,6 +122,8 @@ const CURVAS = {
117
122
  if (t < 2.5 / d) return n * (t -= 2.25 / d) * t + 0.9375;
118
123
  return n * (t -= 2.625 / d) * t + 0.984375;
119
124
  },
125
+ // editorial, camera, arrasto, acelera (kit/base.jsx): a curva certa do guia VOX.
126
+ ...CURVAS_KIT,
120
127
  };
121
128
 
122
129
  /* O valor de uma propriedade animada no segundo "t".
@@ -217,7 +224,12 @@ function Elemento({ el, t, dados }) {
217
224
 
218
225
  const tLocal = t - (el.de || 0);
219
226
  const a = animado(el.anima, tLocal);
220
- const base = posicao(el);
227
+ /* A PEÇA DO KIT desenha nas coordenadas da cena (x, y dela são o lugar do
228
+ assunto, não o canto de uma caixa): a caixa dela é o quadro inteiro, e o
229
+ "anima" do elemento move o conjunto. */
230
+ const base = el.tipo === 'kit'
231
+ ? { position: 'absolute', left: 0, top: 0, width: '100%', height: '100%' }
232
+ : posicao(el);
221
233
  const move = transformDe(a, {});
222
234
  const estilo = {
223
235
  ...base,
@@ -355,6 +367,16 @@ function Elemento({ el, t, dados }) {
355
367
  /* O GRUPO: uma tela dentro da tela. Os filhos vivem no tempo e no espaço
356
368
  dele; com "recorte" o que passa da borda some (a tela do celular). O
357
369
  grupo anima como qualquer elemento: cresce, sai do celular, cobre tudo. */
370
+ case 'kit': {
371
+ const Peca = KIT[el.peca];
372
+ if (!Peca) return null;
373
+ return (
374
+ <div style={{ ...estilo, transformOrigin: el.origem || ((el.x || 0) + 'px ' + (el.y || 0) + 'px') }}>
375
+ <Peca el={el} t={tLocal} fps={dados.fps} />
376
+ </div>
377
+ );
378
+ }
379
+
358
380
  case 'grupo':
359
381
  return (
360
382
  <div style={{
@@ -516,11 +538,36 @@ export function Cena({ cena, dados, tFixo }) {
516
538
  cam.rotacaoY != null ? 'rotateY(' + cam.rotacaoY + 'deg)' : '',
517
539
  ].filter(Boolean).join(' ');
518
540
 
541
+ /* A CÂMERA EM CAMADAS (o parallax do guia VOX): "camadas" são planos de
542
+ profundidade, e o que está longe anda menos que o que está perto. O
543
+ parallax é CONSEQUÊNCIA da câmera, não um efeito à parte: o plano do
544
+ assunto ("sujeito") recebe a câmera exata, os outros na proporção da
545
+ profundidade deles — e cada um com o desfoque da lente naquela distância.
546
+ Cada elemento diz a camada dele ("camada": índice); sem, é a do sujeito. */
547
+ const camadas = Array.isArray(cena.camadas) && cena.camadas.length ? cena.camadas : null;
548
+ const iSujeito = camadas ? Math.max(0, camadas.findIndex((c) => c.sujeito)) : 0;
549
+ const mundo = (els, f, desfoque) => (
550
+ <AbsoluteFill style={{
551
+ transform: f == null ? (camTransform || undefined) : [
552
+ 'translate(' + (-(cam.x || 0) * f + tx) + 'px,' + (-(cam.y || 0) * f + ty) + 'px)',
553
+ 'scale(' + ((1 + ((cam.zoom ?? 1) - 1) * f) * soco) + ')',
554
+ 'rotate(' + ((cam.rotacao || 0) * f + tr) + 'deg)',
555
+ ].join(' '),
556
+ transformOrigin: cena.cameraOrigem || 'center center',
557
+ // O mundo guarda o 3D dos filhos; a perspectiva vem do pai.
558
+ transformStyle: cena.perspectiva ? 'preserve-3d' : undefined,
559
+ filter: desfoque ? 'blur(' + desfoque + 'px)' : undefined,
560
+ }}>
561
+ {els.map((el, i) => <Elemento key={i} el={el} t={t} dados={dados} />)}
562
+ </AbsoluteFill>
563
+ );
564
+
519
565
  return (
520
566
  /* A perspectiva mora AQUI, um nível acima do mundo: se ficasse no mesmo
521
567
  elemento da câmera, girar a câmera sairia achatado. */
522
568
  <AbsoluteFill style={{ ...fundoCSS(cena.fundo), overflow: 'hidden',
523
569
  perspective: cena.perspectiva ? cena.perspectiva + 'px' : undefined }}>
570
+ <Filtros />
524
571
  {cena.fundo && cena.fundo.tipo === 'imagem' && (
525
572
  <Img src={staticFile(cena.fundo.src)} style={{
526
573
  position: 'absolute', inset: 0, width: '100%', height: '100%',
@@ -533,16 +580,13 @@ export function Cena({ cena, dados, tFixo }) {
533
580
  objectFit: cena.fundo.ajuste || 'cover',
534
581
  }} />
535
582
  )}
536
- <AbsoluteFill style={{
537
- transform: camTransform || undefined,
538
- transformOrigin: cena.cameraOrigem || 'center center',
539
- // O mundo guarda o 3D dos filhos; a perspectiva vem do pai.
540
- transformStyle: cena.perspectiva ? 'preserve-3d' : undefined,
541
- }}>
542
- {(cena.elementos || []).map((el, i) => (
543
- <Elemento key={i} el={el} t={t} dados={dados} />
544
- ))}
545
- </AbsoluteFill>
583
+ {camadas
584
+ ? camadas.map((c, ci) => (
585
+ <React.Fragment key={ci}>
586
+ {mundo((cena.elementos || []).filter((el) => (el.camada ?? iSujeito) === ci), (c.p || 1) / (camadas[iSujeito].p || 1), c.desfoque)}
587
+ </React.Fragment>
588
+ ))
589
+ : mundo(cena.elementos || [], null, 0)}
546
590
  {flash > 0.01 && <AbsoluteFill style={{ backgroundColor: cena.corFlash || '#fff', opacity: flash * 0.85 }} />}
547
591
  </AbsoluteFill>
548
592
  );
@@ -552,6 +596,7 @@ export function Cena({ cena, dados, tFixo }) {
552
596
  const RAIZ_JSX = String.raw`import React from 'react';
553
597
  import { AbsoluteFill, Audio, Sequence, staticFile, useCurrentFrame, useVideoConfig, interpolate } from 'remotion';
554
598
  import { Cena } from './Cena';
599
+ import { Acabamento, Hud, bolhaCaminho } from './kit';
555
600
  import plano from './plano.json';
556
601
  import icones from './icones.json';
557
602
 
@@ -560,6 +605,7 @@ import icones from './icones.json';
560
605
  * o "piscar" de fundo que denuncia corte mal feito. */
561
606
  function Transicao({ tipo, progresso, children }) {
562
607
  const p = progresso;
608
+ const { width: W, height: H } = useVideoConfig();
563
609
  let estilo = {};
564
610
  if (tipo === 'esmaecer') estilo = { opacity: p };
565
611
  else if (tipo === 'deslizar') estilo = { transform: 'translateX(' + (1 - p) * 100 + '%)' };
@@ -572,6 +618,12 @@ function Transicao({ tipo, progresso, children }) {
572
618
  // O SOCO: a cena entra grande e bate no lugar.
573
619
  else if (tipo === 'soco') estilo = { transform: 'scale(' + (1.35 - 0.35 * p) + ')', opacity: Math.min(1, p * 3) };
574
620
  else if (tipo === 'flash') estilo = { opacity: Math.min(1, p * 2), filter: 'brightness(' + (1 + (1 - p) * 3) + ')' };
621
+ /* O LÍQUIDO: a cena nova entra como uma mancha que cresce de um canto e
622
+ engole a anterior, com a borda viva (os lóbulos respiram enquanto crescem). */
623
+ else if (tipo === 'liquido' && p < 1) {
624
+ const k = p < 0.5 ? 4 * p * p * p : 1 - Math.pow(-2 * p + 2, 3) / 2;
625
+ estilo = { clipPath: "path('" + bolhaCaminho(W * 0.78, H * 0.62, Math.hypot(W, H) * 0.95 * k, p * 3, 2) + "')" };
626
+ }
575
627
  return <AbsoluteFill style={estilo}>{children}</AbsoluteFill>;
576
628
  }
577
629
 
@@ -591,7 +643,7 @@ export const Paginas = () => {
591
643
  };
592
644
 
593
645
  export const Video = () => {
594
- const { fps } = useVideoConfig();
646
+ const { fps, width: largura, height: altura } = useVideoConfig();
595
647
  const frame = useCurrentFrame();
596
648
  const dados = { fps, icones };
597
649
 
@@ -632,31 +684,17 @@ export const Video = () => {
632
684
  );
633
685
  })}
634
686
 
635
- {/* O ACABAMENTO: granulação de filme, vinheta e a luz da marca passando.
636
- É o que tira a cara de apresentação e dá a cara de peça de 2026. */}
637
- {plano.acabamento && plano.acabamento.luz && (
638
- <AbsoluteFill style={{ pointerEvents: 'none', mixBlendMode: 'screen', opacity: plano.acabamento.luzForca ?? 0.22,
639
- background: 'radial-gradient(circle at ' + (50 + 40 * Math.sin(frame / 38)) + '% ' + (30 + 25 * Math.cos(frame / 51)) + '%, '
640
- + plano.acabamento.luz[0] + ' 0%, transparent 45%), radial-gradient(circle at ' + (50 - 40 * Math.sin(frame / 44)) + '% '
641
- + (75 - 20 * Math.sin(frame / 33)) + '%, ' + (plano.acabamento.luz[1] || plano.acabamento.luz[0]) + ' 0%, transparent 40%)' }} />
642
- )}
643
- {plano.acabamento && plano.acabamento.grade && (
644
- <AbsoluteFill style={{ pointerEvents: 'none', opacity: plano.acabamento.grade,
645
- backgroundImage: 'linear-gradient(rgba(255,255,255,0.5) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,0.5) 1px, transparent 1px)',
646
- backgroundSize: (plano.acabamento.passoGrade || 120) + 'px ' + (plano.acabamento.passoGrade || 120) + 'px',
647
- backgroundPosition: (frame * 0.2) + 'px ' + (frame * 0.1) + 'px' }} />
648
- )}
649
- {plano.acabamento && plano.acabamento.vinheta && (
650
- <AbsoluteFill style={{ pointerEvents: 'none', background: 'radial-gradient(ellipse at center, transparent 55%, rgba(0,0,0,' + plano.acabamento.vinheta + ') 100%)' }} />
651
- )}
652
- {plano.acabamento && plano.acabamento.grao && (
653
- <AbsoluteFill style={{ pointerEvents: 'none', opacity: plano.acabamento.grao, mixBlendMode: 'overlay' }}>
654
- <svg width="100%" height="100%">
655
- <filter id="grao"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" seed={frame % 60} /></filter>
656
- <rect width="100%" height="100%" filter="url(#grao)" />
657
- </svg>
658
- </AbsoluteFill>
659
- )}
687
+ {/* O ACABAMENTO: granulação de filme, vinheta e a luz da marca passando
688
+ (kit/base.jsx). É o que tira a cara de apresentação. */}
689
+ <Acabamento ac={plano.acabamento} frame={frame} />
690
+ {/* O HUD do motion designer: cantos, timecode, fps e a seção da cena. */}
691
+ {plano.hud && (() => {
692
+ const atual = pedacos.filter((q) => frame >= q.de).pop() || pedacos[0];
693
+ const total = pedacos.length ? pedacos[pedacos.length - 1].de + pedacos[pedacos.length - 1].dur : 1;
694
+ // A cena diz se é escura (o HUD claro) ou clara (o HUD escuro).
695
+ const escuro = !(atual && atual.cena.escuro === false);
696
+ return <Hud hud={plano.hud} frame={frame} fps={fps} total={total} W={largura} H={altura} secao={atual && atual.cena.secao} escuro={escuro} />;
697
+ })()}
660
698
 
661
699
  {plano.audio && plano.audio.narracao && (
662
700
  <Audio src={staticFile(plano.audio.narracao)} volume={plano.audio.volumeNarracao ?? 1} />
@@ -800,6 +838,8 @@ function montar({ pasta, titulo, plano, icones = {} }) {
800
838
  escrever(path.join(src, 'Video.jsx'), RAIZ_JSX);
801
839
  escrever(path.join(src, 'Cena.jsx'), CENA_JSX);
802
840
  escrever(path.join(src, 'morfar.js'), require('./morfar.js').fonteEsm());
841
+ // O kit de motion (kit.js): as peças que o elemento {"tipo":"kit"} monta.
842
+ for (const f of require('./kit.js').arquivos()) escrever(path.join(src, f.nome), f.conteudo);
803
843
  escrever(path.join(src, 'plano.json'), JSON.stringify(plano, null, 2));
804
844
  escrever(path.join(src, 'icones.json'), JSON.stringify(icones, null, 2));
805
845
  escrever(path.join(src, 'fontes.css'), fontesCSS(plano));
@@ -826,16 +866,30 @@ function montar({ pasta, titulo, plano, icones = {} }) {
826
866
  do usuário e roda com rede disponível no `npm install`; o Chromium do
827
867
  Remotion baixa a fonte no primeiro quadro e o Remotion espera por ela. */
828
868
  function fontesCSS(plano) {
869
+ /* Toda chave que começa com "fonte" (fonte, fonteMono, fonteDef…), em
870
+ qualquer profundidade: dentro de grupo, na legenda, nas peças do kit e
871
+ no HUD. Fonte que o plano usa e o CSS não declara sai em Times. */
829
872
  const usadas = new Set();
830
- for (const cena of plano.cenas || []) {
831
- for (const el of cena.elementos || []) {
832
- if (el.fonte) usadas.add(el.fonte);
833
- if (el.estilo && el.estilo.fonte) usadas.add(el.estilo.fonte);
873
+ const andar = (o) => {
874
+ if (!o || typeof o !== 'object') return;
875
+ if (Array.isArray(o)) return o.forEach(andar);
876
+ for (const [k, v] of Object.entries(o)) {
877
+ if (/^fonte/.test(k) && typeof v === 'string' && v.trim()) usadas.add(v.trim());
878
+ else if (v && typeof v === 'object') andar(v);
834
879
  }
835
- }
880
+ };
881
+ for (const cena of plano.cenas || []) andar(cena.elementos || []);
882
+ andar(plano.hud);
836
883
  if (!usadas.size) return '/* nenhuma fonte externa neste vídeo */\n';
884
+ /* A FONTE VARIÁVEL: o motion gerativo anima os eixos (wght, wdth, GRAD…)
885
+ e precisa do arquivo variável inteiro, pedido pela faixa de cada eixo
886
+ ("Roboto+Flex:opsz,slnt,wdth,wght,GRAD@8..144,…"). As outras seguem
887
+ com os pesos fixos. */
888
+ const variaveis = plano.fontesVariaveis || {};
837
889
  const familias = [...usadas]
838
- .map((f) => 'family=' + encodeURIComponent(f).replace(/%20/g, '+') + ':wght@300;400;500;600;700;800;900')
890
+ // O Google devolve o que a família tem e ignora o resto: pedir todos os pesos e o itálico não quebra.
891
+ .map((f) => 'family=' + encodeURIComponent(f).replace(/%20/g, '+')
892
+ + (variaveis[f] ? ':' + variaveis[f] : ':ital,wght@0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,400;1,600'))
839
893
  .join('&');
840
894
  return `@import url('https://fonts.googleapis.com/css2?${familias}&display=block');\n`;
841
895
  }
@@ -860,15 +914,47 @@ function citarWin(a) {
860
914
  return '"' + t.replace(/"/g, '""') + '"';
861
915
  }
862
916
 
917
+ /* O REMOTION COMPARTILHADO. Cada projeto rodava o próprio `npm install`
918
+ (minutos, e ~300 MB por vídeo) — e um 404 passageiro do registro no meio
919
+ dele derrubava o vídeo inteiro (medido em 30/09: source-map-js 1.2.2).
920
+ Agora a instalação mora UMA vez em ~/.primocode/studio/remotion-base, com
921
+ as mesmas dependências, e o projeto aponta para ela. Dependência diferente
922
+ (versão nova do Remotion) reinstala a base. */
923
+ const os = require('os');
924
+ function baseCompartilhada(raiz) {
925
+ const base = path.join(os.homedir(), '.primocode', 'studio', 'remotion-base');
926
+ let deps = '';
927
+ try { const pj = JSON.parse(fs.readFileSync(path.join(raiz, 'package.json'), 'utf8')); deps = JSON.stringify({ d: pj.dependencies || {}, v: pj.devDependencies || {} }); } catch { return null; }
928
+ return { base, deps, pronta: (() => {
929
+ try { return fs.existsSync(path.join(base, 'node_modules', '.bin')) && fs.readFileSync(path.join(base, 'deps.json'), 'utf8') === deps; } catch { return false; }
930
+ })() };
931
+ }
932
+
863
933
  function renderizar({ raiz, saida, minutos = 30, aoFalar = () => {} }) {
864
934
  return new Promise((resolve) => {
865
- const temNodeModules = fs.existsSync(path.join(raiz, 'node_modules'));
866
- const passos = temNodeModules ? [] : [['npm', ['install', '--no-audit', '--no-fund']]];
867
- passos.push(['npx', ['remotion', 'render', 'Video', saida, '--log=warn']]);
868
-
869
- const correr = (i) => {
935
+ let temNodeModules = fs.existsSync(path.join(raiz, 'node_modules'));
936
+ const comp = temNodeModules || process.platform === 'win32' ? null : baseCompartilhada(raiz);
937
+ if (comp && comp.pronta) {
938
+ try { fs.symlinkSync(path.join(comp.base, 'node_modules'), path.join(raiz, 'node_modules'), 'dir'); temNodeModules = true; } catch { /* instala aqui mesmo */ }
939
+ }
940
+ const passos = [];
941
+ if (!temNodeModules && comp) {
942
+ // Instala na base (com uma segunda tentativa: o registro às vezes devolve 404 por instantes) e liga.
943
+ try { fs.mkdirSync(comp.base, { recursive: true }); fs.copyFileSync(path.join(raiz, 'package.json'), path.join(comp.base, 'package.json')); } catch { /* segue */ }
944
+ passos.push(['npm', ['install', '--no-audit', '--no-fund'], { cwd: comp.base, tentativas: 2, depois: () => {
945
+ fs.writeFileSync(path.join(comp.base, 'deps.json'), comp.deps);
946
+ try { fs.symlinkSync(path.join(comp.base, 'node_modules'), path.join(raiz, 'node_modules'), 'dir'); } catch { /* já existe */ }
947
+ } }]);
948
+ } else if (!temNodeModules) passos.push(['npm', ['install', '--no-audit', '--no-fund'], { tentativas: 2 }]);
949
+ // Vídeo de b-roll pesado (Pexels HD) levava mais de 30 s para o primeiro quadro.
950
+ /* VÁRIOS TRABALHADORES no render: o Remotion usa metade dos núcleos por
951
+ padrão; aqui vão quase todos (um fica para o terminal respirar). */
952
+ const nucleos = Math.max(2, (require('os').cpus() || []).length - 1);
953
+ passos.push(['npx', ['remotion', 'render', 'Video', saida, '--log=warn', '--timeout=120000', `--concurrency=${nucleos}`]]);
954
+
955
+ const correr = (i, tentativa = 1) => {
870
956
  if (i >= passos.length) return resolve({ ok: true, arquivo: saida });
871
- const [cmd, args] = passos[i];
957
+ const [cmd, args, op = {}] = passos[i];
872
958
  aoFalar(i === 0 && !temNodeModules
873
959
  ? 'instalando o Remotion nesta pasta (uma vez só, alguns minutos)'
874
960
  : 'renderizando quadro a quadro');
@@ -882,7 +968,7 @@ function renderizar({ raiz, saida, minutos = 30, aoFalar = () => {} }) {
882
968
  CVE-2024-27980 o Node recusa spawnar npm.cmd/npx.cmd sem shell.
883
969
  Então as aspas entram na mão, só onde o shell existe. */
884
970
  const citados = process.platform === 'win32' ? args.map(citarWin) : args;
885
- const p = spawn(cmd, citados, { cwd: raiz, shell: process.platform === 'win32' });
971
+ const p = spawn(cmd, citados, { cwd: op.cwd || raiz, shell: process.platform === 'win32' });
886
972
  let erro = '';
887
973
  p.stderr.on('data', (d) => { erro += d.toString().slice(0, 2000); });
888
974
  p.stdout.on('data', (d) => {
@@ -893,8 +979,10 @@ function renderizar({ raiz, saida, minutos = 30, aoFalar = () => {} }) {
893
979
  p.on('close', (code) => {
894
980
  clearTimeout(relogio);
895
981
  if (code !== 0) {
982
+ if (tentativa < (op.tentativas || 1)) { aoFalar('o npm tropeçou — tentando de novo'); return setTimeout(() => correr(i, tentativa + 1), 3000); }
896
983
  return resolve({ ok: false, error: `${cmd} saiu com ${code}: ${erro.slice(-600).trim()}` });
897
984
  }
985
+ try { if (op.depois) op.depois(); } catch { /* segue */ }
898
986
  correr(i + 1);
899
987
  });
900
988
  p.on('error', (e) => {
@@ -917,4 +1005,4 @@ function renderizar({ raiz, saida, minutos = 30, aoFalar = () => {} }) {
917
1005
  });
918
1006
  }
919
1007
 
920
- module.exports = { montar, renderizar, fontesCSS, VERSAO_REMOTION, CENA_JSX };
1008
+ module.exports = { montar, renderizar, fontesCSS, VERSAO_REMOTION, CENA_JSX, pkgJson, baseCompartilhada, TSCONFIG };
@@ -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.0, antes = 0.08, cauda = 0.6, minimo = 0.9 } = {}) {
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.4, baixo = 0.2 } = {}) {
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 };
@@ -32,6 +32,14 @@ const EFEITOS = {
32
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
33
  // Toque grave e macio: o que chega e assenta.
34
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
+ /* Os CHEIOS do motion gerativo ("motion é vida: efeito sonoro NA ação",
36
+ 30/09): o whoosh da passagem, o impacto da tese, o riser antes da
37
+ revelação e o pop da letra que nasce. Ainda sintetizados, ainda limpos. */
38
+ whoosh: ['-f', 'lavfi', '-i', 'anoisesrc=d=0.75:c=pink:a=0.9', '-af',
39
+ 'bandpass=f=1100:width_type=o:w=2.2,afade=t=in:st=0:d=0.42:curve=exp,afade=t=out:st=0.42:d=0.33:curve=qsin,volume=2.2'],
40
+ impacto: ['-f', 'lavfi', '-i', "aevalsrc='0.95*sin(2*PI*(62-26*t)*t)*exp(-4.5*t)+0.45*(random(0)*2-1)*exp(-38*t)':s=44100:d=1.1", '-af', 'lowpass=f=2600,volume=1.4'],
41
+ riser: ['-f', 'lavfi', '-i', "aevalsrc='(0.32*sin(2*PI*(170+620*t*t)*t)+0.22*(random(0)*2-1))*pow(t/1.1,2)':s=44100:d=1.1", '-af', 'highpass=f=260,afade=t=out:st=1.04:d=0.06,volume=1.3'],
42
+ pop: ['-f', 'lavfi', '-i', "aevalsrc='0.8*sin(2*PI*(980-640*t)*t)*exp(-28*t)*(1-exp(-700*t))':s=44100:d=0.18"],
35
43
  };
36
44
 
37
45
  /**
@@ -59,4 +67,33 @@ function preparar(pasta, { rodar = null } = {}) {
59
67
  return sons;
60
68
  }
61
69
 
62
- module.exports = { EFEITOS, preparar };
70
+ /**
71
+ * A família de sons de UM vídeo (gerativo.js sonsDoVideo): cada receita vira
72
+ * midia/sfx-g<semente>-<nome>.mp3. O que já existe não é refeito.
73
+ */
74
+ function prepararReceitas(pasta, { receitas = {}, semente = 0 } = {}, { rodar = null } = {}) {
75
+ const exec = rodar || ((args) => {
76
+ try { require('child_process').execFileSync('ffmpeg', args, { stdio: 'ignore', timeout: 30000 }); return true; }
77
+ catch { return false; }
78
+ });
79
+ const sons = {};
80
+ const sufixo = (semente >>> 0).toString(36);
81
+ for (const [nome, args] of Object.entries(receitas)) {
82
+ const rel = `midia/sfx-g${sufixo}-${nome}.mp3`;
83
+ const cheio = path.join(pasta, rel);
84
+ if (!fs.existsSync(cheio)) {
85
+ fs.mkdirSync(path.dirname(cheio), { recursive: true });
86
+ // O limitador no fim: o golpe sintetizado passava de 0 dB e estalava.
87
+ // "{pasta}" é a pasta do projeto: o som feito da própria trilha lê o arquivo dela.
88
+ const a = args.map((x) => String(x).split('{pasta}').join(pasta));
89
+ const fc = a.indexOf('-filter_complex'), i = a.indexOf('-af');
90
+ if (fc >= 0) a[fc + 1] += ',alimiter=limit=0.8:level=false';
91
+ else if (i >= 0) a[i + 1] += ',alimiter=limit=0.8:level=false'; else a.push('-af', 'alimiter=limit=0.8:level=false');
92
+ if (!exec(['-y', '-loglevel', 'error', ...a, '-q:a', '3', cheio])) continue;
93
+ }
94
+ sons[nome] = rel;
95
+ }
96
+ return sons;
97
+ }
98
+
99
+ module.exports = { EFEITOS, preparar, prepararReceitas };
@@ -0,0 +1,105 @@
1
+ /**
2
+ * variedade.js — a trilha e os efeitos dos vídeos de corte (híbrido, cinema,
3
+ * impacto) sem repetir de um vídeo para o outro.
4
+ *
5
+ * "todos os vídeos é sempre as mesmas músicas, os mesmos efeitos sonoros,
6
+ * sempre o mesmo padrão de vídeo" (Joel, 02/10)
7
+ *
8
+ * A causa era a mesma nos dois: um termo fixo na busca ("inspiring
9
+ * corporate", "cinematic whoosh") e o PRIMEIRO resultado baixado. A busca
10
+ * devolve a mesma lista na mesma ordem, então todo vídeo saía com a mesma
11
+ * faixa e o mesmo whoosh. Aqui:
12
+ * 1. o termo sai de um cardápio pelo clima do roteiro, em ordem sorteada;
13
+ * 2. dos resultados, sorteia — e pula o que os últimos vídeos já usaram;
14
+ * 3. o que foi usado entra na memória (~/.primocode/studio/<tipo>-usadas.json).
15
+ */
16
+
17
+ 'use strict';
18
+
19
+ const fs = require('fs');
20
+ const os = require('os');
21
+ const path = require('path');
22
+
23
+ const PASTA = path.join(os.homedir(), '.primocode', 'studio');
24
+ const MEMORIA = { trilha: 16, sfx: 40 };
25
+
26
+ function arquivo(tipo) { return path.join(PASTA, `${tipo}-usadas.json`); }
27
+
28
+ function usadas(tipo) {
29
+ try { const l = JSON.parse(fs.readFileSync(arquivo(tipo), 'utf8')); return Array.isArray(l) ? l : []; } catch { return []; }
30
+ }
31
+
32
+ function marcar(tipo, id) {
33
+ if (!id) return;
34
+ try {
35
+ fs.mkdirSync(PASTA, { recursive: true });
36
+ const l = [id, ...usadas(tipo).filter((x) => x !== id)].slice(0, MEMORIA[tipo] || 20);
37
+ fs.writeFileSync(arquivo(tipo), JSON.stringify(l));
38
+ } catch { /* sem memória: só perde a garantia de não repetir */ }
39
+ }
40
+
41
+ function embaralhar(lista, aleatorio = Math.random) {
42
+ const a = [...lista];
43
+ for (let i = a.length - 1; i > 0; i--) {
44
+ const j = Math.floor(aleatorio() * (i + 1));
45
+ [a[i], a[j]] = [a[j], a[i]];
46
+ }
47
+ return a;
48
+ }
49
+
50
+ /** Os itens da busca na ordem de tentar: os nunca usados primeiro (sorteados), os usados no fim. */
51
+ function ordenar(itens, tipo, aleatorio = Math.random) {
52
+ const ja = new Set(usadas(tipo));
53
+ const validos = (itens || []).filter((x) => x && x.url);
54
+ return [...embaralhar(validos.filter((x) => !ja.has(x.url)), aleatorio), ...validos.filter((x) => ja.has(x.url))];
55
+ }
56
+
57
+ // ── a trilha: termos por clima ──────────────────────────────────────────
58
+ const CLIMAS = {
59
+ tecnologia: ['electronic instrumental', 'synthwave instrumental', 'future bass instrumental', 'downtempo electronic', 'minimal techno instrumental', 'chillwave instrumental'],
60
+ energetico: ['funk instrumental', 'rock instrumental', 'drum and bass instrumental', 'breakbeat instrumental', 'upbeat indie instrumental', 'house instrumental'],
61
+ epico: ['epic orchestral', 'trailer instrumental', 'cinematic drums', 'epic hybrid instrumental', 'dramatic strings'],
62
+ calmo: ['lofi hip hop instrumental', 'ambient instrumental', 'acoustic guitar instrumental', 'piano instrumental', 'jazz instrumental'],
63
+ alegre: ['ukulele instrumental', 'indie pop instrumental', 'happy acoustic instrumental', 'latin instrumental', 'disco instrumental'],
64
+ emocional: ['piano emotional instrumental', 'post rock instrumental', 'cinematic strings', 'neo classical instrumental'],
65
+ misterio: ['dark ambient instrumental', 'trip hop instrumental', 'suspense instrumental', 'noir jazz instrumental'],
66
+ neutro: ['indie instrumental', 'soul instrumental', 'jazz hop instrumental', 'world instrumental', 'electronic instrumental', 'acoustic instrumental'],
67
+ };
68
+ const SINONIMOS = {
69
+ tecnologia: /tec|tech|ia\b|ai\b|software|app|digital|futur/, energetico: /energ|agit|rapid|forte|esport|academ/,
70
+ epico: /epic|epico|grandi|trailer|impact/, calmo: /calm|tranq|suave|relax|chill/, alegre: /aleg|feliz|divert|leve/,
71
+ emocional: /emoc|emoti|inspir|sentim|historia/, misterio: /mist|sombr|dark|suspen|tens/,
72
+ };
73
+
74
+ function climaDe(roteiro = {}, estilo = '') {
75
+ const t = String([roteiro.clima, roteiro.genero, roteiro.tom].filter(Boolean).join(' ')).toLowerCase()
76
+ .normalize('NFD').replace(/[̀-ͯ]/g, '');
77
+ for (const [clima, re] of Object.entries(SINONIMOS)) if (re.test(t)) return clima;
78
+ return estilo === 'impacto' ? 'energetico' : 'neutro';
79
+ }
80
+
81
+ /**
82
+ * Os termos para buscar a trilha, em ordem de tentar. O pedido explícito
83
+ * (roteiro.musica) vem primeiro; depois o clima, sorteado; e por último um
84
+ * clima vizinho, para nunca sair sem música.
85
+ */
86
+ function termosTrilha(roteiro = {}, estilo = '', aleatorio = Math.random) {
87
+ const clima = climaDe(roteiro, estilo);
88
+ const reserva = clima === 'neutro' ? 'calmo' : 'neutro';
89
+ return [...new Set([roteiro.musica, ...embaralhar(CLIMAS[clima], aleatorio).slice(0, 3),
90
+ ...embaralhar(CLIMAS[reserva], aleatorio).slice(0, 2)].filter(Boolean))];
91
+ }
92
+
93
+ // ── os efeitos: várias formas de pedir o mesmo som ───────────────────────
94
+ const SFX = {
95
+ teclado: ['keyboard typing', 'typing keyboard', 'mechanical keyboard', 'laptop typing', 'typewriter keys'],
96
+ whoosh: ['cinematic whoosh', 'swoosh transition', 'air whoosh', 'fast swipe', 'woosh pass by', 'riser whoosh'],
97
+ impacto: ['cinematic boom impact', 'deep cinematic impact', 'trailer hit', 'bass drop hit', 'punch hit', 'sub boom'],
98
+ sala: ['crowd murmur indoor', 'office ambience', 'people talking background', 'cafe ambience', 'room tone people'],
99
+ };
100
+
101
+ function termosSfx(nome, aleatorio = Math.random) {
102
+ return embaralhar(SFX[nome] || [nome], aleatorio).slice(0, 3);
103
+ }
104
+
105
+ module.exports = { usadas, marcar, embaralhar, ordenar, climaDe, termosTrilha, termosSfx, CLIMAS, SFX };