primocode 10.0.0 → 10.1.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 (73) hide show
  1. package/README.md +4 -4
  2. package/bin/primocode.js +88 -3
  3. package/lib/act.js +122 -20
  4. package/lib/browser.js +89 -5
  5. package/lib/catalogo.js +6 -3
  6. package/lib/claude-engine.js +38 -17
  7. package/lib/codex-engine.js +27 -9
  8. package/lib/config.js +10 -2
  9. package/lib/entrada.js +73 -9
  10. package/lib/estudio/assets.js +12 -6
  11. package/lib/estudio/biblioteca.js +174 -0
  12. package/lib/estudio/cinema.js +63 -25
  13. package/lib/estudio/composicao.js +2 -1
  14. package/lib/estudio/diretor.js +13 -1
  15. package/lib/estudio/edicao.js +2 -1
  16. package/lib/estudio/ende.js +16 -5
  17. package/lib/estudio/estilo.js +41 -10
  18. package/lib/estudio/fluido.js +283 -0
  19. package/lib/estudio/gerativo.js +1471 -0
  20. package/lib/estudio/hibrido.js +169 -0
  21. package/lib/estudio/impacto.js +66 -31
  22. package/lib/estudio/index.js +366 -47
  23. package/lib/estudio/kit/fluido.jsx +493 -0
  24. package/lib/estudio/kit/gerativo.jsx +990 -0
  25. package/lib/estudio/kit/index.jsx +4 -1
  26. package/lib/estudio/kit/tres.jsx +375 -0
  27. package/lib/estudio/kit.js +9 -1
  28. package/lib/estudio/kokoro.js +111 -0
  29. package/lib/estudio/lancamento.js +27 -0
  30. package/lib/estudio/legibilidade.js +20 -0
  31. package/lib/estudio/referencias.js +105 -0
  32. package/lib/estudio/remotion.js +103 -14
  33. package/lib/estudio/ritmo.js +2 -2
  34. package/lib/estudio/sons-leves.js +38 -1
  35. package/lib/estudio/variacao3d.js +46 -0
  36. package/lib/estudio/variedade.js +183 -0
  37. package/lib/estudio/vox.js +37 -8
  38. package/lib/mcp.js +156 -0
  39. package/lib/personas.js +67 -0
  40. package/lib/portao-skill.js +150 -0
  41. package/lib/skills.js +51 -1
  42. package/lib/tools.js +53 -8
  43. package/lib/ui.js +41 -6
  44. package/lib/video/auto.js +329 -0
  45. package/lib/video/camadas.js +207 -0
  46. package/lib/video/cli.js +1 -1
  47. package/lib/video/filters.js +39 -6
  48. package/lib/video/fx3d/Camadas.jsx +64 -0
  49. package/lib/video/fx3d/Objetos.jsx +128 -0
  50. package/lib/video/fx3d/Root.jsx +18 -0
  51. package/lib/video/fx3d/modelos.jsx +304 -0
  52. package/lib/video/fx3d.js +108 -0
  53. package/lib/video/gerados.js +119 -0
  54. package/lib/video/objetos.js +111 -0
  55. package/lib/video/ops-fx.js +453 -0
  56. package/lib/video/ops.js +30 -5
  57. package/lib/video/paralelo.js +50 -0
  58. package/lib/video/pedido.js +191 -0
  59. package/lib/video/rastrear.js +95 -0
  60. package/lib/video/rastro.js +276 -0
  61. package/lib/video/render.js +67 -14
  62. package/lib/video/texto.js +6 -4
  63. package/lib/video/timeline.js +9 -1
  64. package/package.json +2 -2
  65. package/skills/escrever-em-portugues/SKILL.md +3 -0
  66. package/skills/interface-revisada/SKILL.md +7 -1
  67. package/skills/motion-de-primeira/SKILL.md +5 -4
  68. package/skills/projeto-no-github/SKILL.md +6 -0
  69. package/skills/publicar-na-internet/SKILL.md +3 -0
  70. package/skills/site-com-motion/SKILL.md +2 -0
  71. package/studio/narrar.py +10 -7
  72. package/studio/rastrear.py +399 -0
  73. package/studio/voz_kokoro.py +98 -0
@@ -0,0 +1,105 @@
1
+ /**
2
+ * referencias.js — de onde o vídeo tira as referências, decidido pela
3
+ * PESSOA antes de começar.
4
+ *
5
+ * "A IA deve pedir: pegar referências da web com escopo aberto, pegar
6
+ * referências da web com escopo que você define (escopo fechado), ou sem
7
+ * referências — só motion." (29/09)
8
+ *
9
+ * Três modos:
10
+ * aberto pesquisa, site da marca e b-roll de banco à vontade (o híbrido)
11
+ * fechado só o que a pessoa disse: os sites e termos do escopo, nada além
12
+ * sem nenhuma foto nem vídeo de fora: motion puro (o kit), sem b-roll
13
+ *
14
+ * A escolha vale para o pedido (o terminal pergunta a cada vídeo novo) e é
15
+ * lida em dois lugares: a instrução que vai para o agente e o Estúdio, que
16
+ * não baixa b-roll no "sem" e só busca o escopo no "fechado".
17
+ */
18
+
19
+ 'use strict';
20
+
21
+ let ATUAL = { modo: null, escopo: '' };
22
+
23
+ function definir(modo, escopo = '') {
24
+ ATUAL = { modo: ['aberto', 'fechado', 'sem'].includes(modo) ? modo : null, escopo: String(escopo || '').trim() };
25
+ return ATUAL;
26
+ }
27
+ function atual() { return ATUAL; }
28
+ function limpar() { ATUAL = { modo: null, escopo: '' }; }
29
+
30
+ /** Os termos do escopo fechado: "site:anthropic.com, claude ai, sonnet" → lista. */
31
+ function termos(escopo = ATUAL.escopo) {
32
+ return String(escopo || '').split(/[,;\n]+/).map((x) => x.trim()).filter(Boolean).slice(0, 8);
33
+ }
34
+
35
+ /** A resposta da pergunta ("1", "2", "aberto", "só motion"…) → modo. */
36
+ function entender(resposta) {
37
+ const r = String(resposta || '').toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '').trim();
38
+ if (/^1\b|aberto|livre|qualquer/.test(r)) return 'aberto';
39
+ if (/^2\b|fechad|escopo|defino|so (d|n)(e|o)ss/.test(r)) return 'fechado';
40
+ if (/^3\b|sem ref|nenhum|so motion|motion|sem foto|sem imagem/.test(r)) return 'sem';
41
+ return null;
42
+ }
43
+
44
+ /* O MODO DEDUZIDO DO PEDIDO. Quem diz no pedido não é perguntado:
45
+ "só motion", "sem referência", "sem foto" → sem
46
+ "só do site x.com", "usa apenas anthropic.com e …" → fechado (o escopo)
47
+ "web aberta", "escopo aberto", "pesquisa à vontade" → aberto
48
+ O resto volta com `explicito: false` e o terminal pergunta antes de
49
+ começar: "eu posso pedir um vídeo mas estar me referindo ao motion"
50
+ (Joel, 02/10) — chutar o aberto fazia vídeo com foto e b-roll quando a
51
+ pessoa queria só motion. */
52
+ function inferir(texto) {
53
+ const t = String(texto || '').toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '');
54
+ if (/so (o )?motion|motion puro|sem refer|sem (foto|imagem|imagens|video de banco|pesquisa)|nao (pesquis|busqu)|so anima/.test(t)) return { modo: 'sem', escopo: '', explicito: true };
55
+ const dominios = String(texto || '').match(/\b[a-z0-9-]+(\.[a-z0-9-]+)+\.?[a-z]{2,}(\/[^\s,]*)?/gi) || [];
56
+ if (/\b(so|apenas|somente|unicamente)\b[^.]{0,30}\b(site|sites|link|links|fonte|fontes|pagina|de|do|da|em)\b/.test(t) && dominios.length) {
57
+ return { modo: 'fechado', escopo: dominios.join(', '), explicito: true };
58
+ }
59
+ if (/escopo aberto|web aberta|pesquis\w* (a vontade|na web|na internet)|busc\w* (imagens|fotos|videos) na (web|internet)|com (imagens|fotos) da (web|internet)/.test(t)) {
60
+ return { modo: 'aberto', escopo: '', explicito: true };
61
+ }
62
+ return { modo: 'aberto', escopo: '', explicito: false };
63
+ }
64
+
65
+ /** A instrução para o agente, conforme o modo. */
66
+ function instrucao({ modo, escopo } = ATUAL) {
67
+ if (modo === 'sem') {
68
+ return '[REFERÊNCIAS: NENHUMA] A pessoa quer SÓ MOTION: não pesquise (nada de pesquisar_referencia nem estudio_site), '
69
+ + 'não busque foto nem vídeo, não use estudio_midia de banco. Escreva o roteiro com o que o pedido já diz e deixe '
70
+ + 'o motion gerativo fazer o trabalho (roteiro.estilo "gerativo": a tipografia anima sozinha, sem template).';
71
+ }
72
+ if (modo === 'fechado') {
73
+ const t = termos(escopo);
74
+ return `[REFERÊNCIAS: ESCOPO FECHADO] Use SÓ estas fontes e termos: ${t.join(' · ') || escopo}. `
75
+ + 'Pesquise apenas dentro delas (em pesquisar_referencia, cite o site ou o termo no próprio texto da busca). '
76
+ + 'Nada de fato, foto ou vídeo de fora desse escopo; se faltar algo, diga no fim em uma linha. '
77
+ + `No roteiro, ponha roteiro.busca = ${JSON.stringify(t)} para o b-roll.`;
78
+ }
79
+ if (modo === 'aberto') {
80
+ return '[REFERÊNCIAS: ESCOPO ABERTO] Pesquise o assunto à vontade (pesquisar_referencia; estudio_site se for de uma marca) '
81
+ + 'e dê em cada parte do roteiro um "busca" em inglês com o que mostrar de fundo — gente, lugar, tela, objeto de verdade.';
82
+ }
83
+ return '';
84
+ }
85
+
86
+ /** Os domínios do escopo fechado: "só do site anthropic.com" → ['anthropic.com']. */
87
+ function dominios(escopo = ATUAL.escopo) {
88
+ return termos(escopo).map((t) => t.replace(/^site:/, '').replace(/^https?:\/\//, '').replace(/^www\./, '').split('/')[0].toLowerCase())
89
+ .filter((t) => /^[a-z0-9-]+(\.[a-z0-9-]+)+$/.test(t));
90
+ }
91
+
92
+ /** O resultado da pesquisa sem o que está fora do escopo fechado (subdomínios valem: www-cdn.anthropic.com). */
93
+ function filtrar(r, { modo, escopo } = ATUAL) {
94
+ if (modo !== 'fechado' || !r || !Array.isArray(r.referencias)) return r;
95
+ const ds = dominios(escopo);
96
+ if (!ds.length) return r;
97
+ const dentro = (u) => { try { const h = new URL(u).hostname.toLowerCase(); return ds.some((d) => h === d || h.endsWith('.' + d)); } catch { return false; } };
98
+ const refs = r.referencias.filter((x) => dentro(x.url));
99
+ const fora = r.referencias.length - refs.length;
100
+ if (!refs.length) return { ok: false, consulta: r.consulta, error: `nada desta busca é de ${ds.join(', ')} (escopo fechado)`,
101
+ comoCorrigir: `Abra o site direto: browser_open https://${ds[0]} e browser_read. Não use fonte de fora.` };
102
+ return { ...r, fontes: refs.length, referencias: refs, ...(fora ? { foraDoEscopo: fora } : {}) };
103
+ }
104
+
105
+ module.exports = { definir, atual, limpar, termos, entender, inferir, instrucao, dominios, filtrar };
@@ -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
  }
@@ -275,14 +279,20 @@ function Elemento({ el, t, dados }) {
275
279
  if (Array.isArray(el.revelar) && typeof el.texto === 'string') {
276
280
  const palavras = el.texto.split(' ');
277
281
  conteudo = palavras.map((w, i) => {
278
- const t0 = el.revelar[Math.min(i, el.revelar.length - 1)] ?? 0;
279
- const k = Math.min(1, Math.max(0, (tLocal - t0) / 0.28));
282
+ const marcada = !!(el.destaques && el.destaques.includes(i));
283
+ /* A PALAVRA DE DESTAQUE: com "fonteDestaque", o acento é a serifa em
284
+ itálico (na cor do texto), que chega um instante depois das outras.
285
+ Um acento só — nada de caixa de cor atrás (a "cara de IA", 05/10). */
286
+ const serifa = marcada && el.fonteDestaque;
287
+ const t0 = (el.revelar[Math.min(i, el.revelar.length - 1)] ?? 0) + (serifa ? 0.12 : 0);
288
+ const k = Math.min(1, Math.max(0, (tLocal - t0) / (serifa ? 0.45 : 0.28)));
280
289
  const e = CURVAS.saida(k);
281
290
  // O espaço fica FORA do span: é ali que a linha pode quebrar.
282
291
  return (
283
292
  <React.Fragment key={i}>
284
293
  <span style={{ display: 'inline-block', opacity: e, transform: 'translateY(' + (1 - e) * 0.35 + 'em)',
285
- color: el.destaques && el.destaques.includes(i) ? (el.corDestaque || el.cor) : undefined }}>{w}</span>
294
+ color: marcada && !serifa ? (el.corDestaque || el.cor) : undefined,
295
+ ...(serifa ? { fontFamily: '"' + el.fonteDestaque + '", Georgia, serif', fontStyle: 'italic', fontWeight: 400, fontSize: '1.1em', letterSpacing: 0 } : {}) }}>{w}</span>
286
296
  {i < palavras.length - 1 ? ' ' : null}
287
297
  </React.Fragment>
288
298
  );
@@ -461,12 +471,14 @@ function Elemento({ el, t, dados }) {
461
471
  display: 'inline-block',
462
472
  // A palavra forte é MAIOR na fonte, não na escala: escala não
463
473
  // abre espaço, e ela cresceria por cima da vizinha.
464
- fontSize: p.forte ? '1.22em' : undefined,
474
+ fontSize: p.forte ? (e.fonteForte ? '1.1em' : '1.22em') : undefined,
475
+ // Com "fonteForte", a palavra forte é a serifa em itálico, não o letreiro maior.
476
+ ...(p.forte && e.fonteForte ? { fontFamily: '"' + e.fonteForte + '", Georgia, serif', fontStyle: 'italic', fontWeight: 400, textTransform: 'none' } : {}),
465
477
  transform: 'scale(' + salto + ') translateY(' + (1 - entra) * 18 + 'px)',
466
478
  opacity: entra,
467
479
  color: cor,
468
480
  textShadow: e.sombra || '0 6px 22px rgba(0,0,0,.6), 0 2px 0 rgba(0,0,0,.35)',
469
- WebkitTextStroke: e.contorno || '2px rgba(0,0,0,.55)',
481
+ WebkitTextStroke: p.forte && e.fonteForte ? undefined : (e.contorno || '2px rgba(0,0,0,.55)'),
470
482
  paintOrder: 'stroke fill',
471
483
  background: falando && e.fundoAtivo ? e.fundoAtivo : undefined,
472
484
  borderRadius: '0.18em', padding: falando && e.fundoAtivo ? '0 0.12em' : undefined,
@@ -877,9 +889,15 @@ function fontesCSS(plano) {
877
889
  for (const cena of plano.cenas || []) andar(cena.elementos || []);
878
890
  andar(plano.hud);
879
891
  if (!usadas.size) return '/* nenhuma fonte externa neste vídeo */\n';
892
+ /* A FONTE VARIÁVEL: o motion gerativo anima os eixos (wght, wdth, GRAD…)
893
+ e precisa do arquivo variável inteiro, pedido pela faixa de cada eixo
894
+ ("Roboto+Flex:opsz,slnt,wdth,wght,GRAD@8..144,…"). As outras seguem
895
+ com os pesos fixos. */
896
+ const variaveis = plano.fontesVariaveis || {};
880
897
  const familias = [...usadas]
881
898
  // 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')
899
+ .map((f) => 'family=' + encodeURIComponent(f).replace(/%20/g, '+')
900
+ + (variaveis[f] ? ':' + variaveis[f] : ':ital,wght@0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,400;1,600'))
883
901
  .join('&');
884
902
  return `@import url('https://fonts.googleapis.com/css2?${familias}&display=block');\n`;
885
903
  }
@@ -904,15 +922,47 @@ function citarWin(a) {
904
922
  return '"' + t.replace(/"/g, '""') + '"';
905
923
  }
906
924
 
925
+ /* O REMOTION COMPARTILHADO. Cada projeto rodava o próprio `npm install`
926
+ (minutos, e ~300 MB por vídeo) — e um 404 passageiro do registro no meio
927
+ dele derrubava o vídeo inteiro (medido em 30/09: source-map-js 1.2.2).
928
+ Agora a instalação mora UMA vez em ~/.primocode/studio/remotion-base, com
929
+ as mesmas dependências, e o projeto aponta para ela. Dependência diferente
930
+ (versão nova do Remotion) reinstala a base. */
931
+ const os = require('os');
932
+ function baseCompartilhada(raiz) {
933
+ const base = path.join(os.homedir(), '.primocode', 'studio', 'remotion-base');
934
+ let deps = '';
935
+ 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; }
936
+ return { base, deps, pronta: (() => {
937
+ try { return fs.existsSync(path.join(base, 'node_modules', '.bin')) && fs.readFileSync(path.join(base, 'deps.json'), 'utf8') === deps; } catch { return false; }
938
+ })() };
939
+ }
940
+
907
941
  function renderizar({ raiz, saida, minutos = 30, aoFalar = () => {} }) {
908
942
  return new Promise((resolve) => {
909
- const temNodeModules = fs.existsSync(path.join(raiz, 'node_modules'));
910
- const passos = temNodeModules ? [] : [['npm', ['install', '--no-audit', '--no-fund']]];
911
- passos.push(['npx', ['remotion', 'render', 'Video', saida, '--log=warn']]);
912
-
913
- const correr = (i) => {
943
+ let temNodeModules = fs.existsSync(path.join(raiz, 'node_modules'));
944
+ const comp = temNodeModules || process.platform === 'win32' ? null : baseCompartilhada(raiz);
945
+ if (comp && comp.pronta) {
946
+ try { fs.symlinkSync(path.join(comp.base, 'node_modules'), path.join(raiz, 'node_modules'), 'dir'); temNodeModules = true; } catch { /* instala aqui mesmo */ }
947
+ }
948
+ const passos = [];
949
+ if (!temNodeModules && comp) {
950
+ // Instala na base (com uma segunda tentativa: o registro às vezes devolve 404 por instantes) e liga.
951
+ try { fs.mkdirSync(comp.base, { recursive: true }); fs.copyFileSync(path.join(raiz, 'package.json'), path.join(comp.base, 'package.json')); } catch { /* segue */ }
952
+ passos.push(['npm', ['install', '--no-audit', '--no-fund'], { cwd: comp.base, tentativas: 2, depois: () => {
953
+ fs.writeFileSync(path.join(comp.base, 'deps.json'), comp.deps);
954
+ try { fs.symlinkSync(path.join(comp.base, 'node_modules'), path.join(raiz, 'node_modules'), 'dir'); } catch { /* já existe */ }
955
+ } }]);
956
+ } else if (!temNodeModules) passos.push(['npm', ['install', '--no-audit', '--no-fund'], { tentativas: 2 }]);
957
+ // Vídeo de b-roll pesado (Pexels HD) levava mais de 30 s para o primeiro quadro.
958
+ /* VÁRIOS TRABALHADORES no render: o Remotion usa metade dos núcleos por
959
+ padrão; aqui vão quase todos (um fica para o terminal respirar). */
960
+ const nucleos = Math.max(2, (require('os').cpus() || []).length - 1);
961
+ passos.push(['npx', ['remotion', 'render', 'Video', saida, '--log=warn', '--timeout=120000', `--concurrency=${nucleos}`]]);
962
+
963
+ const correr = (i, tentativa = 1) => {
914
964
  if (i >= passos.length) return resolve({ ok: true, arquivo: saida });
915
- const [cmd, args] = passos[i];
965
+ const [cmd, args, op = {}] = passos[i];
916
966
  aoFalar(i === 0 && !temNodeModules
917
967
  ? 'instalando o Remotion nesta pasta (uma vez só, alguns minutos)'
918
968
  : 'renderizando quadro a quadro');
@@ -926,7 +976,7 @@ function renderizar({ raiz, saida, minutos = 30, aoFalar = () => {} }) {
926
976
  CVE-2024-27980 o Node recusa spawnar npm.cmd/npx.cmd sem shell.
927
977
  Então as aspas entram na mão, só onde o shell existe. */
928
978
  const citados = process.platform === 'win32' ? args.map(citarWin) : args;
929
- const p = spawn(cmd, citados, { cwd: raiz, shell: process.platform === 'win32' });
979
+ const p = spawn(cmd, citados, { cwd: op.cwd || raiz, shell: process.platform === 'win32' });
930
980
  let erro = '';
931
981
  p.stderr.on('data', (d) => { erro += d.toString().slice(0, 2000); });
932
982
  p.stdout.on('data', (d) => {
@@ -937,8 +987,10 @@ function renderizar({ raiz, saida, minutos = 30, aoFalar = () => {} }) {
937
987
  p.on('close', (code) => {
938
988
  clearTimeout(relogio);
939
989
  if (code !== 0) {
990
+ if (tentativa < (op.tentativas || 1)) { aoFalar('o npm tropeçou — tentando de novo'); return setTimeout(() => correr(i, tentativa + 1), 3000); }
940
991
  return resolve({ ok: false, error: `${cmd} saiu com ${code}: ${erro.slice(-600).trim()}` });
941
992
  }
993
+ try { if (op.depois) op.depois(); } catch { /* segue */ }
942
994
  correr(i + 1);
943
995
  });
944
996
  p.on('error', (e) => {
@@ -961,4 +1013,41 @@ function renderizar({ raiz, saida, minutos = 30, aoFalar = () => {} }) {
961
1013
  });
962
1014
  }
963
1015
 
964
- module.exports = { montar, renderizar, fontesCSS, VERSAO_REMOTION, CENA_JSX };
1016
+ /* ── ABRIR PARA EDITAR ────────────────────────────────────────────────
1017
+ "Quando eu falar pra editar, edita no editor de vídeo open source." O
1018
+ projeto já é Remotion: editar é abrir o Remotion Studio na pasta dele. Ele
1019
+ sobe solto (o PrimoCode não espera) e abre o navegador sozinho. */
1020
+ function comandoStudio(raiz) {
1021
+ return { cmd: 'npx', args: ['remotion', 'studio'], cwd: raiz };
1022
+ }
1023
+
1024
+ function abrirStudio({ raiz, spawnar = spawn } = {}) {
1025
+ if (!raiz || !fs.existsSync(path.join(raiz, 'package.json'))) {
1026
+ return { ok: false, error: 'não achei o projeto Remotion nesta pasta.' };
1027
+ }
1028
+ // O Studio precisa do Remotion instalado. A base compartilhada já vem do primeiro render.
1029
+ if (!fs.existsSync(path.join(raiz, 'node_modules'))) {
1030
+ const comp = process.platform === 'win32' ? null : baseCompartilhada(raiz);
1031
+ if (comp && comp.pronta) {
1032
+ try { fs.symlinkSync(path.join(comp.base, 'node_modules'), path.join(raiz, 'node_modules'), 'dir'); } catch { /* segue */ }
1033
+ }
1034
+ if (!fs.existsSync(path.join(raiz, 'node_modules'))) {
1035
+ return { ok: false, error: 'o Remotion ainda não está instalado para este projeto.',
1036
+ comoResolver: 'renderize uma vez com estudio_render (instala o Remotion), depois abra o editor.' };
1037
+ }
1038
+ }
1039
+ const c = comandoStudio(raiz);
1040
+ const win = process.platform === 'win32';
1041
+ try {
1042
+ const p = spawnar(c.cmd, win ? c.args.map(citarWin) : c.args,
1043
+ { cwd: c.cwd, detached: true, stdio: 'ignore', shell: win });
1044
+ if (p && p.on) p.on('error', () => { /* sem npx: o caminho vai na resposta */ });
1045
+ if (p && p.unref) p.unref();
1046
+ } catch (e) {
1047
+ return { ok: false, error: e.message };
1048
+ }
1049
+ return { ok: true, raiz, comando: `cd "${raiz}" && npx remotion studio` };
1050
+ }
1051
+
1052
+ module.exports = { montar, renderizar, fontesCSS, VERSAO_REMOTION, CENA_JSX, pkgJson, baseCompartilhada, TSCONFIG,
1053
+ comandoStudio, abrirStudio };
@@ -25,7 +25,7 @@ const r2 = (n) => Math.round(n * 100) / 100;
25
25
  *
26
26
  * @returns {{ini:number[], dur:number[], total:number, temVoz:boolean}}
27
27
  */
28
- function tempoDasPartes(partes, tempos = [], { semVoz = () => 2.6, antes = 0.12, cauda = 1.1, minimo = 1.1 } = {}) {
28
+ function tempoDasPartes(partes, tempos = [], { semVoz = () => 2.0, antes = 0.08, cauda = 0.6, minimo = 0.9 } = {}) {
29
29
  const temVoz = tempos.length === partes.length && tempos.every((t) => t && t.ate > t.de);
30
30
  const ini = [];
31
31
  if (temVoz) partes.forEach((_, i) => ini.push(i === 0 ? 0 : r2(Math.max(ini[i - 1] + minimo, tempos[i].de - antes))));
@@ -60,7 +60,7 @@ function palavrasNaCena(tempo, inicio) {
60
60
  }
61
61
 
62
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 } = {}) {
63
+ function volumesDaTrilha(tempos, total, { alto = 0.4, baixo = 0.2 } = {}) {
64
64
  const k = (t, v) => ({ t: r2(t), v });
65
65
  const vs = [k(0, 0.05), k(0.4, alto)];
66
66
  // Um quadro-chave de cada vez, sempre depois do anterior: fala colada na outra não volta no tempo.
@@ -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,46 @@
1
+ /**
2
+ * variacao3d.js — o produto em 3D (kit/tres.jsx) diferente a cada vídeo.
3
+ *
4
+ * "várias opções de elementos 3D... vários estilos... que não sejam padrão
5
+ * da IA" (Joel, 05/10)
6
+ *
7
+ * Antes, todo fone saía igual: o mesmo material, a mesma câmera, a mesma luz
8
+ * e o mesmo giro. Agora cada vídeo sorteia o acabamento, o enquadramento, a
9
+ * luz e o ritmo — sem repetir os últimos (memória '3d' do variedade.js). Os
10
+ * nomes daqui são as chaves que o tres.jsx desenha.
11
+ */
12
+
13
+ 'use strict';
14
+
15
+ const variedade = require('./variedade.js');
16
+
17
+ const VARIACOES = {
18
+ acabamento: ['verniz', 'metal', 'fosco', 'perola', 'acetinado'],
19
+ camera: ['frente', 'alto', 'baixo', 'perfil', 'perto'],
20
+ luz: ['estudio', 'recorte', 'suave', 'lateral'],
21
+ ritmo: ['lento', 'medio', 'rapido', 'pendulo'],
22
+ };
23
+
24
+ /** Um 3D por vídeo: cada faceta sorteada sem repetir as últimas. */
25
+ function escolherVariacao3d(aleatorio = Math.random) {
26
+ const v = {};
27
+ for (const [faceta, opcoes] of Object.entries(VARIACOES)) v[faceta] = variedade.sortear(opcoes, '3d', { prefixo: faceta + ':', aleatorio });
28
+ return v;
29
+ }
30
+
31
+ /** Qual modelo o pedido descreve. Sem pista, a caixa — um produto genérico que ainda gira. */
32
+ const MODELOS3D = [
33
+ ['fone', /fone|headset|headphone|auricular/i],
34
+ ['celular', /celular|smartphone|iphone|phone|telefone/i],
35
+ ['notebook', /notebook|laptop|computador|macbook/i],
36
+ ['relogio', /rel[oó]gio|smartwatch|\bwatch\b/i],
37
+ ['garrafa', /garrafa|squeeze|bottle|garrafinha|vinho|perfume/i],
38
+ ['lata', /lata|bebida|refri|cerveja|energ[eé]tico/i],
39
+ ];
40
+
41
+ function modeloDoPedido(texto) {
42
+ const achado = MODELOS3D.find(([, re]) => re.test(String(texto || '')));
43
+ return achado ? achado[0] : 'caixa';
44
+ }
45
+
46
+ module.exports = { VARIACOES, MODELOS3D, escolherVariacao3d, modeloDoPedido };
@@ -0,0 +1,183 @@
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, broll: 80, icones: 4, estilo: 8, '3d': 24 };
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
+ /**
58
+ * Uma das opções, sem repetir as últimas: as nunca usadas primeiro (sorteadas),
59
+ * depois a usada há mais tempo. Marca a escolhida na memória do tipo. Com
60
+ * `prefixo`, várias listas dividem o mesmo arquivo ('acabamento:fosco').
61
+ */
62
+ function sortear(opcoes, tipo, { prefixo = '', aleatorio = Math.random } = {}) {
63
+ if (!opcoes || !opcoes.length) return null;
64
+ const ja = usadas(tipo);
65
+ const idade = (o) => ja.indexOf(prefixo + o); // 0 = a última usada
66
+ const novas = embaralhar(opcoes.filter((o) => idade(o) < 0), aleatorio);
67
+ const velhas = opcoes.filter((o) => idade(o) >= 0).sort((a, b) => idade(b) - idade(a));
68
+ const escolha = [...novas, ...velhas][0];
69
+ marcar(tipo, prefixo + escolha);
70
+ return escolha;
71
+ }
72
+
73
+ // ── a trilha: termos por clima ──────────────────────────────────────────
74
+ const CLIMAS = {
75
+ tecnologia: ['electronic instrumental', 'synthwave instrumental', 'future bass instrumental', 'downtempo electronic', 'minimal techno instrumental', 'chillwave instrumental'],
76
+ energetico: ['funk instrumental', 'rock instrumental', 'drum and bass instrumental', 'breakbeat instrumental', 'upbeat indie instrumental', 'house instrumental'],
77
+ epico: ['epic orchestral', 'trailer instrumental', 'cinematic drums', 'epic hybrid instrumental', 'dramatic strings'],
78
+ calmo: ['lofi hip hop instrumental', 'ambient instrumental', 'acoustic guitar instrumental', 'piano instrumental', 'jazz instrumental'],
79
+ alegre: ['ukulele instrumental', 'indie pop instrumental', 'happy acoustic instrumental', 'latin instrumental', 'disco instrumental'],
80
+ emocional: ['piano emotional instrumental', 'post rock instrumental', 'cinematic strings', 'neo classical instrumental'],
81
+ misterio: ['dark ambient instrumental', 'trip hop instrumental', 'suspense instrumental', 'noir jazz instrumental'],
82
+ neutro: ['indie instrumental', 'soul instrumental', 'jazz hop instrumental', 'world instrumental', 'electronic instrumental', 'acoustic instrumental'],
83
+ };
84
+ const SINONIMOS = {
85
+ tecnologia: /tec|tech|ia\b|ai\b|software|app|digital|futur/, energetico: /energ|agit|rapid|forte|esport|academ/,
86
+ epico: /epic|epico|grandi|trailer|impact/, calmo: /calm|tranq|suave|relax|chill/, alegre: /aleg|feliz|divert|leve/,
87
+ emocional: /emoc|emoti|inspir|sentim|historia/, misterio: /mist|sombr|dark|suspen|tens/,
88
+ };
89
+
90
+ function climaDe(roteiro = {}, estilo = '') {
91
+ const t = String([roteiro.clima, roteiro.genero, roteiro.tom].filter(Boolean).join(' ')).toLowerCase()
92
+ .normalize('NFD').replace(/[̀-ͯ]/g, '');
93
+ for (const [clima, re] of Object.entries(SINONIMOS)) if (re.test(t)) return clima;
94
+ return estilo === 'impacto' ? 'energetico' : 'neutro';
95
+ }
96
+
97
+ /**
98
+ * Os termos para buscar a trilha, em ordem de tentar. O pedido explícito
99
+ * (roteiro.musica) vem primeiro; depois o clima, sorteado; e por último um
100
+ * clima vizinho, para nunca sair sem música.
101
+ */
102
+ function termosTrilha(roteiro = {}, estilo = '', aleatorio = Math.random) {
103
+ const clima = climaDe(roteiro, estilo);
104
+ const reserva = clima === 'neutro' ? 'calmo' : 'neutro';
105
+ return [...new Set([roteiro.musica, ...embaralhar(CLIMAS[clima], aleatorio).slice(0, 3),
106
+ ...embaralhar(CLIMAS[reserva], aleatorio).slice(0, 2)].filter(Boolean))];
107
+ }
108
+
109
+ // ── os efeitos: várias formas de pedir o mesmo som ───────────────────────
110
+ const SFX = {
111
+ teclado: ['keyboard typing', 'typing keyboard', 'mechanical keyboard', 'laptop typing', 'typewriter keys'],
112
+ whoosh: ['cinematic whoosh', 'swoosh transition', 'air whoosh', 'fast swipe', 'woosh pass by', 'riser whoosh'],
113
+ impacto: ['cinematic boom impact', 'deep cinematic impact', 'trailer hit', 'bass drop hit', 'punch hit', 'sub boom'],
114
+ sala: ['crowd murmur indoor', 'office ambience', 'people talking background', 'cafe ambience', 'room tone people'],
115
+ };
116
+
117
+ function termosSfx(nome, aleatorio = Math.random) {
118
+ return embaralhar(SFX[nome] || [nome], aleatorio).slice(0, 3);
119
+ }
120
+
121
+ // ── o b-roll: o mesmo pedido, de jeitos diferentes ───────────────────────
122
+ /* "não ficar só nas mesmas fontes, mesmos estilos" (Joel, 05/10): o mesmo
123
+ roteiro renderizado duas vezes trocou fonte, cor e música, mas o b-roll
124
+ veio IDÊNTICO — as mesmas mãos no teclado. O termo era o mesmo e o
125
+ primeiro resultado do Pexels também. Agora o termo ganha um jeito de
126
+ filmar sorteado, e o clipe já usado vai para o fim da fila. */
127
+ const JEITOS = ['cinematic', 'slow motion', 'close up', 'natural light', 'detail shot', 'wide shot', 'handheld'];
128
+
129
+ function termosBroll(termo, aleatorio = Math.random) {
130
+ const t = String(termo || '').replace(/\s+/g, ' ').trim();
131
+ if (!t) return [];
132
+ const jeitos = embaralhar(JEITOS.filter((j) => !t.toLowerCase().includes(j)), aleatorio).slice(0, 2);
133
+ return embaralhar([t, ...jeitos.map((j) => `${t} ${j}`)], aleatorio);
134
+ }
135
+
136
+ /**
137
+ * Busca com as variações do termo e devolve os itens na ordem de tentar:
138
+ * para no primeiro termo que trouxer algo nunca usado. `buscar(termo)`
139
+ * devolve a lista de itens ({url, …}) — é a busca de verdade, ou um stub.
140
+ */
141
+ async function buscarSemRepetir(buscar, termo, tipo = 'broll', aleatorio = Math.random) {
142
+ const ja = new Set(usadas(tipo));
143
+ let reserva = [];
144
+ for (const t of termosBroll(termo, aleatorio)) {
145
+ let itens = [];
146
+ try { itens = ordenar(await buscar(t), tipo, aleatorio); } catch { continue; }
147
+ if (itens.length && !ja.has(itens[0].url)) return itens;
148
+ if (!reserva.length) reserva = itens;
149
+ }
150
+ return reserva;
151
+ }
152
+
153
+ // ── os ícones: a coleção muda de um vídeo para o outro ──────────────────
154
+ /* O ícone automático (edicao.iconeDe) é sempre "fluent-3d:<emoji>". Estas
155
+ coleções do Iconify usam os mesmos nomes de emoji, então dá para trocar a
156
+ coleção inteira do vídeo; o que não existir na coleção nova fica no 3D. */
157
+ const FAMILIAS_ICONE = ['fluent-3d', 'fluent-emoji-flat', 'fluent-emoji', 'noto', 'twemoji', 'openmoji'];
158
+
159
+ function colecaoIcones(aleatorio = Math.random) {
160
+ return ordenar(FAMILIAS_ICONE.map((url) => ({ url })), 'icones', aleatorio)[0].url;
161
+ }
162
+
163
+ function cadaIcone(plano, fn) {
164
+ for (const cena of (plano && plano.cenas) || []) for (const el of cena.elementos || []) if (el && el.tipo === 'icone' && el.nome) fn(el);
165
+ }
166
+
167
+ /** Os ícones automáticos do plano — os que o roteiro pediu pelo nome ficam como estão. */
168
+ function iconesTrocaveis(plano, explicitos = []) {
169
+ const fixos = new Set(explicitos);
170
+ const nomes = new Set();
171
+ cadaIcone(plano, (el) => { if (/^fluent-3d:/.test(el.nome) && !fixos.has(el.nome)) nomes.add(el.nome); });
172
+ return [...nomes];
173
+ }
174
+
175
+ /** Troca no plano cada nome que estiver em `troca` ({velho: novo}). Devolve quantos trocou. */
176
+ function trocarIcones(plano, troca = {}) {
177
+ let n = 0;
178
+ cadaIcone(plano, (el) => { if (troca[el.nome]) { el.nome = troca[el.nome]; n++; } });
179
+ return n;
180
+ }
181
+
182
+ module.exports = { usadas, marcar, embaralhar, ordenar, sortear, climaDe, termosTrilha, termosSfx, termosBroll, buscarSemRepetir,
183
+ colecaoIcones, iconesTrocaveis, trocarIcones, CLIMAS, SFX, JEITOS, FAMILIAS_ICONE };