primocode 9.8.0-beta.2 → 9.8.0-beta.21

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 (74) hide show
  1. package/README.md +33 -6
  2. package/app/ansi.js +55 -0
  3. package/app/app.css +482 -281
  4. package/app/app.js +888 -315
  5. package/app/index.html +176 -101
  6. package/app/primo-marca.js +58 -1
  7. package/bin/primocode.js +697 -118
  8. package/comandos/explicar.md +14 -0
  9. package/comandos/publicar.md +10 -0
  10. package/comandos/revisar.md +13 -0
  11. package/lib/act.js +130 -26
  12. package/lib/api.js +8 -1
  13. package/lib/app.js +317 -74
  14. package/lib/browser.js +79 -3
  15. package/lib/catalogo.js +11 -3
  16. package/lib/checkpoint-git.js +218 -0
  17. package/lib/checkpoint.js +249 -0
  18. package/lib/claude-engine.js +25 -3
  19. package/lib/comandos-extra.js +481 -0
  20. package/lib/comandos.js +104 -0
  21. package/lib/config.js +21 -4
  22. package/lib/estudio/acervo-site.js +157 -0
  23. package/lib/estudio/assets.js +77 -3
  24. package/lib/estudio/cinema.js +354 -0
  25. package/lib/estudio/composicao.js +470 -0
  26. package/lib/estudio/edicao.js +450 -0
  27. package/lib/estudio/impacto.js +263 -0
  28. package/lib/estudio/index.js +769 -5
  29. package/lib/estudio/plano.js +5 -2
  30. package/lib/estudio/remotion.js +228 -23
  31. package/lib/estudio/slides.js +34 -30
  32. package/lib/fala.js +95 -1
  33. package/lib/local/cerebro.json +361 -180
  34. package/lib/local/entrada.js +18 -8
  35. package/lib/local/motor-cli.js +1 -1
  36. package/lib/mencoes.js +98 -0
  37. package/lib/offline.js +168 -0
  38. package/lib/painel.js +291 -0
  39. package/lib/perigo.js +256 -0
  40. package/lib/pre-leitura.js +118 -0
  41. package/lib/repeticao.js +4 -2
  42. package/lib/skills.js +6 -1
  43. package/lib/tempos.js +62 -0
  44. package/lib/ui.js +171 -17
  45. package/package.json +3 -2
  46. package/skills/apresentacao-pptx/SKILL.md +52 -0
  47. package/skills/cartaz-de-arte/LICENSE.txt +202 -0
  48. package/skills/cartaz-de-arte/SKILL.md +47 -0
  49. package/skills/criar-skill/LICENSE.txt +202 -0
  50. package/skills/criar-skill/SKILL.md +49 -0
  51. package/skills/depurar-de-verdade/LICENSE.txt +21 -0
  52. package/skills/depurar-de-verdade/SKILL.md +14 -5
  53. package/skills/design-de-verdade/LICENSE.txt +177 -0
  54. package/skills/design-de-verdade/SKILL.md +42 -0
  55. package/skills/documento-word/SKILL.md +57 -0
  56. package/skills/interface-revisada/SKILL.md +47 -0
  57. package/skills/me-ensina/LICENSE.txt +21 -0
  58. package/skills/me-ensina/SKILL.md +27 -0
  59. package/skills/me-entrevista/LICENSE.txt +21 -0
  60. package/skills/me-entrevista/SKILL.md +27 -0
  61. package/skills/motion-de-primeira/SKILL.md +1 -1
  62. package/skills/pdf-bonito/SKILL.md +48 -0
  63. package/skills/planilha-excel/SKILL.md +47 -0
  64. package/skills/react-sem-dor/SKILL.md +48 -0
  65. package/skills/revisao-antes-de-entregar/LICENSE.txt +21 -0
  66. package/skills/revisao-antes-de-entregar/SKILL.md +6 -1
  67. package/skills/site-com-motion/SKILL.md +39 -0
  68. package/skills/temas-prontos/LICENSE.txt +202 -0
  69. package/skills/temas-prontos/SKILL.md +50 -0
  70. package/skills/testar-o-site/LICENSE.txt +202 -0
  71. package/skills/testar-o-site/SKILL.md +46 -0
  72. package/skills/video-no-remotion/SKILL.md +48 -0
  73. package/studio/narrar.py +111 -10
  74. package/studio/rostos.py +52 -0
@@ -40,6 +40,11 @@ const assets = require('./assets.js');
40
40
  const video = require('./video.js');
41
41
  const identidade = require('./identidade.js');
42
42
  const slides = require('./slides.js');
43
+ const edicao = require('./edicao.js');
44
+ const composicao = require('./composicao.js');
45
+ const acervoSite = require('./acervo-site.js');
46
+ const impacto = require('./impacto.js');
47
+ const cinema = require('./cinema.js');
43
48
 
44
49
  const PY = path.join(__dirname, '..', '..', 'studio');
45
50
 
@@ -79,6 +84,37 @@ function rodarPython(script, args, { timeout = 20 * 60000 } = {}) {
79
84
  });
80
85
  }
81
86
 
87
+ /* O RECORTE DA PESSOA precisa do MediaPipe, e o MediaPipe só existe até o
88
+ Python 3.12 — no Python 3.14 do Homebrew ele nem instala (medido em
89
+ 25/09: o recorte nunca tinha rodado nesta máquina). Com o `uv`, o script
90
+ roda num Python 3.12 isolado, baixado uma vez; sem ele, tenta o Python da
91
+ máquina, como antes. */
92
+ function acharUv() {
93
+ const { execFileSync } = require('child_process');
94
+ for (const c of ['uv', path.join(os.homedir(), '.local', 'bin', 'uv'), '/opt/homebrew/bin/uv', '/usr/local/bin/uv']) {
95
+ try { execFileSync(c, ['--version'], { stdio: 'ignore', timeout: 5000 }); return c; } catch { /* próximo */ }
96
+ }
97
+ return null;
98
+ }
99
+ function rodarPessoa(args, { timeout = 40 * 60000 } = {}) {
100
+ const uv = acharUv();
101
+ if (!uv) return rodarPython('pessoa.py', args, { timeout });
102
+ return new Promise((resolve) => {
103
+ const p = spawn(uv, ['run', '--quiet', '--python', '3.12', '--with', 'mediapipe<0.10.22',
104
+ '--with', 'opencv-python', '--with', 'numpy', 'python', path.join(PY, 'pessoa.py'), ...args]);
105
+ let saida = '', erro = '';
106
+ p.stdout.on('data', (d) => { saida += d.toString(); });
107
+ p.stderr.on('data', (d) => { erro += d.toString(); });
108
+ const relogio = setTimeout(() => { try { p.kill(); } catch {} }, timeout);
109
+ p.on('close', () => {
110
+ clearTimeout(relogio);
111
+ try { resolve(JSON.parse(saida.trim().split('\n').pop())); }
112
+ catch { resolve({ ok: false, error: (erro || saida).slice(-400).trim() || 'o recorte não respondeu' }); }
113
+ });
114
+ p.on('error', (e) => { clearTimeout(relogio); resolve({ ok: false, error: e.message }); });
115
+ });
116
+ }
117
+
82
118
  // ── estudio_projeto ──────────────────────────────────────────────────────
83
119
 
84
120
  /**
@@ -165,18 +201,125 @@ function listarArquivos(pasta) {
165
201
 
166
202
  // ── estudio_plano ────────────────────────────────────────────────────────
167
203
 
204
+ /* O projeto de AGORA, quando o modelo esquece o id: o criado há menos de
205
+ 30 minutos. Medido com o modelo grátis em 25/09: ele chamou estudio_plano
206
+ sem id logo depois de criar o projeto, e a tarefa morreu ali. */
207
+ function projetoRecente() {
208
+ const agora = Date.now();
209
+ const lista = projeto.listar({ limite: 8 })
210
+ .filter((x) => x.criado && agora - new Date(x.criado).getTime() < 30 * 60000)
211
+ .sort((a, b) => new Date(b.criado) - new Date(a.criado));
212
+ return lista[0] || null;
213
+ }
214
+
215
+ /** O roteiro é utilizável como veio? (objeto ou JSON, com partes de tipo conhecido) */
216
+ function roteiroValido(r) {
217
+ let x = r;
218
+ if (typeof x === 'string') { try { x = JSON.parse(x); } catch { return null; } }
219
+ const partes = Array.isArray(x) ? x : x && x.partes;
220
+ if (!Array.isArray(partes) || partes.length < 2) return null;
221
+ const CINEMA = ['frase', 'montagem', 'numeros', 'escada', 'dia', 'depoimento', 'climax', 'final'];
222
+ if (!partes.every((pt) => pt && (composicao.TIPOS.includes(pt.tipo) || CINEMA.includes(pt.tipo)))) return null;
223
+ const obj = Array.isArray(x) ? { partes } : x;
224
+ return partes.some((pt) => CINEMA.includes(pt.tipo)) && !obj.estilo ? { ...obj, estilo: 'cinema' } : obj;
225
+ }
226
+
227
+ // O último site lido, para o projeto que nasceu DEPOIS da leitura.
228
+ let ultimoSite = null;
229
+
230
+ async function garantirAcervo(p) {
231
+ if (p.acervo && (p.acervo.fotos || []).length) return p;
232
+ if (!ultimoSite || Date.now() - ultimoSite.quando > 60 * 60000) return p;
233
+ try {
234
+ const a = await acervoSite.colher({ url: ultimoSite.url, pedido: p.pedido || '', pasta: p.pasta, htmlInicial: ultimoSite.html,
235
+ buscar: identidade.buscar, baixar: assets.baixar });
236
+ projeto.salvar(p.id, { acervo: { fotos: a.fotos, videos: a.videos, fatos: a.fatos, site: ultimoSite.url }, ...(p.marca ? {} : { marca: ultimoSite.marca }) });
237
+ projeto.anotar(p.id, 'midia', `acervo do site: ${a.fotos.length} fotos, ${a.videos.length} vídeos`);
238
+ } catch { /* segue sem acervo */ }
239
+ return projeto.ler(p.id);
240
+ }
241
+
242
+ /* O ROTEIRISTA do servidor: quando o modelo não manda um roteiro que preste
243
+ (nenhum, ou texto corrido), o pedido e os fatos da marca viram o roteiro
244
+ numa chamada só, com uma tarefa só. */
245
+ async function roteiroDoServidor(p, args) {
246
+ const cfg = require('../config').loadConfig();
247
+ const api = require('../api');
248
+ const pedido = [p.pedido, args.pedido].filter(Boolean).join(' — ');
249
+ const seg = Number(args.segundos) || Number((pedido.match(/(\d{2,3})\s*(s\b|seg)/i) || [])[1]) || 30;
250
+ const acervo = p.acervo || {};
251
+ const r = await api.request(cfg.server, '/api/roteiro', {
252
+ pedido, segundos: seg,
253
+ fatos: (acervo.fatos || []).map((f) => f.texto).join('\n\n').slice(0, 5000),
254
+ fotos: (acervo.fotos || []).length ? `${acervo.fotos.length} fotos reais` : '',
255
+ site: acervo.site ? new URL(acervo.site).hostname.replace(/^www\./, '') : '',
256
+ rascunho: typeof args.roteiro === 'string' ? args.roteiro : '',
257
+ estilo: args.estilo || (/an[uú]nci|reels?|divulg|promo|lan[çc]a|vend|campanha|convite|inscri/i.test(pedido) ? 'impacto' : undefined),
258
+ }, cfg.token);
259
+ if (!r || !r.ok) return { ok: false, error: (r && r.erro) || 'o roteirista não respondeu.' };
260
+ return { ok: true, roteiro: { ...r.roteiro, marca: (args.roteiro && args.roteiro.marca) || p.marca || undefined } };
261
+ }
262
+
168
263
  async function ferramentaPlano(args) {
169
- const p = projeto.ler(args.id);
264
+ let p = projeto.ler(args.id);
265
+ // Só quando o id FALTOU: id errado continua sendo erro, para não escrever no projeto errado.
266
+ if (!p && (args.id == null || String(args.id).trim() === '') && (args.roteiro || args.plano || args.cenas)) {
267
+ const recente = projetoRecente();
268
+ if (recente) { args = { ...args, id: recente.id }; p = recente; }
269
+ }
170
270
  if (!p) return semProjeto(args.id);
171
271
 
272
+ /* Roteiro que não serve (texto corrido, JSON quebrado, tipo inventado) ou
273
+ nenhum, num projeto ainda sem plano: o roteirista escreve. */
274
+ const valido = args.roteiro ? roteiroValido(args.roteiro) : null;
275
+ if (!args.plano && !args.cenas && !args.voltarPara && !valido && (args.roteiro || !projeto.lerPlano(args.id))) {
276
+ p = await garantirAcervo(p);
277
+ const escrito = await roteiroDoServidor(p, args);
278
+ if (!escrito.ok) return escrito;
279
+ args = { ...args, roteiro: escrito.roteiro };
280
+ projeto.anotar(p.id, 'plano', `roteiro escrito pelo roteirista: ${escrito.roteiro.partes.length} partes`);
281
+ } else if (valido) {
282
+ args = { ...args, roteiro: valido };
283
+ p = await garantirAcervo(p);
284
+ }
285
+
172
286
  if (args.voltarPara) return projeto.voltarPara(args.id, args.voltarPara);
173
287
 
174
- if (!args.plano && !args.cenas) {
288
+ if (!args.plano && !args.cenas && !args.roteiro) {
175
289
  const atual = projeto.lerPlano(args.id);
176
290
  if (!atual) return { ok: false, error: 'este projeto ainda não tem plano — mande um.' };
177
291
  return { ok: true, plano: atual, resumo: plano.resumo(atual) };
178
292
  }
179
293
 
294
+ /* O ROTEIRO: o modelo diz o que cada parte é e o que ela diz, e o código
295
+ faz o resto — layout, voz com entonação, ícones, imagens, música e
296
+ sons (lib/estudio/composicao.js). É o caminho para qualquer modelo
297
+ entregar motion e slides que prestam. */
298
+ if (args.roteiro) {
299
+ const r = await prepararRoteiro(p, args);
300
+ if (!r.ok) return r;
301
+ let conferida = plano.conferir(r.plano, { pasta: p.pasta });
302
+ /* A rede de segurança: o elemento que não passa sai, e o vídeo segue.
303
+ Um defeito num ícone não pode custar o vídeo inteiro. */
304
+ if (!conferida.ok) {
305
+ projeto.anotar(p.id, 'defeito', `plano do roteiro reprovou: ${conferida.problemas.slice(0, 4).map((x) => x.onde + ' ' + x.oque).join(' | ')}`);
306
+ const ruins = new Set(conferida.problemas.map((x) => (String(x.onde).match(/^cenas\[(\d+)\]\.elementos\[(\d+)\]/) || []).slice(1, 3).join(':')).filter((k) => k.includes(':')));
307
+ r.plano.cenas.forEach((c, i) => { c.elementos = c.elementos.filter((_, j) => !ruins.has(`${i}:${j}`)); });
308
+ conferida = plano.conferir(r.plano, { pasta: p.pasta });
309
+ }
310
+ if (!conferida.ok) {
311
+ return { ok: false, error: 'o roteiro virou um plano com problemas (defeito do PrimoCode, não seu).', problemas: conferida.problemas.slice(0, 12) };
312
+ }
313
+ projeto.salvarPlano(args.id, r.plano, args.motivo || 'montado do roteiro');
314
+ // O roteiro fica guardado: ajustar uma frase é mandar o roteiro de novo, não reescrever tudo.
315
+ try { fs.writeFileSync(path.join(p.pasta, 'roteiro.json'), JSON.stringify(args.roteiro, null, 2)); } catch { /* segue */ }
316
+ projeto.salvar(args.id, { duracao: conferida.duracao });
317
+ return {
318
+ ok: true, duracao: conferida.duracao, ...r.resumo, resumo: plano.resumo(r.plano),
319
+ proximo: 'estudio_render com este id. Para ajustar, mande o roteiro de novo (é rápido) — não edite as cenas à mão.',
320
+ };
321
+ }
322
+
180
323
  // Um plano inteiro, ou só as cenas (que é o caso comum).
181
324
  let novo = args.plano || { formato: args.formato, cenas: args.cenas, audio: args.audio };
182
325
 
@@ -399,9 +542,110 @@ async function ferramentaVideo(args, ctx = {}) {
399
542
  return transcrever(p, alvo, ctx);
400
543
  }
401
544
 
545
+ /* A EDIÇÃO INTEIRA, em código (lib/estudio/edicao.js): corta o silêncio,
546
+ transcreve palavra por palavra e monta o plano — legenda animada,
547
+ ícones na hora da palavra, zoom de corte, pop e whoosh. É o caminho
548
+ para qualquer modelo entregar vídeo de videomaker: ele só chama isto e
549
+ renderiza. "Até uma batata faz vídeos assim." (25/09/2026) */
550
+ if (acao === 'editar' || acao === 'edicao' || acao === 'auto') {
551
+ let fonte = alvo;
552
+ let cortes = 0;
553
+ if (args.cortarSilencio !== false) {
554
+ const saida = path.join(p.pasta, 'midia', `cortado-${Date.now().toString(36)}.mp4`);
555
+ const c = await video.cortarSilencio(alvo, saida, args);
556
+ if (c.ok && !c.semCorte) { fonte = saida; cortes = c.cortes || 0; }
557
+ }
558
+ /* A VOZ NO VOLUME CERTO. A gravação de celular sai baixa (medido em
559
+ 25/09: -37,6 dB de média no vídeo pronto, e "está sem som"). O
560
+ loudnorm leva a fala a -15 LUFS, o volume de vídeo de internet; o
561
+ vídeo em si não é recodificado. */
562
+ const normal = path.join(p.pasta, 'midia', `voz-normal-${Date.now().toString(36)}.mp4`);
563
+ const ln = await video.rodar(video.ffmpeg(), ['-y', '-i', fonte, '-c:v', 'copy',
564
+ '-af', 'loudnorm=I=-15:TP=-1.5:LRA=11', '-c:a', 'aac', '-b:a', '192k', normal], { timeout: 10 * 60000 });
565
+ if (ln.ok) fonte = normal;
566
+
567
+ const m = await video.medir(fonte);
568
+ if (!m.ok) return m;
569
+
570
+ const tr = await transcrever(p, fonte, ctx);
571
+ if (!tr.ok) return tr;
572
+ let palavras = tr.palavras || [];
573
+ // Servidor sem o tempo por palavra: reparte cada trecho pelas palavras
574
+ // dele. Sai menos preciso, mas a legenda continua animada.
575
+ if (!palavras.length) palavras = repartirTrechos(tr.trechos || []);
576
+
577
+ const candidatos = [...new Set(palavras.map((w) => edicao.iconeDe(w.texto)).filter(Boolean))];
578
+ const iconesOk = new Set();
579
+ for (const nome of candidatos) {
580
+ try { const r = await assets.icone(nome); if (r && (r.ok !== false)) iconesOk.add(nome); } catch { /* fica sem */ }
581
+ }
582
+ const sons = await edicao.prepararSons(p.pasta, video);
583
+
584
+ // As imagens de apoio: uma por termo, a primeira que baixar.
585
+ const imagens = {};
586
+ if ((args.estilo || 'viral') !== 'sem-apoio') {
587
+ const orientacao = m.largura >= m.altura ? 'landscape' : 'portrait';
588
+ for (const termo of edicao.termosDeApoio(palavras).slice(0, 8)) {
589
+ imagens[termo] = await baixarApoio(p, termo, { orientacao, video: true });
590
+ if (!imagens[termo]) delete imagens[termo];
591
+ }
592
+ }
593
+
594
+ // A música de fundo: uma trilha longa, de licença aberta.
595
+ let trilha = null;
596
+ if (args.musica !== false) {
597
+ try {
598
+ const b = await assets.procurarMusica(args.trilha || 'upbeat corporate', { quantas: 6, duracaoMin: 45 });
599
+ for (const item of (b.ok ? b.sons : [])) {
600
+ const d = await assets.baixar(item, { pasta: p.pasta, nome: 'trilha' });
601
+ if (d.ok) { trilha = d.relativo; break; }
602
+ }
603
+ } catch { /* segue sem música */ }
604
+ }
605
+
606
+ const base = {
607
+ src: path.relative(p.pasta, fonte).replace(/\\/g, '/'),
608
+ duracao: m.segundos, palavras, largura: m.largura, altura: m.altura,
609
+ estilo: args.estilo || 'viral', sons, iconesOk, imagens, trilha,
610
+ };
611
+ let montado = edicao.montarEdicao(base);
612
+ if (!montado.ok) return montado;
613
+
614
+ /* A segunda passada: nas frases escolhidas, a pessoa recortada na
615
+ frente da palavra gigante. Só os trechos que entram são recortados
616
+ (2 s cada), não o vídeo inteiro — recortar 52 s levaria minutos. */
617
+ if ((args.estilo || 'viral') === 'viral' && args.recorte !== false) {
618
+ const recortes = {};
619
+ for (const [i, info] of montado.infos.entries()) {
620
+ if (!edicao.temRecorte(i) || !info.forte || info.forte.peso < 9 || Object.keys(recortes).length >= 3) continue;
621
+ const de = Math.max(0, info.forte.de - 0.15);
622
+ const ate = Math.min(info.dur, de + 2.2);
623
+ if (ate - de < 1) continue;
624
+ const seg = path.join(p.pasta, 'midia', `seg-${i + 1}-${Date.now().toString(36)}.mp4`);
625
+ const corte = await video.rodar(video.ffmpeg(), ['-y', '-ss', String(info.ini + de), '-t', String(ate - de), '-i', fonte,
626
+ '-vf', `scale=${Math.min(1280, m.largura)}:-2`, '-an', '-c:v', 'libx264', '-preset', 'veryfast', seg], { timeout: 120000 });
627
+ if (!corte.ok) continue;
628
+ const saida = path.join(p.pasta, 'midia', `pessoa-${i + 1}-${Date.now().toString(36)}.webm`);
629
+ const r = await rodarPessoa(['recortar', '--entrada', seg, '--saida', saida], { timeout: 10 * 60000 });
630
+ try { fs.unlinkSync(seg); } catch { /* fica */ }
631
+ if (!r.ok) break; // sem MediaPipe, nenhum recorte vai dar: não tenta os outros
632
+ recortes[i] = { src: path.relative(p.pasta, saida).replace(/\\/g, '/'), de: Math.round(de * 100) / 100, ate: Math.round(ate * 100) / 100 };
633
+ }
634
+ if (Object.keys(recortes).length) montado = edicao.montarEdicao({ ...base, recortes });
635
+ }
636
+ const salvo = await ferramentaPlano({ id: args.id, plano: montado.plano, motivo: `edição automática (${args.estilo || 'viral'})` });
637
+ if (!salvo.ok) return salvo;
638
+ projeto.anotar(p.id, 'edicao', `${montado.resumo.cenas} cenas, ${montado.resumo.icones} ícones, ${cortes} cortes de silêncio`);
639
+ return {
640
+ ok: true, ...montado.resumo, cortesDeSilencio: cortes, duracao: salvo.duracao,
641
+ proximo: 'estudio_render com este id. Antes, se quiser, ajuste uma cena com estudio_plano cena:N '
642
+ + '(ex.: trocar um ícone, pôr uma imagem de apoio atrás).',
643
+ };
644
+ }
645
+
402
646
  if (acao === 'recortar-pessoa' || acao === 'pessoa') {
403
647
  const saida = path.join(p.pasta, 'midia', `pessoa-${Date.now().toString(36)}.webm`);
404
- const r = await rodarPython('pessoa.py', [
648
+ const r = await rodarPessoa([
405
649
  'recortar', '--entrada', alvo, '--saida', saida,
406
650
  ...(args.suavizar != null ? ['--suavizar', String(args.suavizar)] : []),
407
651
  ...(args.encolher != null ? ['--encolher', String(args.encolher)] : []),
@@ -418,6 +662,435 @@ async function ferramentaVideo(args, ctx = {}) {
418
662
  return { ok: false, error: `não conheço a ação "${acao}".` };
419
663
  }
420
664
 
665
+ /* A mídia de apoio de um termo: vídeo do Pexels quando pedido e houver,
666
+ senão a primeira foto que baixar. Devolve {src, tipo} ou null. */
667
+ async function baixarApoio(p, termo, { orientacao = null, video: querVideo = false } = {}) {
668
+ if (querVideo) {
669
+ try {
670
+ const b = await assets.procurarVideos(termo, { quantas: 4, orientacao });
671
+ for (const item of (b.ok ? b.videos : []).filter((v) => !v.duracao || v.duracao >= 3)) {
672
+ const d = await assets.baixar(item, { pasta: p.pasta, nome: 'apoio-' + termo });
673
+ if (d.ok) return { src: d.relativo, tipo: 'video' };
674
+ }
675
+ } catch { /* cai para a foto */ }
676
+ }
677
+ try {
678
+ const b = await assets.procurarImagens(termo, { quantas: 4, orientacao });
679
+ for (const item of (b.ok ? b.imagens : [])) {
680
+ const d = await assets.baixar(item, { pasta: p.pasta, nome: 'apoio-' + termo });
681
+ if (d.ok) return { src: d.relativo, tipo: 'imagem' };
682
+ }
683
+ } catch { /* sem apoio */ }
684
+ return null;
685
+ }
686
+
687
+ /* ── A VOZ DO GEMINI ─────────────────────────────────────────────────────
688
+ * O roteiro inteiro numa chamada só (o plano grátis tem poucos pedidos por
689
+ * minuto), e as partes achadas depois na transcrição do próprio áudio: a
690
+ * primeira palavra de cada parte, procurada em ordem. Sem achar, o tempo é
691
+ * repartido pelo tamanho do texto de cada parte. */
692
+ const VOZ_GEMINI = { thalita: 'Kore', francisca: 'Aoede', antonio: 'Charon', feminina: 'Kore', masculina: 'Charon' };
693
+ const tokens = (t) => String(t || '').normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
694
+
695
+ function alinharPartes(lista, palavras, total) {
696
+ const ws = palavras.map((w) => ({ ...w, k: tokens(w.texto)[0] || '' }));
697
+ const chars = lista.map((x) => x.texto.length);
698
+ const soma = chars.reduce((a, b) => a + b, 0) || 1;
699
+ const inicios = [0];
700
+ let cursor = 0, acum = 0;
701
+ for (let i = 1; i < lista.length; i++) {
702
+ acum += chars[i - 1];
703
+ const esperado = Math.round(ws.length * acum / soma);
704
+ const alvo = tokens(lista[i].texto).find((w) => w.length >= 3) || tokens(lista[i].texto)[0];
705
+ let achado = -1;
706
+ // Procura perto de onde a proporção diz que a parte começa.
707
+ for (let j = Math.max(cursor + 1, esperado - 12); j < Math.min(ws.length, esperado + 12); j++) {
708
+ if (ws[j].k === alvo) { achado = j; break; }
709
+ }
710
+ const idx = achado > cursor ? achado : Math.max(cursor + 1, Math.min(ws.length - 1, esperado));
711
+ inicios.push(idx);
712
+ cursor = idx;
713
+ }
714
+ return lista.map((_, i) => {
715
+ const a = ws[inicios[i]], b = ws[(inicios[i + 1] || ws.length) - 1];
716
+ // As palavras da parte vão junto: o estilo impacto corta no tempo delas.
717
+ const palavras = ws.slice(inicios[i], inicios[i + 1] || ws.length).map(({ de, ate, texto }) => ({ de, ate, texto }));
718
+ return { de: i === 0 ? 0 : (a ? a.de : 0), ate: b ? b.ate : total, palavras };
719
+ });
720
+ }
721
+
722
+ async function narrarComGemini(lista, mp3, { voz, direcao } = {}) {
723
+ try {
724
+ const cfg = require('../config').loadConfig();
725
+ const api = require('../api');
726
+ const r = await api.request(cfg.server, '/api/voz', {
727
+ texto: lista.map((x) => x.texto).join('\n\n'),
728
+ voz: VOZ_GEMINI[String(voz || '').toLowerCase()] || voz || 'Charon',
729
+ ...(direcao ? { direcao } : {}),
730
+ }, cfg.token);
731
+ if (!r || !r.ok) return { ok: false, error: (r && r.erro) || 'sem voz no servidor' };
732
+ const pcm = mp3.replace(/\.mp3$/, '.pcm');
733
+ fs.writeFileSync(pcm, Buffer.from(r.audio, 'base64'));
734
+ const taxa = (String(r.formato).match(/rate=(\d+)/) || [])[1] || '24000';
735
+ const c = await video.rodar(video.ffmpeg(), ['-y', '-f', 's16le', '-ar', taxa, '-ac', '1', '-i', pcm,
736
+ '-af', 'loudnorm=I=-14:TP=-1.5:LRA=9', '-c:a', 'libmp3lame', '-q:a', '3', mp3], { timeout: 120000 });
737
+ try { fs.unlinkSync(pcm); } catch { /* fica */ }
738
+ if (!c.ok) return { ok: false, error: 'não consegui converter a voz' };
739
+ const med = await video.medir(mp3);
740
+ /* A voz CORTADA: o Gemini às vezes devolve metade do roteiro, sem erro
741
+ (medido em 25/09: 27,5 s de áudio para um texto de ~55 s). Fala
742
+ normal passa de 11 caracteres por segundo; menos que isso é corte. */
743
+ const chars = lista.reduce((n, x) => n + x.texto.length, 0);
744
+ if (med.segundos < chars / 22) return { ok: false, error: `a voz veio cortada (${med.segundos.toFixed(1)} s para ${chars} caracteres)` };
745
+ const tr = await api.requestTranscricao(cfg.server, { token: cfg.token, audio: fs.readFileSync(mp3).toString('base64'), nome: 'voz.mp3', idioma: 'pt' });
746
+ const palavras = (tr && tr.palavras) || [];
747
+ if (!palavras.length) return { ok: false, error: 'sem a transcrição da voz para achar as partes' };
748
+ return { ok: true, voz: `gemini ${r.voz}`, segundos: med.segundos, partes: alinharPartes(lista, palavras, med.segundos) };
749
+ } catch (e) {
750
+ return { ok: false, error: e.message };
751
+ }
752
+ }
753
+
754
+ /* Onde estão os rostos em cada foto (studio/rostos.py, pelo uv). Guardado no
755
+ projeto: a mesma foto não é analisada duas vezes. */
756
+ async function acharRostos(p, srcs) {
757
+ const guardado = (p.acervo && p.acervo.rostos) || {};
758
+ const falta = srcs.filter((s0) => !(s0 in guardado));
759
+ if (falta.length) {
760
+ const uv = acharUv();
761
+ const r = await new Promise((resolve) => {
762
+ const args = ['run', '--quiet', '--python', '3.12', '--with', 'mediapipe<0.10.22', '--with', 'opencv-python', '--with', 'numpy', 'python', path.join(PY, 'rostos.py'), ...falta.map((x) => path.join(p.pasta, x))];
763
+ const pr = uv ? spawn(uv, args) : spawn(python() || 'python3', [path.join(PY, 'rostos.py'), ...falta.map((x) => path.join(p.pasta, x))]);
764
+ let out = '';
765
+ pr.stdout.on('data', (d) => { out += d; });
766
+ pr.on('close', () => { try { resolve(JSON.parse(out.trim().split('\n').pop())); } catch { resolve({ ok: false }); } });
767
+ pr.on('error', () => resolve({ ok: false }));
768
+ });
769
+ if (r && r.ok) {
770
+ for (const [cheio, info] of Object.entries(r.fotos || {})) guardado[path.relative(p.pasta, cheio).replace(/\\/g, '/')] = info;
771
+ for (const x of falta) if (!(x in guardado)) guardado[x] = null;
772
+ projeto.salvar(p.id, { acervo: { ...(projeto.ler(p.id).acervo || {}), rostos: guardado } });
773
+ }
774
+ }
775
+ return guardado;
776
+ }
777
+
778
+ /* O DEPOIMENTO cortado na PALAVRA, não no trecho: começa na primeira palavra
779
+ da frase e termina logo depois da última, com fade, a voz normalizada e um
780
+ respiro no fim. "Fica sempre um 'é', um 'e' das pessoas… a música muito
781
+ alta e não dá pra ouvir as pessoas" (25/09). */
782
+ async function prepararDepoimento(p, c) {
783
+ const origem = path.join(p.pasta, c.src);
784
+ if (!fs.existsSync(origem) || c.pronto) return {};
785
+ let de = c.de, ate = c.ate;
786
+ try {
787
+ const cfg = require('../config').loadConfig();
788
+ const amostra = path.join(p.pasta, 'midia', `depo-amostra-${Date.now().toString(36)}.mp3`);
789
+ await video.rodar(video.ffmpeg(), ['-y', '-ss', String(Math.max(0, c.de - 1.5)), '-t', String(c.ate - c.de + 3), '-i', origem, '-ac', '1', '-ar', '16000', '-b:a', '32k', amostra], { timeout: 60000 });
790
+ const tr = await require('../api').requestTranscricao(cfg.server, { token: cfg.token, audio: fs.readFileSync(amostra).toString('base64'), nome: 'd.mp3', idioma: 'pt' });
791
+ try { fs.unlinkSync(amostra); } catch { /* fica */ }
792
+ const base0 = Math.max(0, c.de - 1.5);
793
+ const ws = ((tr && tr.palavras) || []).map((w) => ({ ...w, de: w.de + base0, ate: w.ate + base0 }));
794
+ const alvo = tokens(c.frase || '');
795
+ // A primeira palavra da frase depois do começo pedido, e a última antes do fim pedido.
796
+ const iniW = ws.find((w) => w.de >= c.de - 0.6 && (!alvo.length || alvo.includes(tokens(w.texto)[0])));
797
+ const fimWs = ws.filter((w) => w.ate <= c.ate + 0.8);
798
+ if (iniW) de = Math.max(0, iniW.de - 0.08);
799
+ if (fimWs.length) ate = fimWs[fimWs.length - 1].ate;
800
+ } catch { /* fica o trecho pedido */ }
801
+ const dur = ate - de + 0.7; // o respiro depois da última palavra
802
+ const saida = path.join(p.pasta, 'midia', `depo-${path.basename(c.src, path.extname(c.src))}-${Math.round(de)}.mp4`);
803
+ const r = await video.rodar(video.ffmpeg(), ['-y', '-ss', String(de), '-t', String(dur), '-i', origem,
804
+ '-af', `loudnorm=I=-14:TP=-1.5:LRA=9,afade=t=in:d=0.12,afade=t=out:st=${Math.max(0, dur - 0.55).toFixed(2)}:d=0.55`,
805
+ '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '18', '-c:a', 'aac', '-b:a', '192k', saida], { timeout: 5 * 60000 });
806
+ if (!r.ok) return {};
807
+ return { src: path.relative(p.pasta, saida).replace(/\\/g, '/'), de: 0, ate: Math.round((dur - 0.3) * 100) / 100, pronto: true };
808
+ }
809
+
810
+ /* A narração com cache (a mesma fala, na mesma direção, não é gerada duas vezes). */
811
+ async function narrarComCache(lista, mp3, { voz, direcao } = {}) {
812
+ const chave = require('crypto').createHash('sha1').update(JSON.stringify([lista.map((x) => x.texto), voz || '', direcao || ''])).digest('hex').slice(0, 16);
813
+ const dir = path.join(os.homedir(), '.primocode', 'studio', 'cache-voz');
814
+ const cm = path.join(dir, chave + '.mp3'), cj = path.join(dir, chave + '.json');
815
+ if (fs.existsSync(cm) && fs.existsSync(cj)) { fs.copyFileSync(cm, mp3); return { ok: true, ...JSON.parse(fs.readFileSync(cj, 'utf8')) }; }
816
+ const v = await narrarComGemini(lista, mp3, { voz, direcao });
817
+ if (v.ok) { try { fs.mkdirSync(dir, { recursive: true }); fs.copyFileSync(mp3, cm); fs.writeFileSync(cj, JSON.stringify({ voz: v.voz, segundos: v.segundos, partes: v.partes })); } catch { /* sem cache */ } }
818
+ return v;
819
+ }
820
+
821
+ /* A MÚSICA TEM LETRA? O acervo não diz se a faixa é instrumental, então ela
822
+ é ouvida: 25 s do meio vão para a transcrição, e mais de 6 palavras é voz
823
+ cantada. Sem servidor para ouvir, a faixa passa (melhor música que nenhuma). */
824
+ async function temLetra(arquivo) {
825
+ try {
826
+ const m = await video.medir(arquivo);
827
+ const ini = Math.max(0, (m.segundos || 60) * 0.35);
828
+ const amostra = arquivo.replace(/\.[a-z0-9]+$/i, '') + '.amostra.mp3';
829
+ const c = await video.rodar(video.ffmpeg(), ['-y', '-ss', String(ini), '-t', '25', '-i', arquivo, '-ac', '1', '-ar', '16000', '-b:a', '32k', amostra], { timeout: 60000 });
830
+ if (!c.ok) return false;
831
+ const cfg = require('../config').loadConfig();
832
+ const r = await require('../api').requestTranscricao(cfg.server, { token: cfg.token, audio: fs.readFileSync(amostra).toString('base64'), nome: 'musica.mp3', idioma: 'en' });
833
+ try { fs.unlinkSync(amostra); } catch { /* fica */ }
834
+ const palavras = (r && r.palavras ? r.palavras.length : String((r && r.texto) || '').split(/\s+/).filter(Boolean).length);
835
+ return palavras > 6;
836
+ } catch { return false; }
837
+ }
838
+
839
+ /* OS SONS DE VERDADE: gravação real de acervo aberto (teclado, whoosh
840
+ cinematográfico, impacto grave, som de sala), e o sintetizado só quando a
841
+ busca falha. "Esses efeitos sonoros estão genéricos e sem impacto."
842
+ (25/09/2026) */
843
+ async function sonsReais(p, video) {
844
+ const sint = await edicao.prepararSons(p.pasta, video);
845
+ const quer = {
846
+ teclado: ['keyboard typing', 'typing keyboard'],
847
+ whoosh: ['cinematic whoosh', 'swoosh transition'],
848
+ impacto: ['cinematic boom impact', 'deep cinematic impact', 'trailer hit'],
849
+ sala: ['crowd murmur indoor', 'office ambience', 'people talking background'],
850
+ };
851
+ const sons = { ...sint };
852
+ for (const [nome, termos] of Object.entries(quer)) {
853
+ for (const termo of termos) {
854
+ try {
855
+ const b = await assets.procurarSom(termo, { quantas: 5 });
856
+ const item = (b.ok ? b.sons : []).find((x) => x.url);
857
+ if (!item) continue;
858
+ const d = await assets.baixar(item, { pasta: p.pasta, nome: 'sfx-real-' + nome });
859
+ if (d.ok) { sons[nome] = d.relativo; break; }
860
+ } catch { /* o próximo termo */ }
861
+ }
862
+ }
863
+ return sons;
864
+ }
865
+
866
+ const PROPORCOES = { '16:9': [1920, 1080], '9:16': [1080, 1920], '1:1': [1080, 1080], '4:5': [1080, 1350] };
867
+
868
+ /** O roteiro vira plano: baixa o que falta, narra, e chama o compositor. */
869
+ async function prepararRoteiro(p, args) {
870
+ const roteiro = typeof args.roteiro === 'string' ? JSON.parse(args.roteiro) : args.roteiro;
871
+ if (Array.isArray(roteiro)) return prepararRoteiro(p, { ...args, roteiro: { partes: roteiro } });
872
+ /* O que o modelo manda e o disco não tem vira problema de render: um
873
+ "src" que é endereço da internet vira busca, e um arquivo que não
874
+ existe na pasta sai. A logo da marca vale só se estiver lá. */
875
+ const existe = (rel) => rel && !/^https?:/i.test(rel) && fs.existsSync(path.resolve(p.pasta, rel));
876
+ for (const pt of roteiro.partes || []) {
877
+ if (pt.src && !existe(pt.src)) { if (!pt.busca && /^https?:/i.test(pt.src)) pt.busca = pt.titulo || ''; delete pt.src; }
878
+ }
879
+ if (roteiro.marca) {
880
+ roteiro.marca = { ...(p.marca || {}), ...roteiro.marca };
881
+ if (roteiro.marca.logo && !existe(roteiro.marca.logo)) roteiro.marca.logo = (p.marca && existe(p.marca.logo)) ? p.marca.logo : undefined;
882
+ } else if (p.marca) roteiro.marca = p.marca;
883
+ const partes = roteiro.partes || [];
884
+ /* O ESTILO: "impacto" (anúncio, corte a cada batida) ou "limpo" (uma cena
885
+ por parte). Pedido de anúncio, reels, divulgação ou venda já vem em
886
+ impacto — é o que se espera de uma peça dessas em 2026. */
887
+ const estilo = args.estilo || roteiro.estilo
888
+ || (/an[uú]nci|reels?|divulg|promo|lan[çc]a|vend|campanha|convite|inscri/i.test(`${p.pedido || ''} ${args.pedido || ''}`) ? 'impacto' : 'limpo');
889
+ const modo = (args.modo || roteiro.modo || (p.formato === 'slides' || p.formato === 'arte' ? 'slides' : 'video'));
890
+ const [largura, altura] = PROPORCOES[roteiro.proporcao || args.proporcao] || (modo === 'slides' ? PROPORCOES['16:9'] : PROPORCOES['16:9']);
891
+
892
+ const iconesOk = new Set();
893
+ for (const nome of composicao.iconesDoRoteiro(roteiro)) {
894
+ try { const r = await assets.icone(nome); if (r && r.ok) iconesOk.add(nome); } catch { /* sem */ }
895
+ }
896
+ /* O ACERVO DA MARCA (guardado pelo estudio_site): as fotos reais entram
897
+ nas partes de imagem e de FUNDO nas cenas de texto; os vídeos, nas
898
+ partes de imagem do vídeo. A foto escolhida é a que mais combina, pela
899
+ descrição, com o que a parte diz. Sem acervo, vale a busca (Pexels). */
900
+ const acervo = (p.acervo && (p.acervo.fotos || []).length) ? p.acervo : { fotos: [], videos: [] };
901
+ const usadas = new Set();
902
+ const palavrasDe = (t) => new Set(String(t || '').normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase().split(/[^a-z0-9]+/).filter((w) => w.length >= 4));
903
+ const melhorFoto = (texto) => {
904
+ const quer = palavrasDe(texto);
905
+ let melhor = null, pontos = -1;
906
+ for (const f of acervo.fotos) {
907
+ if (usadas.has(f.src)) continue;
908
+ const n = [...palavrasDe(f.alt)].filter((w) => quer.has(w)).length;
909
+ if (n > pontos) { melhor = f; pontos = n; }
910
+ }
911
+ if (melhor) usadas.add(melhor.src);
912
+ return melhor ? { src: melhor.src, tipo: 'imagem' } : null;
913
+ };
914
+ const videosMarca = [...(acervo.videos || [])];
915
+ const imagens = {}, fundos = {};
916
+ for (const [i, parte] of partes.entries()) {
917
+ if (parte.tipo !== 'imagem' || parte.src) continue;
918
+ const texto = `${parte.titulo || ''} ${parte.sub || ''} ${parte.fala || ''} ${parte.busca || ''}`;
919
+ if (modo === 'video' && videosMarca.length && i % 2 === 1) { imagens[i] = { src: videosMarca.shift().src, tipo: 'video' }; continue; }
920
+ const m = melhorFoto(texto) || (parte.busca ? await baixarApoio(p, parte.busca, { orientacao: largura >= altura ? 'landscape' : 'portrait', video: modo === 'video' }) : null);
921
+ if (m) imagens[i] = m;
922
+ }
923
+ if (acervo.fotos.length) {
924
+ const comFundo = ['abertura', 'titulo', 'contraste', 'citacao', 'cta', 'passos'];
925
+ let alterna = 0;
926
+ for (const [i, parte] of partes.entries()) {
927
+ if (!comFundo.includes(parte.tipo)) continue;
928
+ if (i !== 0 && alterna++ % 2 === 1) continue; // uma sim, uma não: o respiro também é ritmo
929
+ const m = melhorFoto(`${parte.titulo || ''} ${parte.sim || ''} ${parte.fala || ''}`);
930
+ if (m) fundos[i] = m;
931
+ }
932
+ }
933
+
934
+ let tempos = [], narracao = null, sons = {}, trilha = null;
935
+ if (modo === 'video' && estilo === 'cinema') {
936
+ // Só as partes com voz vão para a narração; o depoimento tem a voz do aluno.
937
+ const lista = partes.filter((pt) => pt.tipo !== 'depoimento').map((pt) => ({ texto: cinema.falaDe(pt), registro: 'normal' }));
938
+ const mp3 = path.join(p.pasta, 'midia', `narracao-${Date.now().toString(36)}.mp3`);
939
+ /* O CACHE DA VOZ: a mesma fala, na mesma voz, não é gerada duas vezes.
940
+ Refazer o roteiro em outro formato, ou mexer numa cena, não gasta a
941
+ cota do Gemini — que acaba no meio do dia (medido em 25/09). */
942
+ const chaveVoz = require('crypto').createHash('sha1').update(JSON.stringify([lista.map((x) => x.texto), args.voz || roteiro.voz || '', 'cinema-v1'])).digest('hex').slice(0, 16);
943
+ const cacheDir = path.join(os.homedir(), '.primocode', 'studio', 'cache-voz');
944
+ const cacheMp3 = path.join(cacheDir, chaveVoz + '.mp3'), cacheJson = path.join(cacheDir, chaveVoz + '.json');
945
+ let v = null;
946
+ if (fs.existsSync(cacheMp3) && fs.existsSync(cacheJson)) {
947
+ fs.copyFileSync(cacheMp3, mp3);
948
+ v = { ok: true, ...JSON.parse(fs.readFileSync(cacheJson, 'utf8')), doCache: true };
949
+ } else {
950
+ v = await narrarComGemini(lista, mp3, { voz: args.voz || roteiro.voz,
951
+ direcao: 'Leia em português do Brasil como narrador de trailer de marca premium: voz grave, confiante e humana, '
952
+ + 'ritmo firme, pausas curtas entre as frases, sem arrastar.' });
953
+ if (v.ok) {
954
+ 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 */ }
955
+ }
956
+ }
957
+ if (!v.ok) {
958
+ const arq = path.join(p.pasta, 'midia', `roteiro-${Date.now().toString(36)}.json`);
959
+ // Ritmo NORMAL: o "citação" (-10%, respiro longo) esticou o vídeo para 125 s.
960
+ fs.writeFileSync(arq, JSON.stringify(lista.map((x) => ({ ...x, registro: 'normal' }))));
961
+ v = await rodarPython('narrar.py', ['roteiro', '--arquivo', arq, '--saida', mp3, '--voz', String(args.voz || roteiro.voz || 'antonio')], { timeout: 10 * 60000 });
962
+ }
963
+ if (!v.ok) return v;
964
+ /* A DURAÇÃO que o roteiro pede ("60–75 s"): se a voz passa do que cabe,
965
+ ela acelera de leve — até 25%, sem mudar o tom (atempo) — e os tempos
966
+ de cada parte acompanham. Medido em 25/09: 98 s de voz num vídeo de 70. */
967
+ const alvo = Number(roteiro.duracao || args.segundos) || 0;
968
+ if (alvo) {
969
+ const depo = partes.filter((pt) => pt.tipo === 'depoimento').reduce((n, pt) => n + (pt.clipes || []).reduce((m, c) => m + (c.ate - c.de + 0.3), 0), 0);
970
+ const cabe = Math.max(15, alvo - depo - 0.7 * lista.length);
971
+ if (v.segundos > cabe * 1.05) {
972
+ const k = Math.min(1.25, v.segundos / cabe);
973
+ const rapido = mp3.replace(/\.mp3$/, '-r.mp3');
974
+ const c = await video.rodar(video.ffmpeg(), ['-y', '-i', mp3, '-filter:a', `atempo=${k.toFixed(3)}`, rapido], { timeout: 120000 });
975
+ if (c.ok) {
976
+ fs.renameSync(rapido, mp3);
977
+ const div = (x) => Math.round((x / k) * 1000) / 1000;
978
+ v.partes = v.partes.map((pt) => ({ de: div(pt.de), ate: div(pt.ate), palavras: (pt.palavras || []).map((w) => ({ ...w, de: div(w.de), ate: div(w.ate) })) }));
979
+ v.segundos = div(v.segundos);
980
+ }
981
+ }
982
+ }
983
+ projeto.anotar(p.id, 'narracao', `${v.segundos}s em ${lista.length} partes, voz ${v.voz}`);
984
+ const sonsC = await sonsReais(p, video);
985
+ let trilhaC = null;
986
+ // Vários termos e durações: vídeo sem música foi o que saiu na primeira vez (25/09).
987
+ // E só instrumental: "essa música daria pra deixar só o instrumental?" — a faixa é OUVIDA.
988
+ for (const [termo, min] of [[roteiro.musica, 60], ['instrumental cinematic inspiring', 70], ['instrumental piano cinematic', 60], ['instrumental epic cinematic', 60], ['instrumental ambient inspiring', 45], ['instrumental', 45]]) {
989
+ if (!termo || trilhaC) continue;
990
+ try {
991
+ const b = await assets.procurarMusica(termo, { quantas: 6, duracaoMin: min });
992
+ for (const item of (b.ok ? b.sons : [])) {
993
+ const d = await assets.baixar(item, { pasta: p.pasta, nome: 'trilha' });
994
+ if (!d.ok) continue;
995
+ if (await temLetra(path.join(p.pasta, d.relativo))) { projeto.anotar(p.id, 'midia', `trilha com voz descartada: ${item.titulo}`); continue; }
996
+ trilhaC = d.relativo; break;
997
+ }
998
+ } catch { /* o próximo termo */ }
999
+ }
1000
+ /* AS FOTOS, separadas e enquadradas. "Uma sala, 20 cadeiras" com foto de
1001
+ dashboard "não tem nada a ver" (25/09): foto de gente e print de tela
1002
+ são coisas diferentes, e a miniatura de depoimento fica de fora. E
1003
+ onde estão os rostos, para o recorte vertical não cortar ninguém. */
1004
+ const TELA = /dashboard|pauta|kpi|tend[eê]ncias|landing|review|tela|screen|painel|relat[oó]rio/i;
1005
+ const MINI = /poster|face \d+|\bthumb/i;
1006
+ const fotosC = acervo.fotos.filter((f) => !MINI.test(f.alt || ''))
1007
+ .map((f) => ({ src: f.src, tipo: 'imagem', cat: TELA.test(f.alt || '') ? 'tela' : 'pessoas', alt: f.alt }));
1008
+ const rostos = await acharRostos(p, fotosC.map((f) => f.src));
1009
+ for (const f of fotosC) if (rostos[f.src]) f.rosto = rostos[f.src];
1010
+ // Os depoimentos: cortados na palavra, com a voz no volume certo e fade.
1011
+ for (const pt of partes) {
1012
+ if (pt.tipo !== 'depoimento') continue;
1013
+ for (const c of pt.clipes || []) Object.assign(c, await prepararDepoimento(p, c));
1014
+ }
1015
+ // A voz FORTE: a parte com "falaForte" é narrada à parte, com a direção dela.
1016
+ const fortes = {};
1017
+ let iNarr = 0;
1018
+ for (const [i, pt] of partes.entries()) {
1019
+ if (pt.tipo === 'depoimento') continue;
1020
+ if (pt.falaForte) {
1021
+ const arqF = path.join(p.pasta, 'midia', `voz-forte-${i + 1}-${Date.now().toString(36)}.mp3`);
1022
+ const vf = await narrarComCache([{ texto: pt.falaForte }], arqF, { voz: args.voz || roteiro.voz,
1023
+ direcao: pt.entonacao || 'Leia em português do Brasil como a VIRADA de um trailer: voz grave, intensa e muito firme, '
1024
+ + 'com peso em cada palavra, pausa dramática no meio, sem gritar.' });
1025
+ if (vf.ok) fortes[iNarr] = { src: path.relative(p.pasta, arqF).replace(/\\/g, '/'), segundos: vf.segundos, palavras: (vf.partes[0] || {}).palavras || [] };
1026
+ }
1027
+ iNarr++;
1028
+ }
1029
+ const r = cinema.montarCinema({ roteiro, largura, altura, tempos: v.partes, narracao: path.relative(p.pasta, mp3).replace(/\\/g, '/'),
1030
+ sons: sonsC, trilha: trilhaC, fotos: fotosC, fortes });
1031
+ if (r.ok) projeto.anotar(p.id, 'plano', `estilo cinema: ${r.resumo.cenas} cenas, ${r.resumo.segundos}s`);
1032
+ return r;
1033
+ }
1034
+ if (modo === 'video') {
1035
+ const lista = partes.map((pt, i) => ({ texto: composicao.falaDe(pt), registro: composicao.registroDe(pt, i, partes.length) }));
1036
+ const arq = path.join(p.pasta, 'midia', `roteiro-${Date.now().toString(36)}.json`);
1037
+ fs.mkdirSync(path.dirname(arq), { recursive: true });
1038
+ fs.writeFileSync(arq, JSON.stringify(lista));
1039
+ const mp3 = path.join(p.pasta, 'midia', `narracao-${Date.now().toString(36)}.mp3`);
1040
+ // A voz de gente primeiro (Gemini, pelo servidor); o Edge é a reserva.
1041
+ let v = await narrarComGemini(lista, mp3, { voz: args.voz || roteiro.voz,
1042
+ direcao: estilo === 'impacto' ? 'Leia em português do Brasil como locutor de comercial agressivo e vendedor: energia alta, ritmo rápido, '
1043
+ + 'voz encorpada, ênfase forte nas palavras de impacto, pausas curtas e secas entre as frases, sorrindo na voz.' : undefined });
1044
+ if (!v.ok) v = await rodarPython('narrar.py', ['roteiro', '--arquivo', arq, '--saida', mp3, '--voz', String(args.voz || roteiro.voz || 'thalita')], { timeout: 10 * 60000 });
1045
+ if (!v.ok) return v;
1046
+ tempos = v.partes;
1047
+ narracao = path.relative(p.pasta, mp3).replace(/\\/g, '/');
1048
+ projeto.anotar(p.id, 'narracao', `${v.segundos}s em ${partes.length} partes, voz ${v.voz}`);
1049
+ sons = await edicao.prepararSons(p.pasta, video);
1050
+ try {
1051
+ const b = await assets.procurarMusica(roteiro.musica || args.trilha || (estilo === 'impacto' ? 'epic energetic' : 'inspiring corporate'), { quantas: 6, duracaoMin: 45 });
1052
+ for (const item of (b.ok ? b.sons : [])) {
1053
+ const d = await assets.baixar(item, { pasta: p.pasta, nome: 'trilha' });
1054
+ if (d.ok) { trilha = d.relativo; break; }
1055
+ }
1056
+ } catch { /* sem música */ }
1057
+ }
1058
+ if (estilo === 'impacto' && modo === 'video') {
1059
+ // Os emojis das palavras FALADAS também (a batida mostra a fala).
1060
+ const faladas = tempos.flatMap((t) => (t.palavras || []).map((w) => w.texto));
1061
+ for (const nome of new Set(faladas.map((w) => edicao.iconeDe(w)).filter(Boolean))) {
1062
+ if (iconesOk.has(nome)) continue;
1063
+ try { const r = await assets.icone(nome); if (r && r.ok) iconesOk.add(nome); } catch { /* sem */ }
1064
+ }
1065
+ const fotos = [
1066
+ ...acervo.fotos.map((f) => ({ src: f.src, tipo: 'imagem' })),
1067
+ ...(acervo.videos || []).map((v) => ({ src: v.src, tipo: 'video' })),
1068
+ ...Object.values(imagens),
1069
+ ];
1070
+ const r = impacto.montarImpacto({ roteiro, largura, altura, tempos, narracao, sons, trilha, fotos, iconesOk });
1071
+ if (r.ok) projeto.anotar(p.id, 'plano', `estilo impacto: ${r.resumo.cenas} batidas`);
1072
+ return r;
1073
+ }
1074
+ return composicao.comporRoteiro({ roteiro, modo, largura, altura, tempos, narracao, sons, trilha, imagens, iconesOk, fundos });
1075
+ }
1076
+
1077
+ /* Sem o tempo de cada palavra, cada trecho é repartido pelas palavras dele,
1078
+ proporcional ao tamanho de cada uma. */
1079
+ function repartirTrechos(trechos) {
1080
+ const out = [];
1081
+ for (const t of trechos) {
1082
+ const ws = String(t.texto || '').split(/\s+/).filter(Boolean);
1083
+ const letras = ws.reduce((n, w) => n + w.length, 0) || 1;
1084
+ let cur = t.de;
1085
+ for (const w of ws) {
1086
+ const d = (t.ate - t.de) * (w.length / letras);
1087
+ out.push({ de: cur, ate: cur + d, texto: w });
1088
+ cur += d;
1089
+ }
1090
+ }
1091
+ return out;
1092
+ }
1093
+
421
1094
  /**
422
1095
  * A transcrição com tempo — é ela que dá legenda sincronizada.
423
1096
  *
@@ -448,7 +1121,7 @@ async function transcrever(p, alvo, ctx) {
448
1121
  if (!r || r.ok === false) return { ok: false, error: (r && r.erro) || 'a transcrição falhou.' };
449
1122
  projeto.anotar(p.id, 'transcricao', `${(r.trechos || []).length} trechos`);
450
1123
  return {
451
- ok: true, texto: r.texto, trechos: r.trechos,
1124
+ ok: true, texto: r.texto, trechos: r.trechos, palavras: r.palavras || [],
452
1125
  comoUsar: 'cada trecho vira uma fala da legenda: {"de":…, "ate":…, "texto":…}. '
453
1126
  + 'O estilo da legenda (fonte, cor, peso, posição, animação) é escolha sua — não há padrão.',
454
1127
  };
@@ -518,6 +1191,17 @@ async function ferramentaRender(args, ctx = {}) {
518
1191
  });
519
1192
  if (!r.ok) return { ...r, faltaram };
520
1193
 
1194
+ /* O VOLUME FINAL: a mistura (voz, música, efeitos, depoimento) sai no
1195
+ padrão de Reels e YouTube, -14 LUFS. Medido em 25/09: o vídeo pronto
1196
+ saía com -23,6 dB de média. A imagem não é recodificada. */
1197
+ if (pl.audio && fs.existsSync(saida)) {
1198
+ const tmp = saida.replace(/\.mp4$/, '.norm.mp4');
1199
+ const ln = await video.rodar(video.ffmpeg(), ['-y', '-i', saida, '-c:v', 'copy', '-af', 'loudnorm=I=-14:TP=-1.2:LRA=10',
1200
+ '-c:a', 'aac', '-b:a', '256k', tmp], { timeout: 15 * 60000 });
1201
+ if (ln.ok && fs.existsSync(tmp)) fs.renameSync(tmp, saida);
1202
+ else { try { fs.unlinkSync(tmp); } catch { /* não havia */ } }
1203
+ }
1204
+
521
1205
  const bytes = fs.existsSync(saida) ? fs.statSync(saida).size : 0;
522
1206
  projeto.salvar(args.id, { renders: [...(p.renders || []), { quando: new Date().toISOString(), arquivo: saida, bytes }] });
523
1207
  projeto.anotar(args.id, 'render', `${(bytes / 1048576).toFixed(1)} MB, ${conferida.duracao}s`);
@@ -535,6 +1219,36 @@ async function ferramentaRender(args, ctx = {}) {
535
1219
 
536
1220
  // ── estudio_site ─────────────────────────────────────────────────────────
537
1221
 
1222
+ /* A PALETA INTEIRA do site, não só a primeira cor. O fundo é o neutro
1223
+ escuro que o site mais usa (#121218 na Paris Group); o destaque é a cor da
1224
+ marca que mais aparece sobre ele (o laranja, não o azul escuro que some); o
1225
+ apoio é a seguinte. A fonte serifada, quando existe, vai para citação. */
1226
+ function paletaDoSite(r) {
1227
+ const hexRgb = (h) => { const n = parseInt(String(h).slice(1).padEnd(6, '0').slice(0, 6), 16); return [n >> 16, (n >> 8) & 255, n & 255]; };
1228
+ const luz = (h) => { const [a, b, c] = hexRgb(h); return (0.299 * a + 0.587 * b + 0.114 * c) / 255; };
1229
+ const sat = (h) => { const v = hexRgb(h); return (Math.max(...v) - Math.min(...v)) / 255; };
1230
+ const neutras = ((r.cores && (r.cores.neutras || r.cores.fundo)) || []).map((x) => x.cor || x).filter((c) => /^#/.test(c));
1231
+ const fundo = neutras.find((c) => luz(c) < 0.15 && luz(c) > 0.02) || neutras.find((c) => luz(c) < 0.2) || undefined;
1232
+ const base = fundo || '#121218';
1233
+ const marca = ((r.cores && r.cores.marca) || []).map((x) => x.cor).filter(Boolean);
1234
+ const nota = (c) => sat(c) * 1.2 + Math.abs(luz(c) - luz(base));
1235
+ const ordenadas = [...marca].sort((a, b) => nota(b) - nota(a));
1236
+ const destaque = ordenadas[0];
1237
+ // O apoio é OUTRA cor, de outro matiz: dois laranjas não fazem paleta.
1238
+ const matiz = (h) => { const [a, b, c] = hexRgb(h).map((v) => v / 255); const mx = Math.max(a, b, c), mn = Math.min(a, b, c), d = mx - mn;
1239
+ if (!d) return 0; const x = mx === a ? ((b - c) / d) % 6 : mx === b ? (c - a) / d + 2 : (a - b) / d + 4; return (x * 60 + 360) % 360; };
1240
+ const longe = (a, b) => { const d = Math.abs(matiz(a) - matiz(b)); return Math.min(d, 360 - d) > 60; };
1241
+ const apoio = ordenadas.find((c) => c !== destaque && longe(c, destaque) && sat(c) > 0.3) || ordenadas[1];
1242
+ const fontes = ((r.fontes && r.fontes.declaradas) || []).map((x) => x.fonte).filter((f) => !/mono|menlo|courier|system|arial|helvetica/i.test(f));
1243
+ return {
1244
+ cores: { ...(fundo ? { fundo } : {}), ...(destaque ? { destaque } : {}), ...(apoio ? { apoio } : {}) },
1245
+ fontes: {
1246
+ ...(fontes[0] ? { titulo: fontes.find((f) => !/serif/i.test(f) || /sans/i.test(f)) || fontes[0], texto: fontes.find((f) => !/serif/i.test(f) || /sans/i.test(f)) || fontes[0] } : {}),
1247
+ ...(fontes.find((f) => /serif/i.test(f) && !/sans/i.test(f)) ? { serifa: fontes.find((f) => /serif/i.test(f) && !/sans/i.test(f)) } : {}),
1248
+ },
1249
+ };
1250
+ }
1251
+
538
1252
  async function ferramentaSite(args) {
539
1253
  let alvo = String(args.url || args.site || '').trim();
540
1254
 
@@ -564,6 +1278,10 @@ async function ferramentaSite(args) {
564
1278
  uma pasta de projeto, é lixo que ninguém recolhe. */
565
1279
  const html = r.html;
566
1280
  delete r.html; // o HTML cru não vai para o modelo
1281
+ if (r.ok) {
1282
+ const marcaLida = paletaDoSite(r);
1283
+ ultimoSite = { url: r.url, html, quando: Date.now(), marca: marcaLida };
1284
+ }
567
1285
  if (r.ok && args.id && args.logo !== false) {
568
1286
  const p = projeto.ler(args.id);
569
1287
  if (p) {
@@ -580,11 +1298,57 @@ async function ferramentaSite(args) {
580
1298
  if (r.ok && args.id) {
581
1299
  projeto.anotar(args.id, 'referencia', `leu ${r.url}: ${(r.cores.marca[0] || {}).cor || 'sem cor'} · ${(r.fontes.declaradas[0] || {}).fonte || 'sem fonte'}`);
582
1300
  }
1301
+
1302
+ /* O ACERVO DA MARCA: as fotos e os vídeos reais do site, e o texto das
1303
+ páginas que combinam com o pedido (lib/estudio/acervo-site.js). As
1304
+ fotos ficam guardadas no projeto e o roteiro as usa sozinho; os fatos
1305
+ voltam para o modelo escrever o roteiro com dado de verdade. */
1306
+ if (r.ok && args.id && args.acervo !== false) {
1307
+ const p = projeto.ler(args.id);
1308
+ if (p) {
1309
+ try {
1310
+ const a = await acervoSite.colher({
1311
+ url: r.url, pedido: args.pedido || p.pedido || '', pasta: p.pasta, htmlInicial: html,
1312
+ buscar: identidade.buscar, baixar: assets.baixar,
1313
+ });
1314
+ const marca = { ...ultimoSite.marca, ...(r.logo && r.logo.ok ? { logo: r.logo.src } : {}) };
1315
+ /* O GRADIENTE da marca sai da própria logo, quando ela é SVG com
1316
+ gradiente (a da Paris Group vai de #2E81ED a #9567DB). */
1317
+ try {
1318
+ if (marca.logo && /\.svg$/i.test(marca.logo)) {
1319
+ const svg = fs.readFileSync(path.join(p.pasta, marca.logo), 'utf8');
1320
+ const stops = [...new Set((svg.match(/stop-color=["'](#[0-9a-f]{3,6})["']/gi) || []).map((x) => x.match(/#[0-9a-f]{3,6}/i)[0].toUpperCase()))];
1321
+ if (stops.length >= 2) {
1322
+ marca.gradiente = stops.slice(0, 2);
1323
+ // A logo manda na identidade: o destaque é a cor dela, não a do CSS.
1324
+ marca.cores = { ...(marca.cores || {}), destaque: stops[1], apoio: stops[0] };
1325
+ }
1326
+ }
1327
+ } catch { /* sem gradiente */ }
1328
+ projeto.salvar(args.id, { acervo: { fotos: a.fotos, videos: a.videos, fatos: a.fatos, site: r.url }, marca });
1329
+ projeto.anotar(args.id, 'midia', `acervo do site: ${a.fotos.length} fotos, ${a.videos.length} vídeos, ${a.paginas.length} páginas lidas`);
1330
+ let orcamento = 4200;
1331
+ r.fatos = a.fatos.map((f) => {
1332
+ const texto = f.texto.slice(0, Math.max(0, Math.min(1400, orcamento)));
1333
+ orcamento -= texto.length;
1334
+ return { pagina: f.pagina, texto };
1335
+ }).filter((f) => f.texto);
1336
+ r.acervo = { fotos: a.fotos.length, videos: a.videos.length,
1337
+ exemplos: a.fotos.slice(0, 6).map((f) => f.alt).filter(Boolean) };
1338
+ r.comoUsar = 'Escreva o roteiro com os FATOS acima (datas, local, números, frases da marca) — nada '
1339
+ + 'inventado. As fotos e os vídeos reais da marca entram SOZINHOS no vídeo: não precisa pôr '
1340
+ + '"busca" nas partes de imagem (use tipo "imagem" com só o título). Use as cores, a fonte e '
1341
+ + 'a logo em roteiro.marca.';
1342
+ } catch (e) {
1343
+ r.acervo = { erro: e.message };
1344
+ }
1345
+ }
1346
+ }
583
1347
  return r;
584
1348
  }
585
1349
 
586
1350
  module.exports = {
587
1351
  ferramentaProjeto, ferramentaPlano, ferramentaMidia, ferramentaNarrar,
588
1352
  ferramentaVideo, ferramentaRender, ferramentaSite,
589
- projeto, plano, remotion, assets, video, identidade, slides, python, rodarPython,
1353
+ projeto, plano, remotion, assets, video, identidade, slides, python, rodarPython, alinharPartes,
590
1354
  };