primocode 10.0.9 → 10.2.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 (46) hide show
  1. package/README.md +4 -4
  2. package/bin/primocode.js +25 -28
  3. package/lib/act.js +5 -2
  4. package/lib/catalogo.js +7 -4
  5. package/lib/config.js +10 -2
  6. package/lib/estudio/assets.js +86 -6
  7. package/lib/estudio/batida.js +184 -0
  8. package/lib/estudio/biblioteca.js +214 -0
  9. package/lib/estudio/cinema.js +37 -17
  10. package/lib/estudio/composicao.js +2 -1
  11. package/lib/estudio/decisao.js +106 -0
  12. package/lib/estudio/edicao.js +36 -17
  13. package/lib/estudio/estilo.js +35 -8
  14. package/lib/estudio/fluido.js +352 -0
  15. package/lib/estudio/gerativo.js +21 -3
  16. package/lib/estudio/hibrido.js +16 -12
  17. package/lib/estudio/impacto.js +66 -31
  18. package/lib/estudio/index.js +245 -42
  19. package/lib/estudio/kit/fluido.jsx +614 -0
  20. package/lib/estudio/kit/gerativo.jsx +15 -5
  21. package/lib/estudio/kit/index.jsx +2 -1
  22. package/lib/estudio/kit/tres.jsx +88 -21
  23. package/lib/estudio/kit.js +3 -1
  24. package/lib/estudio/kokoro.js +111 -0
  25. package/lib/estudio/lancamento.js +3 -4
  26. package/lib/estudio/legibilidade.js +20 -0
  27. package/lib/estudio/referencias.js +36 -25
  28. package/lib/estudio/remotion.js +53 -6
  29. package/lib/estudio/revisor.js +117 -0
  30. package/lib/estudio/variacao3d.js +46 -0
  31. package/lib/estudio/variedade.js +140 -8
  32. package/lib/portao-skill.js +150 -0
  33. package/lib/skills.js +15 -1
  34. package/lib/tools.js +31 -5
  35. package/lib/video/filters.js +6 -1
  36. package/lib/video/ops.js +4 -3
  37. package/lib/video/timeline.js +1 -1
  38. package/lib/video/zoom.js +275 -0
  39. package/package.json +2 -2
  40. package/skills/escrever-em-portugues/SKILL.md +3 -0
  41. package/skills/interface-revisada/SKILL.md +7 -1
  42. package/skills/motion-de-primeira/SKILL.md +3 -5
  43. package/skills/projeto-no-github/SKILL.md +6 -0
  44. package/skills/publicar-na-internet/SKILL.md +3 -0
  45. package/skills/site-com-motion/SKILL.md +2 -0
  46. package/studio/voz_kokoro.py +98 -0
@@ -35,12 +35,16 @@ const { spawn } = require('child_process');
35
35
  const projeto = require('./projeto.js');
36
36
  const acervo = require('./acervo.js');
37
37
  const hibrido = require('./hibrido.js');
38
+ const biblioteca = require('./biblioteca.js');
38
39
  const referencias = require('./referencias.js');
39
40
  const variedade = require('./variedade.js');
41
+ const variacao3d = require('./variacao3d.js');
40
42
  const plano = require('./plano.js');
41
43
  const remotion = require('./remotion.js');
42
44
  const assets = require('./assets.js');
43
45
  const video = require('./video.js');
46
+ const batida = require('./batida.js');
47
+ const revisor = require('./revisor.js');
44
48
  const identidade = require('./identidade.js');
45
49
  const slides = require('./slides.js');
46
50
  const edicao = require('./edicao.js');
@@ -50,7 +54,9 @@ const impacto = require('./impacto.js');
50
54
  const cinema = require('./cinema.js');
51
55
  const motion = require('./motion.js');
52
56
  const gerativo = require('./gerativo.js');
57
+ const fluido = require('./fluido.js');
53
58
  const vox = require('./vox.js');
59
+ const kokoro = require('./kokoro.js');
54
60
  const lancamento = require('./lancamento.js');
55
61
  const glifos = require('./glifos.js');
56
62
  const { escolherEstilo, LEVES } = require('./estilo.js');
@@ -153,9 +159,11 @@ function semProjeto(id) {
153
159
  };
154
160
  }
155
161
 
156
- async function ferramentaProjeto(args) {
162
+ async function ferramentaProjeto(args, ctx = {}) {
157
163
  const acao = String(args.acao || 'listar').toLowerCase();
158
164
 
165
+ if (acao === 'editar' || acao === 'editor' || acao === 'studio') return editarNoStudio(args, ctx);
166
+
159
167
  if (acao === 'criar' || acao === 'novo') {
160
168
  const r = projeto.criar({
161
169
  titulo: args.titulo, pedido: args.pedido || args.titulo, formato: args.formato || 'video',
@@ -199,6 +207,28 @@ async function ferramentaProjeto(args) {
199
207
  };
200
208
  }
201
209
 
210
+ /* EDITAR um vídeo que o Estúdio fez: abre o projeto Remotion dele no
211
+ Remotion Studio. Sem id, é o projeto mais novo. */
212
+ async function editarNoStudio(args, ctx = {}) {
213
+ const p = args.id ? projeto.ler(args.id) : projeto.listar({ limite: 1 })[0];
214
+ if (!p) return semProjeto(args.id);
215
+ const raiz = path.join(p.pasta, 'remotion');
216
+ if (!fs.existsSync(path.join(raiz, 'package.json'))) {
217
+ // Projeto que ainda não virou Remotion: escreve agora, sem renderizar.
218
+ const m = await ferramentaRender({ id: p.id, somenteProjeto: true }, ctx);
219
+ if (!m.ok) return m;
220
+ }
221
+ const r = remotion.abrirStudio({ raiz, spawnar: ctx.spawnar });
222
+ if (!r.ok) return { ...r, pasta: raiz };
223
+ projeto.anotar(p.id, 'editor', 'abriu no Remotion Studio');
224
+ return {
225
+ ok: true, id: p.id, pasta: raiz, comando: r.comando,
226
+ aviso: 'O Remotion Studio está ABRINDO no navegador (geralmente http://localhost:3000). '
227
+ + `Diga ao usuário onde está o projeto: ${raiz}. Lá ele mexe e renderiza pelo botão Render do Studio. `
228
+ + 'Cuidado: estudio_render reescreve a pasta src/ a partir do plano e apaga o que ele mudou à mão.',
229
+ };
230
+ }
231
+
202
232
  function listarArquivos(pasta) {
203
233
  const saida = {};
204
234
  for (const sub of ['entrada', 'midia', 'saida']) {
@@ -317,6 +347,8 @@ async function ferramentaPlano(args) {
317
347
  try { original = JSON.parse(typeof args.roteiro === 'string' ? args.roteiro : JSON.stringify(args.roteiro)); } catch { original = args.roteiro; }
318
348
  const r = await prepararRoteiro(p, args);
319
349
  if (!r.ok) return r;
350
+ const ic = await variarIcones(r.plano, original);
351
+ if (ic.trocados) projeto.anotar(p.id, 'icones', `coleção ${ic.colecao}: ${ic.trocados} ícones`);
320
352
  let conferida = plano.conferir(r.plano, { pasta: p.pasta });
321
353
  /* A rede de segurança: o elemento que não passa sai, e o vídeo segue.
322
354
  Um defeito num ícone não pode custar o vídeo inteiro. */
@@ -384,7 +416,7 @@ async function ferramentaMidia(args) {
384
416
 
385
417
  if (acao === 'fontes') {
386
418
  if (args.conferir) return assets.conferirFonte(args.conferir);
387
- return { ok: true, fontes: assets.FONTES,
419
+ return { ok: true, fontes: assets.FONTES, biblioteca: biblioteca.cardapio(),
388
420
  nota: 'qualquer família do Google Fonts funciona — esta lista é para não repetir sempre as mesmas.' };
389
421
  }
390
422
 
@@ -636,7 +668,7 @@ async function ferramentaVideo(args, ctx = {}) {
636
668
  if ((args.estilo || 'viral') === 'viral' && args.recorte !== false) {
637
669
  const recortes = {};
638
670
  for (const [i, info] of montado.infos.entries()) {
639
- if (!edicao.temRecorte(i) || !info.forte || info.forte.peso < 9 || Object.keys(recortes).length >= 3) continue;
671
+ if (!info.recorte || !info.forte || info.forte.peso < 9 || Object.keys(recortes).length >= 3) continue;
640
672
  const de = Math.max(0, info.forte.de - 0.15);
641
673
  const ate = Math.min(info.dur, de + 2.2);
642
674
  if (ate - de < 1) continue;
@@ -682,27 +714,81 @@ async function ferramentaVideo(args, ctx = {}) {
682
714
  }
683
715
 
684
716
  /* A mídia de apoio de um termo: vídeo do Pexels quando pedido e houver,
685
- senão a primeira foto que baixar. Devolve {src, tipo} ou null. */
717
+ senão uma foto. Devolve {src, tipo} ou null. O clipe sai sorteado entre
718
+ os que os últimos vídeos não usaram (variedade.js), não o primeiro da
719
+ busca — "o b-roll veio igual nos dois vídeos" (Joel, 05/10). */
720
+ /** A foto editorial (Wikimedia, museu) que os últimos vídeos não usaram; o crédito fica no caderno. */
721
+ async function baixarEditorial(p, termo) {
722
+ try {
723
+ const b = await assets.procurarEditorial(termo, { quantas: 8 });
724
+ for (const item of variedade.ordenar(b.imagens, 'broll').slice(0, 4)) {
725
+ const d = await assets.baixar(item, { pasta: p.pasta, nome: 'foto-' + termo });
726
+ if (d.ok) { variedade.marcar('broll', item.url); projeto.anotar(p.id, 'creditos', item.creditos); return { src: d.relativo, tipo: 'imagem' }; }
727
+ }
728
+ } catch { /* sem foto */ }
729
+ return null;
730
+ }
731
+
686
732
  async function baixarApoio(p, termo, { orientacao = null, video: querVideo = false } = {}) {
687
733
  if (querVideo) {
688
734
  try {
689
- const b = await assets.procurarVideos(termo, { quantas: 4, orientacao });
690
- for (const item of (b.ok ? b.videos : []).filter((v) => !v.duracao || v.duracao >= 3)) {
735
+ const itens = await variedade.buscarSemRepetir(async (t) => {
736
+ const b = await assets.procurarVideos(t, { quantas: 8, orientacao });
737
+ return (b.ok ? b.videos : []).filter((v) => !v.duracao || v.duracao >= 3);
738
+ }, termo);
739
+ for (const item of itens.slice(0, 4)) {
691
740
  const d = await assets.baixar(item, { pasta: p.pasta, nome: 'apoio-' + termo });
692
- if (d.ok) return { src: d.relativo, tipo: 'video' };
741
+ if (d.ok) { variedade.marcar('broll', item.url); return { src: d.relativo, tipo: 'video' }; }
693
742
  }
694
743
  } catch { /* cai para a foto */ }
695
744
  }
696
745
  try {
697
- const b = await assets.procurarImagens(termo, { quantas: 4, orientacao });
698
- for (const item of (b.ok ? b.imagens : [])) {
746
+ const itens = await variedade.buscarSemRepetir(async (t) => {
747
+ const b = await assets.procurarImagens(t, { quantas: 8, orientacao });
748
+ return b.ok ? b.imagens : [];
749
+ }, termo);
750
+ for (const item of itens.slice(0, 4)) {
699
751
  const d = await assets.baixar(item, { pasta: p.pasta, nome: 'apoio-' + termo });
700
- if (d.ok) return { src: d.relativo, tipo: 'imagem' };
752
+ if (d.ok) { variedade.marcar('broll', item.url); return { src: d.relativo, tipo: 'imagem' }; }
701
753
  }
702
754
  } catch { /* sem apoio */ }
703
755
  return null;
704
756
  }
705
757
 
758
+ /* Os ícones automáticos trocam de coleção a cada vídeo (Joel, 05/10: "várias
759
+ opções de ícones... que não sejam padrão da IA"). O ícone que o roteiro
760
+ pediu pelo nome fica; o que não existe na coleção sorteada fica no 3D. */
761
+ async function variarIcones(plano, roteiro) {
762
+ const colecao = variedade.colecaoIcones();
763
+ const explicitos = [];
764
+ try { JSON.stringify(roteiro || {}, (k, v) => { if (k === 'icone' && typeof v === 'string') explicitos.push(v); return v; }); } catch { /* sem */ }
765
+ const troca = {};
766
+ for (const nome of variedade.iconesTrocaveis(plano, explicitos)) {
767
+ /* Primeiro O ÍCONE: o conceito escolhe entre as formas dele
768
+ (edicao.ALTERNATIVAS) pelos pontos da variedade — o que os últimos
769
+ vídeos menos usaram. Depois A COLEÇÃO. */
770
+ const base = nome.split(':')[1];
771
+ let escolhido = base;
772
+ const alts = edicao.ALTERNATIVAS[base];
773
+ if (alts) {
774
+ const ordem = variedade.ordenar([base, ...alts].map((url) => ({ url })), 'icone-nome').map((x) => x.url);
775
+ for (const n of ordem) {
776
+ if (n === base) { escolhido = base; break; }
777
+ try { const r = await assets.icone('fluent-3d:' + n); if (r && r.ok) { escolhido = n; break; } } catch { /* tenta o próximo */ }
778
+ }
779
+ variedade.marcar('icone-nome', escolhido);
780
+ }
781
+ let novo = 'fluent-3d:' + escolhido;
782
+ if (colecao !== 'fluent-3d') {
783
+ try { const r = await assets.icone(colecao + ':' + escolhido); if (r && r.ok) novo = colecao + ':' + escolhido; } catch { /* fica o 3D */ }
784
+ }
785
+ if (novo !== nome) troca[nome] = novo;
786
+ }
787
+ const n = variedade.trocarIcones(plano, troca);
788
+ if (n || colecao === 'fluent-3d') variedade.marcar('icones', colecao);
789
+ return { colecao, trocados: n };
790
+ }
791
+
706
792
  /* ── A VOZ DO GEMINI ─────────────────────────────────────────────────────
707
793
  * O roteiro inteiro numa chamada só (o plano grátis tem poucos pedidos por
708
794
  * minuto), e as partes achadas depois na transcrição do próprio áudio: a
@@ -867,11 +953,23 @@ async function narrarComCache(lista, mp3, { voz, direcao } = {}) {
867
953
  const dir = path.join(os.homedir(), '.primocode', 'studio', 'cache-voz');
868
954
  const cm = path.join(dir, chave + '.mp3'), cj = path.join(dir, chave + '.json');
869
955
  if (fs.existsSync(cm) && fs.existsSync(cj)) { fs.copyFileSync(cm, mp3); return { ok: true, ...JSON.parse(fs.readFileSync(cj, 'utf8')) }; }
956
+ // A voz local pedida (voz: 'kokoro'): direto nela, sem gastar a cota do Gemini.
957
+ if (kokoro.querKokoro(voz)) return kokoro.narrar(lista, mp3, { voz, uv: acharUv() });
870
958
  const v = await narrarComGemini(lista, mp3, { voz, direcao });
871
959
  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 */ } }
872
960
  return v;
873
961
  }
874
962
 
963
+ /* A VOZ DE RESERVA, quando o Gemini falha: o Kokoro (local, sem cota) e
964
+ depois o Edge. A falha de uma passa para a próxima, nunca derruba. */
965
+ function narrarReserva(lista, mp3, arq, voz, vozEdge, { soEdge = false, semKokoro = false } = {}) {
966
+ const passos = kokoro.ordem(voz, process.env, { soEdge }).filter((n) => n !== 'gemini' && !(semKokoro && n === 'kokoro'));
967
+ return kokoro.corrente(passos, {
968
+ kokoro: () => kokoro.narrar(lista, mp3, { voz, uv: acharUv() }),
969
+ edge: () => rodarPython('narrar.py', ['roteiro', '--arquivo', arq, '--saida', mp3, '--voz', String(kokoro.querKokoro(voz, {}) ? vozEdge : voz || vozEdge)], { timeout: 10 * 60000 }),
970
+ });
971
+ }
972
+
875
973
  /* A MÚSICA TEM LETRA? O acervo não diz se a faixa é instrumental, então ela
876
974
  é ouvida: 25 s do meio vão para a transcrição, e mais de 6 palavras é voz
877
975
  cantada. Sem servidor para ouvir, a faixa passa (melhor música que nenhuma). */
@@ -903,7 +1001,7 @@ async function sonsReais(p, video) {
903
1001
  for (const termo of variedade.termosSfx(nome)) {
904
1002
  if (sons[nome] !== sint[nome]) break;
905
1003
  try {
906
- const b = await assets.procurarSom(termo, { quantas: 10 });
1004
+ const b = await assets.procurarSom(termo, { quantas: 10, livre: true });
907
1005
  for (const item of variedade.ordenar(b.ok ? b.sons : [], 'sfx').slice(0, 3)) {
908
1006
  const d = await assets.baixar(item, { pasta: p.pasta, nome: 'sfx-real-' + nome });
909
1007
  if (d.ok) { sons[nome] = d.relativo; variedade.marcar('sfx', item.url); break; }
@@ -939,24 +1037,44 @@ async function prepararRoteiro(p, args) {
939
1037
  IMPACTO. O que o agente pediu vale mais que tudo. */
940
1038
  const pedidoTxt = `${p.pedido || ''} ${args.pedido || ''}`;
941
1039
  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;
1040
+ /* Sem imagem é o padrão (07/10): sem o pedido dizer, nada de foto nem
1041
+ vídeo de fora. Ninguém escolheu o estilo? Ele gira (estilo.js) — sem
1042
+ imagem, só entre os que não vivem de foto. O híbrido e o cinema, pedidos
1043
+ pelo nome sem imagem, caem para o gerativo (sem nada baixado). */
1044
+ const semImagem = referencias.semImagem(ref);
1045
+ let estilo = escolherEstilo({ pedido: pedidoTxt, agente: args.estilo, roteiro: roteiro.estilo, girar: true, semImagem });
1046
+ if (semImagem && (estilo === 'hibrido' || estilo === 'cinema')) estilo = 'gerativo';
950
1047
  /* "O vídeo deve ter um 3D do fone" (30/09): pedido de 3D vira o gerativo
951
1048
  com o produto modelado girando numa batida (kit/tres.jsx) — a menos que
952
1049
  o agente tenha escolhido outro estilo pelo nome. */
953
1050
  if (/\b3\s?d\b|tridimensional/i.test(pedidoTxt) && (!args.estilo || estilo === 'gerativo' || estilo === 'lancamento')) {
954
1051
  // O 3D mora numa batida do gerativo; o lançamento antigo, só pelo nome.
955
1052
  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';
1053
+ if (!roteiro.modelo3d) roteiro.modelo3d = variacao3d.modeloDoPedido(pedidoTxt);
1054
+ }
1055
+ // O 3D de cada vídeo: acabamento, câmera, luz e ritmo sorteados sem repetir os últimos.
1056
+ /* Por PROJETO, não por chamada: mandar o roteiro de novo para ajustar uma
1057
+ frase não pode sortear outro 3D nem outro vídeo. Projeto novo, sorteio novo. */
1058
+ if (roteiro.modelo3d && !roteiro.variacao3d) {
1059
+ roteiro.variacao3d = p.variacao3d || variacao3d.escolherVariacao3d();
1060
+ if (!p.variacao3d) projeto.salvar(p.id, { variacao3d: roteiro.variacao3d });
1061
+ }
1062
+ /* A SEMENTE do gerativo saía do texto do roteiro: o mesmo roteiro dava o
1063
+ mesmo vídeo, batida por batida (05/10). Sem semente do roteiro, cada
1064
+ projeto sorteia a sua. */
1065
+ if (estilo === 'gerativo' && roteiro.semente == null) {
1066
+ roteiro.semente = p.semente != null ? p.semente : Math.floor(Math.random() * 1e9);
1067
+ if (p.semente == null) projeto.salvar(p.id, { semente: roteiro.semente });
958
1068
  }
959
1069
  if (ref.modo === 'fechado' && !roteiro.busca && referencias.termos().length) roteiro.busca = referencias.termos();
1070
+ /* A BIBLIOTECA (biblioteca.js): sem fonte e cor da marca, o híbrido, o
1071
+ cinema e o impacto caíam sempre no Inter e no azul e roxo — "os vídeos
1072
+ todos iguais" (02/10). A marca manda; depois o roteiro.visual da IA;
1073
+ sem nada, o sorteio que pula o que os últimos vídeos usaram. */
1074
+ if (['hibrido', 'cinema', 'impacto'].includes(estilo)) {
1075
+ const v = biblioteca.vestir(roteiro, { estilo });
1076
+ if (v.mudou) projeto.anotar(p.id, 'visual', [v.titulo && `fonte ${v.titulo} + ${v.texto} (tom ${v.tom})`, v.paleta && `paleta ${v.paleta}`].filter(Boolean).join(', '));
1077
+ }
960
1078
  // Os estilos de motion usam os efeitos leves e a trilha do ende.app; os de corte, os cheios.
961
1079
  const ehMotion = LEVES.has(estilo);
962
1080
  const modo = (args.modo || roteiro.modo || (p.formato === 'slides' || p.formato === 'arte' ? 'slides' : 'video'));
@@ -987,13 +1105,13 @@ async function prepararRoteiro(p, args) {
987
1105
  const videosMarca = [...(acervo.videos || [])];
988
1106
  const imagens = {}, fundos = {};
989
1107
  for (const [i, parte] of partes.entries()) {
990
- if (parte.tipo !== 'imagem' || parte.src) continue;
1108
+ if (semImagem || parte.tipo !== 'imagem' || parte.src) continue;
991
1109
  const texto = `${parte.titulo || ''} ${parte.sub || ''} ${parte.fala || ''} ${parte.busca || ''}`;
992
1110
  if (modo === 'video' && videosMarca.length && i % 2 === 1) { imagens[i] = { src: videosMarca.shift().src, tipo: 'video' }; continue; }
993
1111
  const m = melhorFoto(texto) || (parte.busca ? await baixarApoio(p, parte.busca, { orientacao: largura >= altura ? 'landscape' : 'portrait', video: modo === 'video' }) : null);
994
1112
  if (m) imagens[i] = m;
995
1113
  }
996
- if (acervo.fotos.length) {
1114
+ if (acervo.fotos.length && !semImagem) {
997
1115
  const comFundo = ['abertura', 'titulo', 'contraste', 'citacao', 'cta', 'passos'];
998
1116
  let alterna = 0;
999
1117
  for (const [i, parte] of partes.entries()) {
@@ -1044,6 +1162,8 @@ async function prepararRoteiro(p, args) {
1044
1162
  }
1045
1163
  } catch { /* fica o alinhamento guardado */ }
1046
1164
  }
1165
+ } else if (kokoro.querKokoro(args.voz || roteiro.voz)) {
1166
+ v = { ok: false, error: 'kokoro pedido' };
1047
1167
  } else {
1048
1168
  v = await narrarComGemini(lista, mp3, { voz: args.voz || roteiro.voz,
1049
1169
  direcao: 'Leia em português do Brasil como narrador de trailer de marca premium: voz grave, confiante e humana, '
@@ -1056,7 +1176,7 @@ async function prepararRoteiro(p, args) {
1056
1176
  const arq = path.join(p.pasta, 'midia', `roteiro-${Date.now().toString(36)}.json`);
1057
1177
  // Ritmo NORMAL: o "citação" (-10%, respiro longo) esticou o vídeo para 125 s.
1058
1178
  fs.writeFileSync(arq, JSON.stringify(lista.map((x) => ({ ...x, registro: 'normal' }))));
1059
- v = await rodarPython('narrar.py', ['roteiro', '--arquivo', arq, '--saida', mp3, '--voz', String(args.voz || roteiro.voz || 'antonio')], { timeout: 10 * 60000 });
1179
+ v = await narrarReserva(lista, mp3, arq, args.voz || roteiro.voz, 'antonio', { soEdge });
1060
1180
  }
1061
1181
  if (!v.ok) return v;
1062
1182
  /* A DURAÇÃO que o roteiro pede ("60–75 s"): se a voz passa do que cabe,
@@ -1171,20 +1291,27 @@ async function prepararRoteiro(p, args) {
1171
1291
  const proxy = `${slug}.leve.mp4`;
1172
1292
  if (jaTem.includes(proxy)) out.push({ src: 'midia/' + proxy, tipo: 'video', cat: cat(termo), alt: termo });
1173
1293
  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)) {
1294
+ // Sorteado entre os não usados, com o termo variado (variedade.js, 05/10).
1295
+ const itens = await variedade.buscarSemRepetir(async (t) => {
1296
+ const b = await assets.procurarVideos(t, { quantas: 8, orientacao });
1297
+ return (b.ok ? b.videos : []).filter((v) => !v.duracao || v.duracao >= 4);
1298
+ }, termo);
1299
+ for (const item of itens.slice(0, 1)) {
1176
1300
  const d = await assets.baixar(item, { pasta: p.pasta, nome: slug });
1177
1301
  const l = d.ok ? await leve(d.relativo, proxy) : null;
1178
- if (l) out.push({ src: l, tipo: 'video', cat: cat(termo), alt: termo });
1302
+ if (l) { out.push({ src: l, tipo: 'video', cat: cat(termo), alt: termo }); variedade.marcar('broll', item.url); }
1179
1303
  }
1180
1304
  } catch { /* segue com a foto */ }
1181
1305
  const foto = jaTem.find((f) => f.startsWith(slug + '-') && /\.(jpe?g|png|webp)$/i.test(f));
1182
1306
  if (foto) out.push({ src: 'midia/' + foto, tipo: 'imagem', cat: cat(termo), alt: termo });
1183
1307
  else try {
1184
- const b = await assets.procurarImagens(termo, { quantas: 3, orientacao });
1185
- for (const item of (b.ok ? b.imagens : []).slice(0, 1)) {
1308
+ const itens = await variedade.buscarSemRepetir(async (t) => {
1309
+ const b = await assets.procurarImagens(t, { quantas: 8, orientacao });
1310
+ return b.ok ? b.imagens : [];
1311
+ }, termo);
1312
+ for (const item of itens.slice(0, 1)) {
1186
1313
  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 });
1314
+ if (d.ok) { out.push({ src: d.relativo, tipo: 'imagem', cat: cat(termo), alt: termo }); variedade.marcar('broll', item.url); }
1188
1315
  }
1189
1316
  } catch { /* sem */ }
1190
1317
  return out;
@@ -1241,7 +1368,7 @@ async function prepararRoteiro(p, args) {
1241
1368
  const r = cinema.montarCinema({ roteiro, largura, altura, tempos: v.partes, narracao: path.relative(p.pasta, mp3).replace(/\\/g, '/'),
1242
1369
  sons: sonsC, trilha: trilhaC, fotos: fotosC, fortes, ritmoLeitura: hib ? 0.55 : 1 });
1243
1370
  if (r.ok && hib) {
1244
- hibrido.enfeitar(r.plano, { extras: hib.extras, partes, marca: roteiro.marca || {}, largura, altura });
1371
+ hibrido.enfeitar(r.plano, { extras: hib.extras, partes, marca: roteiro.marca || {}, largura, altura, hud: roteiro.hud === true, particulas: roteiro.particulas === true });
1245
1372
  r.resumo.estilo = 'hibrido';
1246
1373
  }
1247
1374
  if (r.ok) projeto.anotar(p.id, 'plano', `estilo ${hib ? 'hibrido' : 'cinema'}: ${r.resumo.cenas} cenas, ${r.resumo.segundos}s`);
@@ -1263,18 +1390,23 @@ async function prepararRoteiro(p, args) {
1263
1390
  ? 'Leia em português do Brasil como locutor de reels de tecnologia: RÁPIDO e contínuo, com voz cheia, quente e encorpada, '
1264
1391
  + 'bem perto do microfone, muita energia e sorriso na voz. Ataque firme e ênfase curta e forte nas palavras de impacto; '
1265
1392
  + 'quase sem pausa entre as frases. Nunca arraste, nunca leia devagar nem solene.'
1393
+ // O fluido é o vídeo de produto: voz limpa e segura, e rápida ("mais rápido", 06/10).
1394
+ : estilo === 'fluido' ? 'Leia em português do Brasil como narrador de vídeo de produto premium: seguro, próximo e RÁPIDO, '
1395
+ + 'frases limpas e contínuas, quase sem pausa entre elas. Nunca arraste, nunca leia devagar; sem tom de anúncio.'
1266
1396
  : estilo === 'impacto' ? 'Leia em português do Brasil como locutor de comercial agressivo e vendedor: energia alta, ritmo rápido, '
1267
1397
  + 'voz encorpada, ênfase forte nas palavras de impacto, pausas curtas e secas entre as frases, sorrindo na voz.'
1268
1398
  // "A narração tá lenta, deixa mais viva" (29/09): todo estilo fala com energia de reels.
1269
1399
  : 'Leia em português do Brasil como narrador de reels de tecnologia: vivo, confiante e rápido, sorrindo na voz, '
1270
1400
  + 'ênfase nas palavras que importam, pausas curtas entre as frases. Nada de leitura lenta ou solene.' });
1271
- if (!v.ok) v = await rodarPython('narrar.py', ['roteiro', '--arquivo', arq, '--saida', mp3, '--voz', String(args.voz || roteiro.voz || 'thalita')], { timeout: 10 * 60000 });
1401
+ // O Kokoro pedido já foi tentado no narrarComCache: a reserva não repete.
1402
+ if (!v.ok) v = await narrarReserva(lista, mp3, arq, args.voz || roteiro.voz, 'thalita', { semKokoro: kokoro.querKokoro(args.voz || roteiro.voz) });
1272
1403
  if (!v.ok) return v;
1273
1404
  /* O RITMO DA VOZ. Mesmo com a direção, a voz sai no passo de leitura, e
1274
1405
  o vídeo — que segue a voz palavra por palavra — sai lento junto. O
1275
1406
  áudio acelera 1,12× (1,3× ficou corrido demais, 29/09; atempo não muda o tom) e cada tempo de palavra
1276
1407
  acompanha; `roteiro.ritmoVoz` ajusta (1 desliga). */
1277
- const ritmo = Math.max(1, Math.min(1.5, Number(roteiro.ritmoVoz || args.ritmoVoz) || 1.12));
1408
+ // O fluido corre mais ("mais rápido", 06/10): 1,22× por padrão.
1409
+ const ritmo = Math.max(1, Math.min(1.5, Number(roteiro.ritmoVoz || args.ritmoVoz) || (estilo === 'fluido' ? 1.22 : 1.12)));
1278
1410
  if (ritmo > 1.01) {
1279
1411
  const rap = mp3.replace(/\.mp3$/, '-r.mp3');
1280
1412
  const c = await video.rodar(video.ffmpeg(), ['-y', '-i', mp3, '-filter:a', `atempo=${ritmo.toFixed(3)}`, rap], { timeout: 120000 });
@@ -1302,9 +1434,8 @@ async function prepararRoteiro(p, args) {
1302
1434
  funk para o fone, eletrônica para IA…), sorteia a faixa e não
1303
1435
  repete as últimas usadas — nada de "inspiring corporate". */
1304
1436
  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 = []; }
1437
+ // A memória da trilha é a da variedade ('trilha'): antes era um arquivo à parte (trilhas-usadas.json).
1438
+ const usadas = variedade.usadas('trilha');
1308
1439
  const mus = estilo === 'gerativo' ? gerativo.musicaDe(roteiro) : null;
1309
1440
  /* A trilha do gerativo vem do Jamendo (Openverse), pelo GÊNERO do
1310
1441
  assunto, instrumental, sorteada e sem repetir as últimas — a
@@ -1315,9 +1446,10 @@ async function prepararRoteiro(p, args) {
1315
1446
  classical: 'piano classical instrumental', 'soul-rnb': 'soul instrumental' };
1316
1447
  try {
1317
1448
  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 || ''));
1449
+ // Pelos pontos da variedade: a recente e a muito usada vão para o fim, não somem.
1450
+ const opcoes = variedade.ordenar((b.ok ? b.sons : []).filter((x) => !/vocal|voice|feat|ft\.|remix/i.test(x.titulo || '')), 'trilha');
1319
1451
  for (let k = 0; k < opcoes.length && !m; k++) {
1320
- const item = opcoes[(mus.semente + k) % opcoes.length];
1452
+ const item = opcoes[k];
1321
1453
  const d = await assets.baixar(item, { pasta: p.pasta, nome: 'trilha-' + (roteiro.genero || mus.genero) });
1322
1454
  if (!d.ok) continue;
1323
1455
  if (await temLetra(path.join(p.pasta, d.relativo))) { projeto.anotar(p.id, 'midia', `trilha com voz descartada: ${item.titulo}`); continue; }
@@ -1326,12 +1458,12 @@ async function prepararRoteiro(p, args) {
1326
1458
  } catch { /* cai na ende.app */ }
1327
1459
  }
1328
1460
  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 */ }
1461
+ variedade.marcar('trilha', m.id);
1330
1462
  projeto.anotar(p.id, 'trilha', `gênero ${roteiro.genero || mus.genero} (Jamendo)`);
1331
1463
  } else if (mus) {
1332
1464
  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
1465
  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 */ }
1466
+ if (id) variedade.marcar('trilha', id);
1335
1467
  if (m) projeto.anotar(p.id, 'trilha', `gênero ${roteiro.genero || mus.genero}`);
1336
1468
  } else m = await ende.provedor({ pasta: path.join(p.pasta, 'midia') }).gerar({ clima: roteiro.clima || 'tecnologia', segundos });
1337
1469
  if (m) { trilha = path.relative(p.pasta, m.src).replace(/\\/g, '/'); projeto.anotar(p.id, 'trilha', m.credito); }
@@ -1405,6 +1537,51 @@ async function prepararRoteiro(p, args) {
1405
1537
  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
1538
  return r;
1407
1539
  }
1540
+ if (estilo === 'fluido' && modo === 'video') {
1541
+ /* A CARA de cada vídeo do fluido, por PROJETO (ajustar uma frase não
1542
+ troca): a semente (marcador, jeito das imagens) e a paleta, que a
1543
+ memória de variedade não repete do vídeo anterior (08/10: "sempre o
1544
+ mesmo azul"). A paleta ou o tema pedidos mandam. */
1545
+ if (roteiro.semente == null) {
1546
+ roteiro.semente = p.semente != null ? p.semente : Math.floor(Math.random() * 1e9);
1547
+ if (p.semente == null) projeto.salvar(p.id, { semente: roteiro.semente });
1548
+ }
1549
+ /* O modelo também vicia: pedia "noite" em todo vídeo (era a primeira da
1550
+ lista). A paleta dele vale, menos quando é uma das 2 últimas usadas —
1551
+ aí vem a menos usada do mesmo tema (escura ou clara). */
1552
+ if (!p.paletaFluido) {
1553
+ const P = fluido.PALETAS, nomes = Object.keys(P);
1554
+ const pedida = P[String(roteiro.paleta || '').toLowerCase()] ? String(roteiro.paleta).toLowerCase() : null;
1555
+ const recentes = variedade.usadas('paleta-fluido').slice(0, 2);
1556
+ const escuro = pedida ? P[pedida].escuro : /escuro|dark/i.test(roteiro.tema || '') ? true : /claro|light/i.test(roteiro.tema || '') ? false : null;
1557
+ let escolha = pedida && !recentes.includes(pedida) ? pedida : null;
1558
+ if (!escolha) escolha = variedade.sortear(nomes.filter((n) => escuro == null || P[n].escuro === escuro), 'paleta-fluido');
1559
+ else variedade.marcar('paleta-fluido', escolha);
1560
+ if (pedida && escolha !== pedida) projeto.anotar(p.id, 'visual', `paleta ${pedida} pedida, mas foi a de um dos últimos vídeos: ${escolha}`);
1561
+ projeto.salvar(p.id, { paletaFluido: escolha });
1562
+ p.paletaFluido = escolha;
1563
+ }
1564
+ roteiro.paleta = p.paletaFluido;
1565
+ /* As IMAGENS do fluido misto, só com imagem pedida: as partes com
1566
+ "busca", no máximo 1 em 3 e espalhadas (fluido.js decide o jeito). */
1567
+ const fotosFluido = {};
1568
+ if (!semImagem) {
1569
+ const comBusca = partes.map((pt, k) => (pt.busca || pt.src ? k : -1)).filter((k) => k >= 0 && (partes[k].tipo !== 'cta'));
1570
+ const teto = Math.max(1, Math.floor(partes.length / 3));
1571
+ const escolhidas = [];
1572
+ for (const k of comBusca) if (escolhidas.length < teto && !escolhidas.some((j) => Math.abs(j - k) < 2)) escolhidas.push(k);
1573
+ for (const k of escolhidas) {
1574
+ const pt = partes[k];
1575
+ // O acervo EDITORIAL (assets.procurarEditorial), não o banco de stock: sem foto que sirva, a parte fica sem imagem.
1576
+ const m = pt.src ? { src: pt.src } : await baixarEditorial(p, pt.busca);
1577
+ if (m && m.src) fotosFluido[k] = { src: m.src };
1578
+ }
1579
+ if (Object.keys(fotosFluido).length) projeto.anotar(p.id, 'midia', `fluido misto: imagem nas partes ${Object.keys(fotosFluido).map((k) => Number(k) + 1).join(', ')}`);
1580
+ }
1581
+ const r = fluido.montarFluido({ imagens: fotosFluido, roteiro: { ...roteiro, pedido: roteiro.pedido || pedidoTxt }, largura, altura, tempos, narracao, sons, trilha });
1582
+ if (r.ok) projeto.anotar(p.id, 'plano', `estilo fluido: ${r.resumo.batidas} batidas${r.resumo.imagens.length ? ` (imagens: ${r.resumo.imagens.join(', ')})` : ''}, 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`);
1583
+ return r;
1584
+ }
1408
1585
  if (estilo === 'motion' && modo === 'video') {
1409
1586
  const r = motion.montarMotion({ roteiro, largura, altura, tempos, narracao, sons, trilha, imagens });
1410
1587
  if (r.ok) projeto.anotar(p.id, 'plano', `estilo motion: ${r.resumo.segundos}s contínuos, ${r.resumo.partes} partes, ${r.resumo.fps} fps`);
@@ -1489,10 +1666,34 @@ async function ferramentaRender(args, ctx = {}) {
1489
1666
  problemas: conferida.problemas.slice(0, 12) };
1490
1667
  }
1491
1668
 
1669
+ /* A REVISÃO (revisor.js): o plano roda; agora, ele está bom? Conserta a
1670
+ receita (transição repetida, câmera sem respiro, som duplicado) e
1671
+ devolve o resto como aviso para quem fez o plano. */
1672
+ const revisao = revisor.revisar(pl, { semImagem: referencias.semImagem() });
1673
+ if (revisao.consertos.length) projeto.anotar(args.id, 'revisao', revisao.consertos.join('; '));
1674
+ if (revisao.avisos.length) projeto.anotar(args.id, 'revisao', 'avisos: ' + revisao.avisos.join('; '));
1675
+
1492
1676
  // Os ícones vêm ANTES do render: um render que busca ícone na internet
1493
1677
  // falha no meio, depois de vinte minutos.
1494
1678
  const { mapa, faltaram } = await assets.iconesDoPlano(pl, p.pasta);
1495
1679
 
1680
+ /* A MÚSICA NO CORTE (batida.js): a faixa começa no ponto em que as
1681
+ batidas caem nos cortes do plano, e o corte de impacto num acento. Só
1682
+ quando ninguém disse o início (audio.trilha.inicio). */
1683
+ const tr = pl.audio && pl.audio.trilha;
1684
+ if (tr && !(typeof tr === 'object' && tr.inicio != null) && !(p.formato === 'slides' || p.formato === 'arte' || args.paginas)) {
1685
+ try {
1686
+ const src = path.join(p.pasta, tr.src || tr);
1687
+ const analise = await batida.analisar(src, { ffmpeg: video.ffmpeg() || 'ffmpeg' });
1688
+ if (analise && analise.batidas.length) {
1689
+ const duracao = (pl.cenas || []).reduce((n, c) => n + (Number(c.duracao) || 0), 0);
1690
+ const r = batida.melhorInicio(batida.cortesDoPlano(pl), analise, { maximo: Math.min(20, analise.segundos - duracao - 0.5) });
1691
+ pl.audio.trilha = { ...(typeof tr === 'object' ? tr : { src: tr }), inicio: r.inicio };
1692
+ projeto.anotar(args.id, 'trilha', `${analise.bpm} BPM; a faixa entra em ${r.inicio}s e ${r.alinhados} de ${r.total} cortes caem na batida`);
1693
+ }
1694
+ } catch { /* sem análise, a faixa entra do começo */ }
1695
+ }
1696
+
1496
1697
  const montado = remotion.montar({ pasta: p.pasta, titulo: p.titulo, plano: pl, icones: mapa });
1497
1698
 
1498
1699
  if (args.somenteProjeto) {
@@ -1558,7 +1759,9 @@ async function ferramentaRender(args, ctx = {}) {
1558
1759
  return {
1559
1760
  ok: true, arquivo: saida, bytes, segundos: conferida.duracao,
1560
1761
  faltaram: faltaram.length ? faltaram : undefined,
1561
- aviso: 'ABERTO para o usuário assistir. Chame finish dizendo o que foi feito.',
1762
+ revisao: revisao.consertos.length || revisao.avisos.length ? { consertado: revisao.consertos, avisos: revisao.avisos } : undefined,
1763
+ aviso: 'ABERTO para o usuário assistir. Chame finish dizendo o que foi feito'
1764
+ + (revisao.avisos.length ? ' — e cite os avisos da revisão que valem um novo plano.' : '.'),
1562
1765
  };
1563
1766
  }
1564
1767
 
@@ -1692,7 +1895,7 @@ async function ferramentaSite(args) {
1692
1895
  return r;
1693
1896
  }
1694
1897
 
1695
- module.exports = {
1898
+ module.exports = { variarIcones,
1696
1899
  ferramentaProjeto, ferramentaPlano, ferramentaMidia, ferramentaNarrar,
1697
1900
  ferramentaVideo, ferramentaRender, ferramentaSite,
1698
1901
  projeto, plano, remotion, assets, video, identidade, slides, python, rodarPython, alinharPartes, prepararRoteiro,