primocode 10.5.0 → 10.7.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 (44) hide show
  1. package/README.md +2 -2
  2. package/bin/primocode.js +117 -24
  3. package/lib/act.js +141 -9
  4. package/lib/agents-md.js +140 -0
  5. package/lib/auditoria.js +122 -0
  6. package/lib/browser.js +48 -1
  7. package/lib/catalogo.js +29 -6
  8. package/lib/comandos-extra.js +2 -0
  9. package/lib/config.js +32 -4
  10. package/lib/entrada.js +33 -11
  11. package/lib/estudio/cenarios.js +91 -0
  12. package/lib/estudio/colecao.js +35 -1
  13. package/lib/estudio/diretor.js +1 -0
  14. package/lib/estudio/falantes.js +30 -6
  15. package/lib/estudio/hibrido.js +144 -8
  16. package/lib/estudio/index.js +196 -63
  17. package/lib/estudio/kit/mundo.jsx +1060 -0
  18. package/lib/estudio/kit/voxel.jsx +315 -70
  19. package/lib/estudio/kit.js +1 -1
  20. package/lib/estudio/kokoro.js +18 -4
  21. package/lib/estudio/margem.js +1 -1
  22. package/lib/estudio/movimentos.js +212 -0
  23. package/lib/estudio/mundo.js +328 -0
  24. package/lib/estudio/personagem.js +164 -11
  25. package/lib/estudio/plano.js +6 -0
  26. package/lib/estudio/producao.js +107 -17
  27. package/lib/estudio/projeto.js +77 -22
  28. package/lib/leitura.js +176 -0
  29. package/lib/local/cerebro.json +180 -3
  30. package/lib/local/entrada.js +61 -4
  31. package/lib/mcp.js +296 -12
  32. package/lib/memoria-conversa.js +32 -3
  33. package/lib/mencoes.js +5 -1
  34. package/lib/nome-curto.js +126 -0
  35. package/lib/nome-projeto.js +5 -2
  36. package/lib/outra-pasta.js +161 -0
  37. package/lib/perigo.js +34 -2
  38. package/lib/referencia.js +40 -11
  39. package/lib/repeticao.js +20 -1
  40. package/lib/tela.js +1 -1
  41. package/lib/tools.js +76 -6
  42. package/lib/travado.js +141 -0
  43. package/lib/ui.js +1 -0
  44. package/package.json +2 -2
@@ -66,10 +66,14 @@ const kokoro = require('./kokoro.js');
66
66
  const lancamento = require('./lancamento.js');
67
67
  const glifos = require('./glifos.js');
68
68
  const personagem = require('./personagem.js');
69
+ const mundo = require('./mundo.js');
70
+ const cenarios = require('./cenarios.js');
71
+ const movimentos = require('./movimentos.js');
69
72
  const colecao = require('./colecao.js');
70
73
  const { escolherEstilo, LEVES } = require('./estilo.js');
71
74
  const sonsLeves = require('./sons-leves.js');
72
75
  const ende = require('./ende.js');
76
+ const producao = require('./producao.js');
73
77
 
74
78
  const PY = path.join(__dirname, '..', '..', 'studio');
75
79
 
@@ -163,7 +167,7 @@ function semProjeto(id) {
163
167
  ok: false,
164
168
  error: `não achei o projeto "${id}".`,
165
169
  comoResolver: 'liste com estudio_projeto acao:"listar" e use um id de lá — '
166
- + 'o id é o nome da pasta (ex: "2026-09-22-anuncio-picpay"), não o título.',
170
+ + 'o id é o nome da pasta (ex: "anuncio-picpay"), não o título.',
167
171
  };
168
172
  }
169
173
 
@@ -188,6 +192,9 @@ async function ferramentaProjeto(args, ctx = {}) {
188
192
  // O caderno é o que faz "mexe naquela parte" funcionar semanas
189
193
  // depois: sem ele, reabrir diz o que existe e nunca por quê.
190
194
  caderno: projeto.caderno(args.id, { ultimas: 25 }),
195
+ // A conversa das sessões em que se mexeu neste projeto (AGENTS.md
196
+ // da pasta dele, lib/agents-md.js): o que foi pedido e decidido.
197
+ memoria: require('../agents-md.js').ler(p.pasta) || undefined,
191
198
  plano: pl ? plano.resumo(pl) : 'ainda não há plano',
192
199
  versoes: projeto.versoes(args.id),
193
200
  arquivos: listarArquivos(p.pasta),
@@ -272,6 +279,28 @@ function projetoRecente() {
272
279
  return lista[0] || null;
273
280
  }
274
281
 
282
+ /* Sem id, o projeto é o que acabou de ser criado. Visto em 12/10: o agente
283
+ criou o projeto, chamou estudio_narrar sem id e o vídeo morreu ali. Só vale
284
+ para chamada SEM id (id errado continua sendo erro) e para projeto de até
285
+ 30 minutos, como no estudio_plano. */
286
+ function projetoOuRecente(id) {
287
+ if (id != null && String(id).trim() !== '') return projeto.ler(id);
288
+ return projetoRecente();
289
+ }
290
+
291
+ /* roteiro.objetos: [{nome, src}] com o PNG DENTRO da pasta do projeto (o que
292
+ o estudio_midia baixou ou recortou). Caminho de fora some: o render só
293
+ enxerga a pasta do projeto, e a IA não escolhe arquivo da máquina. */
294
+ function objetosDoRoteiro(objetos, pasta) {
295
+ const raiz = path.resolve(pasta);
296
+ return (Array.isArray(objetos) ? objetos : []).map((o) => {
297
+ if (!o || typeof o.src !== 'string') return null;
298
+ const abs = path.resolve(raiz, o.src);
299
+ if (!abs.startsWith(raiz + path.sep) || !/\.png$/i.test(abs) || !fs.existsSync(abs)) return null;
300
+ return { nome: o.nome, src: path.relative(raiz, abs).replace(/\\/g, '/') };
301
+ }).filter(Boolean);
302
+ }
303
+
275
304
  /** O roteiro é utilizável como veio? (objeto ou JSON, com partes de tipo conhecido) */
276
305
  function roteiroValido(r) {
277
306
  let x = r;
@@ -522,6 +551,19 @@ async function ferramentaMidia(args) {
522
551
  /* A BIBLIOTECA (colecao.js, 11/10): o cardápio inteiro, o ícone 3D e a
523
552
  animação pronta. Procurar não usa rede (o índice mora no pacote); com
524
553
  id, baixa para o projeto e devolve o elemento pronto. */
554
+ /* OS MOVIMENTOS (movimentos.js, 12/10): 100+ entradas, saídas, ênfases e loops com nome. */
555
+ if (acao === 'movimentos' || acao === 'movimento') {
556
+ return movimentos.procurar({ termo: args.termo, categoria: args.categoria, limite: Number(args.limite) || 30 });
557
+ }
558
+ /* OS CENÁRIOS (cenarios.js, 12/10): os lugares prontos + os que a IA criou
559
+ e a pessoa deixou guardar. Salvar só com o sim dela (autorizado:true). */
560
+ if (acao === 'cenarios' || acao === 'cenários') {
561
+ return { ok: true, prontos: Object.keys(mundo.LUGARES), salvos: cenarios.listar(),
562
+ comoCriar: 'na cena do estilo personagem: lugar (um pronto como base), ceu [cor de cima, cor do horizonte], chao {tipo, cor, cor2}, parede, luz, acento, hora e elementos. Ficou bom? Pergunte se pode salvar e use acao:"salvar_cenario".' };
563
+ }
564
+ if (acao === 'salvar_cenario' || acao === 'salvar_cenário') {
565
+ return cenarios.salvar({ nome: args.nome, cenario: args.cenario, autorizado: args.autorizado, bases: Object.keys(mundo.LUGARES) });
566
+ }
525
567
  if (acao === 'biblioteca' || acao === 'cardapio' || acao === 'cardápio') return colecao.cardapio();
526
568
  if (acao === 'icones3d' || acao === 'icone3d' || acao === 'animacoes' || acao === 'animacao' || acao === 'animação' || acao === 'animações') {
527
569
  const ehAnim = /^anima/.test(acao);
@@ -580,8 +622,9 @@ async function baixarEscolhido(lista, args, p) {
580
622
  async function ferramentaNarrar(args) {
581
623
  if (args.vozes) return rodarPython('narrar.py', ['vozes'], { timeout: 30000 });
582
624
 
583
- const p = projeto.ler(args.id);
625
+ const p = projetoOuRecente(args.id);
584
626
  if (!p) return semProjeto(args.id);
627
+ if (args.id == null || String(args.id).trim() === '') args = { ...args, id: p.id };
585
628
  const texto = String(args.texto || '').trim();
586
629
  if (!texto) return { ok: false, error: 'não há texto para narrar.' };
587
630
 
@@ -611,8 +654,9 @@ async function ferramentaNarrar(args) {
611
654
 
612
655
  async function ferramentaVideo(args, ctx = {}) {
613
656
  const acao = String(args.acao || 'medir').toLowerCase();
614
- const p = projeto.ler(args.id);
657
+ const p = projetoOuRecente(args.id);
615
658
  if (!p) return semProjeto(args.id);
659
+ if (args.id == null || String(args.id).trim() === '') args = { ...args, id: p.id };
616
660
 
617
661
  const pronto = video.conferir();
618
662
  if (!pronto.ok) return pronto;
@@ -1097,13 +1141,42 @@ async function narrarComCache(lista, mp3, { voz, direcao } = {}) {
1097
1141
  return v;
1098
1142
  }
1099
1143
 
1144
+ /* A PRODUÇÃO (producao.js): cada necessidade — voz, trilha, efeitos — pela
1145
+ corrente de provedores dela, com o cache do projeto. `roteiro.provedores`
1146
+ ({ voz: 'kokoro' }) passa um provedor para a frente, e o caderno guarda
1147
+ quem produziu cada coisa. */
1148
+ async function produzirNecessidade(p, roteiro, pedido, provedores) {
1149
+ const r = await producao.produzirUma(pedido, provedores, { escolha: ((roteiro && roteiro.provedores) || {})[pedido.tipo], cache: producao.cacheDe(p.pasta) });
1150
+ projeto.anotar(p.id, 'producao', producao.linhaCaderno(r), { necessidade: pedido.tipo, provedor: r.quem || null, cache: !!r.doCache });
1151
+ return r;
1152
+ }
1153
+
1154
+ /* A voz como provedores, na ordem do kokoro.ordem: o Gemini (com o cache e a
1155
+ direção dele), o Kokoro local e o Edge. A falha de uma passa para a próxima. */
1156
+ function provedoresDeVoz(lista, mp3, arq, voz, vozEdge, { soEdge = false, gemini, escolha } = {}) {
1157
+ const todos = {
1158
+ gemini: () => gemini(),
1159
+ kokoro: () => kokoro.narrar(lista, mp3, { voz, uv: acharUv() }),
1160
+ edge: () => {
1161
+ // Ritmo NORMAL: o "citação" (-10%, respiro longo) esticou o vídeo para 125 s.
1162
+ if (!fs.existsSync(arq)) fs.writeFileSync(arq, JSON.stringify(lista.map((x) => ({ ...x, registro: 'normal' }))));
1163
+ return narrarReserva(lista, mp3, arq, voz, vozEdge, { soEdge: true });
1164
+ },
1165
+ };
1166
+ const comResultado = (f) => async () => { const v = await f(); return v && v.ok ? { ...v, arquivo: mp3 } : v; };
1167
+ const ordem = kokoro.ordem(voz, process.env, { soEdge });
1168
+ // A escolhida no roteiro entra mesmo fora da ordem (o produzirUma a põe na frente).
1169
+ if (escolha && todos[escolha] && !ordem.includes(escolha)) ordem.push(escolha);
1170
+ return ordem.map((nome) => ({ nome, gerar: comResultado(todos[nome]) }));
1171
+ }
1172
+
1100
1173
  /* A VOZ DE RESERVA, quando o Gemini falha: o Kokoro (local, sem cota) e
1101
1174
  depois o Edge. A falha de uma passa para a próxima, nunca derruba. */
1102
1175
  function narrarReserva(lista, mp3, arq, voz, vozEdge, { soEdge = false, semKokoro = false } = {}) {
1103
1176
  const passos = kokoro.ordem(voz, process.env, { soEdge }).filter((n) => n !== 'gemini' && !(semKokoro && n === 'kokoro'));
1104
1177
  return kokoro.corrente(passos, {
1105
1178
  kokoro: () => kokoro.narrar(lista, mp3, { voz, uv: acharUv() }),
1106
- edge: () => rodarPython('narrar.py', ['roteiro', '--arquivo', arq, '--saida', mp3, '--voz', String(kokoro.querKokoro(voz, {}) ? vozEdge : voz || vozEdge)], { timeout: 10 * 60000 }),
1179
+ edge: () => rodarPython('narrar.py', ['roteiro', '--arquivo', arq, '--saida', mp3, '--voz', kokoro.vozNoEdge(voz, vozEdge)], { timeout: 10 * 60000 }),
1107
1180
  });
1108
1181
  }
1109
1182
 
@@ -1211,6 +1284,11 @@ async function prepararRoteiro(p, args) {
1211
1284
  if (!args.estilo && estilo !== 'lancamento') estilo = 'gerativo';
1212
1285
  if (!roteiro.modelo3d) roteiro.modelo3d = variacao3d.modeloDoPedido(pedidoTxt);
1213
1286
  }
1287
+ // O bicho do vídeo 3D não fala com voz de gente (a menos que o pedido peça): sai da narração e fica na cena.
1288
+ if (estilo === 'personagem') {
1289
+ const calados = personagem.calarBichos(roteiro, pedidoTxt);
1290
+ if (calados.length) projeto.anotar(p.id, 'plano', `sem fala (bicho): ${calados.map((c) => `${c.nome} (${c.especie})`).join(', ')} — fica na cena; para falar, peça "${calados[0].especie} falante"`);
1291
+ }
1214
1292
  // O 3D de cada vídeo: acabamento, câmera, luz e ritmo sorteados sem repetir os últimos.
1215
1293
  /* Por PROJETO, não por chamada: mandar o roteiro de novo para ajustar uma
1216
1294
  frase não pode sortear outro 3D nem outro vídeo. Projeto novo, sorteio novo. */
@@ -1332,35 +1410,38 @@ async function prepararRoteiro(p, args) {
1332
1410
  return narrarReserva(l, m, a, voz, 'antonio', { soEdge });
1333
1411
  } });
1334
1412
  if (!v.ok) projeto.anotar(p.id, 'narracao', `as duas vozes falharam (${v.error}); saiu numa voz só`);
1335
- } else if (soEdge) {
1336
- v = { ok: false, error: 'edge pedido' };
1337
- } else if (fs.existsSync(cacheMp3) && fs.existsSync(cacheJson)) {
1338
- fs.copyFileSync(cacheMp3, mp3);
1339
- v = { ok: true, ...JSON.parse(fs.readFileSync(cacheJson, 'utf8')), doCache: true };
1340
- // Cache sem as palavras (vindo de um plano antigo): transcreve de novo e realinha — sem gastar voz.
1341
- if (!(v.partes || []).some((pt) => (pt.palavras || []).length)) {
1342
- try {
1343
- const cfg = require('../config').loadConfig();
1344
- const tr = await require('../api').requestTranscricao(cfg.server, { token: cfg.token, audio: fs.readFileSync(mp3).toString('base64'), nome: 'voz.mp3', idioma: 'pt' });
1345
- if (tr && (tr.palavras || []).length) {
1346
- v.partes = alinharPartes(lista, tr.palavras, v.segundos);
1347
- fs.writeFileSync(cacheJson, JSON.stringify({ voz: v.voz, segundos: v.segundos, partes: v.partes }));
1348
- }
1349
- } catch { /* fica o alinhamento guardado */ }
1413
+ if (!v.ok) {
1414
+ const arq = path.join(p.pasta, 'midia', `roteiro-${Date.now().toString(36)}.json`);
1415
+ fs.writeFileSync(arq, JSON.stringify(lista.map((x) => ({ ...x, registro: 'normal' }))));
1416
+ v = await narrarReserva(lista, mp3, arq, args.voz || roteiro.voz, 'antonio', { soEdge });
1350
1417
  }
1351
- } else if (kokoro.querKokoro(args.voz || roteiro.voz)) {
1352
- v = { ok: false, error: 'kokoro pedido' };
1353
1418
  } else {
1354
- v = await narrarComGemini(lista, mp3, { voz: args.voz || roteiro.voz, direcao: DIRECAO_CINEMA });
1355
- if (v.ok) {
1356
- 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 */ }
1357
- }
1358
- }
1359
- if (!v.ok) {
1360
1419
  const arq = path.join(p.pasta, 'midia', `roteiro-${Date.now().toString(36)}.json`);
1361
- // Ritmo NORMAL: o "citação" (-10%, respiro longo) esticou o vídeo para 125 s.
1362
- fs.writeFileSync(arq, JSON.stringify(lista.map((x) => ({ ...x, registro: 'normal' }))));
1363
- v = await narrarReserva(lista, mp3, arq, args.voz || roteiro.voz, 'antonio', { soEdge });
1420
+ const gemini = async () => {
1421
+ if (fs.existsSync(cacheMp3) && fs.existsSync(cacheJson)) {
1422
+ fs.copyFileSync(cacheMp3, mp3);
1423
+ const g = { ok: true, ...JSON.parse(fs.readFileSync(cacheJson, 'utf8')), doCache: true };
1424
+ // Cache sem as palavras (vindo de um plano antigo): transcreve de novo e realinha — sem gastar voz.
1425
+ if (!(g.partes || []).some((pt) => (pt.palavras || []).length)) {
1426
+ try {
1427
+ const cfg = require('../config').loadConfig();
1428
+ const tr = await require('../api').requestTranscricao(cfg.server, { token: cfg.token, audio: fs.readFileSync(mp3).toString('base64'), nome: 'voz.mp3', idioma: 'pt' });
1429
+ if (tr && (tr.palavras || []).length) {
1430
+ g.partes = alinharPartes(lista, tr.palavras, g.segundos);
1431
+ fs.writeFileSync(cacheJson, JSON.stringify({ voz: g.voz, segundos: g.segundos, partes: g.partes }));
1432
+ }
1433
+ } catch { /* fica o alinhamento guardado */ }
1434
+ }
1435
+ return g;
1436
+ }
1437
+ const g = await narrarComGemini(lista, mp3, { voz: args.voz || roteiro.voz, direcao: DIRECAO_CINEMA });
1438
+ if (g.ok) {
1439
+ try { fs.mkdirSync(cacheDir, { recursive: true }); fs.copyFileSync(mp3, cacheMp3); fs.writeFileSync(cacheJson, JSON.stringify({ voz: g.voz, segundos: g.segundos, partes: g.partes })); } catch { /* sem cache */ }
1440
+ }
1441
+ return g;
1442
+ };
1443
+ v = await produzirNecessidade(p, roteiro, { tipo: 'voz', chave: [lista.map((x) => x.texto), args.voz || roteiro.voz || '', 'cinema-v1', soEdge], destino: mp3 },
1444
+ provedoresDeVoz(lista, mp3, arq, args.voz || roteiro.voz, 'antonio', { soEdge, gemini, escolha: (roteiro.provedores || {}).voz }));
1364
1445
  }
1365
1446
  if (!v.ok) return v;
1366
1447
  /* A DURAÇÃO que o roteiro pede ("60–75 s"): se a voz passa do que cabe,
@@ -1404,25 +1485,32 @@ async function prepararRoteiro(p, args) {
1404
1485
  }
1405
1486
  }
1406
1487
  projeto.anotar(p.id, 'narracao', `${v.segundos}s em ${lista.length} partes, voz ${v.voz}`);
1407
- const sonsC = await sonsReais(p, video);
1408
- let trilhaC = null;
1488
+ /* A chave leva o id do projeto e o hash das falas. Só `cinema` fazia um
1489
+ roteiro novo, no mesmo projeto, reaproveitar os efeitos do anterior. */
1490
+ const marcaSfx = require('crypto').createHash('sha1').update(JSON.stringify({ id: p.id || '', falas: partes.map((pt) => cinema.falaDe(pt)) })).digest('hex').slice(0, 16);
1491
+ const sonsC = (await produzirNecessidade(p, roteiro, { tipo: 'sfx', chave: [p.id || '', marcaSfx] },
1492
+ [{ nome: 'openverse', gerar: async () => ({ sons: await sonsReais(p, video) }) }])).sons || {};
1409
1493
  // Vários termos e durações: vídeo sem música foi o que saiu na primeira vez (25/09).
1410
1494
  // E só instrumental: "essa música daria pra deixar só o instrumental?" — a faixa é OUVIDA.
1411
1495
  // O termo sai do clima do roteiro (variedade.js), não de uma lista fixa de "cinematic inspiring".
1412
- for (const termo of [...variedade.termosTrilha(roteiro, 'cinema'), 'instrumental']) {
1413
- if (trilhaC) break;
1414
- try {
1415
- const b = await assets.procurarMusica(termo, { quantas: 10, duracaoMin: 45 });
1416
- 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)) {
1417
- const d = await assets.baixar(item, { pasta: p.pasta, nome: 'trilha' });
1418
- if (!d.ok) continue;
1419
- if (await temLetra(path.join(p.pasta, d.relativo))) { projeto.anotar(p.id, 'midia', `trilha com voz descartada: ${item.titulo}`); continue; }
1420
- trilhaC = d.relativo; variedade.marcar('trilha', item.url);
1421
- projeto.anotar(p.id, 'trilha', `"${termo}": ${item.titulo} — ${item.autor || 'Jamendo'} (${item.licenca || 'CC'})`);
1422
- break;
1496
+ let trilhaC = (await produzirNecessidade(p, roteiro, { tipo: 'trilha', chave: ['cinema', roteiro.clima || '', roteiro.genero || ''] },
1497
+ [{ nome: 'jamendo', gerar: async () => {
1498
+ for (const termo of [...variedade.termosTrilha(roteiro, 'cinema'), 'instrumental']) {
1499
+ try {
1500
+ const b = await assets.procurarMusica(termo, { quantas: 10, duracaoMin: 45 });
1501
+ 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)) {
1502
+ const d = await assets.baixar(item, { pasta: p.pasta, nome: 'trilha' });
1503
+ if (!d.ok) continue;
1504
+ if (await temLetra(path.join(p.pasta, d.relativo))) { projeto.anotar(p.id, 'midia', `trilha com voz descartada: ${item.titulo}`); continue; }
1505
+ variedade.marcar('trilha', item.url);
1506
+ const credito = `"${termo}": ${item.titulo} — ${item.autor || 'Jamendo'} (${item.licenca || 'CC'})`;
1507
+ projeto.anotar(p.id, 'trilha', credito);
1508
+ return { src: d.relativo, credito };
1509
+ }
1510
+ } catch { /* o próximo termo */ }
1423
1511
  }
1424
- } catch { /* o próximo termo */ }
1425
- }
1512
+ return null;
1513
+ } }])).src || null;
1426
1514
  /* A MÚSICA mais baixa na origem: a faixa vem masterizada alta e comprimida,
1427
1515
  e mesmo a 30% ela cobria a voz e o depoimento ("a música tá muito
1428
1516
  alta, não dá pra ouvir as pessoas", 25/09). Ela vai a -28 LUFS antes
@@ -1469,11 +1557,12 @@ async function prepararRoteiro(p, args) {
1469
1557
  try { fs.unlinkSync(path.join(p.pasta, rel)); } catch { /* fica o original */ }
1470
1558
  return 'midia/' + destino;
1471
1559
  };
1560
+ // banco: true marca o que veio do banco (Pexels): a rota produto prefere a tela da marca (hibrido.ancorar).
1472
1561
  const achados = await Promise.all(hibrido.termosDeBusca(roteiro, hib.extras).map(async (termo) => {
1473
1562
  const out = [];
1474
1563
  const slug = 'broll-' + String(termo).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
1475
1564
  const proxy = `${slug}.leve.mp4`;
1476
- if (jaTem.includes(proxy)) out.push({ src: 'midia/' + proxy, tipo: 'video', cat: cat(termo), alt: termo });
1565
+ if (jaTem.includes(proxy)) out.push({ src: 'midia/' + proxy, tipo: 'video', cat: cat(termo), alt: termo, banco: true });
1477
1566
  else try {
1478
1567
  // Sorteado entre os não usados, com o termo variado (variedade.js, 05/10).
1479
1568
  const itens = await variedade.buscarSemRepetir(async (t) => {
@@ -1483,11 +1572,11 @@ async function prepararRoteiro(p, args) {
1483
1572
  for (const item of itens.slice(0, 1)) {
1484
1573
  const d = await assets.baixar(item, { pasta: p.pasta, nome: slug });
1485
1574
  const l = d.ok ? await leve(d.relativo, proxy) : null;
1486
- if (l) { out.push({ src: l, tipo: 'video', cat: cat(termo), alt: termo }); variedade.marcar('broll', item.url); }
1575
+ if (l) { out.push({ src: l, tipo: 'video', cat: cat(termo), alt: termo, banco: true }); variedade.marcar('broll', item.url); }
1487
1576
  }
1488
1577
  } catch { /* segue com a foto */ }
1489
1578
  const foto = jaTem.find((f) => f.startsWith(slug + '-') && /\.(jpe?g|png|webp)$/i.test(f));
1490
- if (foto) out.push({ src: 'midia/' + foto, tipo: 'imagem', cat: cat(termo), alt: termo });
1579
+ if (foto) out.push({ src: 'midia/' + foto, tipo: 'imagem', cat: cat(termo), alt: termo, banco: true });
1491
1580
  else try {
1492
1581
  const itens = await variedade.buscarSemRepetir(async (t) => {
1493
1582
  const b = await assets.procurarImagens(t, { quantas: 8, orientacao });
@@ -1495,7 +1584,7 @@ async function prepararRoteiro(p, args) {
1495
1584
  }, termo);
1496
1585
  for (const item of itens.slice(0, 1)) {
1497
1586
  const d = await assets.baixar(item, { pasta: p.pasta, nome: slug });
1498
- if (d.ok) { out.push({ src: d.relativo, tipo: 'imagem', cat: cat(termo), alt: termo }); variedade.marcar('broll', item.url); }
1587
+ if (d.ok) { out.push({ src: d.relativo, tipo: 'imagem', cat: cat(termo), alt: termo, banco: true }); variedade.marcar('broll', item.url); }
1499
1588
  }
1500
1589
  } catch { /* sem */ }
1501
1590
  return out;
@@ -1554,7 +1643,9 @@ async function prepararRoteiro(p, args) {
1554
1643
  const r = cinema.montarCinema({ roteiro, largura, altura, tempos: v.partes, narracao: path.relative(p.pasta, mp3).replace(/\\/g, '/'),
1555
1644
  sons: sonsC, trilha: trilhaC, fotos: fotosC, fortes, ritmoLeitura: hib ? 0.55 : 1 });
1556
1645
  if (r.ok && hib) {
1557
- hibrido.enfeitar(r.plano, { extras: hib.extras, partes, marca: roteiro.marca || {}, largura, altura, hud: roteiro.hud === true, particulas: roteiro.particulas === true });
1646
+ hibrido.enfeitar(r.plano, { extras: hib.extras, partes, marca: roteiro.marca || {}, largura, altura, hud: roteiro.hud === true, particulas: roteiro.particulas === true,
1647
+ // O b-roll de cada frase troca 0,2 s depois da palavra-âncora (hibrido.ancorar).
1648
+ tempos: v.partes, fortes, fotos: fotosC });
1558
1649
  r.resumo.estilo = 'hibrido';
1559
1650
  }
1560
1651
  if (r.ok) projeto.anotar(p.id, 'plano', `estilo ${hib ? 'hibrido' : 'cinema'}: ${r.resumo.cenas} cenas, ${r.resumo.segundos}s`);
@@ -1579,15 +1670,41 @@ async function prepararRoteiro(p, args) {
1579
1670
  /* Uma voz por falante (falantes.js). Com uma voz só, um grupo só: é a
1580
1671
  narração de sempre, com o mesmo cache. */
1581
1672
  const vozesL = falantes.vozesDasPartes(partes, roteiro, args.voz || roteiro.voz);
1582
- let v = await falantes.narrarPorFalante(lista.map((x, i) => ({ ...x, voz: vozesL[i] })), mp3, { juntar: juntarVozes,
1583
- narrarUm: async (l, m, voz) => {
1584
- const g = await narrarComCache(l, m, { voz, direcao });
1585
- if (g.ok) return g;
1586
- // O Kokoro pedido já foi tentado no narrarComCache: a reserva não repete.
1587
- const a = m === mp3 ? arq : m.replace(/\.mp3$/, '.json');
1588
- if (a !== arq) fs.writeFileSync(a, JSON.stringify(l));
1589
- return narrarReserva(l, m, a, voz, 'thalita', { semKokoro: kokoro.querKokoro(voz) });
1590
- } });
1673
+ let v = await produzirNecessidade(p, roteiro, { tipo: 'voz', chave: [lista.map((x) => x.texto), vozesL, direcao], destino: mp3 }, [{
1674
+ nome: ((roteiro.provedores || {}).voz) || 'estudio',
1675
+ gerar: async () => {
1676
+ let reserva = false, gemini = false;
1677
+ const comLista = lista.map((x, i) => ({ ...x, voz: vozesL[i] }));
1678
+ const pelaReserva = async (l, m, voz) => {
1679
+ // O Kokoro pedido já foi tentado no narrarComCache: a reserva não repete.
1680
+ const a = m === mp3 ? arq : m.replace(/\.mp3$/, '.json');
1681
+ if (a !== arq) fs.writeFileSync(a, JSON.stringify(l));
1682
+ const rr = await narrarReserva(l, m, a, voz, 'thalita', { semKokoro: kokoro.querKokoro(voz) });
1683
+ if (rr && rr.ok) reserva = true;
1684
+ return rr;
1685
+ };
1686
+ let r = await falantes.narrarPorFalante(comLista, mp3, { juntar: juntarVozes,
1687
+ narrarUm: async (l, m, voz) => {
1688
+ const g = await narrarComCache(l, m, { voz, direcao });
1689
+ if (g.ok) { gemini = true; return g; }
1690
+ return pelaReserva(l, m, voz);
1691
+ } });
1692
+ /* UMA VOZ SÓ POR VÍDEO (11/10, "os personagens falam com vozes
1693
+ diferentes em cada cena"): a cota do Gemini acabava no meio, e o
1694
+ mesmo personagem saía Gemini numa fala e reserva na outra. Se
1695
+ misturou, o vídeo inteiro é refeito na reserva — pior que o
1696
+ Gemini, mas cada um com a mesma voz do começo ao fim. */
1697
+ if (r && r.ok && reserva && gemini) {
1698
+ gemini = false;
1699
+ r = await falantes.narrarPorFalante(comLista, mp3, { juntar: juntarVozes, narrarUm: pelaReserva });
1700
+ }
1701
+ /* A voz de RESERVA (Kokoro/Edge, quando o Gemini falhou) não vai para o
1702
+ cache do projeto: guardada, ela voltava em todo refazer do mesmo
1703
+ texto, mesmo com o Gemini de pé — era a "voz robótica que não sai"
1704
+ (11/10). Na próxima vez o Gemini é tentado de novo. */
1705
+ return r && r.ok ? { ...r, arquivo: mp3, ...(reserva ? { naoGuardar: true } : {}) } : r;
1706
+ },
1707
+ }]);
1591
1708
  if (!v.ok) return v;
1592
1709
  /* O RITMO DA VOZ. Mesmo com a direção, a voz sai no passo de leitura, e
1593
1710
  o vídeo — que segue a voz palavra por palavra — sai lento junto. O
@@ -1793,8 +1910,23 @@ async function prepararRoteiro(p, args) {
1793
1910
  return r;
1794
1911
  }
1795
1912
  if (estilo === 'personagem' && modo === 'video') {
1796
- const r = personagem.montarPersonagem({ roteiro, largura, altura, tempos, narracao, sons, trilha });
1797
- if (r.ok) projeto.anotar(p.id, 'plano', `estilo personagem: ${r.resumo.elenco.join(' e ')} no set ${r.resumo.cenario}, ${r.resumo.planos} planos, ${r.resumo.segundos}s`);
1913
+ const r = personagem.montarPersonagem({ roteiro: { ...roteiro, pedido: roteiro.pedido || pedidoTxt }, largura, altura, tempos, narracao, sons, trilha });
1914
+ if (r.ok) {
1915
+ /* O que não é peça do catálogo vira ícone 3D da biblioteca, em blocos
1916
+ (kit/mundo.jsx): "uma pizza que fala", "um violão na praia". Sem
1917
+ ícone, a peça sai da cena (e a coisa que fala vira o mascote). */
1918
+ const achados = {};
1919
+ for (const nome of mundo.iconesPendentes(r.partitura)) {
1920
+ const id = colecao.melhorIcone3d(nome, 40);
1921
+ if (!id) continue;
1922
+ const b = await colecao.baixar(id, { pasta: p.pasta, largura, altura }).catch(() => null);
1923
+ if (b && b.ok) achados[nome] = b.src;
1924
+ }
1925
+ mundo.aplicarIcones(r.partitura, achados);
1926
+ mundo.adicionarObjetos(r.partitura, objetosDoRoteiro(roteiro.objetos, p.pasta));
1927
+ const icones = Object.keys(achados);
1928
+ projeto.anotar(p.id, 'plano', `estilo personagem: ${r.resumo.elenco.join(' e ')}${r.resumo.figurantes.length ? ` (+ ${r.resumo.figurantes.join(', ')})` : ''} em ${r.resumo.lugares.join(' → ')}, ${r.resumo.planos} planos, ${r.resumo.segundos}s${icones.length ? `; em blocos: ${icones.join(', ')}` : ''}`);
1929
+ }
1798
1930
  return r;
1799
1931
  }
1800
1932
  if (estilo === 'motion' && modo === 'video') {
@@ -1870,8 +2002,9 @@ async function transcrever(p, alvo, ctx) {
1870
2002
  // ── estudio_render ───────────────────────────────────────────────────────
1871
2003
 
1872
2004
  async function ferramentaRender(args, ctx = {}) {
1873
- const p = projeto.ler(args.id);
2005
+ const p = projetoOuRecente(args.id);
1874
2006
  if (!p) return semProjeto(args.id);
2007
+ if (args.id == null || String(args.id).trim() === '') args = { ...args, id: p.id };
1875
2008
  const pl = projeto.lerPlano(args.id);
1876
2009
  if (!pl) return { ok: false, error: 'não há plano neste projeto.' };
1877
2010
 
@@ -2117,7 +2250,7 @@ async function ferramentaSite(args) {
2117
2250
  }
2118
2251
 
2119
2252
  module.exports = {
2120
- vazamentoNoComeco, variarIcones,
2253
+ vazamentoNoComeco, variarIcones, objetosDoRoteiro,
2121
2254
  ferramentaProjeto, ferramentaPlano, ferramentaMidia, ferramentaNarrar,
2122
2255
  ferramentaVideo, ferramentaRender, ferramentaSite,
2123
2256
  projeto, plano, remotion, assets, video, identidade, slides, python, rodarPython, alinharPartes, prepararRoteiro, roteiroValido, juntarVozes,