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
@@ -34,6 +34,11 @@ 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 biblioteca = require('./biblioteca.js');
39
+ const referencias = require('./referencias.js');
40
+ const variedade = require('./variedade.js');
41
+ const variacao3d = require('./variacao3d.js');
37
42
  const plano = require('./plano.js');
38
43
  const remotion = require('./remotion.js');
39
44
  const assets = require('./assets.js');
@@ -46,7 +51,10 @@ const acervoSite = require('./acervo-site.js');
46
51
  const impacto = require('./impacto.js');
47
52
  const cinema = require('./cinema.js');
48
53
  const motion = require('./motion.js');
54
+ const gerativo = require('./gerativo.js');
55
+ const fluido = require('./fluido.js');
49
56
  const vox = require('./vox.js');
57
+ const kokoro = require('./kokoro.js');
50
58
  const lancamento = require('./lancamento.js');
51
59
  const glifos = require('./glifos.js');
52
60
  const { escolherEstilo, LEVES } = require('./estilo.js');
@@ -149,9 +157,11 @@ function semProjeto(id) {
149
157
  };
150
158
  }
151
159
 
152
- async function ferramentaProjeto(args) {
160
+ async function ferramentaProjeto(args, ctx = {}) {
153
161
  const acao = String(args.acao || 'listar').toLowerCase();
154
162
 
163
+ if (acao === 'editar' || acao === 'editor' || acao === 'studio') return editarNoStudio(args, ctx);
164
+
155
165
  if (acao === 'criar' || acao === 'novo') {
156
166
  const r = projeto.criar({
157
167
  titulo: args.titulo, pedido: args.pedido || args.titulo, formato: args.formato || 'video',
@@ -195,6 +205,28 @@ async function ferramentaProjeto(args) {
195
205
  };
196
206
  }
197
207
 
208
+ /* EDITAR um vídeo que o Estúdio fez: abre o projeto Remotion dele no
209
+ Remotion Studio. Sem id, é o projeto mais novo. */
210
+ async function editarNoStudio(args, ctx = {}) {
211
+ const p = args.id ? projeto.ler(args.id) : projeto.listar({ limite: 1 })[0];
212
+ if (!p) return semProjeto(args.id);
213
+ const raiz = path.join(p.pasta, 'remotion');
214
+ if (!fs.existsSync(path.join(raiz, 'package.json'))) {
215
+ // Projeto que ainda não virou Remotion: escreve agora, sem renderizar.
216
+ const m = await ferramentaRender({ id: p.id, somenteProjeto: true }, ctx);
217
+ if (!m.ok) return m;
218
+ }
219
+ const r = remotion.abrirStudio({ raiz, spawnar: ctx.spawnar });
220
+ if (!r.ok) return { ...r, pasta: raiz };
221
+ projeto.anotar(p.id, 'editor', 'abriu no Remotion Studio');
222
+ return {
223
+ ok: true, id: p.id, pasta: raiz, comando: r.comando,
224
+ aviso: 'O Remotion Studio está ABRINDO no navegador (geralmente http://localhost:3000). '
225
+ + `Diga ao usuário onde está o projeto: ${raiz}. Lá ele mexe e renderiza pelo botão Render do Studio. `
226
+ + 'Cuidado: estudio_render reescreve a pasta src/ a partir do plano e apaga o que ele mudou à mão.',
227
+ };
228
+ }
229
+
198
230
  function listarArquivos(pasta) {
199
231
  const saida = {};
200
232
  for (const sub of ['entrada', 'midia', 'saida']) {
@@ -306,8 +338,15 @@ async function ferramentaPlano(args) {
306
338
  sons (lib/estudio/composicao.js). É o caminho para qualquer modelo
307
339
  entregar motion e slides que prestam. */
308
340
  if (args.roteiro) {
341
+ /* O roteiro como a pessoa (ou o modelo) mandou: o híbrido traduz as
342
+ partes para o cinema no caminho, e o que fica guardado tem de ser o
343
+ original — senão o próximo ajuste parte do traduzido. */
344
+ let original = null;
345
+ try { original = JSON.parse(typeof args.roteiro === 'string' ? args.roteiro : JSON.stringify(args.roteiro)); } catch { original = args.roteiro; }
309
346
  const r = await prepararRoteiro(p, args);
310
347
  if (!r.ok) return r;
348
+ const ic = await variarIcones(r.plano, original);
349
+ if (ic.trocados) projeto.anotar(p.id, 'icones', `coleção ${ic.colecao}: ${ic.trocados} ícones`);
311
350
  let conferida = plano.conferir(r.plano, { pasta: p.pasta });
312
351
  /* A rede de segurança: o elemento que não passa sai, e o vídeo segue.
313
352
  Um defeito num ícone não pode custar o vídeo inteiro. */
@@ -322,7 +361,7 @@ async function ferramentaPlano(args) {
322
361
  }
323
362
  projeto.salvarPlano(args.id, r.plano, args.motivo || 'montado do roteiro');
324
363
  // O roteiro fica guardado: ajustar uma frase é mandar o roteiro de novo, não reescrever tudo.
325
- try { fs.writeFileSync(path.join(p.pasta, 'roteiro.json'), JSON.stringify(args.roteiro, null, 2)); } catch { /* segue */ }
364
+ try { fs.writeFileSync(path.join(p.pasta, 'roteiro.json'), JSON.stringify(original || args.roteiro, null, 2)); } catch { /* segue */ }
326
365
  projeto.salvar(args.id, { duracao: conferida.duracao });
327
366
  return {
328
367
  ok: true, duracao: conferida.duracao, ...r.resumo, resumo: plano.resumo(r.plano),
@@ -375,7 +414,7 @@ async function ferramentaMidia(args) {
375
414
 
376
415
  if (acao === 'fontes') {
377
416
  if (args.conferir) return assets.conferirFonte(args.conferir);
378
- return { ok: true, fontes: assets.FONTES,
417
+ return { ok: true, fontes: assets.FONTES, biblioteca: biblioteca.cardapio(),
379
418
  nota: 'qualquer família do Google Fonts funciona — esta lista é para não repetir sempre as mesmas.' };
380
419
  }
381
420
 
@@ -673,27 +712,54 @@ async function ferramentaVideo(args, ctx = {}) {
673
712
  }
674
713
 
675
714
  /* A mídia de apoio de um termo: vídeo do Pexels quando pedido e houver,
676
- senão a primeira foto que baixar. Devolve {src, tipo} ou null. */
715
+ senão uma foto. Devolve {src, tipo} ou null. O clipe sai sorteado entre
716
+ os que os últimos vídeos não usaram (variedade.js), não o primeiro da
717
+ busca — "o b-roll veio igual nos dois vídeos" (Joel, 05/10). */
677
718
  async function baixarApoio(p, termo, { orientacao = null, video: querVideo = false } = {}) {
678
719
  if (querVideo) {
679
720
  try {
680
- const b = await assets.procurarVideos(termo, { quantas: 4, orientacao });
681
- for (const item of (b.ok ? b.videos : []).filter((v) => !v.duracao || v.duracao >= 3)) {
721
+ const itens = await variedade.buscarSemRepetir(async (t) => {
722
+ const b = await assets.procurarVideos(t, { quantas: 8, orientacao });
723
+ return (b.ok ? b.videos : []).filter((v) => !v.duracao || v.duracao >= 3);
724
+ }, termo);
725
+ for (const item of itens.slice(0, 4)) {
682
726
  const d = await assets.baixar(item, { pasta: p.pasta, nome: 'apoio-' + termo });
683
- if (d.ok) return { src: d.relativo, tipo: 'video' };
727
+ if (d.ok) { variedade.marcar('broll', item.url); return { src: d.relativo, tipo: 'video' }; }
684
728
  }
685
729
  } catch { /* cai para a foto */ }
686
730
  }
687
731
  try {
688
- const b = await assets.procurarImagens(termo, { quantas: 4, orientacao });
689
- for (const item of (b.ok ? b.imagens : [])) {
732
+ const itens = await variedade.buscarSemRepetir(async (t) => {
733
+ const b = await assets.procurarImagens(t, { quantas: 8, orientacao });
734
+ return b.ok ? b.imagens : [];
735
+ }, termo);
736
+ for (const item of itens.slice(0, 4)) {
690
737
  const d = await assets.baixar(item, { pasta: p.pasta, nome: 'apoio-' + termo });
691
- if (d.ok) return { src: d.relativo, tipo: 'imagem' };
738
+ if (d.ok) { variedade.marcar('broll', item.url); return { src: d.relativo, tipo: 'imagem' }; }
692
739
  }
693
740
  } catch { /* sem apoio */ }
694
741
  return null;
695
742
  }
696
743
 
744
+ /* Os ícones automáticos trocam de coleção a cada vídeo (Joel, 05/10: "várias
745
+ opções de ícones... que não sejam padrão da IA"). O ícone que o roteiro
746
+ pediu pelo nome fica; o que não existe na coleção sorteada fica no 3D. */
747
+ async function variarIcones(plano, roteiro) {
748
+ const colecao = variedade.colecaoIcones();
749
+ const explicitos = [];
750
+ try { JSON.stringify(roteiro || {}, (k, v) => { if (k === 'icone' && typeof v === 'string') explicitos.push(v); return v; }); } catch { /* sem */ }
751
+ const troca = {};
752
+ if (colecao !== 'fluent-3d') {
753
+ for (const nome of variedade.iconesTrocaveis(plano, explicitos)) {
754
+ const novo = colecao + ':' + nome.split(':')[1];
755
+ try { const r = await assets.icone(novo); if (r && r.ok) troca[nome] = novo; } catch { /* fica o 3D */ }
756
+ }
757
+ }
758
+ const n = variedade.trocarIcones(plano, troca);
759
+ if (n || colecao === 'fluent-3d') variedade.marcar('icones', colecao);
760
+ return { colecao, trocados: n };
761
+ }
762
+
697
763
  /* ── A VOZ DO GEMINI ─────────────────────────────────────────────────────
698
764
  * O roteiro inteiro numa chamada só (o plano grátis tem poucos pedidos por
699
765
  * minuto), e as partes achadas depois na transcrição do próprio áudio: a
@@ -858,11 +924,23 @@ async function narrarComCache(lista, mp3, { voz, direcao } = {}) {
858
924
  const dir = path.join(os.homedir(), '.primocode', 'studio', 'cache-voz');
859
925
  const cm = path.join(dir, chave + '.mp3'), cj = path.join(dir, chave + '.json');
860
926
  if (fs.existsSync(cm) && fs.existsSync(cj)) { fs.copyFileSync(cm, mp3); return { ok: true, ...JSON.parse(fs.readFileSync(cj, 'utf8')) }; }
927
+ // A voz local pedida (voz: 'kokoro'): direto nela, sem gastar a cota do Gemini.
928
+ if (kokoro.querKokoro(voz)) return kokoro.narrar(lista, mp3, { voz, uv: acharUv() });
861
929
  const v = await narrarComGemini(lista, mp3, { voz, direcao });
862
930
  if (v.ok) { try { fs.mkdirSync(dir, { recursive: true }); fs.copyFileSync(mp3, cm); fs.writeFileSync(cj, JSON.stringify({ voz: v.voz, segundos: v.segundos, partes: v.partes })); } catch { /* sem cache */ } }
863
931
  return v;
864
932
  }
865
933
 
934
+ /* A VOZ DE RESERVA, quando o Gemini falha: o Kokoro (local, sem cota) e
935
+ depois o Edge. A falha de uma passa para a próxima, nunca derruba. */
936
+ function narrarReserva(lista, mp3, arq, voz, vozEdge, { soEdge = false, semKokoro = false } = {}) {
937
+ const passos = kokoro.ordem(voz, process.env, { soEdge }).filter((n) => n !== 'gemini' && !(semKokoro && n === 'kokoro'));
938
+ return kokoro.corrente(passos, {
939
+ kokoro: () => kokoro.narrar(lista, mp3, { voz, uv: acharUv() }),
940
+ edge: () => rodarPython('narrar.py', ['roteiro', '--arquivo', arq, '--saida', mp3, '--voz', String(kokoro.querKokoro(voz, {}) ? vozEdge : voz || vozEdge)], { timeout: 10 * 60000 }),
941
+ });
942
+ }
943
+
866
944
  /* A MÚSICA TEM LETRA? O acervo não diz se a faixa é instrumental, então ela
867
945
  é ouvida: 25 s do meio vão para a transcrição, e mais de 6 palavras é voz
868
946
  cantada. Sem servidor para ouvir, a faixa passa (melhor música que nenhuma). */
@@ -887,21 +965,18 @@ async function temLetra(arquivo) {
887
965
  (25/09/2026) */
888
966
  async function sonsReais(p, video) {
889
967
  const sint = await edicao.prepararSons(p.pasta, video);
890
- const quer = {
891
- teclado: ['keyboard typing', 'typing keyboard'],
892
- whoosh: ['cinematic whoosh', 'swoosh transition'],
893
- impacto: ['cinematic boom impact', 'deep cinematic impact', 'trailer hit'],
894
- sala: ['crowd murmur indoor', 'office ambience', 'people talking background'],
895
- };
968
+ /* Termo sorteado e resultado sorteado, sem repetir os últimos: o primeiro
969
+ resultado do termo fixo era o mesmo whoosh em todo vídeo (02/10). */
896
970
  const sons = { ...sint };
897
- for (const [nome, termos] of Object.entries(quer)) {
898
- for (const termo of termos) {
971
+ for (const nome of ['teclado', 'whoosh', 'impacto', 'sala']) {
972
+ for (const termo of variedade.termosSfx(nome)) {
973
+ if (sons[nome] !== sint[nome]) break;
899
974
  try {
900
- const b = await assets.procurarSom(termo, { quantas: 5 });
901
- const item = (b.ok ? b.sons : []).find((x) => x.url);
902
- if (!item) continue;
903
- const d = await assets.baixar(item, { pasta: p.pasta, nome: 'sfx-real-' + nome });
904
- if (d.ok) { sons[nome] = d.relativo; break; }
975
+ const b = await assets.procurarSom(termo, { quantas: 10, livre: true });
976
+ for (const item of variedade.ordenar(b.ok ? b.sons : [], 'sfx').slice(0, 3)) {
977
+ const d = await assets.baixar(item, { pasta: p.pasta, nome: 'sfx-real-' + nome });
978
+ if (d.ok) { sons[nome] = d.relativo; variedade.marcar('sfx', item.url); break; }
979
+ }
905
980
  } catch { /* o próximo termo */ }
906
981
  }
907
982
  }
@@ -932,7 +1007,48 @@ async function prepararRoteiro(p, args) {
932
1007
  lançamento de produto vira LANCAMENTO, anúncio e reels vão para o
933
1008
  IMPACTO. O que o agente pediu vale mais que tudo. */
934
1009
  const pedidoTxt = `${p.pedido || ''} ${args.pedido || ''}`;
935
- const estilo = escolherEstilo({ pedido: pedidoTxt, agente: args.estilo, roteiro: roteiro.estilo });
1010
+ const ref = referencias.atual();
1011
+ /* Ninguém escolheu? O padrão gira (estilo.js, RODIZIO) — menos no "sem
1012
+ referências", que só tem o motion e segue a regra de baixo. */
1013
+ let estilo = escolherEstilo({ pedido: pedidoTxt, agente: args.estilo, roteiro: roteiro.estilo, girar: ref.modo !== 'sem' });
1014
+ /* "Sem referências, só motion": o híbrido vive de b-roll de fora, então
1015
+ cai para o motion — sem nada baixado. */
1016
+ 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);
1017
+ /* E o motion de agora é o GERATIVO: sem template, produto ou não (o
1018
+ produto em 3D entra numa batida dele, logo abaixo). */
1019
+ if (ref.modo === 'sem' && estilo === 'hibrido') estilo = 'gerativo';
1020
+ void produtoNoPedido;
1021
+ /* "O vídeo deve ter um 3D do fone" (30/09): pedido de 3D vira o gerativo
1022
+ com o produto modelado girando numa batida (kit/tres.jsx) — a menos que
1023
+ o agente tenha escolhido outro estilo pelo nome. */
1024
+ if (/\b3\s?d\b|tridimensional/i.test(pedidoTxt) && (!args.estilo || estilo === 'gerativo' || estilo === 'lancamento')) {
1025
+ // O 3D mora numa batida do gerativo; o lançamento antigo, só pelo nome.
1026
+ if (!args.estilo && estilo !== 'lancamento') estilo = 'gerativo';
1027
+ if (!roteiro.modelo3d) roteiro.modelo3d = variacao3d.modeloDoPedido(pedidoTxt);
1028
+ }
1029
+ // O 3D de cada vídeo: acabamento, câmera, luz e ritmo sorteados sem repetir os últimos.
1030
+ /* Por PROJETO, não por chamada: mandar o roteiro de novo para ajustar uma
1031
+ frase não pode sortear outro 3D nem outro vídeo. Projeto novo, sorteio novo. */
1032
+ if (roteiro.modelo3d && !roteiro.variacao3d) {
1033
+ roteiro.variacao3d = p.variacao3d || variacao3d.escolherVariacao3d();
1034
+ if (!p.variacao3d) projeto.salvar(p.id, { variacao3d: roteiro.variacao3d });
1035
+ }
1036
+ /* A SEMENTE do gerativo saía do texto do roteiro: o mesmo roteiro dava o
1037
+ mesmo vídeo, batida por batida (05/10). Sem semente do roteiro, cada
1038
+ projeto sorteia a sua. */
1039
+ if (estilo === 'gerativo' && roteiro.semente == null) {
1040
+ roteiro.semente = p.semente != null ? p.semente : Math.floor(Math.random() * 1e9);
1041
+ if (p.semente == null) projeto.salvar(p.id, { semente: roteiro.semente });
1042
+ }
1043
+ if (ref.modo === 'fechado' && !roteiro.busca && referencias.termos().length) roteiro.busca = referencias.termos();
1044
+ /* A BIBLIOTECA (biblioteca.js): sem fonte e cor da marca, o híbrido, o
1045
+ cinema e o impacto caíam sempre no Inter e no azul e roxo — "os vídeos
1046
+ todos iguais" (02/10). A marca manda; depois o roteiro.visual da IA;
1047
+ sem nada, o sorteio que pula o que os últimos vídeos usaram. */
1048
+ if (['hibrido', 'cinema', 'impacto'].includes(estilo)) {
1049
+ const v = biblioteca.vestir(roteiro);
1050
+ if (v.mudou) projeto.anotar(p.id, 'visual', [v.titulo && `fonte ${v.titulo} + ${v.texto}`, v.paleta && `paleta ${v.paleta}`].filter(Boolean).join(', '));
1051
+ }
936
1052
  // Os estilos de motion usam os efeitos leves e a trilha do ende.app; os de corte, os cheios.
937
1053
  const ehMotion = LEVES.has(estilo);
938
1054
  const modo = (args.modo || roteiro.modo || (p.formato === 'slides' || p.formato === 'arte' ? 'slides' : 'video'));
@@ -981,7 +1097,15 @@ async function prepararRoteiro(p, args) {
981
1097
  }
982
1098
 
983
1099
  let tempos = [], narracao = null, sons = {}, trilha = null;
984
- if (modo === 'video' && estilo === 'cinema') {
1100
+ /* O HÍBRIDO (hibrido.js) é o cinema com o kit por cima: o roteiro de
1101
+ qualquer estilo vira os tipos do cinema aqui, antes da voz — a voz lê
1102
+ o que o cinema vai mostrar. */
1103
+ let hib = null;
1104
+ if (modo === 'video' && estilo === 'hibrido') {
1105
+ hib = hibrido.traduzir(roteiro);
1106
+ partes.splice(0, partes.length, ...hib.roteiro.partes);
1107
+ }
1108
+ if (modo === 'video' && (estilo === 'cinema' || estilo === 'hibrido')) {
985
1109
  // Só as partes com voz vão para a narração; o depoimento tem a voz do aluno.
986
1110
  // Só as partes com voz: o depoimento tem a voz do aluno, e a parte muda não tem voz.
987
1111
  // A parte com voz forte é narrada à parte: se entrasse aqui também, seria falada duas vezes (e muda uma delas).
@@ -1012,6 +1136,8 @@ async function prepararRoteiro(p, args) {
1012
1136
  }
1013
1137
  } catch { /* fica o alinhamento guardado */ }
1014
1138
  }
1139
+ } else if (kokoro.querKokoro(args.voz || roteiro.voz)) {
1140
+ v = { ok: false, error: 'kokoro pedido' };
1015
1141
  } else {
1016
1142
  v = await narrarComGemini(lista, mp3, { voz: args.voz || roteiro.voz,
1017
1143
  direcao: 'Leia em português do Brasil como narrador de trailer de marca premium: voz grave, confiante e humana, '
@@ -1024,7 +1150,7 @@ async function prepararRoteiro(p, args) {
1024
1150
  const arq = path.join(p.pasta, 'midia', `roteiro-${Date.now().toString(36)}.json`);
1025
1151
  // Ritmo NORMAL: o "citação" (-10%, respiro longo) esticou o vídeo para 125 s.
1026
1152
  fs.writeFileSync(arq, JSON.stringify(lista.map((x) => ({ ...x, registro: 'normal' }))));
1027
- v = await rodarPython('narrar.py', ['roteiro', '--arquivo', arq, '--saida', mp3, '--voz', String(args.voz || roteiro.voz || 'antonio')], { timeout: 10 * 60000 });
1153
+ v = await narrarReserva(lista, mp3, arq, args.voz || roteiro.voz, 'antonio', { soEdge });
1028
1154
  }
1029
1155
  if (!v.ok) return v;
1030
1156
  /* A DURAÇÃO que o roteiro pede ("60–75 s"): se a voz passa do que cabe,
@@ -1051,20 +1177,39 @@ async function prepararRoteiro(p, args) {
1051
1177
  }
1052
1178
  }
1053
1179
  }
1180
+ /* No HÍBRIDO a voz anda um pouco mais rápido por padrão (1,12×, como no
1181
+ vox): o trailer premium do cinema lido pelo Gemini sai no passo de
1182
+ leitura, e o vídeo segue a voz. `roteiro.ritmoVoz` ajusta. */
1183
+ if (hib && ritmoVoz <= 1.01) {
1184
+ const kh = Math.max(1, Math.min(1.5, Number(roteiro.ritmoVoz) || 1.12));
1185
+ const rapido = mp3.replace(/\.mp3$/, '-h.mp3');
1186
+ const c = kh > 1.01 ? await video.rodar(video.ffmpeg(), ['-y', '-i', mp3, '-filter:a', `atempo=${kh.toFixed(3)}`, rapido], { timeout: 120000 }) : { ok: false };
1187
+ 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`);
1188
+ if (c.ok && fs.existsSync(rapido)) {
1189
+ fs.renameSync(rapido, mp3);
1190
+ ritmoVoz = kh;
1191
+ const div = (x) => Math.round((x / kh) * 1000) / 1000;
1192
+ 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) })) }));
1193
+ v.segundos = div(v.segundos);
1194
+ }
1195
+ }
1054
1196
  projeto.anotar(p.id, 'narracao', `${v.segundos}s em ${lista.length} partes, voz ${v.voz}`);
1055
1197
  const sonsC = await sonsReais(p, video);
1056
1198
  let trilhaC = null;
1057
1199
  // Vários termos e durações: vídeo sem música foi o que saiu na primeira vez (25/09).
1058
1200
  // E só instrumental: "essa música daria pra deixar só o instrumental?" — a faixa é OUVIDA.
1059
- 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]]) {
1060
- if (!termo || trilhaC) continue;
1201
+ // O termo sai do clima do roteiro (variedade.js), não de uma lista fixa de "cinematic inspiring".
1202
+ for (const termo of [...variedade.termosTrilha(roteiro, 'cinema'), 'instrumental']) {
1203
+ if (trilhaC) break;
1061
1204
  try {
1062
- const b = await assets.procurarMusica(termo, { quantas: 6, duracaoMin: min });
1063
- for (const item of (b.ok ? b.sons : [])) {
1205
+ const b = await assets.procurarMusica(termo, { quantas: 10, duracaoMin: 45 });
1206
+ 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)) {
1064
1207
  const d = await assets.baixar(item, { pasta: p.pasta, nome: 'trilha' });
1065
1208
  if (!d.ok) continue;
1066
1209
  if (await temLetra(path.join(p.pasta, d.relativo))) { projeto.anotar(p.id, 'midia', `trilha com voz descartada: ${item.titulo}`); continue; }
1067
- trilhaC = d.relativo; break;
1210
+ trilhaC = d.relativo; variedade.marcar('trilha', item.url);
1211
+ projeto.anotar(p.id, 'trilha', `"${termo}": ${item.titulo} — ${item.autor || 'Jamendo'} (${item.licenca || 'CC'})`);
1212
+ break;
1068
1213
  }
1069
1214
  } catch { /* o próximo termo */ }
1070
1215
  }
@@ -1085,7 +1230,76 @@ async function prepararRoteiro(p, args) {
1085
1230
  const MINI = /poster|face \d+|\bthumb/i;
1086
1231
  const fotosC = acervo.fotos.filter((f) => !MINI.test(f.alt || ''))
1087
1232
  .map((f) => ({ src: f.src, tipo: 'imagem', cat: TELA.test(f.alt || '') ? 'tela' : 'pessoas', alt: f.alt }));
1088
- const rostos = await acharRostos(p, fotosC.map((f) => f.src));
1233
+ /* O B-ROLL do híbrido: o cinema vive de foto que troca por baixo do
1234
+ texto, e o acervo da marca raramente passa de meia dúzia. Vídeo de
1235
+ banco primeiro (o fundo que se move sozinho), foto como reserva —
1236
+ em paralelo, porque são oito buscas. */
1237
+ /* Foto de compartilhamento (og:image) e logo trazem o NOME da marca em
1238
+ letra gigante: como fundo, viravam um "THRO" cortado atrás do texto. */
1239
+ /* E arte de banner (2400×1260 e afins): é o logo da marca desenhado, não
1240
+ uma cena — cortada no vídeo em pé, vira um "✳ Cl" gigante. */
1241
+ 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; };
1242
+ 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);
1243
+ if (hib && fotosC.length < 14) {
1244
+ const orientacao = largura >= altura ? 'landscape' : 'portrait';
1245
+ const pastaMidia = path.join(p.pasta, 'midia');
1246
+ const jaTem = (() => { try { return fs.readdirSync(pastaMidia); } catch { return []; } })();
1247
+ const cat = (termo) => (/screen|code|data|server|dashboard/i.test(termo) ? 'tela' : 'pessoas');
1248
+ /* A versão LEVE do b-roll: o Pexels entrega 1080×1920 inteiro (até 30
1249
+ MB e 20 s), e o render parava num quadro esperando vários vídeos
1250
+ assim ao mesmo tempo (medido: timeout de 120 s no quadro 184). Cada
1251
+ um vira 8 s, 720 de largura, sem áudio — e fica guardado, para o
1252
+ próximo render não baixar de novo. */
1253
+ const leve = async (rel, destino) => {
1254
+ const alvo = path.join(pastaMidia, destino);
1255
+ const escala = largura >= altura ? 'scale=1280:-2' : 'scale=720:-2';
1256
+ const c = await video.rodar(video.ffmpeg(), ['-y', '-i', path.join(p.pasta, rel), '-t', '8', '-vf', escala + ',fps=30', '-an',
1257
+ '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '24', '-pix_fmt', 'yuv420p', '-movflags', '+faststart', alvo], { timeout: 180000 });
1258
+ if (!c.ok || !fs.existsSync(alvo)) return null;
1259
+ try { fs.unlinkSync(path.join(p.pasta, rel)); } catch { /* fica o original */ }
1260
+ return 'midia/' + destino;
1261
+ };
1262
+ const achados = await Promise.all(hibrido.termosDeBusca(roteiro, hib.extras).map(async (termo) => {
1263
+ const out = [];
1264
+ const slug = 'broll-' + String(termo).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
1265
+ const proxy = `${slug}.leve.mp4`;
1266
+ if (jaTem.includes(proxy)) out.push({ src: 'midia/' + proxy, tipo: 'video', cat: cat(termo), alt: termo });
1267
+ else try {
1268
+ // Sorteado entre os não usados, com o termo variado (variedade.js, 05/10).
1269
+ const itens = await variedade.buscarSemRepetir(async (t) => {
1270
+ const b = await assets.procurarVideos(t, { quantas: 8, orientacao });
1271
+ return (b.ok ? b.videos : []).filter((v) => !v.duracao || v.duracao >= 4);
1272
+ }, termo);
1273
+ for (const item of itens.slice(0, 1)) {
1274
+ const d = await assets.baixar(item, { pasta: p.pasta, nome: slug });
1275
+ const l = d.ok ? await leve(d.relativo, proxy) : null;
1276
+ if (l) { out.push({ src: l, tipo: 'video', cat: cat(termo), alt: termo }); variedade.marcar('broll', item.url); }
1277
+ }
1278
+ } catch { /* segue com a foto */ }
1279
+ const foto = jaTem.find((f) => f.startsWith(slug + '-') && /\.(jpe?g|png|webp)$/i.test(f));
1280
+ if (foto) out.push({ src: 'midia/' + foto, tipo: 'imagem', cat: cat(termo), alt: termo });
1281
+ else try {
1282
+ const itens = await variedade.buscarSemRepetir(async (t) => {
1283
+ const b = await assets.procurarImagens(t, { quantas: 8, orientacao });
1284
+ return b.ok ? b.imagens : [];
1285
+ }, termo);
1286
+ for (const item of itens.slice(0, 1)) {
1287
+ const d = await assets.baixar(item, { pasta: p.pasta, nome: slug });
1288
+ if (d.ok) { out.push({ src: d.relativo, tipo: 'imagem', cat: cat(termo), alt: termo }); variedade.marcar('broll', item.url); }
1289
+ }
1290
+ } catch { /* sem */ }
1291
+ return out;
1292
+ }));
1293
+ const novos = achados.flat();
1294
+ // Intercalados com as da marca: a foto da marca não pode ficar toda no começo.
1295
+ const marcaF = fotosC.splice(0);
1296
+ for (let q = 0; q < Math.max(marcaF.length, novos.length); q++) {
1297
+ if (novos[q]) fotosC.push(novos[q]);
1298
+ if (marcaF[q]) fotosC.push(marcaF[q]);
1299
+ }
1300
+ 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`);
1301
+ }
1302
+ const rostos = await acharRostos(p, fotosC.filter((f) => f.tipo !== 'video').map((f) => f.src));
1089
1303
  for (const f of fotosC) if (rostos[f.src]) f.rosto = rostos[f.src];
1090
1304
  // Os depoimentos: cortados na palavra, com a voz no volume certo e fade.
1091
1305
  for (const pt of partes) {
@@ -1126,8 +1340,12 @@ async function prepararRoteiro(p, args) {
1126
1340
  iNarr++;
1127
1341
  }
1128
1342
  const r = cinema.montarCinema({ roteiro, largura, altura, tempos: v.partes, narracao: path.relative(p.pasta, mp3).replace(/\\/g, '/'),
1129
- sons: sonsC, trilha: trilhaC, fotos: fotosC, fortes });
1130
- if (r.ok) projeto.anotar(p.id, 'plano', `estilo cinema: ${r.resumo.cenas} cenas, ${r.resumo.segundos}s`);
1343
+ sons: sonsC, trilha: trilhaC, fotos: fotosC, fortes, ritmoLeitura: hib ? 0.55 : 1 });
1344
+ if (r.ok && hib) {
1345
+ hibrido.enfeitar(r.plano, { extras: hib.extras, partes, marca: roteiro.marca || {}, largura, altura, hud: roteiro.hud === true, particulas: roteiro.particulas === true });
1346
+ r.resumo.estilo = 'hibrido';
1347
+ }
1348
+ if (r.ok) projeto.anotar(p.id, 'plano', `estilo ${hib ? 'hibrido' : 'cinema'}: ${r.resumo.cenas} cenas, ${r.resumo.segundos}s`);
1131
1349
  return r;
1132
1350
  }
1133
1351
  if (modo === 'video') {
@@ -1137,31 +1355,105 @@ async function prepararRoteiro(p, args) {
1137
1355
  fs.writeFileSync(arq, JSON.stringify(lista));
1138
1356
  const mp3 = path.join(p.pasta, 'midia', `narracao-${Date.now().toString(36)}.mp3`);
1139
1357
  // A voz de gente primeiro (Gemini, pelo servidor); o Edge é a reserva.
1140
- let v = await narrarComGemini(lista, mp3, { voz: args.voz || roteiro.voz,
1141
- direcao: estilo === 'impacto' ? 'Leia em português do Brasil como locutor de comercial agressivo e vendedor: energia alta, ritmo rápido, '
1142
- + 'voz encorpada, ênfase forte nas palavras de impacto, pausas curtas e secas entre as frases, sorrindo na voz.' : undefined });
1143
- if (!v.ok) v = await rodarPython('narrar.py', ['roteiro', '--arquivo', arq, '--saida', mp3, '--voz', String(args.voz || roteiro.voz || 'thalita')], { timeout: 10 * 60000 });
1358
+ // Com cache: refazer o vídeo (outro estilo, outra proporção) não gasta a cota da voz.
1359
+ let v = await narrarComCache(lista, mp3, { voz: args.voz || roteiro.voz,
1360
+ direcao: estilo === 'gerativo'
1361
+ /* O gerativo pede voz com CORPO ("narração encorpada, extravagante",
1362
+ 30/09): cheia, quente, perto do microfone — e o ritmo segue 1,12×. */
1363
+ /* (A primeira versão pedia "trailer" e "ênfase sustentada": a voz saiu 25% mais lenta, medido em 30/09.) */
1364
+ ? 'Leia em português do Brasil como locutor de reels de tecnologia: RÁPIDO e contínuo, com voz cheia, quente e encorpada, '
1365
+ + 'bem perto do microfone, muita energia e sorriso na voz. Ataque firme e ênfase curta e forte nas palavras de impacto; '
1366
+ + 'quase sem pausa entre as frases. Nunca arraste, nunca leia devagar nem solene.'
1367
+ // O fluido é o vídeo de produto: voz limpa e segura, e rápida ("mais rápido", 06/10).
1368
+ : estilo === 'fluido' ? 'Leia em português do Brasil como narrador de vídeo de produto premium: seguro, próximo e RÁPIDO, '
1369
+ + 'frases limpas e contínuas, quase sem pausa entre elas. Nunca arraste, nunca leia devagar; sem tom de anúncio.'
1370
+ : estilo === 'impacto' ? 'Leia em português do Brasil como locutor de comercial agressivo e vendedor: energia alta, ritmo rápido, '
1371
+ + 'voz encorpada, ênfase forte nas palavras de impacto, pausas curtas e secas entre as frases, sorrindo na voz.'
1372
+ // "A narração tá lenta, deixa mais viva" (29/09): todo estilo fala com energia de reels.
1373
+ : 'Leia em português do Brasil como narrador de reels de tecnologia: vivo, confiante e rápido, sorrindo na voz, '
1374
+ + 'ênfase nas palavras que importam, pausas curtas entre as frases. Nada de leitura lenta ou solene.' });
1375
+ // O Kokoro pedido já foi tentado no narrarComCache: a reserva não repete.
1376
+ if (!v.ok) v = await narrarReserva(lista, mp3, arq, args.voz || roteiro.voz, 'thalita', { semKokoro: kokoro.querKokoro(args.voz || roteiro.voz) });
1144
1377
  if (!v.ok) return v;
1378
+ /* O RITMO DA VOZ. Mesmo com a direção, a voz sai no passo de leitura, e
1379
+ o vídeo — que segue a voz palavra por palavra — sai lento junto. O
1380
+ áudio acelera 1,12× (1,3× ficou corrido demais, 29/09; atempo não muda o tom) e cada tempo de palavra
1381
+ acompanha; `roteiro.ritmoVoz` ajusta (1 desliga). */
1382
+ // O fluido corre mais ("mais rápido", 06/10): 1,22× por padrão.
1383
+ const ritmo = Math.max(1, Math.min(1.5, Number(roteiro.ritmoVoz || args.ritmoVoz) || (estilo === 'fluido' ? 1.22 : 1.12)));
1384
+ if (ritmo > 1.01) {
1385
+ const rap = mp3.replace(/\.mp3$/, '-r.mp3');
1386
+ const c = await video.rodar(video.ffmpeg(), ['-y', '-i', mp3, '-filter:a', `atempo=${ritmo.toFixed(3)}`, rap], { timeout: 120000 });
1387
+ if (c.ok && fs.existsSync(rap)) {
1388
+ fs.renameSync(rap, mp3);
1389
+ const esc = (x) => (typeof x === 'number' ? Math.round((x / ritmo) * 1000) / 1000 : x);
1390
+ v.segundos = esc(v.segundos);
1391
+ v.partes = (v.partes || []).map((pt) => ({ ...pt, de: esc(pt.de), ate: esc(pt.ate),
1392
+ palavras: (pt.palavras || []).map((w) => ({ ...w, de: esc(w.de), ate: esc(w.ate) })) }));
1393
+ }
1394
+ }
1145
1395
  tempos = v.partes;
1146
1396
  narracao = path.relative(p.pasta, mp3).replace(/\\/g, '/');
1147
1397
  projeto.anotar(p.id, 'narracao', `${v.segundos}s em ${partes.length} partes, voz ${v.voz}`);
1148
1398
  // O motion usa os efeitos leves; os estilos de corte, os cheios.
1149
- sons = ehMotion ? sonsLeves.preparar(p.pasta) : await edicao.prepararSons(p.pasta, video);
1399
+ // O gerativo tem a família de sons DELE (sintetizada da semente); os outros motion, os leves.
1400
+ // O gerativo desenha os sons DEPOIS da partitura (um arquivo por evento, da própria trilha).
1401
+ sons = estilo === 'gerativo' ? {}
1402
+ : ehMotion ? sonsLeves.preparar(p.pasta) : await edicao.prepararSons(p.pasta, video);
1150
1403
  // A trilha do motion: ende.app (CC BY 4.0, sem Content ID), clima tecnologia.
1151
1404
  if (ehMotion) {
1152
1405
  try {
1153
1406
  const segundos = tempos.length ? tempos[tempos.length - 1].ate + 2 : 20;
1154
- const m = await ende.provedor({ pasta: path.join(p.pasta, 'midia') }).gerar({ clima: roteiro.clima || 'tecnologia', segundos });
1407
+ /* O gerativo escolhe o GÊNERO pelo assunto (jazz para o explainer,
1408
+ funk para o fone, eletrônica para IA…), sorteia a faixa e não
1409
+ repete as últimas usadas — nada de "inspiring corporate". */
1410
+ let m = null;
1411
+ const usadasArq = path.join(os.homedir(), '.primocode', 'studio', 'trilhas-usadas.json');
1412
+ let usadas = [];
1413
+ try { usadas = JSON.parse(fs.readFileSync(usadasArq, 'utf8')); } catch { usadas = []; }
1414
+ const mus = estilo === 'gerativo' ? gerativo.musicaDe(roteiro) : null;
1415
+ /* A trilha do gerativo vem do Jamendo (Openverse), pelo GÊNERO do
1416
+ assunto, instrumental, sorteada e sem repetir as últimas — a
1417
+ ende.app fica de reserva ("parar de cair sempre na ende", 30/09). */
1418
+ if (mus) {
1419
+ const BUSCA = { jazz: 'jazz instrumental', 'lofi-chill-beats': 'lofi hip hop instrumental', electronica: 'electronic instrumental', funk: 'funk instrumental',
1420
+ 'dance-club': 'house instrumental', ambient: 'ambient instrumental', acoustic: 'acoustic guitar instrumental', cinematic: 'cinematic instrumental',
1421
+ classical: 'piano classical instrumental', 'soul-rnb': 'soul instrumental' };
1422
+ try {
1423
+ const b = await assets.procurarMusica(BUSCA[roteiro.genero || mus.genero] || ((roteiro.genero || mus.genero) + ' instrumental'), { quantas: 8, duracaoMin: Math.max(45, segundos + 10) });
1424
+ const opcoes = (b.ok ? b.sons : []).filter((x) => x.url && !usadas.includes(x.url) && !/vocal|voice|feat|ft\.|remix/i.test(x.titulo || ''));
1425
+ for (let k = 0; k < opcoes.length && !m; k++) {
1426
+ const item = opcoes[(mus.semente + k) % opcoes.length];
1427
+ const d = await assets.baixar(item, { pasta: p.pasta, nome: 'trilha-' + (roteiro.genero || mus.genero) });
1428
+ if (!d.ok) continue;
1429
+ if (await temLetra(path.join(p.pasta, d.relativo))) { projeto.anotar(p.id, 'midia', `trilha com voz descartada: ${item.titulo}`); continue; }
1430
+ 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 };
1431
+ }
1432
+ } catch { /* cai na ende.app */ }
1433
+ }
1434
+ if (mus && m) {
1435
+ try { fs.writeFileSync(usadasArq, JSON.stringify([m.id, ...usadas.filter((x) => x !== m.id)].slice(0, 16))); } catch { /* sem memória */ }
1436
+ projeto.anotar(p.id, 'trilha', `gênero ${roteiro.genero || mus.genero} (Jamendo)`);
1437
+ } else if (mus) {
1438
+ 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 });
1439
+ const id = m && Number((/ende-(\d+)\.mp3$/.exec(m.src) || [])[1]);
1440
+ if (id) try { fs.writeFileSync(usadasArq, JSON.stringify([id, ...usadas.filter((x) => x !== id)].slice(0, 16))); } catch { /* sem memória */ }
1441
+ if (m) projeto.anotar(p.id, 'trilha', `gênero ${roteiro.genero || mus.genero}`);
1442
+ } else m = await ende.provedor({ pasta: path.join(p.pasta, 'midia') }).gerar({ clima: roteiro.clima || 'tecnologia', segundos });
1155
1443
  if (m) { trilha = path.relative(p.pasta, m.src).replace(/\\/g, '/'); projeto.anotar(p.id, 'trilha', m.credito); }
1156
1444
  } catch { /* cai na busca de sempre */ }
1157
1445
  }
1158
- if (!trilha) try {
1159
- const b = await assets.procurarMusica(roteiro.musica || args.trilha || (estilo === 'impacto' ? 'epic energetic' : 'inspiring corporate'), { quantas: 6, duracaoMin: 45 });
1160
- for (const item of (b.ok ? b.sons : [])) {
1161
- const d = await assets.baixar(item, { pasta: p.pasta, nome: 'trilha' });
1162
- if (d.ok) { trilha = d.relativo; break; }
1163
- }
1164
- } catch { /* sem música */ }
1446
+ // Termo pelo clima e faixa sorteada sem repetir — o "inspiring corporate" fixo era a mesma música em todo vídeo.
1447
+ for (const termo of [args.trilha, ...variedade.termosTrilha(roteiro, estilo)].filter(Boolean)) {
1448
+ if (trilha) break;
1449
+ try {
1450
+ const b = await assets.procurarMusica(termo, { quantas: 10, duracaoMin: 45 });
1451
+ for (const item of variedade.ordenar(b.ok ? b.sons : [], 'trilha').slice(0, 3)) {
1452
+ const d = await assets.baixar(item, { pasta: p.pasta, nome: 'trilha' });
1453
+ if (d.ok) { trilha = d.relativo; variedade.marcar('trilha', item.url); projeto.anotar(p.id, 'trilha', `"${termo}": ${item.titulo}`); break; }
1454
+ }
1455
+ } catch { /* o próximo termo */ }
1456
+ }
1165
1457
  }
1166
1458
  if (estilo === 'impacto' && modo === 'video') {
1167
1459
  // Os emojis das palavras FALADAS também (a batida mostra a fala).
@@ -1197,6 +1489,33 @@ async function prepararRoteiro(p, args) {
1197
1489
  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)') : ''}`);
1198
1490
  return r;
1199
1491
  }
1492
+ if (estilo === 'gerativo' && modo === 'video') {
1493
+ /* A música a -18 LUFS (a voz sai a -14): os volumes do plano partem
1494
+ daí — cheia no respiro, uns 9 dB abaixo quando a voz fala. */
1495
+ if (trilha && !/-n18\.mp3$/.test(trilha)) {
1496
+ const norm = path.join(p.pasta, 'midia', path.basename(trilha).replace(/\.[a-z0-9]+$/i, '') + '-n18.mp3');
1497
+ /* A introdução da faixa fica de fora (ela começa morna): corta de 12% em
1498
+ diante, com 0,4 s de entrada, no tamanho do vídeo. */
1499
+ let ini = 0;
1500
+ 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 */ }
1501
+ 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),
1502
+ '-af', 'afade=t=in:st=0:d=0.4,loudnorm=I=-18:TP=-2:LRA=11', '-ar', '44100', norm], { timeout: 5 * 60000 });
1503
+ if (c.ok && fs.existsSync(norm)) trilha = path.relative(p.pasta, norm).replace(/\\/g, '/');
1504
+ }
1505
+ const r = gerativo.montarGerativo({ roteiro, largura, altura, tempos, narracao, sons, trilha });
1506
+ if (r.ok) {
1507
+ const feitos = sonsLeves.prepararReceitas(p.pasta, r.somReceitas);
1508
+ // Som que o ffmpeg não conseguiu fazer sai do plano (o vídeo não espera um arquivo que não existe).
1509
+ r.plano.audio.efeitos = r.plano.audio.efeitos.filter((e) => Object.values(feitos).includes(e.src));
1510
+ }
1511
+ 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}`);
1512
+ return r;
1513
+ }
1514
+ if (estilo === 'fluido' && modo === 'video') {
1515
+ const r = fluido.montarFluido({ roteiro, largura, altura, tempos, narracao, sons, trilha });
1516
+ if (r.ok) projeto.anotar(p.id, 'plano', `estilo fluido: ${r.resumo.batidas} batidas, paleta ${r.resumo.paleta}, ${r.resumo.segundos}s, batida atrasada no máximo ${r.resumo.atraso}s, palavra atrás da voz no máximo ${r.resumo.sincronia}s`);
1517
+ return r;
1518
+ }
1200
1519
  if (estilo === 'motion' && modo === 'video') {
1201
1520
  const r = motion.montarMotion({ roteiro, largura, altura, tempos, narracao, sons, trilha, imagens });
1202
1521
  if (r.ok) projeto.anotar(p.id, 'plano', `estilo motion: ${r.resumo.segundos}s contínuos, ${r.resumo.partes} partes, ${r.resumo.fps} fps`);