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
@@ -34,6 +34,9 @@ const { spawn } = require('child_process');
34
34
 
35
35
  const projeto = require('./projeto.js');
36
36
  const acervo = require('./acervo.js');
37
+ const hibrido = require('./hibrido.js');
38
+ const referencias = require('./referencias.js');
39
+ const variedade = require('./variedade.js');
37
40
  const plano = require('./plano.js');
38
41
  const remotion = require('./remotion.js');
39
42
  const assets = require('./assets.js');
@@ -46,6 +49,11 @@ const acervoSite = require('./acervo-site.js');
46
49
  const impacto = require('./impacto.js');
47
50
  const cinema = require('./cinema.js');
48
51
  const motion = require('./motion.js');
52
+ const gerativo = require('./gerativo.js');
53
+ const vox = require('./vox.js');
54
+ const lancamento = require('./lancamento.js');
55
+ const glifos = require('./glifos.js');
56
+ const { escolherEstilo, LEVES } = require('./estilo.js');
49
57
  const sonsLeves = require('./sons-leves.js');
50
58
  const ende = require('./ende.js');
51
59
 
@@ -222,7 +230,8 @@ function roteiroValido(r) {
222
230
  const partes = Array.isArray(x) ? x : x && x.partes;
223
231
  if (!Array.isArray(partes) || partes.length < 2) return null;
224
232
  const CINEMA = ['frase', 'montagem', 'numeros', 'escada', 'dia', 'depoimento', 'climax', 'final'];
225
- if (!partes.every((pt) => pt && (composicao.TIPOS.includes(pt.tipo) || CINEMA.includes(pt.tipo)))) return null;
233
+ const NOVOS = ['logo', 'cores', 'produto']; // vox.js e lancamento.js
234
+ if (!partes.every((pt) => pt && (composicao.TIPOS.includes(pt.tipo) || CINEMA.includes(pt.tipo) || NOVOS.includes(pt.tipo)))) return null;
226
235
  const obj = Array.isArray(x) ? { partes } : x;
227
236
  return partes.some((pt) => CINEMA.includes(pt.tipo)) && !obj.estilo ? { ...obj, estilo: 'cinema' } : obj;
228
237
  }
@@ -245,7 +254,6 @@ async function garantirAcervo(p) {
245
254
  /* O ROTEIRISTA do servidor: quando o modelo não manda um roteiro que preste
246
255
  (nenhum, ou texto corrido), o pedido e os fatos da marca viram o roteiro
247
256
  numa chamada só, com uma tarefa só. */
248
- const PEDE_MOTION = /\bmotion\b|anima[çc][ãa]o|animad[oa]/i;
249
257
 
250
258
  async function roteiroDoServidor(p, args) {
251
259
  const cfg = require('../config').loadConfig();
@@ -260,7 +268,7 @@ async function roteiroDoServidor(p, args) {
260
268
  site: acervo.site ? new URL(acervo.site).hostname.replace(/^www\./, '') : '',
261
269
  rascunho: typeof args.roteiro === 'string' ? args.roteiro : '',
262
270
  // Pedido de motion nunca vira impacto, nem sendo anúncio.
263
- estilo: args.estilo || (!PEDE_MOTION.test(pedido) && /an[uú]nci|reels?|divulg|promo|lan[çc]a|vend|campanha|convite|inscri/i.test(pedido) ? 'impacto' : undefined),
271
+ estilo: args.estilo || (escolherEstilo({ pedido }) === 'impacto' ? 'impacto' : undefined),
264
272
  }, cfg.token);
265
273
  if (!r || !r.ok) return { ok: false, error: (r && r.erro) || 'o roteirista não respondeu.' };
266
274
  return { ok: true, roteiro: { ...r.roteiro, marca: (args.roteiro && args.roteiro.marca) || p.marca || undefined } };
@@ -302,6 +310,11 @@ async function ferramentaPlano(args) {
302
310
  sons (lib/estudio/composicao.js). É o caminho para qualquer modelo
303
311
  entregar motion e slides que prestam. */
304
312
  if (args.roteiro) {
313
+ /* O roteiro como a pessoa (ou o modelo) mandou: o híbrido traduz as
314
+ partes para o cinema no caminho, e o que fica guardado tem de ser o
315
+ original — senão o próximo ajuste parte do traduzido. */
316
+ let original = null;
317
+ try { original = JSON.parse(typeof args.roteiro === 'string' ? args.roteiro : JSON.stringify(args.roteiro)); } catch { original = args.roteiro; }
305
318
  const r = await prepararRoteiro(p, args);
306
319
  if (!r.ok) return r;
307
320
  let conferida = plano.conferir(r.plano, { pasta: p.pasta });
@@ -318,7 +331,7 @@ async function ferramentaPlano(args) {
318
331
  }
319
332
  projeto.salvarPlano(args.id, r.plano, args.motivo || 'montado do roteiro');
320
333
  // O roteiro fica guardado: ajustar uma frase é mandar o roteiro de novo, não reescrever tudo.
321
- try { fs.writeFileSync(path.join(p.pasta, 'roteiro.json'), JSON.stringify(args.roteiro, null, 2)); } catch { /* segue */ }
334
+ try { fs.writeFileSync(path.join(p.pasta, 'roteiro.json'), JSON.stringify(original || args.roteiro, null, 2)); } catch { /* segue */ }
322
335
  projeto.salvar(args.id, { duracao: conferida.duracao });
323
336
  return {
324
337
  ok: true, duracao: conferida.duracao, ...r.resumo, resumo: plano.resumo(r.plano),
@@ -883,21 +896,18 @@ async function temLetra(arquivo) {
883
896
  (25/09/2026) */
884
897
  async function sonsReais(p, video) {
885
898
  const sint = await edicao.prepararSons(p.pasta, video);
886
- const quer = {
887
- teclado: ['keyboard typing', 'typing keyboard'],
888
- whoosh: ['cinematic whoosh', 'swoosh transition'],
889
- impacto: ['cinematic boom impact', 'deep cinematic impact', 'trailer hit'],
890
- sala: ['crowd murmur indoor', 'office ambience', 'people talking background'],
891
- };
899
+ /* Termo sorteado e resultado sorteado, sem repetir os últimos: o primeiro
900
+ resultado do termo fixo era o mesmo whoosh em todo vídeo (02/10). */
892
901
  const sons = { ...sint };
893
- for (const [nome, termos] of Object.entries(quer)) {
894
- for (const termo of termos) {
902
+ for (const nome of ['teclado', 'whoosh', 'impacto', 'sala']) {
903
+ for (const termo of variedade.termosSfx(nome)) {
904
+ if (sons[nome] !== sint[nome]) break;
895
905
  try {
896
- const b = await assets.procurarSom(termo, { quantas: 5 });
897
- const item = (b.ok ? b.sons : []).find((x) => x.url);
898
- if (!item) continue;
899
- const d = await assets.baixar(item, { pasta: p.pasta, nome: 'sfx-real-' + nome });
900
- if (d.ok) { sons[nome] = d.relativo; break; }
906
+ const b = await assets.procurarSom(termo, { quantas: 10 });
907
+ for (const item of variedade.ordenar(b.ok ? b.sons : [], 'sfx').slice(0, 3)) {
908
+ const d = await assets.baixar(item, { pasta: p.pasta, nome: 'sfx-real-' + nome });
909
+ if (d.ok) { sons[nome] = d.relativo; variedade.marcar('sfx', item.url); break; }
910
+ }
901
911
  } catch { /* o próximo termo */ }
902
912
  }
903
913
  }
@@ -922,20 +932,33 @@ async function prepararRoteiro(p, args) {
922
932
  if (roteiro.marca.logo && !existe(roteiro.marca.logo)) roteiro.marca.logo = (p.marca && existe(p.marca.logo)) ? p.marca.logo : undefined;
923
933
  } else if (p.marca) roteiro.marca = p.marca;
924
934
  const partes = roteiro.partes || [];
925
- /* O ESTILO. O padrão é MOTION (motion.js): um plano contínuo em 3D, com
926
- um protagonista que se deforma de parte em parte — "isso sim é motion"
927
- (Joel, 28/09). "limpo" era o nome antigo do padrão e vira motion
928
- também. Anúncio, reels e venda vão para o "impacto" (corte a cada
929
- batida), a menos que o pedido diga motion ou animação. A composição
930
- antiga, uma cena por parte, só com estilo: "composicao". */
935
+ /* O ESTILO (estilo.js). O padrão é MOTION (motion.js): um plano contínuo
936
+ em 3D — "isso sim é motion" (Joel, 28/09). Explicação vira VOX
937
+ (editorial, tinta sobre papel), marca vira MARCA (brand reveal),
938
+ lançamento de produto vira LANCAMENTO, anúncio e reels vão para o
939
+ IMPACTO. O que o agente pediu vale mais que tudo. */
931
940
  const pedidoTxt = `${p.pedido || ''} ${args.pedido || ''}`;
932
- // O estilo que o AGENTE pediu vale; o que o roteirista pôs no roteiro não
933
- // passa por cima de um pedido de motion.
934
- const pedido0 = args.estilo || (PEDE_MOTION.test(pedidoTxt) ? null : roteiro.estilo);
935
- const estilo = pedido0 === 'limpo' ? 'motion' : (pedido0
936
- || (PEDE_MOTION.test(pedidoTxt) ? 'motion'
937
- : /an[uú]nci|reels?|divulg|promo|lan[çc]a|vend|campanha|convite|inscri/i.test(pedidoTxt) ? 'impacto' : 'motion'));
938
- const ehMotion = estilo === 'motion';
941
+ const ref = referencias.atual();
942
+ let estilo = escolherEstilo({ pedido: pedidoTxt, agente: args.estilo, roteiro: roteiro.estilo });
943
+ /* "Sem referências, só motion": o híbrido vive de b-roll de fora, então
944
+ cai para o motion — sem nada baixado. */
945
+ const produtoNoPedido = /produto|an[uú]nci|lan[çc]a|headset|fone|celular|t[eê]nis|rel[oó]gio|garrafa|lata|app\b/i.test(pedidoTxt);
946
+ /* E o motion de agora é o GERATIVO: sem template, produto ou não (o
947
+ produto em 3D entra numa batida dele, logo abaixo). */
948
+ if (ref.modo === 'sem' && estilo === 'hibrido') estilo = 'gerativo';
949
+ void produtoNoPedido;
950
+ /* "O vídeo deve ter um 3D do fone" (30/09): pedido de 3D vira o gerativo
951
+ com o produto modelado girando numa batida (kit/tres.jsx) — a menos que
952
+ o agente tenha escolhido outro estilo pelo nome. */
953
+ if (/\b3\s?d\b|tridimensional/i.test(pedidoTxt) && (!args.estilo || estilo === 'gerativo' || estilo === 'lancamento')) {
954
+ // O 3D mora numa batida do gerativo; o lançamento antigo, só pelo nome.
955
+ if (!args.estilo && estilo !== 'lancamento') estilo = 'gerativo';
956
+ if (!roteiro.modelo3d) roteiro.modelo3d = /fone|headset|headphone/i.test(pedidoTxt) ? 'fone'
957
+ : /celular|smartphone|phone/i.test(pedidoTxt) ? 'celular' : /lata|bebida|garrafa/i.test(pedidoTxt) ? 'lata' : 'caixa';
958
+ }
959
+ if (ref.modo === 'fechado' && !roteiro.busca && referencias.termos().length) roteiro.busca = referencias.termos();
960
+ // Os estilos de motion usam os efeitos leves e a trilha do ende.app; os de corte, os cheios.
961
+ const ehMotion = LEVES.has(estilo);
939
962
  const modo = (args.modo || roteiro.modo || (p.formato === 'slides' || p.formato === 'arte' ? 'slides' : 'video'));
940
963
  const [largura, altura] = PROPORCOES[roteiro.proporcao || args.proporcao] || (modo === 'slides' ? PROPORCOES['16:9'] : PROPORCOES['16:9']);
941
964
 
@@ -982,7 +1005,15 @@ async function prepararRoteiro(p, args) {
982
1005
  }
983
1006
 
984
1007
  let tempos = [], narracao = null, sons = {}, trilha = null;
985
- if (modo === 'video' && estilo === 'cinema') {
1008
+ /* O HÍBRIDO (hibrido.js) é o cinema com o kit por cima: o roteiro de
1009
+ qualquer estilo vira os tipos do cinema aqui, antes da voz — a voz lê
1010
+ o que o cinema vai mostrar. */
1011
+ let hib = null;
1012
+ if (modo === 'video' && estilo === 'hibrido') {
1013
+ hib = hibrido.traduzir(roteiro);
1014
+ partes.splice(0, partes.length, ...hib.roteiro.partes);
1015
+ }
1016
+ if (modo === 'video' && (estilo === 'cinema' || estilo === 'hibrido')) {
986
1017
  // Só as partes com voz vão para a narração; o depoimento tem a voz do aluno.
987
1018
  // Só as partes com voz: o depoimento tem a voz do aluno, e a parte muda não tem voz.
988
1019
  // A parte com voz forte é narrada à parte: se entrasse aqui também, seria falada duas vezes (e muda uma delas).
@@ -1052,20 +1083,39 @@ async function prepararRoteiro(p, args) {
1052
1083
  }
1053
1084
  }
1054
1085
  }
1086
+ /* No HÍBRIDO a voz anda um pouco mais rápido por padrão (1,12×, como no
1087
+ vox): o trailer premium do cinema lido pelo Gemini sai no passo de
1088
+ leitura, e o vídeo segue a voz. `roteiro.ritmoVoz` ajusta. */
1089
+ if (hib && ritmoVoz <= 1.01) {
1090
+ const kh = Math.max(1, Math.min(1.5, Number(roteiro.ritmoVoz) || 1.12));
1091
+ const rapido = mp3.replace(/\.mp3$/, '-h.mp3');
1092
+ const c = kh > 1.01 ? await video.rodar(video.ffmpeg(), ['-y', '-i', mp3, '-filter:a', `atempo=${kh.toFixed(3)}`, rapido], { timeout: 120000 }) : { ok: false };
1093
+ if (process.env.PRIMOCODE_TRACO === '1') process.stderr.write(`[traço] voz do híbrido ${kh}× ok=${c.ok} existe=${fs.existsSync(rapido)} ${String(c.erro || '').slice(-120)}\n`);
1094
+ if (c.ok && fs.existsSync(rapido)) {
1095
+ fs.renameSync(rapido, mp3);
1096
+ ritmoVoz = kh;
1097
+ const div = (x) => Math.round((x / kh) * 1000) / 1000;
1098
+ v.partes = v.partes.map((pt) => ({ de: div(pt.de), ate: div(pt.ate), palavras: (pt.palavras || []).map((w) => ({ ...w, de: div(w.de), ate: div(w.ate) })) }));
1099
+ v.segundos = div(v.segundos);
1100
+ }
1101
+ }
1055
1102
  projeto.anotar(p.id, 'narracao', `${v.segundos}s em ${lista.length} partes, voz ${v.voz}`);
1056
1103
  const sonsC = await sonsReais(p, video);
1057
1104
  let trilhaC = null;
1058
1105
  // Vários termos e durações: vídeo sem música foi o que saiu na primeira vez (25/09).
1059
1106
  // E só instrumental: "essa música daria pra deixar só o instrumental?" — a faixa é OUVIDA.
1060
- for (const [termo, min] of [[roteiro.musica, 60], ['instrumental cinematic inspiring', 70], ['instrumental piano cinematic', 60], ['instrumental epic cinematic', 60], ['instrumental ambient inspiring', 45], ['instrumental', 45]]) {
1061
- if (!termo || trilhaC) continue;
1107
+ // O termo sai do clima do roteiro (variedade.js), não de uma lista fixa de "cinematic inspiring".
1108
+ for (const termo of [...variedade.termosTrilha(roteiro, 'cinema'), 'instrumental']) {
1109
+ if (trilhaC) break;
1062
1110
  try {
1063
- const b = await assets.procurarMusica(termo, { quantas: 6, duracaoMin: min });
1064
- for (const item of (b.ok ? b.sons : [])) {
1111
+ const b = await assets.procurarMusica(termo, { quantas: 10, duracaoMin: 45 });
1112
+ for (const item of variedade.ordenar(b.ok ? b.sons : [], 'trilha').filter((x) => !/vocal|voice|feat|ft\.|remix/i.test(x.titulo || '')).slice(0, 4)) {
1065
1113
  const d = await assets.baixar(item, { pasta: p.pasta, nome: 'trilha' });
1066
1114
  if (!d.ok) continue;
1067
1115
  if (await temLetra(path.join(p.pasta, d.relativo))) { projeto.anotar(p.id, 'midia', `trilha com voz descartada: ${item.titulo}`); continue; }
1068
- trilhaC = d.relativo; break;
1116
+ trilhaC = d.relativo; variedade.marcar('trilha', item.url);
1117
+ projeto.anotar(p.id, 'trilha', `"${termo}": ${item.titulo} — ${item.autor || 'Jamendo'} (${item.licenca || 'CC'})`);
1118
+ break;
1069
1119
  }
1070
1120
  } catch { /* o próximo termo */ }
1071
1121
  }
@@ -1086,7 +1136,69 @@ async function prepararRoteiro(p, args) {
1086
1136
  const MINI = /poster|face \d+|\bthumb/i;
1087
1137
  const fotosC = acervo.fotos.filter((f) => !MINI.test(f.alt || ''))
1088
1138
  .map((f) => ({ src: f.src, tipo: 'imagem', cat: TELA.test(f.alt || '') ? 'tela' : 'pessoas', alt: f.alt }));
1089
- const rostos = await acharRostos(p, fotosC.map((f) => f.src));
1139
+ /* O B-ROLL do híbrido: o cinema vive de foto que troca por baixo do
1140
+ texto, e o acervo da marca raramente passa de meia dúzia. Vídeo de
1141
+ banco primeiro (o fundo que se move sozinho), foto como reserva —
1142
+ em paralelo, porque são oito buscas. */
1143
+ /* Foto de compartilhamento (og:image) e logo trazem o NOME da marca em
1144
+ letra gigante: como fundo, viravam um "THRO" cortado atrás do texto. */
1145
+ /* E arte de banner (2400×1260 e afins): é o logo da marca desenhado, não
1146
+ uma cena — cortada no vídeo em pé, vira um "✳ Cl" gigante. */
1147
+ const banner = (alt) => { const m = /(\d{3,4})x(\d{3,4})/.exec(alt || ''); return m && Number(m[1]) / Number(m[2]) > 1.7 && altura > largura; };
1148
+ if (hib) for (let q = fotosC.length - 1; q >= 0; q--) if (/open graph|og[:_ -]?image|\blogo|banner|wordmark/i.test(fotosC[q].alt || '') || banner(fotosC[q].alt)) fotosC.splice(q, 1);
1149
+ if (hib && fotosC.length < 14) {
1150
+ const orientacao = largura >= altura ? 'landscape' : 'portrait';
1151
+ const pastaMidia = path.join(p.pasta, 'midia');
1152
+ const jaTem = (() => { try { return fs.readdirSync(pastaMidia); } catch { return []; } })();
1153
+ const cat = (termo) => (/screen|code|data|server|dashboard/i.test(termo) ? 'tela' : 'pessoas');
1154
+ /* A versão LEVE do b-roll: o Pexels entrega 1080×1920 inteiro (até 30
1155
+ MB e 20 s), e o render parava num quadro esperando vários vídeos
1156
+ assim ao mesmo tempo (medido: timeout de 120 s no quadro 184). Cada
1157
+ um vira 8 s, 720 de largura, sem áudio — e fica guardado, para o
1158
+ próximo render não baixar de novo. */
1159
+ const leve = async (rel, destino) => {
1160
+ const alvo = path.join(pastaMidia, destino);
1161
+ const escala = largura >= altura ? 'scale=1280:-2' : 'scale=720:-2';
1162
+ const c = await video.rodar(video.ffmpeg(), ['-y', '-i', path.join(p.pasta, rel), '-t', '8', '-vf', escala + ',fps=30', '-an',
1163
+ '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '24', '-pix_fmt', 'yuv420p', '-movflags', '+faststart', alvo], { timeout: 180000 });
1164
+ if (!c.ok || !fs.existsSync(alvo)) return null;
1165
+ try { fs.unlinkSync(path.join(p.pasta, rel)); } catch { /* fica o original */ }
1166
+ return 'midia/' + destino;
1167
+ };
1168
+ const achados = await Promise.all(hibrido.termosDeBusca(roteiro, hib.extras).map(async (termo) => {
1169
+ const out = [];
1170
+ const slug = 'broll-' + String(termo).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
1171
+ const proxy = `${slug}.leve.mp4`;
1172
+ if (jaTem.includes(proxy)) out.push({ src: 'midia/' + proxy, tipo: 'video', cat: cat(termo), alt: termo });
1173
+ else try {
1174
+ const b = await assets.procurarVideos(termo, { quantas: 3, orientacao });
1175
+ for (const item of (b.ok ? b.videos : []).filter((v) => !v.duracao || v.duracao >= 4).slice(0, 1)) {
1176
+ const d = await assets.baixar(item, { pasta: p.pasta, nome: slug });
1177
+ const l = d.ok ? await leve(d.relativo, proxy) : null;
1178
+ if (l) out.push({ src: l, tipo: 'video', cat: cat(termo), alt: termo });
1179
+ }
1180
+ } catch { /* segue com a foto */ }
1181
+ const foto = jaTem.find((f) => f.startsWith(slug + '-') && /\.(jpe?g|png|webp)$/i.test(f));
1182
+ if (foto) out.push({ src: 'midia/' + foto, tipo: 'imagem', cat: cat(termo), alt: termo });
1183
+ else try {
1184
+ const b = await assets.procurarImagens(termo, { quantas: 3, orientacao });
1185
+ for (const item of (b.ok ? b.imagens : []).slice(0, 1)) {
1186
+ const d = await assets.baixar(item, { pasta: p.pasta, nome: slug });
1187
+ if (d.ok) out.push({ src: d.relativo, tipo: 'imagem', cat: cat(termo), alt: termo });
1188
+ }
1189
+ } catch { /* sem */ }
1190
+ return out;
1191
+ }));
1192
+ const novos = achados.flat();
1193
+ // Intercalados com as da marca: a foto da marca não pode ficar toda no começo.
1194
+ const marcaF = fotosC.splice(0);
1195
+ for (let q = 0; q < Math.max(marcaF.length, novos.length); q++) {
1196
+ if (novos[q]) fotosC.push(novos[q]);
1197
+ if (marcaF[q]) fotosC.push(marcaF[q]);
1198
+ }
1199
+ projeto.anotar(p.id, 'midia', `b-roll do híbrido: ${novos.filter((f) => f.tipo === 'video').length} vídeos e ${novos.filter((f) => f.tipo === 'imagem').length} fotos`);
1200
+ }
1201
+ const rostos = await acharRostos(p, fotosC.filter((f) => f.tipo !== 'video').map((f) => f.src));
1090
1202
  for (const f of fotosC) if (rostos[f.src]) f.rosto = rostos[f.src];
1091
1203
  // Os depoimentos: cortados na palavra, com a voz no volume certo e fade.
1092
1204
  for (const pt of partes) {
@@ -1127,8 +1239,12 @@ async function prepararRoteiro(p, args) {
1127
1239
  iNarr++;
1128
1240
  }
1129
1241
  const r = cinema.montarCinema({ roteiro, largura, altura, tempos: v.partes, narracao: path.relative(p.pasta, mp3).replace(/\\/g, '/'),
1130
- sons: sonsC, trilha: trilhaC, fotos: fotosC, fortes });
1131
- if (r.ok) projeto.anotar(p.id, 'plano', `estilo cinema: ${r.resumo.cenas} cenas, ${r.resumo.segundos}s`);
1242
+ sons: sonsC, trilha: trilhaC, fotos: fotosC, fortes, ritmoLeitura: hib ? 0.55 : 1 });
1243
+ if (r.ok && hib) {
1244
+ hibrido.enfeitar(r.plano, { extras: hib.extras, partes, marca: roteiro.marca || {}, largura, altura });
1245
+ r.resumo.estilo = 'hibrido';
1246
+ }
1247
+ if (r.ok) projeto.anotar(p.id, 'plano', `estilo ${hib ? 'hibrido' : 'cinema'}: ${r.resumo.cenas} cenas, ${r.resumo.segundos}s`);
1132
1248
  return r;
1133
1249
  }
1134
1250
  if (modo === 'video') {
@@ -1138,31 +1254,100 @@ async function prepararRoteiro(p, args) {
1138
1254
  fs.writeFileSync(arq, JSON.stringify(lista));
1139
1255
  const mp3 = path.join(p.pasta, 'midia', `narracao-${Date.now().toString(36)}.mp3`);
1140
1256
  // A voz de gente primeiro (Gemini, pelo servidor); o Edge é a reserva.
1141
- let v = await narrarComGemini(lista, mp3, { voz: args.voz || roteiro.voz,
1142
- direcao: estilo === 'impacto' ? 'Leia em português do Brasil como locutor de comercial agressivo e vendedor: energia alta, ritmo rápido, '
1143
- + 'voz encorpada, ênfase forte nas palavras de impacto, pausas curtas e secas entre as frases, sorrindo na voz.' : undefined });
1257
+ // Com cache: refazer o vídeo (outro estilo, outra proporção) não gasta a cota da voz.
1258
+ let v = await narrarComCache(lista, mp3, { voz: args.voz || roteiro.voz,
1259
+ direcao: estilo === 'gerativo'
1260
+ /* O gerativo pede voz com CORPO ("narração encorpada, extravagante",
1261
+ 30/09): cheia, quente, perto do microfone — e o ritmo segue 1,12×. */
1262
+ /* (A primeira versão pedia "trailer" e "ênfase sustentada": a voz saiu 25% mais lenta, medido em 30/09.) */
1263
+ ? 'Leia em português do Brasil como locutor de reels de tecnologia: RÁPIDO e contínuo, com voz cheia, quente e encorpada, '
1264
+ + 'bem perto do microfone, muita energia e sorriso na voz. Ataque firme e ênfase curta e forte nas palavras de impacto; '
1265
+ + 'quase sem pausa entre as frases. Nunca arraste, nunca leia devagar nem solene.'
1266
+ : estilo === 'impacto' ? 'Leia em português do Brasil como locutor de comercial agressivo e vendedor: energia alta, ritmo rápido, '
1267
+ + 'voz encorpada, ênfase forte nas palavras de impacto, pausas curtas e secas entre as frases, sorrindo na voz.'
1268
+ // "A narração tá lenta, deixa mais viva" (29/09): todo estilo fala com energia de reels.
1269
+ : 'Leia em português do Brasil como narrador de reels de tecnologia: vivo, confiante e rápido, sorrindo na voz, '
1270
+ + 'ênfase nas palavras que importam, pausas curtas entre as frases. Nada de leitura lenta ou solene.' });
1144
1271
  if (!v.ok) v = await rodarPython('narrar.py', ['roteiro', '--arquivo', arq, '--saida', mp3, '--voz', String(args.voz || roteiro.voz || 'thalita')], { timeout: 10 * 60000 });
1145
1272
  if (!v.ok) return v;
1273
+ /* O RITMO DA VOZ. Mesmo com a direção, a voz sai no passo de leitura, e
1274
+ o vídeo — que segue a voz palavra por palavra — sai lento junto. O
1275
+ áudio acelera 1,12× (1,3× ficou corrido demais, 29/09; atempo não muda o tom) e cada tempo de palavra
1276
+ acompanha; `roteiro.ritmoVoz` ajusta (1 desliga). */
1277
+ const ritmo = Math.max(1, Math.min(1.5, Number(roteiro.ritmoVoz || args.ritmoVoz) || 1.12));
1278
+ if (ritmo > 1.01) {
1279
+ const rap = mp3.replace(/\.mp3$/, '-r.mp3');
1280
+ const c = await video.rodar(video.ffmpeg(), ['-y', '-i', mp3, '-filter:a', `atempo=${ritmo.toFixed(3)}`, rap], { timeout: 120000 });
1281
+ if (c.ok && fs.existsSync(rap)) {
1282
+ fs.renameSync(rap, mp3);
1283
+ const esc = (x) => (typeof x === 'number' ? Math.round((x / ritmo) * 1000) / 1000 : x);
1284
+ v.segundos = esc(v.segundos);
1285
+ v.partes = (v.partes || []).map((pt) => ({ ...pt, de: esc(pt.de), ate: esc(pt.ate),
1286
+ palavras: (pt.palavras || []).map((w) => ({ ...w, de: esc(w.de), ate: esc(w.ate) })) }));
1287
+ }
1288
+ }
1146
1289
  tempos = v.partes;
1147
1290
  narracao = path.relative(p.pasta, mp3).replace(/\\/g, '/');
1148
1291
  projeto.anotar(p.id, 'narracao', `${v.segundos}s em ${partes.length} partes, voz ${v.voz}`);
1149
1292
  // O motion usa os efeitos leves; os estilos de corte, os cheios.
1150
- sons = ehMotion ? sonsLeves.preparar(p.pasta) : await edicao.prepararSons(p.pasta, video);
1293
+ // O gerativo tem a família de sons DELE (sintetizada da semente); os outros motion, os leves.
1294
+ // O gerativo desenha os sons DEPOIS da partitura (um arquivo por evento, da própria trilha).
1295
+ sons = estilo === 'gerativo' ? {}
1296
+ : ehMotion ? sonsLeves.preparar(p.pasta) : await edicao.prepararSons(p.pasta, video);
1151
1297
  // A trilha do motion: ende.app (CC BY 4.0, sem Content ID), clima tecnologia.
1152
1298
  if (ehMotion) {
1153
1299
  try {
1154
1300
  const segundos = tempos.length ? tempos[tempos.length - 1].ate + 2 : 20;
1155
- const m = await ende.provedor({ pasta: path.join(p.pasta, 'midia') }).gerar({ clima: roteiro.clima || 'tecnologia', segundos });
1301
+ /* O gerativo escolhe o GÊNERO pelo assunto (jazz para o explainer,
1302
+ funk para o fone, eletrônica para IA…), sorteia a faixa e não
1303
+ repete as últimas usadas — nada de "inspiring corporate". */
1304
+ let m = null;
1305
+ const usadasArq = path.join(os.homedir(), '.primocode', 'studio', 'trilhas-usadas.json');
1306
+ let usadas = [];
1307
+ try { usadas = JSON.parse(fs.readFileSync(usadasArq, 'utf8')); } catch { usadas = []; }
1308
+ const mus = estilo === 'gerativo' ? gerativo.musicaDe(roteiro) : null;
1309
+ /* A trilha do gerativo vem do Jamendo (Openverse), pelo GÊNERO do
1310
+ assunto, instrumental, sorteada e sem repetir as últimas — a
1311
+ ende.app fica de reserva ("parar de cair sempre na ende", 30/09). */
1312
+ if (mus) {
1313
+ const BUSCA = { jazz: 'jazz instrumental', 'lofi-chill-beats': 'lofi hip hop instrumental', electronica: 'electronic instrumental', funk: 'funk instrumental',
1314
+ 'dance-club': 'house instrumental', ambient: 'ambient instrumental', acoustic: 'acoustic guitar instrumental', cinematic: 'cinematic instrumental',
1315
+ classical: 'piano classical instrumental', 'soul-rnb': 'soul instrumental' };
1316
+ try {
1317
+ const b = await assets.procurarMusica(BUSCA[roteiro.genero || mus.genero] || ((roteiro.genero || mus.genero) + ' instrumental'), { quantas: 8, duracaoMin: Math.max(45, segundos + 10) });
1318
+ const opcoes = (b.ok ? b.sons : []).filter((x) => x.url && !usadas.includes(x.url) && !/vocal|voice|feat|ft\.|remix/i.test(x.titulo || ''));
1319
+ for (let k = 0; k < opcoes.length && !m; k++) {
1320
+ const item = opcoes[(mus.semente + k) % opcoes.length];
1321
+ const d = await assets.baixar(item, { pasta: p.pasta, nome: 'trilha-' + (roteiro.genero || mus.genero) });
1322
+ if (!d.ok) continue;
1323
+ if (await temLetra(path.join(p.pasta, d.relativo))) { projeto.anotar(p.id, 'midia', `trilha com voz descartada: ${item.titulo}`); continue; }
1324
+ m = { src: path.join(p.pasta, d.relativo), credito: `Música: ${item.titulo} — ${item.autor || 'Jamendo'} (${item.licenca || 'CC'}, Jamendo)`, id: item.url, segundos: item.segundos };
1325
+ }
1326
+ } catch { /* cai na ende.app */ }
1327
+ }
1328
+ if (mus && m) {
1329
+ try { fs.writeFileSync(usadasArq, JSON.stringify([m.id, ...usadas.filter((x) => x !== m.id)].slice(0, 16))); } catch { /* sem memória */ }
1330
+ projeto.anotar(p.id, 'trilha', `gênero ${roteiro.genero || mus.genero} (Jamendo)`);
1331
+ } else if (mus) {
1332
+ m = await ende.provedor({ pasta: path.join(p.pasta, 'midia') }).gerar({ clima: roteiro.clima || 'energetico', segundos, genero: roteiro.genero || mus.genero, semente: mus.semente, evitar: usadas });
1333
+ const id = m && Number((/ende-(\d+)\.mp3$/.exec(m.src) || [])[1]);
1334
+ if (id) try { fs.writeFileSync(usadasArq, JSON.stringify([id, ...usadas.filter((x) => x !== id)].slice(0, 16))); } catch { /* sem memória */ }
1335
+ if (m) projeto.anotar(p.id, 'trilha', `gênero ${roteiro.genero || mus.genero}`);
1336
+ } else m = await ende.provedor({ pasta: path.join(p.pasta, 'midia') }).gerar({ clima: roteiro.clima || 'tecnologia', segundos });
1156
1337
  if (m) { trilha = path.relative(p.pasta, m.src).replace(/\\/g, '/'); projeto.anotar(p.id, 'trilha', m.credito); }
1157
1338
  } catch { /* cai na busca de sempre */ }
1158
1339
  }
1159
- if (!trilha) try {
1160
- const b = await assets.procurarMusica(roteiro.musica || args.trilha || (estilo === 'impacto' ? 'epic energetic' : 'inspiring corporate'), { quantas: 6, duracaoMin: 45 });
1161
- for (const item of (b.ok ? b.sons : [])) {
1162
- const d = await assets.baixar(item, { pasta: p.pasta, nome: 'trilha' });
1163
- if (d.ok) { trilha = d.relativo; break; }
1164
- }
1165
- } catch { /* sem música */ }
1340
+ // Termo pelo clima e faixa sorteada sem repetir — o "inspiring corporate" fixo era a mesma música em todo vídeo.
1341
+ for (const termo of [args.trilha, ...variedade.termosTrilha(roteiro, estilo)].filter(Boolean)) {
1342
+ if (trilha) break;
1343
+ try {
1344
+ const b = await assets.procurarMusica(termo, { quantas: 10, duracaoMin: 45 });
1345
+ for (const item of variedade.ordenar(b.ok ? b.sons : [], 'trilha').slice(0, 3)) {
1346
+ const d = await assets.baixar(item, { pasta: p.pasta, nome: 'trilha' });
1347
+ if (d.ok) { trilha = d.relativo; variedade.marcar('trilha', item.url); projeto.anotar(p.id, 'trilha', `"${termo}": ${item.titulo}`); break; }
1348
+ }
1349
+ } catch { /* o próximo termo */ }
1350
+ }
1166
1351
  }
1167
1352
  if (estilo === 'impacto' && modo === 'video') {
1168
1353
  // Os emojis das palavras FALADAS também (a batida mostra a fala).
@@ -1180,12 +1365,58 @@ async function prepararRoteiro(p, args) {
1180
1365
  if (r.ok) projeto.anotar(p.id, 'plano', `estilo impacto: ${r.resumo.cenas} batidas`);
1181
1366
  return r;
1182
1367
  }
1183
- if (ehMotion && modo === 'video') {
1368
+ if (estilo === 'vox' && modo === 'video') {
1369
+ const r = vox.montarVox({ roteiro, largura, altura, tempos, narracao, sons, trilha, imagens });
1370
+ if (r.ok) projeto.anotar(p.id, 'plano', `estilo vox: ${r.resumo.cenas} cenas, ${r.resumo.segundos}s`);
1371
+ return r;
1372
+ }
1373
+ if ((estilo === 'marca' || estilo === 'lancamento') && modo === 'video') {
1374
+ /* As letras em vetor (a peça "vetor") pedem os pontos de verdade da
1375
+ fonte: glifos.js lê o .ttf. Sem rede, o vetor desenha o contorno do
1376
+ próprio texto, sem as alças. */
1377
+ const m = roteiro.marca || {};
1378
+ const nome = m.nome || (partes.find((pt) => pt.tipo === 'logo' || pt.tipo === 'abertura') || {}).titulo || '';
1379
+ const precisa = partes.some((pt) => pt.tipo === 'logo' || pt.visual === 'vetor');
1380
+ const g = precisa ? await glifos.palavra((partes.find((pt) => pt.tipo === 'logo') || {}).titulo || nome,
1381
+ { fonte: (m.fontes && m.fontes.titulo) || (estilo === 'marca' ? 'Plus Jakarta Sans' : 'Inter Tight') }) : null;
1382
+ const r = lancamento.montarLancamento({ roteiro, variante: estilo, largura, altura, tempos, narracao, sons, trilha, imagens, glifos: g });
1383
+ if (r.ok) projeto.anotar(p.id, 'plano', `estilo ${estilo}: ${r.resumo.cenas} cenas, ${r.resumo.segundos}s${precisa ? (g ? ', vetor com os pontos da fonte' : ', vetor sem os pontos (sem a fonte)') : ''}`);
1384
+ return r;
1385
+ }
1386
+ if (estilo === 'gerativo' && modo === 'video') {
1387
+ /* A música a -18 LUFS (a voz sai a -14): os volumes do plano partem
1388
+ daí — cheia no respiro, uns 9 dB abaixo quando a voz fala. */
1389
+ if (trilha && !/-n18\.mp3$/.test(trilha)) {
1390
+ const norm = path.join(p.pasta, 'midia', path.basename(trilha).replace(/\.[a-z0-9]+$/i, '') + '-n18.mp3');
1391
+ /* A introdução da faixa fica de fora (ela começa morna): corta de 12% em
1392
+ diante, com 0,4 s de entrada, no tamanho do vídeo. */
1393
+ let ini = 0;
1394
+ try { const md = await video.medir(path.join(p.pasta, trilha)); const tot = md.segundos || 0; const quer = (tempos.length ? tempos[tempos.length - 1].ate : 30) + 6; if (tot > quer + 8) ini = Math.min(tot * 0.12, tot - quer - 2); } catch { /* do começo */ }
1395
+ const c = fs.existsSync(norm) ? { ok: true } : await video.rodar(video.ffmpeg(), ['-y', ...(ini > 1 ? ['-ss', ini.toFixed(2)] : []), '-i', path.join(p.pasta, trilha),
1396
+ '-af', 'afade=t=in:st=0:d=0.4,loudnorm=I=-18:TP=-2:LRA=11', '-ar', '44100', norm], { timeout: 5 * 60000 });
1397
+ if (c.ok && fs.existsSync(norm)) trilha = path.relative(p.pasta, norm).replace(/\\/g, '/');
1398
+ }
1399
+ const r = gerativo.montarGerativo({ roteiro, largura, altura, tempos, narracao, sons, trilha });
1400
+ if (r.ok) {
1401
+ const feitos = sonsLeves.prepararReceitas(p.pasta, r.somReceitas);
1402
+ // Som que o ffmpeg não conseguiu fazer sai do plano (o vídeo não espera um arquivo que não existe).
1403
+ r.plano.audio.efeitos = r.plano.audio.efeitos.filter((e) => Object.values(feitos).includes(e.src));
1404
+ }
1405
+ if (r.ok) projeto.anotar(p.id, 'plano', `estilo gerativo: ${r.resumo.batidas} batidas (${r.resumo.composicoes.join(' → ')}), ${r.resumo.segundos}s, semente ${r.resumo.semente}`);
1406
+ return r;
1407
+ }
1408
+ if (estilo === 'motion' && modo === 'video') {
1184
1409
  const r = motion.montarMotion({ roteiro, largura, altura, tempos, narracao, sons, trilha, imagens });
1185
1410
  if (r.ok) projeto.anotar(p.id, 'plano', `estilo motion: ${r.resumo.segundos}s contínuos, ${r.resumo.partes} partes, ${r.resumo.fps} fps`);
1186
1411
  return r;
1187
1412
  }
1188
- return composicao.comporRoteiro({ roteiro, modo, largura, altura, tempos, narracao, sons, trilha, imagens, iconesOk, fundos });
1413
+ /* A composição (e os slides) só conhece os tipos antigos: as partes novas
1414
+ viram a mais parecida delas, e o roteiro do vídeo serve à página. */
1415
+ const PARECIDA = { logo: 'abertura', cores: 'lista', produto: 'titulo' };
1416
+ const paraComposicao = { ...roteiro, partes: partes.map((pt) => (PARECIDA[pt.tipo]
1417
+ ? { ...pt, tipo: PARECIDA[pt.tipo], itens: pt.itens || (pt.cores || []).map((c) => (typeof c === 'string' ? c : c.nome)) }
1418
+ : pt)) };
1419
+ return composicao.comporRoteiro({ roteiro: paraComposicao, modo, largura, altura, tempos, narracao, sons, trilha, imagens, iconesOk, fundos });
1189
1420
  }
1190
1421
 
1191
1422
  /* Sem o tempo de cada palavra, cada trecho é repartido pelas palavras dele,