primocode 10.1.0 → 10.3.0-beta.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.
@@ -38,16 +38,23 @@ const hibrido = require('./hibrido.js');
38
38
  const biblioteca = require('./biblioteca.js');
39
39
  const referencias = require('./referencias.js');
40
40
  const variedade = require('./variedade.js');
41
+ const diretor = require('./diretor.js');
42
+ const falantes = require('./falantes.js');
41
43
  const variacao3d = require('./variacao3d.js');
42
44
  const plano = require('./plano.js');
43
45
  const remotion = require('./remotion.js');
44
46
  const assets = require('./assets.js');
45
47
  const video = require('./video.js');
48
+ const batida = require('./batida.js');
49
+ const revisor = require('./revisor.js');
50
+ const assinatura = require('./assinatura.js');
46
51
  const identidade = require('./identidade.js');
47
52
  const slides = require('./slides.js');
48
53
  const edicao = require('./edicao.js');
49
54
  const composicao = require('./composicao.js');
50
55
  const acervoSite = require('./acervo-site.js');
56
+ const printSite = require('./print-site.js');
57
+ const efeitos = require('./efeitos.js');
51
58
  const impacto = require('./impacto.js');
52
59
  const cinema = require('./cinema.js');
53
60
  const motion = require('./motion.js');
@@ -198,6 +205,17 @@ async function ferramentaProjeto(args, ctx = {}) {
198
205
  return { ok: true };
199
206
  }
200
207
 
208
+ /* O FEEDBACK que vira estilo: "adorei a virada", "texto demais" fica na
209
+ assinatura da pessoa (assinatura.js), e o próximo vídeo já parte dele.
210
+ Sem texto, devolve a assinatura (as faixas e as notas). */
211
+ if (acao === 'assinatura' || acao === 'feedback' || acao === 'gostei') {
212
+ const nome = args.nome || args.assinatura || 'fluido';
213
+ if (!args.texto) { const a = assinatura.ler(nome); return a ? { ok: true, assinatura: a } : { ok: false, error: `a assinatura "${nome}" não existe.` }; }
214
+ const r = assinatura.anotar(nome, args.texto);
215
+ if (r.ok && args.id) projeto.anotar(args.id, 'assinatura', `feedback gravado na assinatura ${nome}: ${args.texto}`);
216
+ return r;
217
+ }
218
+
201
219
  const todos = projeto.listar({ limite: Number(args.limite) || 20 });
202
220
  return {
203
221
  ok: true,
@@ -260,7 +278,8 @@ function roteiroValido(r) {
260
278
  const CINEMA = ['frase', 'montagem', 'numeros', 'escada', 'dia', 'depoimento', 'climax', 'final'];
261
279
  const NOVOS = ['logo', 'cores', 'produto']; // vox.js e lancamento.js
262
280
  if (!partes.every((pt) => pt && (composicao.TIPOS.includes(pt.tipo) || CINEMA.includes(pt.tipo) || NOVOS.includes(pt.tipo)))) return null;
263
- const obj = Array.isArray(x) ? { partes } : x;
281
+ // Duas vozes: roteiro.vozes e parte.falante, limpos (falantes.js). Sem eles, nada muda.
282
+ const obj = falantes.normalizar(Array.isArray(x) ? { partes } : x);
264
283
  return partes.some((pt) => CINEMA.includes(pt.tipo)) && !obj.estilo ? { ...obj, estilo: 'cinema' } : obj;
265
284
  }
266
285
 
@@ -363,6 +382,13 @@ async function ferramentaPlano(args) {
363
382
  // O roteiro fica guardado: ajustar uma frase é mandar o roteiro de novo, não reescrever tudo.
364
383
  try { fs.writeFileSync(path.join(p.pasta, 'roteiro.json'), JSON.stringify(original || args.roteiro, null, 2)); } catch { /* segue */ }
365
384
  projeto.salvar(args.id, { duracao: conferida.duracao });
385
+ /* A memória da estrutura (diretor.cardapioEstruturas): o próximo roteiro
386
+ vê o que este usou. Uma vez por projeto — mandar o roteiro de novo
387
+ para ajustar uma frase não é um vídeo novo. */
388
+ if (!p.estrutura) {
389
+ const usada = diretor.registrarEstrutura(original || args.roteiro);
390
+ projeto.salvar(args.id, { estrutura: usada.estrutura || '-' });
391
+ }
366
392
  return {
367
393
  ok: true, duracao: conferida.duracao, ...r.resumo, resumo: plano.resumo(r.plano),
368
394
  proximo: 'estudio_render com este id. Para ajustar, mande o roteiro de novo (é rápido) — não edite as cenas à mão.',
@@ -435,6 +461,26 @@ async function ferramentaMidia(args) {
435
461
  return { ok: true, src: relativo, metodo: r.metodo, aviso: r.aviso || undefined };
436
462
  }
437
463
 
464
+ /* O print de verdade do site (print-site.js), para a peça 3D `printsite`.
465
+ Só roda quando o modelo pede: o cardápio diz quando vale. */
466
+ if (acao === 'print') {
467
+ if (!p) return { ok: false, error: 'para tirar o print é preciso dizer em qual projeto (id).' };
468
+ const r = await printSite.capturarSite(args.url || args.site || args.termo, { pasta: p.pasta, celular: !!args.celular, inteira: args.inteira !== false });
469
+ if (r.ok) projeto.anotar(p.id, 'midia', `print de ${r.url} (${r.tela}): ${r.src}`);
470
+ return r;
471
+ }
472
+
473
+ // O catálogo de efeitos que a IA escreveu (efeitos.js): guardar, listar, reusar.
474
+ if (acao === 'efeitos') return { ok: true, efeitos: efeitos.cardapio() };
475
+ if (acao === 'guardar-efeito' || acao === 'usar-efeito') {
476
+ if (!p) return { ok: false, error: 'diga em qual projeto (id).' };
477
+ const r = acao === 'guardar-efeito'
478
+ ? efeitos.guardar({ pasta: p.pasta, arquivo: args.arquivo || args.src, nome: args.nome, descricao: args.descricao, tags: args.tags })
479
+ : efeitos.reusar({ pasta: p.pasta, nome: args.nome });
480
+ if (r.ok) projeto.anotar(p.id, 'midia', `${acao === 'guardar-efeito' ? 'guardou' : 'reusou'} o efeito ${r.nome || args.nome}`);
481
+ return r;
482
+ }
483
+
438
484
  if (acao === 'logo') {
439
485
  if (!p) return { ok: false, error: 'para baixar a logo é preciso dizer em qual projeto (id).' };
440
486
  const nome = String(args.marca || args.nome || args.termo || '').trim();
@@ -666,7 +712,7 @@ async function ferramentaVideo(args, ctx = {}) {
666
712
  if ((args.estilo || 'viral') === 'viral' && args.recorte !== false) {
667
713
  const recortes = {};
668
714
  for (const [i, info] of montado.infos.entries()) {
669
- if (!edicao.temRecorte(i) || !info.forte || info.forte.peso < 9 || Object.keys(recortes).length >= 3) continue;
715
+ if (!info.recorte || !info.forte || info.forte.peso < 9 || Object.keys(recortes).length >= 3) continue;
670
716
  const de = Math.max(0, info.forte.de - 0.15);
671
717
  const ate = Math.min(info.dur, de + 2.2);
672
718
  if (ate - de < 1) continue;
@@ -715,6 +761,18 @@ async function ferramentaVideo(args, ctx = {}) {
715
761
  senão uma foto. Devolve {src, tipo} ou null. O clipe sai sorteado entre
716
762
  os que os últimos vídeos não usaram (variedade.js), não o primeiro da
717
763
  busca — "o b-roll veio igual nos dois vídeos" (Joel, 05/10). */
764
+ /** A foto editorial (Wikimedia, museu) que os últimos vídeos não usaram; o crédito fica no caderno. */
765
+ async function baixarEditorial(p, termo) {
766
+ try {
767
+ const b = await assets.procurarEditorial(termo, { quantas: 8 });
768
+ for (const item of variedade.ordenar(b.imagens, 'broll').slice(0, 4)) {
769
+ const d = await assets.baixar(item, { pasta: p.pasta, nome: 'foto-' + termo });
770
+ if (d.ok) { variedade.marcar('broll', item.url); projeto.anotar(p.id, 'creditos', item.creditos); return { src: d.relativo, tipo: 'imagem' }; }
771
+ }
772
+ } catch { /* sem foto */ }
773
+ return null;
774
+ }
775
+
718
776
  async function baixarApoio(p, termo, { orientacao = null, video: querVideo = false } = {}) {
719
777
  if (querVideo) {
720
778
  try {
@@ -749,11 +807,26 @@ async function variarIcones(plano, roteiro) {
749
807
  const explicitos = [];
750
808
  try { JSON.stringify(roteiro || {}, (k, v) => { if (k === 'icone' && typeof v === 'string') explicitos.push(v); return v; }); } catch { /* sem */ }
751
809
  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 */ }
810
+ for (const nome of variedade.iconesTrocaveis(plano, explicitos)) {
811
+ /* Primeiro O ÍCONE: o conceito escolhe entre as formas dele
812
+ (edicao.ALTERNATIVAS) pelos pontos da variedade — o que os últimos
813
+ vídeos menos usaram. Depois A COLEÇÃO. */
814
+ const base = nome.split(':')[1];
815
+ let escolhido = base;
816
+ const alts = edicao.ALTERNATIVAS[base];
817
+ if (alts) {
818
+ const ordem = variedade.ordenar([base, ...alts].map((url) => ({ url })), 'icone-nome').map((x) => x.url);
819
+ for (const n of ordem) {
820
+ if (n === base) { escolhido = base; break; }
821
+ try { const r = await assets.icone('fluent-3d:' + n); if (r && r.ok) { escolhido = n; break; } } catch { /* tenta o próximo */ }
822
+ }
823
+ variedade.marcar('icone-nome', escolhido);
824
+ }
825
+ let novo = 'fluent-3d:' + escolhido;
826
+ if (colecao !== 'fluent-3d') {
827
+ try { const r = await assets.icone(colecao + ':' + escolhido); if (r && r.ok) novo = colecao + ':' + escolhido; } catch { /* fica o 3D */ }
756
828
  }
829
+ if (novo !== nome) troca[nome] = novo;
757
830
  }
758
831
  const n = variedade.trocarIcones(plano, troca);
759
832
  if (n || colecao === 'fluent-3d') variedade.marcar('icones', colecao);
@@ -941,6 +1014,20 @@ function narrarReserva(lista, mp3, arq, voz, vozEdge, { soEdge = false, semKokor
941
1014
  });
942
1015
  }
943
1016
 
1017
+ /* DUAS VOZES (falantes.js): os mp3 de cada falante, um depois do outro, num
1018
+ arquivo só — reamostrados, porque o Gemini sai a 24 kHz e o Edge não. A
1019
+ duração de cada um é MEDIDA: é ela que empurra os tempos do grupo seguinte. */
1020
+ async function juntarVozes(arquivos, mp3) {
1021
+ const fil = arquivos.map((_, i) => `[${i}:a]aresample=44100,aformat=channel_layouts=mono[a${i}]`).join(';')
1022
+ + ';' + arquivos.map((_, i) => `[a${i}]`).join('') + `concat=n=${arquivos.length}:v=0:a=1[s]`;
1023
+ const c = await video.rodar(video.ffmpeg(), ['-y', ...arquivos.flatMap((a) => ['-i', a]), '-filter_complex', fil, '-map', '[s]',
1024
+ '-c:a', 'libmp3lame', '-q:a', '3', mp3], { timeout: 120000 });
1025
+ if (!c.ok) return { ok: false, error: 'não consegui juntar as vozes' };
1026
+ const duracoes = [];
1027
+ for (const a of arquivos) duracoes.push((await video.medir(a)).segundos);
1028
+ return { ok: true, duracoes };
1029
+ }
1030
+
944
1031
  /* A MÚSICA TEM LETRA? O acervo não diz se a faixa é instrumental, então ela
945
1032
  é ouvida: 25 s do meio vão para a transcrição, e mais de 6 palavras é voz
946
1033
  cantada. Sem servidor para ouvir, a faixa passa (melhor música que nenhuma). */
@@ -986,6 +1073,14 @@ async function sonsReais(p, video) {
986
1073
  const PROPORCOES = { '16:9': [1920, 1080], '9:16': [1080, 1920], '1:1': [1080, 1080], '4:5': [1080, 1350] };
987
1074
 
988
1075
  /** O roteiro vira plano: baixa o que falta, narra, e chama o compositor. */
1076
+ /* A ASSINATURA (assinatura.js): o vídeo é medido e conferido contra as
1077
+ faixas do estilo; o que saiu volta para o agente e fica no caderno. */
1078
+ function conferirAssinatura(p, r, nome) {
1079
+ const fora = assinatura.descrever(assinatura.conferir(nome, assinatura.medir(r.partitura, r.plano)).fora);
1080
+ r.resumo.assinatura = { nome, seguiu: !fora.length, fora: fora.length ? fora : undefined };
1081
+ projeto.anotar(p.id, 'assinatura', fora.length ? `fora da assinatura ${nome}: ${fora.join('; ')}` : `seguiu a assinatura ${nome}`);
1082
+ }
1083
+
989
1084
  async function prepararRoteiro(p, args) {
990
1085
  const roteiro = typeof args.roteiro === 'string' ? JSON.parse(args.roteiro) : args.roteiro;
991
1086
  if (Array.isArray(roteiro)) return prepararRoteiro(p, { ...args, roteiro: { partes: roteiro } });
@@ -1008,16 +1103,13 @@ async function prepararRoteiro(p, args) {
1008
1103
  IMPACTO. O que o agente pediu vale mais que tudo. */
1009
1104
  const pedidoTxt = `${p.pedido || ''} ${args.pedido || ''}`;
1010
1105
  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;
1106
+ /* Sem imagem é o padrão (07/10): sem o pedido dizer, nada de foto nem
1107
+ vídeo de fora. Ninguém escolheu o estilo? Ele gira (estilo.js) — sem
1108
+ imagem, só entre os que não vivem de foto. O híbrido e o cinema, pedidos
1109
+ pelo nome sem imagem, caem para o gerativo (sem nada baixado). */
1110
+ const semImagem = referencias.semImagem(ref);
1111
+ let estilo = escolherEstilo({ pedido: pedidoTxt, agente: args.estilo, roteiro: roteiro.estilo, girar: true, semImagem });
1112
+ if (semImagem && (estilo === 'hibrido' || estilo === 'cinema')) estilo = 'gerativo';
1021
1113
  /* "O vídeo deve ter um 3D do fone" (30/09): pedido de 3D vira o gerativo
1022
1114
  com o produto modelado girando numa batida (kit/tres.jsx) — a menos que
1023
1115
  o agente tenha escolhido outro estilo pelo nome. */
@@ -1040,14 +1132,22 @@ async function prepararRoteiro(p, args) {
1040
1132
  roteiro.semente = p.semente != null ? p.semente : Math.floor(Math.random() * 1e9);
1041
1133
  if (p.semente == null) projeto.salvar(p.id, { semente: roteiro.semente });
1042
1134
  }
1135
+ /* O humor, a paleta, as fontes e o gênero do gerativo, com memória entre
1136
+ vídeos (09/10, "vício em cores, músicas, fontes"): por PROJETO, como a semente. */
1137
+ // O assunto do vídeo é o rótulo da moldura (quando ela sai): o título do projeto.
1138
+ if (estilo === 'gerativo' && !roteiro.titulo && p.titulo) roteiro.titulo = p.titulo;
1139
+ if (estilo === 'gerativo' && !roteiro.escolhasGerativo) {
1140
+ roteiro.escolhasGerativo = p.escolhasGerativo || gerativo.escolherVisual(roteiro);
1141
+ if (!p.escolhasGerativo) projeto.salvar(p.id, { escolhasGerativo: roteiro.escolhasGerativo });
1142
+ }
1043
1143
  if (ref.modo === 'fechado' && !roteiro.busca && referencias.termos().length) roteiro.busca = referencias.termos();
1044
1144
  /* A BIBLIOTECA (biblioteca.js): sem fonte e cor da marca, o híbrido, o
1045
1145
  cinema e o impacto caíam sempre no Inter e no azul e roxo — "os vídeos
1046
1146
  todos iguais" (02/10). A marca manda; depois o roteiro.visual da IA;
1047
1147
  sem nada, o sorteio que pula o que os últimos vídeos usaram. */
1048
1148
  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(', '));
1149
+ const v = biblioteca.vestir(roteiro, { estilo });
1150
+ 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(', '));
1051
1151
  }
1052
1152
  // Os estilos de motion usam os efeitos leves e a trilha do ende.app; os de corte, os cheios.
1053
1153
  const ehMotion = LEVES.has(estilo);
@@ -1079,13 +1179,13 @@ async function prepararRoteiro(p, args) {
1079
1179
  const videosMarca = [...(acervo.videos || [])];
1080
1180
  const imagens = {}, fundos = {};
1081
1181
  for (const [i, parte] of partes.entries()) {
1082
- if (parte.tipo !== 'imagem' || parte.src) continue;
1182
+ if (semImagem || parte.tipo !== 'imagem' || parte.src) continue;
1083
1183
  const texto = `${parte.titulo || ''} ${parte.sub || ''} ${parte.fala || ''} ${parte.busca || ''}`;
1084
1184
  if (modo === 'video' && videosMarca.length && i % 2 === 1) { imagens[i] = { src: videosMarca.shift().src, tipo: 'video' }; continue; }
1085
1185
  const m = melhorFoto(texto) || (parte.busca ? await baixarApoio(p, parte.busca, { orientacao: largura >= altura ? 'landscape' : 'portrait', video: modo === 'video' }) : null);
1086
1186
  if (m) imagens[i] = m;
1087
1187
  }
1088
- if (acervo.fotos.length) {
1188
+ if (acervo.fotos.length && !semImagem) {
1089
1189
  const comFundo = ['abertura', 'titulo', 'contraste', 'citacao', 'cta', 'passos'];
1090
1190
  let alterna = 0;
1091
1191
  for (const [i, parte] of partes.entries()) {
@@ -1109,7 +1209,13 @@ async function prepararRoteiro(p, args) {
1109
1209
  // Só as partes com voz vão para a narração; o depoimento tem a voz do aluno.
1110
1210
  // Só as partes com voz: o depoimento tem a voz do aluno, e a parte muda não tem voz.
1111
1211
  // A parte com voz forte é narrada à parte: se entrasse aqui também, seria falada duas vezes (e muda uma delas).
1112
- const lista = partes.filter((pt) => pt.tipo !== 'depoimento' && cinema.falaDe(pt) !== '' && !pt.falaForte).map((pt) => ({ texto: cinema.falaDe(pt), registro: 'normal' }));
1212
+ const comVoz = partes.filter((pt) => pt.tipo !== 'depoimento' && cinema.falaDe(pt) !== '' && !pt.falaForte);
1213
+ const lista = comVoz.map((pt) => ({ texto: cinema.falaDe(pt), registro: 'normal' }));
1214
+ // Duas vozes: a voz de cada parte pelo falante dela (roteiro.vozes).
1215
+ const vozesL = falantes.vozesDasPartes(comVoz, roteiro, args.voz || roteiro.voz);
1216
+ const variasVozes = new Set(vozesL).size > 1;
1217
+ const DIRECAO_CINEMA = 'Leia em português do Brasil como narrador de trailer de marca premium: voz grave, confiante e humana, '
1218
+ + 'ritmo firme, pausas curtas entre as frases, sem arrastar.';
1113
1219
  const mp3 = path.join(p.pasta, 'midia', `narracao-${Date.now().toString(36)}.mp3`);
1114
1220
  /* O CACHE DA VOZ: a mesma fala, na mesma voz, não é gerada duas vezes.
1115
1221
  Refazer o roteiro em outro formato, ou mexer numa cena, não gasta a
@@ -1120,7 +1226,20 @@ async function prepararRoteiro(p, args) {
1120
1226
  let v = null;
1121
1227
  // motorVoz "edge": a voz do Edge direto, sem esperar a cota do Gemini ("a narração pode manter a do Edge", 25/09).
1122
1228
  const soEdge = String(roteiro.motorVoz || args.motorVoz || '').toLowerCase() === 'edge';
1123
- if (soEdge) {
1229
+ if (variasVozes) {
1230
+ /* Um grupo por falante, cada um com a sua voz e o seu cache (a chave
1231
+ do narrarComCache leva a voz). Falhou? Fica anotado, e a reserva
1232
+ de baixo narra numa voz só — melhor o vídeo do que nada. */
1233
+ v = await falantes.narrarPorFalante(lista.map((x, i) => ({ ...x, voz: vozesL[i] })), mp3, { juntar: juntarVozes,
1234
+ narrarUm: async (l, m, voz) => {
1235
+ const g = soEdge ? { ok: false } : await narrarComCache(l, m, { voz, direcao: DIRECAO_CINEMA });
1236
+ if (g.ok) return g;
1237
+ const a = m.replace(/\.mp3$/, '.json');
1238
+ fs.writeFileSync(a, JSON.stringify(l));
1239
+ return narrarReserva(l, m, a, voz, 'antonio', { soEdge });
1240
+ } });
1241
+ if (!v.ok) projeto.anotar(p.id, 'narracao', `as duas vozes falharam (${v.error}); saiu numa voz só`);
1242
+ } else if (soEdge) {
1124
1243
  v = { ok: false, error: 'edge pedido' };
1125
1244
  } else if (fs.existsSync(cacheMp3) && fs.existsSync(cacheJson)) {
1126
1245
  fs.copyFileSync(cacheMp3, mp3);
@@ -1139,9 +1258,7 @@ async function prepararRoteiro(p, args) {
1139
1258
  } else if (kokoro.querKokoro(args.voz || roteiro.voz)) {
1140
1259
  v = { ok: false, error: 'kokoro pedido' };
1141
1260
  } else {
1142
- v = await narrarComGemini(lista, mp3, { voz: args.voz || roteiro.voz,
1143
- direcao: 'Leia em português do Brasil como narrador de trailer de marca premium: voz grave, confiante e humana, '
1144
- + 'ritmo firme, pausas curtas entre as frases, sem arrastar.' });
1261
+ v = await narrarComGemini(lista, mp3, { voz: args.voz || roteiro.voz, direcao: DIRECAO_CINEMA });
1145
1262
  if (v.ok) {
1146
1263
  try { fs.mkdirSync(cacheDir, { recursive: true }); fs.copyFileSync(mp3, cacheMp3); fs.writeFileSync(cacheJson, JSON.stringify({ voz: v.voz, segundos: v.segundos, partes: v.partes })); } catch { /* sem cache */ }
1147
1264
  }
@@ -1316,12 +1433,14 @@ async function prepararRoteiro(p, args) {
1316
1433
  // falaForte: true → a voz forte lê o texto da tela.
1317
1434
  const textoForte = pt.falaForte === true ? cinema.falaDe({ ...pt, fala: undefined }) : pt.falaForte;
1318
1435
  const soEdgeF = String(roteiro.motorVoz || args.motorVoz || '').toLowerCase() === 'edge';
1436
+ // A voz forte é a de quem fala a parte (duas vozes), não sempre a do narrador.
1437
+ const vozForte = falantes.vozesDasPartes([pt], roteiro, args.voz || roteiro.voz)[0];
1319
1438
  const vf = soEdgeF ? await (async () => {
1320
1439
  // No Edge, a ênfase é o registro "contraste": a mesma voz, um pouco mais firme.
1321
1440
  const arqJ = arqF.replace(/\.mp3$/, '.json');
1322
1441
  fs.writeFileSync(arqJ, JSON.stringify([{ texto: textoForte, registro: 'contraste' }]));
1323
- return rodarPython('narrar.py', ['roteiro', '--arquivo', arqJ, '--saida', arqF, '--voz', String(args.voz || roteiro.voz || 'antonio')], { timeout: 5 * 60000 });
1324
- })() : await narrarComCache([{ texto: textoForte }], arqF, { voz: args.voz || roteiro.voz,
1442
+ return rodarPython('narrar.py', ['roteiro', '--arquivo', arqJ, '--saida', arqF, '--voz', String(vozForte || 'antonio')], { timeout: 5 * 60000 });
1443
+ })() : await narrarComCache([{ texto: textoForte }], arqF, { voz: vozForte,
1325
1444
  // A MESMA voz do narrador, só com um pouco mais de ênfase: "não é mudar a voz, é só um pouco a entonação" (25/09).
1326
1445
  direcao: pt.entonacao || 'Leia em português do Brasil com a mesma voz e o mesmo ritmo de um narrador de trailer de marca: '
1327
1446
  + 'confiante e natural, só um pouco mais de ênfase nas palavras principais. Sem dramatizar.' });
@@ -1356,8 +1475,7 @@ async function prepararRoteiro(p, args) {
1356
1475
  const mp3 = path.join(p.pasta, 'midia', `narracao-${Date.now().toString(36)}.mp3`);
1357
1476
  // A voz de gente primeiro (Gemini, pelo servidor); o Edge é a reserva.
1358
1477
  // 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'
1478
+ const direcao = estilo === 'gerativo'
1361
1479
  /* O gerativo pede voz com CORPO ("narração encorpada, extravagante",
1362
1480
  30/09): cheia, quente, perto do microfone — e o ritmo segue 1,12×. */
1363
1481
  /* (A primeira versão pedia "trailer" e "ênfase sustentada": a voz saiu 25% mais lenta, medido em 30/09.) */
@@ -1371,9 +1489,19 @@ async function prepararRoteiro(p, args) {
1371
1489
  + 'voz encorpada, ênfase forte nas palavras de impacto, pausas curtas e secas entre as frases, sorrindo na voz.'
1372
1490
  // "A narração tá lenta, deixa mais viva" (29/09): todo estilo fala com energia de reels.
1373
1491
  : '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) });
1492
+ + 'ênfase nas palavras que importam, pausas curtas entre as frases. Nada de leitura lenta ou solene.';
1493
+ /* Uma voz por falante (falantes.js). Com uma voz só, um grupo só: é a
1494
+ narração de sempre, com o mesmo cache. */
1495
+ const vozesL = falantes.vozesDasPartes(partes, roteiro, args.voz || roteiro.voz);
1496
+ let v = await falantes.narrarPorFalante(lista.map((x, i) => ({ ...x, voz: vozesL[i] })), mp3, { juntar: juntarVozes,
1497
+ narrarUm: async (l, m, voz) => {
1498
+ const g = await narrarComCache(l, m, { voz, direcao });
1499
+ if (g.ok) return g;
1500
+ // O Kokoro pedido já foi tentado no narrarComCache: a reserva não repete.
1501
+ const a = m === mp3 ? arq : m.replace(/\.mp3$/, '.json');
1502
+ if (a !== arq) fs.writeFileSync(a, JSON.stringify(l));
1503
+ return narrarReserva(l, m, a, voz, 'thalita', { semKokoro: kokoro.querKokoro(voz) });
1504
+ } });
1377
1505
  if (!v.ok) return v;
1378
1506
  /* O RITMO DA VOZ. Mesmo com a direção, a voz sai no passo de leitura, e
1379
1507
  o vídeo — que segue a voz palavra por palavra — sai lento junto. O
@@ -1408,9 +1536,8 @@ async function prepararRoteiro(p, args) {
1408
1536
  funk para o fone, eletrônica para IA…), sorteia a faixa e não
1409
1537
  repete as últimas usadas — nada de "inspiring corporate". */
1410
1538
  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 = []; }
1539
+ // A memória da trilha é a da variedade ('trilha'): antes era um arquivo à parte (trilhas-usadas.json).
1540
+ const usadas = variedade.usadas('trilha');
1414
1541
  const mus = estilo === 'gerativo' ? gerativo.musicaDe(roteiro) : null;
1415
1542
  /* A trilha do gerativo vem do Jamendo (Openverse), pelo GÊNERO do
1416
1543
  assunto, instrumental, sorteada e sem repetir as últimas — a
@@ -1421,9 +1548,10 @@ async function prepararRoteiro(p, args) {
1421
1548
  classical: 'piano classical instrumental', 'soul-rnb': 'soul instrumental' };
1422
1549
  try {
1423
1550
  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 || ''));
1551
+ // Pelos pontos da variedade: a recente e a muito usada vão para o fim, não somem.
1552
+ const opcoes = variedade.ordenar((b.ok ? b.sons : []).filter((x) => !/vocal|voice|feat|ft\.|remix/i.test(x.titulo || '')), 'trilha');
1425
1553
  for (let k = 0; k < opcoes.length && !m; k++) {
1426
- const item = opcoes[(mus.semente + k) % opcoes.length];
1554
+ const item = opcoes[k];
1427
1555
  const d = await assets.baixar(item, { pasta: p.pasta, nome: 'trilha-' + (roteiro.genero || mus.genero) });
1428
1556
  if (!d.ok) continue;
1429
1557
  if (await temLetra(path.join(p.pasta, d.relativo))) { projeto.anotar(p.id, 'midia', `trilha com voz descartada: ${item.titulo}`); continue; }
@@ -1432,12 +1560,12 @@ async function prepararRoteiro(p, args) {
1432
1560
  } catch { /* cai na ende.app */ }
1433
1561
  }
1434
1562
  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 */ }
1563
+ variedade.marcar('trilha', m.id);
1436
1564
  projeto.anotar(p.id, 'trilha', `gênero ${roteiro.genero || mus.genero} (Jamendo)`);
1437
1565
  } else if (mus) {
1438
1566
  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
1567
  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 */ }
1568
+ if (id) variedade.marcar('trilha', id);
1441
1569
  if (m) projeto.anotar(p.id, 'trilha', `gênero ${roteiro.genero || mus.genero}`);
1442
1570
  } else m = await ende.provedor({ pasta: path.join(p.pasta, 'midia') }).gerar({ clima: roteiro.clima || 'tecnologia', segundos });
1443
1571
  if (m) { trilha = path.relative(p.pasta, m.src).replace(/\\/g, '/'); projeto.anotar(p.id, 'trilha', m.credito); }
@@ -1502,18 +1630,71 @@ async function prepararRoteiro(p, args) {
1502
1630
  '-af', 'afade=t=in:st=0:d=0.4,loudnorm=I=-18:TP=-2:LRA=11', '-ar', '44100', norm], { timeout: 5 * 60000 });
1503
1631
  if (c.ok && fs.existsSync(norm)) trilha = path.relative(p.pasta, norm).replace(/\\/g, '/');
1504
1632
  }
1505
- const r = gerativo.montarGerativo({ roteiro, largura, altura, tempos, narracao, sons, trilha });
1633
+ /* O herói que o roteiro pediu do Iconify ("mdi:bread-slice"): o desenho é
1634
+ baixado aqui (fica em midia/icones); o que não baixar, a batida fica sem. */
1635
+ const icones = {};
1636
+ for (const pt of partes) {
1637
+ const h = typeof (pt.heroi != null ? pt.heroi : pt.icone) === 'string' ? String(pt.heroi != null ? pt.heroi : pt.icone).trim() : '';
1638
+ if (!/^[a-z0-9-]+:[a-z0-9-]+$/i.test(h) || icones[h]) continue;
1639
+ try { const ic = await assets.icone(h, { pasta: p.pasta }); if (ic && ic.ok) icones[h] = ic.svg; else projeto.anotar(p.id, 'icones', `herói ${h} não baixou: ${(ic && ic.error) || '?'}`); } catch { /* fica sem */ }
1640
+ }
1641
+ const r = gerativo.montarGerativo({ roteiro, largura, altura, tempos, narracao, sons, trilha, icones });
1506
1642
  if (r.ok) {
1507
1643
  const feitos = sonsLeves.prepararReceitas(p.pasta, r.somReceitas);
1508
1644
  // Som que o ffmpeg não conseguiu fazer sai do plano (o vídeo não espera um arquivo que não existe).
1509
1645
  r.plano.audio.efeitos = r.plano.audio.efeitos.filter((e) => Object.values(feitos).includes(e.src));
1510
1646
  }
1511
1647
  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}`);
1648
+ if (r.ok) conferirAssinatura(p, r, roteiro.assinatura || 'gerativo');
1512
1649
  return r;
1513
1650
  }
1514
1651
  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`);
1652
+ /* A CARA de cada vídeo do fluido, por PROJETO (ajustar uma frase não
1653
+ troca): a semente (marcador, jeito das imagens) e a paleta, que a
1654
+ memória de variedade não repete do vídeo anterior (08/10: "sempre o
1655
+ mesmo azul"). A paleta ou o tema pedidos mandam. */
1656
+ if (roteiro.semente == null) {
1657
+ roteiro.semente = p.semente != null ? p.semente : Math.floor(Math.random() * 1e9);
1658
+ if (p.semente == null) projeto.salvar(p.id, { semente: roteiro.semente });
1659
+ }
1660
+ /* O modelo também vicia: pedia "noite" em todo vídeo (era a primeira da
1661
+ lista). A paleta dele vale, menos quando é uma das 2 últimas usadas —
1662
+ aí vem a menos usada do mesmo tema (escura ou clara). */
1663
+ if (!p.paletaFluido) {
1664
+ const P = fluido.PALETAS, nomes = Object.keys(P);
1665
+ const pedida = P[String(roteiro.paleta || '').toLowerCase()] ? String(roteiro.paleta).toLowerCase() : null;
1666
+ const recentes = variedade.usadas('paleta-fluido').slice(0, 2);
1667
+ const escuro = pedida ? P[pedida].escuro : /escuro|dark/i.test(roteiro.tema || '') ? true : /claro|light/i.test(roteiro.tema || '') ? false : null;
1668
+ let escolha = pedida && !recentes.includes(pedida) ? pedida : null;
1669
+ if (!escolha) escolha = variedade.sortear(nomes.filter((n) => escuro == null || P[n].escuro === escuro), 'paleta-fluido');
1670
+ else variedade.marcar('paleta-fluido', escolha);
1671
+ if (pedida && escolha !== pedida) projeto.anotar(p.id, 'visual', `paleta ${pedida} pedida, mas foi a de um dos últimos vídeos: ${escolha}`);
1672
+ projeto.salvar(p.id, { paletaFluido: escolha });
1673
+ p.paletaFluido = escolha;
1674
+ }
1675
+ roteiro.paleta = p.paletaFluido;
1676
+ // O trio de fontes, também por projeto e sem repetir os últimos vídeos (09/10, "vício em fontes").
1677
+ if (!p.fontesFluido) { p.fontesFluido = fluido.escolherFontes(roteiro.fontesFluido); projeto.salvar(p.id, { fontesFluido: p.fontesFluido }); }
1678
+ roteiro.fontesFluido = p.fontesFluido;
1679
+ /* As IMAGENS do fluido misto, só com imagem pedida: as partes com
1680
+ "busca", no máximo 1 em 3 e espalhadas (fluido.js decide o jeito). */
1681
+ const fotosFluido = {};
1682
+ if (!semImagem) {
1683
+ const comBusca = partes.map((pt, k) => (pt.busca || pt.src ? k : -1)).filter((k) => k >= 0 && (partes[k].tipo !== 'cta'));
1684
+ const teto = Math.max(1, Math.floor(partes.length / 3));
1685
+ const escolhidas = [];
1686
+ for (const k of comBusca) if (escolhidas.length < teto && !escolhidas.some((j) => Math.abs(j - k) < 2)) escolhidas.push(k);
1687
+ for (const k of escolhidas) {
1688
+ const pt = partes[k];
1689
+ // O acervo EDITORIAL (assets.procurarEditorial), não o banco de stock: sem foto que sirva, a parte fica sem imagem.
1690
+ const m = pt.src ? { src: pt.src } : await baixarEditorial(p, pt.busca);
1691
+ if (m && m.src) fotosFluido[k] = { src: m.src };
1692
+ }
1693
+ if (Object.keys(fotosFluido).length) projeto.anotar(p.id, 'midia', `fluido misto: imagem nas partes ${Object.keys(fotosFluido).map((k) => Number(k) + 1).join(', ')}`);
1694
+ }
1695
+ const r = fluido.montarFluido({ imagens: fotosFluido, roteiro: { ...roteiro, pedido: roteiro.pedido || pedidoTxt }, largura, altura, tempos, narracao, sons, trilha });
1696
+ 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`);
1697
+ if (r.ok) conferirAssinatura(p, r, roteiro.assinatura || 'fluido');
1517
1698
  return r;
1518
1699
  }
1519
1700
  if (estilo === 'motion' && modo === 'video') {
@@ -1600,10 +1781,34 @@ async function ferramentaRender(args, ctx = {}) {
1600
1781
  problemas: conferida.problemas.slice(0, 12) };
1601
1782
  }
1602
1783
 
1784
+ /* A REVISÃO (revisor.js): o plano roda; agora, ele está bom? Conserta a
1785
+ receita (transição repetida, câmera sem respiro, som duplicado) e
1786
+ devolve o resto como aviso para quem fez o plano. */
1787
+ const revisao = revisor.revisar(pl, { semImagem: referencias.semImagem() });
1788
+ if (revisao.consertos.length) projeto.anotar(args.id, 'revisao', revisao.consertos.join('; '));
1789
+ if (revisao.avisos.length) projeto.anotar(args.id, 'revisao', 'avisos: ' + revisao.avisos.join('; '));
1790
+
1603
1791
  // Os ícones vêm ANTES do render: um render que busca ícone na internet
1604
1792
  // falha no meio, depois de vinte minutos.
1605
1793
  const { mapa, faltaram } = await assets.iconesDoPlano(pl, p.pasta);
1606
1794
 
1795
+ /* A MÚSICA NO CORTE (batida.js): a faixa começa no ponto em que as
1796
+ batidas caem nos cortes do plano, e o corte de impacto num acento. Só
1797
+ quando ninguém disse o início (audio.trilha.inicio). */
1798
+ const tr = pl.audio && pl.audio.trilha;
1799
+ if (tr && !(typeof tr === 'object' && tr.inicio != null) && !(p.formato === 'slides' || p.formato === 'arte' || args.paginas)) {
1800
+ try {
1801
+ const src = path.join(p.pasta, tr.src || tr);
1802
+ const analise = await batida.analisar(src, { ffmpeg: video.ffmpeg() || 'ffmpeg' });
1803
+ if (analise && analise.batidas.length) {
1804
+ const duracao = (pl.cenas || []).reduce((n, c) => n + (Number(c.duracao) || 0), 0);
1805
+ const r = batida.melhorInicio(batida.cortesDoPlano(pl), analise, { maximo: Math.min(20, analise.segundos - duracao - 0.5) });
1806
+ pl.audio.trilha = { ...(typeof tr === 'object' ? tr : { src: tr }), inicio: r.inicio };
1807
+ 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`);
1808
+ }
1809
+ } catch { /* sem análise, a faixa entra do começo */ }
1810
+ }
1811
+
1607
1812
  const montado = remotion.montar({ pasta: p.pasta, titulo: p.titulo, plano: pl, icones: mapa });
1608
1813
 
1609
1814
  if (args.somenteProjeto) {
@@ -1669,7 +1874,9 @@ async function ferramentaRender(args, ctx = {}) {
1669
1874
  return {
1670
1875
  ok: true, arquivo: saida, bytes, segundos: conferida.duracao,
1671
1876
  faltaram: faltaram.length ? faltaram : undefined,
1672
- aviso: 'ABERTO para o usuário assistir. Chame finish dizendo o que foi feito.',
1877
+ revisao: revisao.consertos.length || revisao.avisos.length ? { consertado: revisao.consertos, avisos: revisao.avisos } : undefined,
1878
+ aviso: 'ABERTO para o usuário assistir. Chame finish dizendo o que foi feito'
1879
+ + (revisao.avisos.length ? ' — e cite os avisos da revisão que valem um novo plano.' : '.'),
1673
1880
  };
1674
1881
  }
1675
1882
 
@@ -1803,8 +2010,8 @@ async function ferramentaSite(args) {
1803
2010
  return r;
1804
2011
  }
1805
2012
 
1806
- module.exports = {
2013
+ module.exports = { variarIcones,
1807
2014
  ferramentaProjeto, ferramentaPlano, ferramentaMidia, ferramentaNarrar,
1808
2015
  ferramentaVideo, ferramentaRender, ferramentaSite,
1809
- projeto, plano, remotion, assets, video, identidade, slides, python, rodarPython, alinharPartes, prepararRoteiro,
2016
+ projeto, plano, remotion, assets, video, identidade, slides, python, rodarPython, alinharPartes, prepararRoteiro, roteiroValido, juntarVozes,
1810
2017
  };