primocode 10.4.7 → 10.6.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 (50) 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 -5
  8. package/lib/comandos-extra.js +2 -0
  9. package/lib/config.js +32 -4
  10. package/lib/conta.js +9 -3
  11. package/lib/entrada.js +33 -11
  12. package/lib/estudio/colecao.js +218 -0
  13. package/lib/estudio/dados/fluent-3d.json +1 -0
  14. package/lib/estudio/dados/noto-animado.json +1 -0
  15. package/lib/estudio/diretor.js +1 -0
  16. package/lib/estudio/estilo.js +6 -2
  17. package/lib/estudio/falantes.js +30 -6
  18. package/lib/estudio/gerativo.js +8 -0
  19. package/lib/estudio/hibrido.js +144 -8
  20. package/lib/estudio/index.js +202 -83
  21. package/lib/estudio/kit/animacao.jsx +41 -0
  22. package/lib/estudio/kit/gerativo.jsx +21 -1
  23. package/lib/estudio/kit/index.jsx +3 -1
  24. package/lib/estudio/kit/mundo.jsx +1060 -0
  25. package/lib/estudio/kit/voxel.jsx +854 -0
  26. package/lib/estudio/kit.js +5 -1
  27. package/lib/estudio/kokoro.js +18 -4
  28. package/lib/estudio/margem.js +1 -1
  29. package/lib/estudio/mundo.js +305 -0
  30. package/lib/estudio/personagem.js +234 -10
  31. package/lib/estudio/producao.js +107 -17
  32. package/lib/estudio/projeto.js +77 -22
  33. package/lib/estudio/remotion.js +2 -0
  34. package/lib/leitura.js +176 -0
  35. package/lib/local/cerebro.json +180 -3
  36. package/lib/local/entrada.js +61 -4
  37. package/lib/mcp.js +296 -12
  38. package/lib/memoria-conversa.js +32 -3
  39. package/lib/mencoes.js +5 -1
  40. package/lib/nome-curto.js +126 -0
  41. package/lib/nome-projeto.js +5 -2
  42. package/lib/outra-pasta.js +161 -0
  43. package/lib/perigo.js +34 -2
  44. package/lib/referencia.js +40 -11
  45. package/lib/repeticao.js +20 -1
  46. package/lib/tela.js +1 -1
  47. package/lib/tools.js +76 -6
  48. package/lib/travado.js +141 -0
  49. package/lib/ui.js +1 -0
  50. package/package.json +2 -2
@@ -66,9 +66,12 @@ 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 colecao = require('./colecao.js');
69
71
  const { escolherEstilo, LEVES } = require('./estilo.js');
70
72
  const sonsLeves = require('./sons-leves.js');
71
73
  const ende = require('./ende.js');
74
+ const producao = require('./producao.js');
72
75
 
73
76
  const PY = path.join(__dirname, '..', '..', 'studio');
74
77
 
@@ -162,7 +165,7 @@ function semProjeto(id) {
162
165
  ok: false,
163
166
  error: `não achei o projeto "${id}".`,
164
167
  comoResolver: 'liste com estudio_projeto acao:"listar" e use um id de lá — '
165
- + 'o id é o nome da pasta (ex: "2026-09-22-anuncio-picpay"), não o título.',
168
+ + 'o id é o nome da pasta (ex: "anuncio-picpay"), não o título.',
166
169
  };
167
170
  }
168
171
 
@@ -187,6 +190,9 @@ async function ferramentaProjeto(args, ctx = {}) {
187
190
  // O caderno é o que faz "mexe naquela parte" funcionar semanas
188
191
  // depois: sem ele, reabrir diz o que existe e nunca por quê.
189
192
  caderno: projeto.caderno(args.id, { ultimas: 25 }),
193
+ // A conversa das sessões em que se mexeu neste projeto (AGENTS.md
194
+ // da pasta dele, lib/agents-md.js): o que foi pedido e decidido.
195
+ memoria: require('../agents-md.js').ler(p.pasta) || undefined,
190
196
  plano: pl ? plano.resumo(pl) : 'ainda não há plano',
191
197
  versoes: projeto.versoes(args.id),
192
198
  arquivos: listarArquivos(p.pasta),
@@ -518,6 +524,23 @@ async function ferramentaMidia(args) {
518
524
  return r;
519
525
  }
520
526
 
527
+ /* A BIBLIOTECA (colecao.js, 11/10): o cardápio inteiro, o ícone 3D e a
528
+ animação pronta. Procurar não usa rede (o índice mora no pacote); com
529
+ id, baixa para o projeto e devolve o elemento pronto. */
530
+ if (acao === 'biblioteca' || acao === 'cardapio' || acao === 'cardápio') return colecao.cardapio();
531
+ if (acao === 'icones3d' || acao === 'icone3d' || acao === 'animacoes' || acao === 'animacao' || acao === 'animação' || acao === 'animações') {
532
+ const ehAnim = /^anima/.test(acao);
533
+ if (args.id_item || /^(fluent3d|noto):/.test(String(args.item || args.baixar || ''))) {
534
+ const pj = p ? projeto.ler(p.id) : null;
535
+ const fmt = (pj && pj.formato) || {};
536
+ const r = await colecao.baixar(args.id_item || args.item || args.baixar, { pasta: p ? p.pasta : null, largura: fmt.largura || 1920, altura: fmt.altura || 1080 });
537
+ if (r.ok && p) projeto.anotar(p.id, 'midia', `biblioteca: ${r.src} (${r.licenca})`);
538
+ return r;
539
+ }
540
+ return ehAnim ? colecao.procurarAnimacoes(args.termo || args.busca || '', { quantas: Number(args.quantas) || 10 })
541
+ : colecao.procurarIcones3d(args.termo || args.busca || '', { quantas: Number(args.quantas) || 10 });
542
+ }
543
+
521
544
  if (acao === 'icones' || acao === 'icone') {
522
545
  if (args.nome) {
523
546
  const r = await assets.icone(args.nome, { pasta: p ? p.pasta : null });
@@ -1051,12 +1074,24 @@ async function prepararDepoimento(p, c) {
1051
1074
  origem: { src: c.src, de: c.de, ate: c.ate, frase: c.frase }, ...(c.fraseDita ? { frase: c.fraseDita } : {}) };
1052
1075
  }
1053
1076
 
1077
+ /* O tom de cada estilo, em UMA frase de instrução, em inglês e terminando em
1078
+ dois-pontos, que é a forma que o Gemini TTS entende como instrução e não
1079
+ como fala. Se ainda vazar, o corte de vazamentoNoComeco tira (as palavras
1080
+ ditas antes do roteiro são quase todas daqui). */
1081
+ const DIRECAO_DA_VOZ = {
1082
+ gerativo: 'Read aloud in Brazilian Portuguese as a tech reels narrator: full, warm voice close to the mic, energetic and smiling, no long pauses:',
1083
+ fluido: 'Read aloud in Brazilian Portuguese as a premium product video narrator: confident and close, clean sentences that flow into each other, no ad tone:',
1084
+ personagem: 'Read aloud in Brazilian Portuguese as someone really talking on a podcast: natural and close, never like an announcer:',
1085
+ impacto: 'Read aloud in Brazilian Portuguese as an energetic ad announcer: full voice, strong emphasis on the impact words:',
1086
+ padrao: 'Read aloud in Brazilian Portuguese as a lively tech reels narrator: confident, smiling, emphasis on the words that matter:',
1087
+ };
1088
+
1054
1089
  /* A narração com cache (a mesma fala, na mesma direção, não é gerada duas vezes). */
1055
1090
  async function narrarComCache(lista, mp3, { voz, direcao } = {}) {
1056
- /* 'v2' (10/10): as narrações guardadas antes do corte da direção lida em
1057
- voz alta ("Rápido e contínuo.") ficam para trás — reaproveitar uma delas
1058
- trazia o vazamento de volta mesmo com o conserto. */
1059
- const chave = require('crypto').createHash('sha1').update(JSON.stringify(['v2', lista.map((x) => x.texto), voz || '', direcao || ''])).digest('hex').slice(0, 16);
1091
+ /* 'v3' (11/10): as narrações guardadas com a direção antiga (a que tinha
1092
+ "RÁPIDO" e "contínuas") ficam para trás — reaproveitar uma delas trazia o
1093
+ "Rápido e contínuo." de volta mesmo com a direção nova. */
1094
+ const chave = require('crypto').createHash('sha1').update(JSON.stringify(['v3', lista.map((x) => x.texto), voz || '', direcao || ''])).digest('hex').slice(0, 16);
1060
1095
  const dir = path.join(os.homedir(), '.primocode', 'studio', 'cache-voz');
1061
1096
  const cm = path.join(dir, chave + '.mp3'), cj = path.join(dir, chave + '.json');
1062
1097
  if (fs.existsSync(cm) && fs.existsSync(cj)) { fs.copyFileSync(cm, mp3); return { ok: true, ...JSON.parse(fs.readFileSync(cj, 'utf8')) }; }
@@ -1067,13 +1102,42 @@ async function narrarComCache(lista, mp3, { voz, direcao } = {}) {
1067
1102
  return v;
1068
1103
  }
1069
1104
 
1105
+ /* A PRODUÇÃO (producao.js): cada necessidade — voz, trilha, efeitos — pela
1106
+ corrente de provedores dela, com o cache do projeto. `roteiro.provedores`
1107
+ ({ voz: 'kokoro' }) passa um provedor para a frente, e o caderno guarda
1108
+ quem produziu cada coisa. */
1109
+ async function produzirNecessidade(p, roteiro, pedido, provedores) {
1110
+ const r = await producao.produzirUma(pedido, provedores, { escolha: ((roteiro && roteiro.provedores) || {})[pedido.tipo], cache: producao.cacheDe(p.pasta) });
1111
+ projeto.anotar(p.id, 'producao', producao.linhaCaderno(r), { necessidade: pedido.tipo, provedor: r.quem || null, cache: !!r.doCache });
1112
+ return r;
1113
+ }
1114
+
1115
+ /* A voz como provedores, na ordem do kokoro.ordem: o Gemini (com o cache e a
1116
+ direção dele), o Kokoro local e o Edge. A falha de uma passa para a próxima. */
1117
+ function provedoresDeVoz(lista, mp3, arq, voz, vozEdge, { soEdge = false, gemini, escolha } = {}) {
1118
+ const todos = {
1119
+ gemini: () => gemini(),
1120
+ kokoro: () => kokoro.narrar(lista, mp3, { voz, uv: acharUv() }),
1121
+ edge: () => {
1122
+ // Ritmo NORMAL: o "citação" (-10%, respiro longo) esticou o vídeo para 125 s.
1123
+ if (!fs.existsSync(arq)) fs.writeFileSync(arq, JSON.stringify(lista.map((x) => ({ ...x, registro: 'normal' }))));
1124
+ return narrarReserva(lista, mp3, arq, voz, vozEdge, { soEdge: true });
1125
+ },
1126
+ };
1127
+ const comResultado = (f) => async () => { const v = await f(); return v && v.ok ? { ...v, arquivo: mp3 } : v; };
1128
+ const ordem = kokoro.ordem(voz, process.env, { soEdge });
1129
+ // A escolhida no roteiro entra mesmo fora da ordem (o produzirUma a põe na frente).
1130
+ if (escolha && todos[escolha] && !ordem.includes(escolha)) ordem.push(escolha);
1131
+ return ordem.map((nome) => ({ nome, gerar: comResultado(todos[nome]) }));
1132
+ }
1133
+
1070
1134
  /* A VOZ DE RESERVA, quando o Gemini falha: o Kokoro (local, sem cota) e
1071
1135
  depois o Edge. A falha de uma passa para a próxima, nunca derruba. */
1072
1136
  function narrarReserva(lista, mp3, arq, voz, vozEdge, { soEdge = false, semKokoro = false } = {}) {
1073
1137
  const passos = kokoro.ordem(voz, process.env, { soEdge }).filter((n) => n !== 'gemini' && !(semKokoro && n === 'kokoro'));
1074
1138
  return kokoro.corrente(passos, {
1075
1139
  kokoro: () => kokoro.narrar(lista, mp3, { voz, uv: acharUv() }),
1076
- edge: () => rodarPython('narrar.py', ['roteiro', '--arquivo', arq, '--saida', mp3, '--voz', String(kokoro.querKokoro(voz, {}) ? vozEdge : voz || vozEdge)], { timeout: 10 * 60000 }),
1140
+ edge: () => rodarPython('narrar.py', ['roteiro', '--arquivo', arq, '--saida', mp3, '--voz', kokoro.vozNoEdge(voz, vozEdge)], { timeout: 10 * 60000 }),
1077
1141
  });
1078
1142
  }
1079
1143
 
@@ -1181,6 +1245,11 @@ async function prepararRoteiro(p, args) {
1181
1245
  if (!args.estilo && estilo !== 'lancamento') estilo = 'gerativo';
1182
1246
  if (!roteiro.modelo3d) roteiro.modelo3d = variacao3d.modeloDoPedido(pedidoTxt);
1183
1247
  }
1248
+ // 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.
1249
+ if (estilo === 'personagem') {
1250
+ const calados = personagem.calarBichos(roteiro, pedidoTxt);
1251
+ 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"`);
1252
+ }
1184
1253
  // O 3D de cada vídeo: acabamento, câmera, luz e ritmo sorteados sem repetir os últimos.
1185
1254
  /* Por PROJETO, não por chamada: mandar o roteiro de novo para ajustar uma
1186
1255
  frase não pode sortear outro 3D nem outro vídeo. Projeto novo, sorteio novo. */
@@ -1302,35 +1371,38 @@ async function prepararRoteiro(p, args) {
1302
1371
  return narrarReserva(l, m, a, voz, 'antonio', { soEdge });
1303
1372
  } });
1304
1373
  if (!v.ok) projeto.anotar(p.id, 'narracao', `as duas vozes falharam (${v.error}); saiu numa voz só`);
1305
- } else if (soEdge) {
1306
- v = { ok: false, error: 'edge pedido' };
1307
- } else if (fs.existsSync(cacheMp3) && fs.existsSync(cacheJson)) {
1308
- fs.copyFileSync(cacheMp3, mp3);
1309
- v = { ok: true, ...JSON.parse(fs.readFileSync(cacheJson, 'utf8')), doCache: true };
1310
- // Cache sem as palavras (vindo de um plano antigo): transcreve de novo e realinha — sem gastar voz.
1311
- if (!(v.partes || []).some((pt) => (pt.palavras || []).length)) {
1312
- try {
1313
- const cfg = require('../config').loadConfig();
1314
- const tr = await require('../api').requestTranscricao(cfg.server, { token: cfg.token, audio: fs.readFileSync(mp3).toString('base64'), nome: 'voz.mp3', idioma: 'pt' });
1315
- if (tr && (tr.palavras || []).length) {
1316
- v.partes = alinharPartes(lista, tr.palavras, v.segundos);
1317
- fs.writeFileSync(cacheJson, JSON.stringify({ voz: v.voz, segundos: v.segundos, partes: v.partes }));
1318
- }
1319
- } catch { /* fica o alinhamento guardado */ }
1374
+ if (!v.ok) {
1375
+ const arq = path.join(p.pasta, 'midia', `roteiro-${Date.now().toString(36)}.json`);
1376
+ fs.writeFileSync(arq, JSON.stringify(lista.map((x) => ({ ...x, registro: 'normal' }))));
1377
+ v = await narrarReserva(lista, mp3, arq, args.voz || roteiro.voz, 'antonio', { soEdge });
1320
1378
  }
1321
- } else if (kokoro.querKokoro(args.voz || roteiro.voz)) {
1322
- v = { ok: false, error: 'kokoro pedido' };
1323
1379
  } else {
1324
- v = await narrarComGemini(lista, mp3, { voz: args.voz || roteiro.voz, direcao: DIRECAO_CINEMA });
1325
- if (v.ok) {
1326
- 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 */ }
1327
- }
1328
- }
1329
- if (!v.ok) {
1330
1380
  const arq = path.join(p.pasta, 'midia', `roteiro-${Date.now().toString(36)}.json`);
1331
- // Ritmo NORMAL: o "citação" (-10%, respiro longo) esticou o vídeo para 125 s.
1332
- fs.writeFileSync(arq, JSON.stringify(lista.map((x) => ({ ...x, registro: 'normal' }))));
1333
- v = await narrarReserva(lista, mp3, arq, args.voz || roteiro.voz, 'antonio', { soEdge });
1381
+ const gemini = async () => {
1382
+ if (fs.existsSync(cacheMp3) && fs.existsSync(cacheJson)) {
1383
+ fs.copyFileSync(cacheMp3, mp3);
1384
+ const g = { ok: true, ...JSON.parse(fs.readFileSync(cacheJson, 'utf8')), doCache: true };
1385
+ // Cache sem as palavras (vindo de um plano antigo): transcreve de novo e realinha — sem gastar voz.
1386
+ if (!(g.partes || []).some((pt) => (pt.palavras || []).length)) {
1387
+ try {
1388
+ const cfg = require('../config').loadConfig();
1389
+ const tr = await require('../api').requestTranscricao(cfg.server, { token: cfg.token, audio: fs.readFileSync(mp3).toString('base64'), nome: 'voz.mp3', idioma: 'pt' });
1390
+ if (tr && (tr.palavras || []).length) {
1391
+ g.partes = alinharPartes(lista, tr.palavras, g.segundos);
1392
+ fs.writeFileSync(cacheJson, JSON.stringify({ voz: g.voz, segundos: g.segundos, partes: g.partes }));
1393
+ }
1394
+ } catch { /* fica o alinhamento guardado */ }
1395
+ }
1396
+ return g;
1397
+ }
1398
+ const g = await narrarComGemini(lista, mp3, { voz: args.voz || roteiro.voz, direcao: DIRECAO_CINEMA });
1399
+ if (g.ok) {
1400
+ 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 */ }
1401
+ }
1402
+ return g;
1403
+ };
1404
+ v = await produzirNecessidade(p, roteiro, { tipo: 'voz', chave: [lista.map((x) => x.texto), args.voz || roteiro.voz || '', 'cinema-v1', soEdge], destino: mp3 },
1405
+ provedoresDeVoz(lista, mp3, arq, args.voz || roteiro.voz, 'antonio', { soEdge, gemini, escolha: (roteiro.provedores || {}).voz }));
1334
1406
  }
1335
1407
  if (!v.ok) return v;
1336
1408
  /* A DURAÇÃO que o roteiro pede ("60–75 s"): se a voz passa do que cabe,
@@ -1374,25 +1446,32 @@ async function prepararRoteiro(p, args) {
1374
1446
  }
1375
1447
  }
1376
1448
  projeto.anotar(p.id, 'narracao', `${v.segundos}s em ${lista.length} partes, voz ${v.voz}`);
1377
- const sonsC = await sonsReais(p, video);
1378
- let trilhaC = null;
1449
+ /* A chave leva o id do projeto e o hash das falas. Só `cinema` fazia um
1450
+ roteiro novo, no mesmo projeto, reaproveitar os efeitos do anterior. */
1451
+ const marcaSfx = require('crypto').createHash('sha1').update(JSON.stringify({ id: p.id || '', falas: partes.map((pt) => cinema.falaDe(pt)) })).digest('hex').slice(0, 16);
1452
+ const sonsC = (await produzirNecessidade(p, roteiro, { tipo: 'sfx', chave: [p.id || '', marcaSfx] },
1453
+ [{ nome: 'openverse', gerar: async () => ({ sons: await sonsReais(p, video) }) }])).sons || {};
1379
1454
  // Vários termos e durações: vídeo sem música foi o que saiu na primeira vez (25/09).
1380
1455
  // E só instrumental: "essa música daria pra deixar só o instrumental?" — a faixa é OUVIDA.
1381
1456
  // O termo sai do clima do roteiro (variedade.js), não de uma lista fixa de "cinematic inspiring".
1382
- for (const termo of [...variedade.termosTrilha(roteiro, 'cinema'), 'instrumental']) {
1383
- if (trilhaC) break;
1384
- try {
1385
- const b = await assets.procurarMusica(termo, { quantas: 10, duracaoMin: 45 });
1386
- 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)) {
1387
- const d = await assets.baixar(item, { pasta: p.pasta, nome: 'trilha' });
1388
- if (!d.ok) continue;
1389
- if (await temLetra(path.join(p.pasta, d.relativo))) { projeto.anotar(p.id, 'midia', `trilha com voz descartada: ${item.titulo}`); continue; }
1390
- trilhaC = d.relativo; variedade.marcar('trilha', item.url);
1391
- projeto.anotar(p.id, 'trilha', `"${termo}": ${item.titulo} — ${item.autor || 'Jamendo'} (${item.licenca || 'CC'})`);
1392
- break;
1457
+ let trilhaC = (await produzirNecessidade(p, roteiro, { tipo: 'trilha', chave: ['cinema', roteiro.clima || '', roteiro.genero || ''] },
1458
+ [{ nome: 'jamendo', gerar: async () => {
1459
+ for (const termo of [...variedade.termosTrilha(roteiro, 'cinema'), 'instrumental']) {
1460
+ try {
1461
+ const b = await assets.procurarMusica(termo, { quantas: 10, duracaoMin: 45 });
1462
+ 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)) {
1463
+ const d = await assets.baixar(item, { pasta: p.pasta, nome: 'trilha' });
1464
+ if (!d.ok) continue;
1465
+ if (await temLetra(path.join(p.pasta, d.relativo))) { projeto.anotar(p.id, 'midia', `trilha com voz descartada: ${item.titulo}`); continue; }
1466
+ variedade.marcar('trilha', item.url);
1467
+ const credito = `"${termo}": ${item.titulo} — ${item.autor || 'Jamendo'} (${item.licenca || 'CC'})`;
1468
+ projeto.anotar(p.id, 'trilha', credito);
1469
+ return { src: d.relativo, credito };
1470
+ }
1471
+ } catch { /* o próximo termo */ }
1393
1472
  }
1394
- } catch { /* o próximo termo */ }
1395
- }
1473
+ return null;
1474
+ } }])).src || null;
1396
1475
  /* A MÚSICA mais baixa na origem: a faixa vem masterizada alta e comprimida,
1397
1476
  e mesmo a 30% ela cobria a voz e o depoimento ("a música tá muito
1398
1477
  alta, não dá pra ouvir as pessoas", 25/09). Ela vai a -28 LUFS antes
@@ -1439,11 +1518,12 @@ async function prepararRoteiro(p, args) {
1439
1518
  try { fs.unlinkSync(path.join(p.pasta, rel)); } catch { /* fica o original */ }
1440
1519
  return 'midia/' + destino;
1441
1520
  };
1521
+ // banco: true marca o que veio do banco (Pexels): a rota produto prefere a tela da marca (hibrido.ancorar).
1442
1522
  const achados = await Promise.all(hibrido.termosDeBusca(roteiro, hib.extras).map(async (termo) => {
1443
1523
  const out = [];
1444
1524
  const slug = 'broll-' + String(termo).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
1445
1525
  const proxy = `${slug}.leve.mp4`;
1446
- if (jaTem.includes(proxy)) out.push({ src: 'midia/' + proxy, tipo: 'video', cat: cat(termo), alt: termo });
1526
+ if (jaTem.includes(proxy)) out.push({ src: 'midia/' + proxy, tipo: 'video', cat: cat(termo), alt: termo, banco: true });
1447
1527
  else try {
1448
1528
  // Sorteado entre os não usados, com o termo variado (variedade.js, 05/10).
1449
1529
  const itens = await variedade.buscarSemRepetir(async (t) => {
@@ -1453,11 +1533,11 @@ async function prepararRoteiro(p, args) {
1453
1533
  for (const item of itens.slice(0, 1)) {
1454
1534
  const d = await assets.baixar(item, { pasta: p.pasta, nome: slug });
1455
1535
  const l = d.ok ? await leve(d.relativo, proxy) : null;
1456
- if (l) { out.push({ src: l, tipo: 'video', cat: cat(termo), alt: termo }); variedade.marcar('broll', item.url); }
1536
+ if (l) { out.push({ src: l, tipo: 'video', cat: cat(termo), alt: termo, banco: true }); variedade.marcar('broll', item.url); }
1457
1537
  }
1458
1538
  } catch { /* segue com a foto */ }
1459
1539
  const foto = jaTem.find((f) => f.startsWith(slug + '-') && /\.(jpe?g|png|webp)$/i.test(f));
1460
- if (foto) out.push({ src: 'midia/' + foto, tipo: 'imagem', cat: cat(termo), alt: termo });
1540
+ if (foto) out.push({ src: 'midia/' + foto, tipo: 'imagem', cat: cat(termo), alt: termo, banco: true });
1461
1541
  else try {
1462
1542
  const itens = await variedade.buscarSemRepetir(async (t) => {
1463
1543
  const b = await assets.procurarImagens(t, { quantas: 8, orientacao });
@@ -1465,7 +1545,7 @@ async function prepararRoteiro(p, args) {
1465
1545
  }, termo);
1466
1546
  for (const item of itens.slice(0, 1)) {
1467
1547
  const d = await assets.baixar(item, { pasta: p.pasta, nome: slug });
1468
- if (d.ok) { out.push({ src: d.relativo, tipo: 'imagem', cat: cat(termo), alt: termo }); variedade.marcar('broll', item.url); }
1548
+ if (d.ok) { out.push({ src: d.relativo, tipo: 'imagem', cat: cat(termo), alt: termo, banco: true }); variedade.marcar('broll', item.url); }
1469
1549
  }
1470
1550
  } catch { /* sem */ }
1471
1551
  return out;
@@ -1524,7 +1604,9 @@ async function prepararRoteiro(p, args) {
1524
1604
  const r = cinema.montarCinema({ roteiro, largura, altura, tempos: v.partes, narracao: path.relative(p.pasta, mp3).replace(/\\/g, '/'),
1525
1605
  sons: sonsC, trilha: trilhaC, fotos: fotosC, fortes, ritmoLeitura: hib ? 0.55 : 1 });
1526
1606
  if (r.ok && hib) {
1527
- hibrido.enfeitar(r.plano, { extras: hib.extras, partes, marca: roteiro.marca || {}, largura, altura, hud: roteiro.hud === true, particulas: roteiro.particulas === true });
1607
+ hibrido.enfeitar(r.plano, { extras: hib.extras, partes, marca: roteiro.marca || {}, largura, altura, hud: roteiro.hud === true, particulas: roteiro.particulas === true,
1608
+ // O b-roll de cada frase troca 0,2 s depois da palavra-âncora (hibrido.ancorar).
1609
+ tempos: v.partes, fortes, fotos: fotosC });
1528
1610
  r.resumo.estilo = 'hibrido';
1529
1611
  }
1530
1612
  if (r.ok) projeto.anotar(p.id, 'plano', `estilo ${hib ? 'hibrido' : 'cinema'}: ${r.resumo.cenas} cenas, ${r.resumo.segundos}s`);
@@ -1538,38 +1620,52 @@ async function prepararRoteiro(p, args) {
1538
1620
  const mp3 = path.join(p.pasta, 'midia', `narracao-${Date.now().toString(36)}.mp3`);
1539
1621
  // A voz de gente primeiro (Gemini, pelo servidor); o Edge é a reserva.
1540
1622
  // Com cache: refazer o vídeo (outro estilo, outra proporção) não gasta a cota da voz.
1541
- const direcao = estilo === 'gerativo'
1542
- /* O gerativo pede voz com CORPO ("narração encorpada, extravagante",
1543
- 30/09): cheia, quente, perto do microfone — e o ritmo segue 1,12×. */
1544
- /* (A primeira versão pedia "trailer" e "ênfase sustentada": a voz saiu 25% mais lenta, medido em 30/09.) */
1545
- /* Sem a ênfase em caixa alta logo no começo ("rápido" gritado): era
1546
- exatamente o trecho que o Gemini lia em voz alta (10/10). */
1547
- ? 'Leia em português do Brasil como locutor de reels de tecnologia, num ritmo rápido e sem pausas, com voz cheia, quente e encorpada, '
1548
- + 'bem perto do microfone, muita energia e sorriso na voz. Ataque firme e ênfase curta e forte nas palavras de impacto; '
1549
- + 'quase sem pausa entre as frases. Nunca arraste, nunca leia devagar nem solene.'
1550
- // O fluido é o vídeo de produto: voz limpa e segura, e rápida ("mais rápido", 06/10).
1551
- : estilo === 'fluido' ? 'Leia em português do Brasil como narrador de vídeo de produto premium: seguro, próximo e RÁPIDO, '
1552
- + 'frases limpas e contínuas, quase sem pausa entre elas. Nunca arraste, nunca leia devagar; sem tom de anúncio.'
1553
- // O personagem CONVERSA: voz de gente falando com gente, não de locutor.
1554
- : estilo === 'personagem' ? 'Fale em português do Brasil como alguém conversando de verdade num podcast: natural, próximo, '
1555
- + 'com as pausas e a entonação de quem está pensando junto. Sem voz de locutor, sem leitura.'
1556
- : estilo === 'impacto' ? 'Leia em português do Brasil como locutor de comercial agressivo e vendedor: energia alta, ritmo rápido, '
1557
- + 'voz encorpada, ênfase forte nas palavras de impacto, pausas curtas e secas entre as frases, sorrindo na voz.'
1558
- // "A narração tá lenta, deixa mais viva" (29/09): todo estilo fala com energia de reels.
1559
- : 'Leia em português do Brasil como narrador de reels de tecnologia: vivo, confiante e rápido, sorrindo na voz, '
1560
- + 'ênfase nas palavras que importam, pausas curtas entre as frases. Nada de leitura lenta ou solene.';
1623
+ /* A DIREÇÃO DA VOZ. O servidor manda a direção e o texto no MESMO pedido,
1624
+ e o Gemini às vezes lê o começo dela em voz alta: todo vídeo do fluido
1625
+ abria com "Rápido e contínuo." (11/10, "em tudo ela coloca isso, pode
1626
+ ser um negócio de padaria"). Então a direção é curta, descreve só o
1627
+ TOM, e não tem palavra solta que soe como a primeira frase de um
1628
+ roteiro ("rápido", "contínuo", caixa alta). A velocidade não vem
1629
+ daqui: vem do atempo (ritmoVoz) depois da voz pronta. */
1630
+ const direcao = DIRECAO_DA_VOZ[estilo] || DIRECAO_DA_VOZ.padrao;
1561
1631
  /* Uma voz por falante (falantes.js). Com uma voz só, um grupo só: é a
1562
1632
  narração de sempre, com o mesmo cache. */
1563
1633
  const vozesL = falantes.vozesDasPartes(partes, roteiro, args.voz || roteiro.voz);
1564
- let v = await falantes.narrarPorFalante(lista.map((x, i) => ({ ...x, voz: vozesL[i] })), mp3, { juntar: juntarVozes,
1565
- narrarUm: async (l, m, voz) => {
1566
- const g = await narrarComCache(l, m, { voz, direcao });
1567
- if (g.ok) return g;
1568
- // O Kokoro pedido já foi tentado no narrarComCache: a reserva não repete.
1569
- const a = m === mp3 ? arq : m.replace(/\.mp3$/, '.json');
1570
- if (a !== arq) fs.writeFileSync(a, JSON.stringify(l));
1571
- return narrarReserva(l, m, a, voz, 'thalita', { semKokoro: kokoro.querKokoro(voz) });
1572
- } });
1634
+ let v = await produzirNecessidade(p, roteiro, { tipo: 'voz', chave: [lista.map((x) => x.texto), vozesL, direcao], destino: mp3 }, [{
1635
+ nome: ((roteiro.provedores || {}).voz) || 'estudio',
1636
+ gerar: async () => {
1637
+ let reserva = false, gemini = false;
1638
+ const comLista = lista.map((x, i) => ({ ...x, voz: vozesL[i] }));
1639
+ const pelaReserva = async (l, m, voz) => {
1640
+ // O Kokoro pedido já foi tentado no narrarComCache: a reserva não repete.
1641
+ const a = m === mp3 ? arq : m.replace(/\.mp3$/, '.json');
1642
+ if (a !== arq) fs.writeFileSync(a, JSON.stringify(l));
1643
+ const rr = await narrarReserva(l, m, a, voz, 'thalita', { semKokoro: kokoro.querKokoro(voz) });
1644
+ if (rr && rr.ok) reserva = true;
1645
+ return rr;
1646
+ };
1647
+ let r = await falantes.narrarPorFalante(comLista, mp3, { juntar: juntarVozes,
1648
+ narrarUm: async (l, m, voz) => {
1649
+ const g = await narrarComCache(l, m, { voz, direcao });
1650
+ if (g.ok) { gemini = true; return g; }
1651
+ return pelaReserva(l, m, voz);
1652
+ } });
1653
+ /* UMA VOZ SÓ POR VÍDEO (11/10, "os personagens falam com vozes
1654
+ diferentes em cada cena"): a cota do Gemini acabava no meio, e o
1655
+ mesmo personagem saía Gemini numa fala e reserva na outra. Se
1656
+ misturou, o vídeo inteiro é refeito na reserva — pior que o
1657
+ Gemini, mas cada um com a mesma voz do começo ao fim. */
1658
+ if (r && r.ok && reserva && gemini) {
1659
+ gemini = false;
1660
+ r = await falantes.narrarPorFalante(comLista, mp3, { juntar: juntarVozes, narrarUm: pelaReserva });
1661
+ }
1662
+ /* A voz de RESERVA (Kokoro/Edge, quando o Gemini falhou) não vai para o
1663
+ cache do projeto: guardada, ela voltava em todo refazer do mesmo
1664
+ texto, mesmo com o Gemini de pé — era a "voz robótica que não sai"
1665
+ (11/10). Na próxima vez o Gemini é tentado de novo. */
1666
+ return r && r.ok ? { ...r, arquivo: mp3, ...(reserva ? { naoGuardar: true } : {}) } : r;
1667
+ },
1668
+ }]);
1573
1669
  if (!v.ok) return v;
1574
1670
  /* O RITMO DA VOZ. Mesmo com a direção, a voz sai no passo de leitura, e
1575
1671
  o vídeo — que segue a voz palavra por palavra — sai lento junto. O
@@ -1703,6 +1799,15 @@ async function prepararRoteiro(p, args) {
1703
1799
  const icones = {};
1704
1800
  for (const pt of partes) {
1705
1801
  const h = typeof (pt.heroi != null ? pt.heroi : pt.icone) === 'string' ? String(pt.heroi != null ? pt.heroi : pt.icone).trim() : '';
1802
+ if (/^(fluent3d|noto):/i.test(h) && !icones[h]) {
1803
+ // O herói da biblioteca: o PNG 3D ou o Lottie, baixado para o projeto.
1804
+ try {
1805
+ const r = await colecao.baixar(h, { pasta: p.pasta, largura, altura });
1806
+ if (r.ok) icones[h] = /^noto:/i.test(h) ? { lottie: r.src } : { png: r.src };
1807
+ else projeto.anotar(p.id, 'icones', `herói ${h} não baixou: ${r.error || '?'}`);
1808
+ } catch { /* fica sem */ }
1809
+ continue;
1810
+ }
1706
1811
  if (!/^[a-z0-9-]+:[a-z0-9-]+$/i.test(h) || icones[h]) continue;
1707
1812
  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 */ }
1708
1813
  }
@@ -1766,8 +1871,22 @@ async function prepararRoteiro(p, args) {
1766
1871
  return r;
1767
1872
  }
1768
1873
  if (estilo === 'personagem' && modo === 'video') {
1769
- const r = personagem.montarPersonagem({ roteiro, largura, altura, tempos, narracao, sons, trilha });
1770
- 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`);
1874
+ const r = personagem.montarPersonagem({ roteiro: { ...roteiro, pedido: roteiro.pedido || pedidoTxt }, largura, altura, tempos, narracao, sons, trilha });
1875
+ if (r.ok) {
1876
+ /* O que não é peça do catálogo vira ícone 3D da biblioteca, em blocos
1877
+ (kit/mundo.jsx): "uma pizza que fala", "um violão na praia". Sem
1878
+ ícone, a peça sai da cena (e a coisa que fala vira o mascote). */
1879
+ const achados = {};
1880
+ for (const nome of mundo.iconesPendentes(r.partitura)) {
1881
+ const id = colecao.melhorIcone3d(nome, 40);
1882
+ if (!id) continue;
1883
+ const b = await colecao.baixar(id, { pasta: p.pasta, largura, altura }).catch(() => null);
1884
+ if (b && b.ok) achados[nome] = b.src;
1885
+ }
1886
+ mundo.aplicarIcones(r.partitura, achados);
1887
+ const icones = Object.keys(achados);
1888
+ 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(', ')}` : ''}`);
1889
+ }
1771
1890
  return r;
1772
1891
  }
1773
1892
  if (estilo === 'motion' && modo === 'video') {
@@ -0,0 +1,41 @@
1
+ /* kit/animacao.jsx — a animação pronta (Lottie) da biblioteca, quadro a quadro.
2
+ *
3
+ * {"tipo":"kit","peca":"animacao","src":"midia/animacoes/1f525.json","x":…,"y":…,"largura":…,"altura":…,
4
+ * "loop":true,"velocidade":1}
5
+ *
6
+ * O Lottie é vetor: sai nítido em qualquer tamanho. Ele NÃO toca sozinho
7
+ * (o relógio do navegador faria cada render sair diferente): a cada quadro o
8
+ * tempo do elemento vira o quadro da animação (goToAndStop). Os arquivos vêm
9
+ * de colecao.js (o Noto Emoji animado do Google, CC BY 4.0). */
10
+ import React, { useEffect, useRef, useState } from 'react';
11
+ import { staticFile, delayRender, continueRender } from 'remotion';
12
+ import lottie from 'lottie-web';
13
+
14
+ export function Animacao({ el, t }) {
15
+ const caixa = useRef(null);
16
+ const [anim, setAnim] = useState(null);
17
+ const [espera] = useState(() => delayRender('animação ' + el.src));
18
+ useEffect(() => {
19
+ let vivo = true;
20
+ let a = null;
21
+ fetch(/^https?:/.test(el.src) ? el.src : staticFile(el.src))
22
+ .then((r) => r.json())
23
+ .then((dados) => {
24
+ if (!vivo || !caixa.current) return;
25
+ a = lottie.loadAnimation({ container: caixa.current, renderer: 'svg', loop: false, autoplay: false, animationData: dados,
26
+ rendererSettings: { preserveAspectRatio: 'xMidYMid meet' } });
27
+ a.addEventListener('DOMLoaded', () => { setAnim(a); continueRender(espera); });
28
+ })
29
+ .catch(() => continueRender(espera));
30
+ return () => { vivo = false; if (a) a.destroy(); };
31
+ }, [el.src]);
32
+ if (anim) {
33
+ const total = Math.max(1, anim.totalFrames - 1);
34
+ const q = Math.max(0, t) * (anim.frameRate || 30) * (el.velocidade || 1);
35
+ anim.goToAndStop(el.loop === false ? Math.min(q, total) : q % total, true);
36
+ }
37
+ const w = el.largura || 300, h = el.altura || w;
38
+ return <div ref={caixa} style={{ position: 'absolute', left: el.x || 0, top: el.y || 0, width: w, height: h }} />;
39
+ }
40
+
41
+ export const ANIMACAO = { animacao: Animacao };
@@ -12,7 +12,8 @@
12
12
  * quadro as fontes carregam e cada linha é MEDIDA no navegador (delayRender):
13
13
  * o que não cabe encolhe, e o acento sabe onde fica cada palavra. */
14
14
  import React, { useState, useEffect, useMemo } from 'react';
15
- import { delayRender, continueRender } from 'remotion';
15
+ import { delayRender, continueRender, Img, staticFile } from 'remotion';
16
+ import { Animacao } from './animacao';
16
17
  import { SNAPPY, CURVAS_KIT, acaso, suave, alfa, mistura } from './base';
17
18
  import { Produto3D, Icone3D, Heroi3D } from './tres';
18
19
 
@@ -1075,6 +1076,25 @@ export function Gerativo({ el, t, fps }) {
1075
1076
  const [px, py] = achou;
1076
1077
  /* O ÍCONE PLANO (09/10): o do Iconify que o roteiro pediu (o pão, o QR
1077
1078
  code), na cor do acento da identidade — entra em mola e flutua de leve. */
1079
+ /* O ÍCONE DA BIBLIOTECA (11/10): o 3D renderizado do Fluent (png) ou a
1080
+ animação pronta do Noto (lottie). Entra em mola, gira de leve no
1081
+ eixo Y e flutua, com a sombra no chão que dá peso de objeto. */
1082
+ if (ic.png || ic.lottie) {
1083
+ const k = MOLA(t - ic.t), dt = t - ic.t, sai = c01((t - (x.ate - 0.25)) / 0.25);
1084
+ const fl = Math.sin(dt * 1.4) * 0.025 * tam, giro = Math.sin(dt * 0.9) * 10;
1085
+ return (
1086
+ <div style={{ position: 'absolute', left: Math.round(px), top: Math.round(py), width: tam, height: tam, opacity: c01(k * 1.4) * (1 - sai),
1087
+ transform: 'translateZ(140px) scale(' + (0.5 + 0.5 * k).toFixed(4) + ')', transformOrigin: '50% 70%' }}>
1088
+ <div style={{ position: 'absolute', left: '18%', right: '18%', bottom: '-4%', height: '9%', borderRadius: '50%', background: 'rgba(0,0,0,0.28)',
1089
+ filter: 'blur(' + (tam * 0.03).toFixed(1) + 'px)', transform: 'scale(' + (1 - fl / tam * 2).toFixed(3) + ')' }} />
1090
+ <div style={{ position: 'absolute', inset: 0, transform: 'translateY(' + fl.toFixed(1) + 'px) perspective(800px) rotateY(' + giro.toFixed(2) + 'deg) rotate(' + ((1 - k) * -10).toFixed(2) + 'deg)' }}>
1091
+ {ic.png
1092
+ ? <Img src={staticFile(ic.png)} style={{ width: '100%', height: '100%', objectFit: 'contain' }} />
1093
+ : <Animacao el={{ src: ic.lottie, x: 0, y: 0, largura: tam, altura: tam, loop: true }} t={Math.max(0, dt)} />}
1094
+ </div>
1095
+ </div>
1096
+ );
1097
+ }
1078
1098
  if (ic.svg) {
1079
1099
  const k = MOLA(t - ic.t), fl = Math.sin((t - ic.t) * 1.3) * 0.012 * tam, sai = c01((t - (x.ate - 0.25)) / 0.25);
1080
1100
  return <div style={{ position: 'absolute', left: Math.round(px), top: Math.round(py + fl), width: tam, height: tam, color: ic.cor, opacity: c01(k * 1.4) * (1 - sai),
@@ -10,6 +10,8 @@ import { TRES } from './tres';
10
10
  import { GERATIVO } from './gerativo';
11
11
  import { FLUIDO } from './fluido';
12
12
  import { PERSONAGEM } from './personagem';
13
+ import { VOXEL } from './voxel';
14
+ import { ANIMACAO } from './animacao';
13
15
 
14
- export const KIT = { ...VOX, ...MARCA, ...TRES, ...GERATIVO, ...FLUIDO, ...PERSONAGEM, papel: Papel };
16
+ export const KIT = { ...VOX, ...MARCA, ...TRES, ...GERATIVO, ...FLUIDO, ...PERSONAGEM, ...VOXEL, ...ANIMACAO, papel: Papel };
15
17
  export { Filtros, Acabamento, Hud, CURVAS_KIT, bolhaCaminho } from './base';