primocode 9.8.0-beta.9 → 9.8.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.
@@ -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,520 @@ 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
+ /* As DUAS primeiras palavras da parte, seguidas: achar só pela primeira
705
+ palavra longa deixava o "E" de "E sair com algo" no fim da fala
706
+ anterior, e ele soava duas vezes (medido em 25/09, aos 38 s). */
707
+ const tk = tokens(lista[i].texto);
708
+ let achado = -1;
709
+ for (let j = Math.max(cursor + 1, esperado - 14); j < Math.min(ws.length, esperado + 14); j++) {
710
+ if (ws[j].k === tk[0] && (!tk[1] || (ws[j + 1] && ws[j + 1].k === tk[1]))) { achado = j; break; }
711
+ }
712
+ if (achado < 0) {
713
+ const alvo = tk.find((w) => w.length >= 3) || tk[0];
714
+ for (let j = Math.max(cursor + 1, esperado - 12); j < Math.min(ws.length, esperado + 12); j++) {
715
+ if (ws[j].k === alvo) { achado = j; break; }
716
+ }
717
+ // A palavra curta que vem antes da achada ("e", "a", "o") é desta parte, não da anterior.
718
+ while (achado > cursor + 1 && ws[achado - 1].k.length <= 2 && tk.slice(0, 3).includes(ws[achado - 1].k)) achado--;
719
+ }
720
+ const idx = achado > cursor ? achado : Math.max(cursor + 1, Math.min(ws.length - 1, esperado));
721
+ inicios.push(idx);
722
+ cursor = idx;
723
+ }
724
+ return lista.map((_, i) => {
725
+ const a = ws[inicios[i]], b = ws[(inicios[i + 1] || ws.length) - 1];
726
+ // As palavras da parte vão junto: o estilo impacto corta no tempo delas.
727
+ const palavras = ws.slice(inicios[i], inicios[i + 1] || ws.length).map(({ de, ate, texto }) => ({ de, ate, texto }));
728
+ return { de: i === 0 ? 0 : (a ? a.de : 0), ate: b ? b.ate : total, palavras };
729
+ });
730
+ }
731
+
732
+ async function narrarComGemini(lista, mp3, { voz, direcao } = {}) {
733
+ try {
734
+ const cfg = require('../config').loadConfig();
735
+ const api = require('../api');
736
+ /* A cota grátis da voz é por MINUTO e pequena: "quota" é esperar e
737
+ tentar de novo, não desistir para a voz de reserva (medido em 25/09:
738
+ a mesma chamada passava meio minuto depois). */
739
+ let r = null;
740
+ for (let tentativa = 0; tentativa < 4; tentativa++) {
741
+ try {
742
+ r = await api.request(cfg.server, '/api/voz', {
743
+ texto: lista.map((x) => x.texto).join('\n\n'),
744
+ voz: VOZ_GEMINI[String(voz || '').toLowerCase()] || voz || 'Charon',
745
+ ...(direcao ? { direcao } : {}),
746
+ }, cfg.token);
747
+ } catch (e) { r = { ok: false, erro: e.message }; }
748
+ if (r && r.ok) break;
749
+ if (!/quota|rate|429|exhaust/i.test(String(r && r.erro))) break;
750
+ await new Promise((ok) => setTimeout(ok, 30000));
751
+ }
752
+ if (!r || !r.ok) return { ok: false, error: (r && r.erro) || 'sem voz no servidor' };
753
+ const pcm = mp3.replace(/\.mp3$/, '.pcm');
754
+ fs.writeFileSync(pcm, Buffer.from(r.audio, 'base64'));
755
+ const taxa = (String(r.formato).match(/rate=(\d+)/) || [])[1] || '24000';
756
+ const c = await video.rodar(video.ffmpeg(), ['-y', '-f', 's16le', '-ar', taxa, '-ac', '1', '-i', pcm,
757
+ '-af', 'loudnorm=I=-14:TP=-1.5:LRA=9', '-c:a', 'libmp3lame', '-q:a', '3', mp3], { timeout: 120000 });
758
+ try { fs.unlinkSync(pcm); } catch { /* fica */ }
759
+ if (!c.ok) return { ok: false, error: 'não consegui converter a voz' };
760
+ const med = await video.medir(mp3);
761
+ /* A voz CORTADA: o Gemini às vezes devolve metade do roteiro, sem erro
762
+ (medido em 25/09: 27,5 s de áudio para um texto de ~55 s). Fala
763
+ normal passa de 11 caracteres por segundo; menos que isso é corte. */
764
+ const chars = lista.reduce((n, x) => n + x.texto.length, 0);
765
+ if (med.segundos < chars / 22) return { ok: false, error: `a voz veio cortada (${med.segundos.toFixed(1)} s para ${chars} caracteres)` };
766
+ const tr = await api.requestTranscricao(cfg.server, { token: cfg.token, audio: fs.readFileSync(mp3).toString('base64'), nome: 'voz.mp3', idioma: 'pt' });
767
+ const palavras = (tr && tr.palavras) || [];
768
+ if (!palavras.length) return { ok: false, error: 'sem a transcrição da voz para achar as partes' };
769
+ return { ok: true, voz: `gemini ${r.voz}`, segundos: med.segundos, partes: alinharPartes(lista, palavras, med.segundos) };
770
+ } catch (e) {
771
+ return { ok: false, error: e.message };
772
+ }
773
+ }
774
+
775
+ /* Onde estão os rostos em cada foto (studio/rostos.py, pelo uv). Guardado no
776
+ projeto: a mesma foto não é analisada duas vezes. */
777
+ async function acharRostos(p, srcs) {
778
+ const guardado = (p.acervo && p.acervo.rostos) || {};
779
+ const falta = srcs.filter((s0) => !(s0 in guardado));
780
+ if (falta.length) {
781
+ const uv = acharUv();
782
+ const r = await new Promise((resolve) => {
783
+ 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))];
784
+ const pr = uv ? spawn(uv, args) : spawn(python() || 'python3', [path.join(PY, 'rostos.py'), ...falta.map((x) => path.join(p.pasta, x))]);
785
+ let out = '';
786
+ pr.stdout.on('data', (d) => { out += d; });
787
+ pr.on('close', () => { try { resolve(JSON.parse(out.trim().split('\n').pop())); } catch { resolve({ ok: false }); } });
788
+ pr.on('error', () => resolve({ ok: false }));
789
+ });
790
+ if (r && r.ok) {
791
+ for (const [cheio, info] of Object.entries(r.fotos || {})) guardado[path.relative(p.pasta, cheio).replace(/\\/g, '/')] = info;
792
+ for (const x of falta) if (!(x in guardado)) guardado[x] = null;
793
+ projeto.salvar(p.id, { acervo: { ...(projeto.ler(p.id).acervo || {}), rostos: guardado } });
794
+ }
795
+ }
796
+ return guardado;
797
+ }
798
+
799
+ /* O DEPOIMENTO cortado na PALAVRA, não no trecho: começa na primeira palavra
800
+ da frase e termina logo depois da última, com fade, a voz normalizada e um
801
+ respiro no fim. "Fica sempre um 'é', um 'e' das pessoas… a música muito
802
+ alta e não dá pra ouvir as pessoas" (25/09). */
803
+ async function prepararDepoimento(p, c) {
804
+ const origem = path.join(p.pasta, c.src);
805
+ if (!fs.existsSync(origem) || c.pronto) return {};
806
+ let de = c.de, ate = c.ate;
807
+ try {
808
+ const cfg = require('../config').loadConfig();
809
+ const amostra = path.join(p.pasta, 'midia', `depo-amostra-${Date.now().toString(36)}.mp3`);
810
+ 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 });
811
+ const tr = await require('../api').requestTranscricao(cfg.server, { token: cfg.token, audio: fs.readFileSync(amostra).toString('base64'), nome: 'd.mp3', idioma: 'pt' });
812
+ try { fs.unlinkSync(amostra); } catch { /* fica */ }
813
+ const base0 = Math.max(0, c.de - 1.5);
814
+ const ws = ((tr && tr.palavras) || []).map((w) => ({ ...w, de: w.de + base0, ate: w.ate + base0 }));
815
+ /* Só a FRASE de efeito: começa na primeira palavra dela e termina na
816
+ última. "Fala a frase que tem que falar e continua antes de trocar"
817
+ (25/09): o resto da conversa não tem nada a ver com o vídeo. */
818
+ const alvo = tokens(c.frase || '');
819
+ const k0 = (w) => tokens(w.texto)[0] || '';
820
+ const iniW = ws.find((w) => w.de >= c.de - 1.2 && alvo.length && k0(w) === alvo[0])
821
+ || ws.find((w) => w.de >= c.de - 0.6 && alvo.includes(k0(w)));
822
+ const ultimo = alvo[alvo.length - 1];
823
+ const fimW = [...ws].reverse().find((w) => w.ate <= c.ate + 1.5 && (!iniW || w.de > iniW.de) && k0(w) === ultimo);
824
+ if (iniW) de = Math.max(0, iniW.de - 0.08);
825
+ if (fimW) ate = fimW.ate;
826
+ else { const fimWs = ws.filter((w) => w.ate <= c.ate + 0.3); if (fimWs.length) ate = fimWs[fimWs.length - 1].ate; }
827
+ // A frase na tela é o que a pessoa FALA no trecho — palavra por palavra.
828
+ const ditas = ws.filter((w) => w.de >= de - 0.05 && w.ate <= ate + 0.05).map((w) => w.texto).join(' ').trim();
829
+ /* Se a frase do roteiro bate com o que foi dito (a transcrição erra
830
+ detalhe: "eu conseguir" no lugar de "eu consegui"), fica a do roteiro. */
831
+ const tA = tokens(c.frase || ''), tD = tokens(ditas);
832
+ const bate = tA.length && tA.filter((w) => tD.includes(w)).length / tA.length >= 0.7;
833
+ if (!bate && ditas.split(/\s+/).length >= 3) c.fraseDita = ditas.replace(/^./, (x) => x.toUpperCase()).replace(/[,;]$/, '.');
834
+ } catch { /* fica o trecho pedido */ }
835
+ const dur = ate - de + 0.45; // um respiro curto depois da última palavra
836
+ const saida = path.join(p.pasta, 'midia', `depo-${path.basename(c.src, path.extname(c.src))}-${Math.round(de)}.mp4`);
837
+ const r = await video.rodar(video.ffmpeg(), ['-y', '-ss', String(de), '-t', String(dur), '-i', origem,
838
+ '-af', `loudnorm=I=-14:TP=-1.5:LRA=9,afade=t=in:d=0.1,afade=t=out:st=${Math.max(0, dur - 0.4).toFixed(2)}:d=0.4`,
839
+ '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '18', '-c:a', 'aac', '-b:a', '192k', saida], { timeout: 5 * 60000 });
840
+ if (!r.ok) return {};
841
+ return { src: path.relative(p.pasta, saida).replace(/\\/g, '/'), de: 0, ate: Math.round((dur - 0.3) * 100) / 100, pronto: true,
842
+ origem: { src: c.src, de: c.de, ate: c.ate, frase: c.frase }, ...(c.fraseDita ? { frase: c.fraseDita } : {}) };
843
+ }
844
+
845
+ /* A narração com cache (a mesma fala, na mesma direção, não é gerada duas vezes). */
846
+ async function narrarComCache(lista, mp3, { voz, direcao } = {}) {
847
+ const chave = require('crypto').createHash('sha1').update(JSON.stringify([lista.map((x) => x.texto), voz || '', direcao || ''])).digest('hex').slice(0, 16);
848
+ const dir = path.join(os.homedir(), '.primocode', 'studio', 'cache-voz');
849
+ const cm = path.join(dir, chave + '.mp3'), cj = path.join(dir, chave + '.json');
850
+ if (fs.existsSync(cm) && fs.existsSync(cj)) { fs.copyFileSync(cm, mp3); return { ok: true, ...JSON.parse(fs.readFileSync(cj, 'utf8')) }; }
851
+ const v = await narrarComGemini(lista, mp3, { voz, direcao });
852
+ 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 */ } }
853
+ return v;
854
+ }
855
+
856
+ /* A MÚSICA TEM LETRA? O acervo não diz se a faixa é instrumental, então ela
857
+ é ouvida: 25 s do meio vão para a transcrição, e mais de 6 palavras é voz
858
+ cantada. Sem servidor para ouvir, a faixa passa (melhor música que nenhuma). */
859
+ async function temLetra(arquivo) {
860
+ try {
861
+ const m = await video.medir(arquivo);
862
+ const ini = Math.max(0, (m.segundos || 60) * 0.35);
863
+ const amostra = arquivo.replace(/\.[a-z0-9]+$/i, '') + '.amostra.mp3';
864
+ 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 });
865
+ if (!c.ok) return false;
866
+ const cfg = require('../config').loadConfig();
867
+ const r = await require('../api').requestTranscricao(cfg.server, { token: cfg.token, audio: fs.readFileSync(amostra).toString('base64'), nome: 'musica.mp3', idioma: 'en' });
868
+ try { fs.unlinkSync(amostra); } catch { /* fica */ }
869
+ const palavras = (r && r.palavras ? r.palavras.length : String((r && r.texto) || '').split(/\s+/).filter(Boolean).length);
870
+ return palavras > 6;
871
+ } catch { return false; }
872
+ }
873
+
874
+ /* OS SONS DE VERDADE: gravação real de acervo aberto (teclado, whoosh
875
+ cinematográfico, impacto grave, som de sala), e o sintetizado só quando a
876
+ busca falha. "Esses efeitos sonoros estão genéricos e sem impacto."
877
+ (25/09/2026) */
878
+ async function sonsReais(p, video) {
879
+ const sint = await edicao.prepararSons(p.pasta, video);
880
+ const quer = {
881
+ teclado: ['keyboard typing', 'typing keyboard'],
882
+ whoosh: ['cinematic whoosh', 'swoosh transition'],
883
+ impacto: ['cinematic boom impact', 'deep cinematic impact', 'trailer hit'],
884
+ sala: ['crowd murmur indoor', 'office ambience', 'people talking background'],
885
+ };
886
+ const sons = { ...sint };
887
+ for (const [nome, termos] of Object.entries(quer)) {
888
+ for (const termo of termos) {
889
+ try {
890
+ const b = await assets.procurarSom(termo, { quantas: 5 });
891
+ const item = (b.ok ? b.sons : []).find((x) => x.url);
892
+ if (!item) continue;
893
+ const d = await assets.baixar(item, { pasta: p.pasta, nome: 'sfx-real-' + nome });
894
+ if (d.ok) { sons[nome] = d.relativo; break; }
895
+ } catch { /* o próximo termo */ }
896
+ }
897
+ }
898
+ return sons;
899
+ }
900
+
901
+ const PROPORCOES = { '16:9': [1920, 1080], '9:16': [1080, 1920], '1:1': [1080, 1080], '4:5': [1080, 1350] };
902
+
903
+ /** O roteiro vira plano: baixa o que falta, narra, e chama o compositor. */
904
+ async function prepararRoteiro(p, args) {
905
+ const roteiro = typeof args.roteiro === 'string' ? JSON.parse(args.roteiro) : args.roteiro;
906
+ if (Array.isArray(roteiro)) return prepararRoteiro(p, { ...args, roteiro: { partes: roteiro } });
907
+ /* O que o modelo manda e o disco não tem vira problema de render: um
908
+ "src" que é endereço da internet vira busca, e um arquivo que não
909
+ existe na pasta sai. A logo da marca vale só se estiver lá. */
910
+ const existe = (rel) => rel && !/^https?:/i.test(rel) && fs.existsSync(path.resolve(p.pasta, rel));
911
+ for (const pt of roteiro.partes || []) {
912
+ if (pt.src && !existe(pt.src)) { if (!pt.busca && /^https?:/i.test(pt.src)) pt.busca = pt.titulo || ''; delete pt.src; }
913
+ }
914
+ if (roteiro.marca) {
915
+ roteiro.marca = { ...(p.marca || {}), ...roteiro.marca };
916
+ if (roteiro.marca.logo && !existe(roteiro.marca.logo)) roteiro.marca.logo = (p.marca && existe(p.marca.logo)) ? p.marca.logo : undefined;
917
+ } else if (p.marca) roteiro.marca = p.marca;
918
+ const partes = roteiro.partes || [];
919
+ /* O ESTILO: "impacto" (anúncio, corte a cada batida) ou "limpo" (uma cena
920
+ por parte). Pedido de anúncio, reels, divulgação ou venda já vem em
921
+ impacto — é o que se espera de uma peça dessas em 2026. */
922
+ const estilo = args.estilo || roteiro.estilo
923
+ || (/an[uú]nci|reels?|divulg|promo|lan[çc]a|vend|campanha|convite|inscri/i.test(`${p.pedido || ''} ${args.pedido || ''}`) ? 'impacto' : 'limpo');
924
+ const modo = (args.modo || roteiro.modo || (p.formato === 'slides' || p.formato === 'arte' ? 'slides' : 'video'));
925
+ const [largura, altura] = PROPORCOES[roteiro.proporcao || args.proporcao] || (modo === 'slides' ? PROPORCOES['16:9'] : PROPORCOES['16:9']);
926
+
927
+ const iconesOk = new Set();
928
+ for (const nome of composicao.iconesDoRoteiro(roteiro)) {
929
+ try { const r = await assets.icone(nome); if (r && r.ok) iconesOk.add(nome); } catch { /* sem */ }
930
+ }
931
+ /* O ACERVO DA MARCA (guardado pelo estudio_site): as fotos reais entram
932
+ nas partes de imagem e de FUNDO nas cenas de texto; os vídeos, nas
933
+ partes de imagem do vídeo. A foto escolhida é a que mais combina, pela
934
+ descrição, com o que a parte diz. Sem acervo, vale a busca (Pexels). */
935
+ const acervo = (p.acervo && (p.acervo.fotos || []).length) ? p.acervo : { fotos: [], videos: [] };
936
+ const usadas = new Set();
937
+ const palavrasDe = (t) => new Set(String(t || '').normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase().split(/[^a-z0-9]+/).filter((w) => w.length >= 4));
938
+ const melhorFoto = (texto) => {
939
+ const quer = palavrasDe(texto);
940
+ let melhor = null, pontos = -1;
941
+ for (const f of acervo.fotos) {
942
+ if (usadas.has(f.src)) continue;
943
+ const n = [...palavrasDe(f.alt)].filter((w) => quer.has(w)).length;
944
+ if (n > pontos) { melhor = f; pontos = n; }
945
+ }
946
+ if (melhor) usadas.add(melhor.src);
947
+ return melhor ? { src: melhor.src, tipo: 'imagem' } : null;
948
+ };
949
+ const videosMarca = [...(acervo.videos || [])];
950
+ const imagens = {}, fundos = {};
951
+ for (const [i, parte] of partes.entries()) {
952
+ if (parte.tipo !== 'imagem' || parte.src) continue;
953
+ const texto = `${parte.titulo || ''} ${parte.sub || ''} ${parte.fala || ''} ${parte.busca || ''}`;
954
+ if (modo === 'video' && videosMarca.length && i % 2 === 1) { imagens[i] = { src: videosMarca.shift().src, tipo: 'video' }; continue; }
955
+ const m = melhorFoto(texto) || (parte.busca ? await baixarApoio(p, parte.busca, { orientacao: largura >= altura ? 'landscape' : 'portrait', video: modo === 'video' }) : null);
956
+ if (m) imagens[i] = m;
957
+ }
958
+ if (acervo.fotos.length) {
959
+ const comFundo = ['abertura', 'titulo', 'contraste', 'citacao', 'cta', 'passos'];
960
+ let alterna = 0;
961
+ for (const [i, parte] of partes.entries()) {
962
+ if (!comFundo.includes(parte.tipo)) continue;
963
+ if (i !== 0 && alterna++ % 2 === 1) continue; // uma sim, uma não: o respiro também é ritmo
964
+ const m = melhorFoto(`${parte.titulo || ''} ${parte.sim || ''} ${parte.fala || ''}`);
965
+ if (m) fundos[i] = m;
966
+ }
967
+ }
968
+
969
+ let tempos = [], narracao = null, sons = {}, trilha = null;
970
+ if (modo === 'video' && estilo === 'cinema') {
971
+ // Só as partes com voz vão para a narração; o depoimento tem a voz do aluno.
972
+ // Só as partes com voz: o depoimento tem a voz do aluno, e a parte muda não tem voz.
973
+ // A parte com voz forte é narrada à parte: se entrasse aqui também, seria falada duas vezes (e muda uma delas).
974
+ const lista = partes.filter((pt) => pt.tipo !== 'depoimento' && cinema.falaDe(pt) !== '' && !pt.falaForte).map((pt) => ({ texto: cinema.falaDe(pt), registro: 'normal' }));
975
+ const mp3 = path.join(p.pasta, 'midia', `narracao-${Date.now().toString(36)}.mp3`);
976
+ /* O CACHE DA VOZ: a mesma fala, na mesma voz, não é gerada duas vezes.
977
+ Refazer o roteiro em outro formato, ou mexer numa cena, não gasta a
978
+ cota do Gemini — que acaba no meio do dia (medido em 25/09). */
979
+ 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);
980
+ const cacheDir = path.join(os.homedir(), '.primocode', 'studio', 'cache-voz');
981
+ const cacheMp3 = path.join(cacheDir, chaveVoz + '.mp3'), cacheJson = path.join(cacheDir, chaveVoz + '.json');
982
+ let v = null;
983
+ // motorVoz "edge": a voz do Edge direto, sem esperar a cota do Gemini ("a narração pode manter a do Edge", 25/09).
984
+ const soEdge = String(roteiro.motorVoz || args.motorVoz || '').toLowerCase() === 'edge';
985
+ if (soEdge) {
986
+ v = { ok: false, error: 'edge pedido' };
987
+ } else if (fs.existsSync(cacheMp3) && fs.existsSync(cacheJson)) {
988
+ fs.copyFileSync(cacheMp3, mp3);
989
+ v = { ok: true, ...JSON.parse(fs.readFileSync(cacheJson, 'utf8')), doCache: true };
990
+ // Cache sem as palavras (vindo de um plano antigo): transcreve de novo e realinha — sem gastar voz.
991
+ if (!(v.partes || []).some((pt) => (pt.palavras || []).length)) {
992
+ try {
993
+ const cfg = require('../config').loadConfig();
994
+ const tr = await require('../api').requestTranscricao(cfg.server, { token: cfg.token, audio: fs.readFileSync(mp3).toString('base64'), nome: 'voz.mp3', idioma: 'pt' });
995
+ if (tr && (tr.palavras || []).length) {
996
+ v.partes = alinharPartes(lista, tr.palavras, v.segundos);
997
+ fs.writeFileSync(cacheJson, JSON.stringify({ voz: v.voz, segundos: v.segundos, partes: v.partes }));
998
+ }
999
+ } catch { /* fica o alinhamento guardado */ }
1000
+ }
1001
+ } else {
1002
+ v = await narrarComGemini(lista, mp3, { voz: args.voz || roteiro.voz,
1003
+ direcao: 'Leia em português do Brasil como narrador de trailer de marca premium: voz grave, confiante e humana, '
1004
+ + 'ritmo firme, pausas curtas entre as frases, sem arrastar.' });
1005
+ if (v.ok) {
1006
+ 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 */ }
1007
+ }
1008
+ }
1009
+ if (!v.ok) {
1010
+ const arq = path.join(p.pasta, 'midia', `roteiro-${Date.now().toString(36)}.json`);
1011
+ // Ritmo NORMAL: o "citação" (-10%, respiro longo) esticou o vídeo para 125 s.
1012
+ fs.writeFileSync(arq, JSON.stringify(lista.map((x) => ({ ...x, registro: 'normal' }))));
1013
+ v = await rodarPython('narrar.py', ['roteiro', '--arquivo', arq, '--saida', mp3, '--voz', String(args.voz || roteiro.voz || 'antonio')], { timeout: 10 * 60000 });
1014
+ }
1015
+ if (!v.ok) return v;
1016
+ /* A DURAÇÃO que o roteiro pede ("60–75 s"): se a voz passa do que cabe,
1017
+ ela acelera de leve — até 25%, sem mudar o tom (atempo) — e os tempos
1018
+ de cada parte acompanham. Medido em 25/09: 98 s de voz num vídeo de 70. */
1019
+ const alvo = Number(roteiro.duracao || args.segundos) || 0;
1020
+ let ritmoVoz = 1;
1021
+ if (alvo) {
1022
+ 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);
1023
+ // A voz forte (narrada à parte) e as partes mudas também ocupam o tempo.
1024
+ const forteEst = partes.filter((pt) => pt.falaForte).reduce((n, pt) => n + cinema.falaDe({ ...pt, fala: undefined }).length / 13 + 1.9, 0);
1025
+ const mudas = partes.filter((pt) => pt.tipo !== 'depoimento' && cinema.falaDe(pt) === '').length * 4;
1026
+ const cabe = Math.max(15, alvo - depo - forteEst - mudas - 0.45 * lista.length);
1027
+ if (v.segundos > cabe * 1.05) {
1028
+ const k = Math.min(1.25, v.segundos / cabe);
1029
+ ritmoVoz = k;
1030
+ const rapido = mp3.replace(/\.mp3$/, '-r.mp3');
1031
+ const c = await video.rodar(video.ffmpeg(), ['-y', '-i', mp3, '-filter:a', `atempo=${k.toFixed(3)}`, rapido], { timeout: 120000 });
1032
+ if (c.ok) {
1033
+ fs.renameSync(rapido, mp3);
1034
+ const div = (x) => Math.round((x / k) * 1000) / 1000;
1035
+ 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) })) }));
1036
+ v.segundos = div(v.segundos);
1037
+ }
1038
+ }
1039
+ }
1040
+ projeto.anotar(p.id, 'narracao', `${v.segundos}s em ${lista.length} partes, voz ${v.voz}`);
1041
+ const sonsC = await sonsReais(p, video);
1042
+ let trilhaC = null;
1043
+ // Vários termos e durações: vídeo sem música foi o que saiu na primeira vez (25/09).
1044
+ // E só instrumental: "essa música daria pra deixar só o instrumental?" — a faixa é OUVIDA.
1045
+ 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]]) {
1046
+ if (!termo || trilhaC) continue;
1047
+ try {
1048
+ const b = await assets.procurarMusica(termo, { quantas: 6, duracaoMin: min });
1049
+ for (const item of (b.ok ? b.sons : [])) {
1050
+ const d = await assets.baixar(item, { pasta: p.pasta, nome: 'trilha' });
1051
+ if (!d.ok) continue;
1052
+ if (await temLetra(path.join(p.pasta, d.relativo))) { projeto.anotar(p.id, 'midia', `trilha com voz descartada: ${item.titulo}`); continue; }
1053
+ trilhaC = d.relativo; break;
1054
+ }
1055
+ } catch { /* o próximo termo */ }
1056
+ }
1057
+ /* A MÚSICA mais baixa na origem: a faixa vem masterizada alta e comprimida,
1058
+ e mesmo a 30% ela cobria a voz e o depoimento ("a música tá muito
1059
+ alta, não dá pra ouvir as pessoas", 25/09). Ela vai a -28 LUFS antes
1060
+ da mistura; os volumes do plano partem daí. */
1061
+ if (trilhaC) {
1062
+ const norm = path.join(p.pasta, 'midia', 'trilha-baixa-' + path.basename(trilhaC).replace(/\.[a-z0-9]+$/i, '.mp3'));
1063
+ const c = await video.rodar(video.ffmpeg(), ['-y', '-i', path.join(p.pasta, trilhaC), '-af', 'loudnorm=I=-28:TP=-6:LRA=11', '-ar', '44100', norm], { timeout: 5 * 60000 });
1064
+ if (c.ok) trilhaC = path.relative(p.pasta, norm).replace(/\\/g, '/');
1065
+ }
1066
+ /* AS FOTOS, separadas e enquadradas. "Uma sala, 20 cadeiras" com foto de
1067
+ dashboard "não tem nada a ver" (25/09): foto de gente e print de tela
1068
+ são coisas diferentes, e a miniatura de depoimento fica de fora. E
1069
+ onde estão os rostos, para o recorte vertical não cortar ninguém. */
1070
+ const TELA = /dashboard|pauta|kpi|tend[eê]ncias|landing|review|tela|screen|painel|relat[oó]rio/i;
1071
+ const MINI = /poster|face \d+|\bthumb/i;
1072
+ const fotosC = acervo.fotos.filter((f) => !MINI.test(f.alt || ''))
1073
+ .map((f) => ({ src: f.src, tipo: 'imagem', cat: TELA.test(f.alt || '') ? 'tela' : 'pessoas', alt: f.alt }));
1074
+ const rostos = await acharRostos(p, fotosC.map((f) => f.src));
1075
+ for (const f of fotosC) if (rostos[f.src]) f.rosto = rostos[f.src];
1076
+ // Os depoimentos: cortados na palavra, com a voz no volume certo e fade.
1077
+ for (const pt of partes) {
1078
+ if (pt.tipo !== 'depoimento') continue;
1079
+ for (const c of pt.clipes || []) Object.assign(c, await prepararDepoimento(p, c));
1080
+ }
1081
+ // A voz FORTE: a parte com "falaForte" é narrada à parte, com a direção dela.
1082
+ const fortes = {};
1083
+ let iNarr = 0;
1084
+ for (const [i, pt] of partes.entries()) {
1085
+ if (pt.tipo === 'depoimento' || cinema.falaDe(pt) === '') continue;
1086
+ if (pt.falaForte) {
1087
+ const arqF = path.join(p.pasta, 'midia', `voz-forte-${i + 1}-${Date.now().toString(36)}.mp3`);
1088
+ // falaForte: true → a voz forte lê o texto da tela.
1089
+ const textoForte = pt.falaForte === true ? cinema.falaDe({ ...pt, fala: undefined }) : pt.falaForte;
1090
+ const soEdgeF = String(roteiro.motorVoz || args.motorVoz || '').toLowerCase() === 'edge';
1091
+ const vf = soEdgeF ? await (async () => {
1092
+ // No Edge, a ênfase é o registro "contraste": a mesma voz, um pouco mais firme.
1093
+ const arqJ = arqF.replace(/\.mp3$/, '.json');
1094
+ fs.writeFileSync(arqJ, JSON.stringify([{ texto: textoForte, registro: 'contraste' }]));
1095
+ return rodarPython('narrar.py', ['roteiro', '--arquivo', arqJ, '--saida', arqF, '--voz', String(args.voz || roteiro.voz || 'antonio')], { timeout: 5 * 60000 });
1096
+ })() : await narrarComCache([{ texto: textoForte }], arqF, { voz: args.voz || roteiro.voz,
1097
+ // A MESMA voz do narrador, só com um pouco mais de ênfase: "não é mudar a voz, é só um pouco a entonação" (25/09).
1098
+ direcao: pt.entonacao || 'Leia em português do Brasil com a mesma voz e o mesmo ritmo de um narrador de trailer de marca: '
1099
+ + 'confiante e natural, só um pouco mais de ênfase nas palavras principais. Sem dramatizar.' });
1100
+ // A voz forte no mesmo ritmo da narração (se ela foi acelerada, esta também).
1101
+ if (vf.ok && ritmoVoz > 1.01) {
1102
+ const rap = arqF.replace(/\.mp3$/, '-r.mp3');
1103
+ const c = await video.rodar(video.ffmpeg(), ['-y', '-i', arqF, '-filter:a', `atempo=${ritmoVoz.toFixed(3)}`, rap], { timeout: 120000 });
1104
+ if (c.ok) {
1105
+ fs.renameSync(rap, arqF);
1106
+ vf.segundos = vf.segundos / ritmoVoz;
1107
+ if (vf.partes && vf.partes[0]) vf.partes[0].palavras = (vf.partes[0].palavras || []).map((w) => ({ ...w, de: w.de / ritmoVoz, ate: w.ate / ritmoVoz }));
1108
+ }
1109
+ }
1110
+ if (vf.ok) fortes[i] = { src: path.relative(p.pasta, arqF).replace(/\\/g, '/'), segundos: vf.segundos, palavras: (vf.partes[0] || {}).palavras || [] };
1111
+ }
1112
+ iNarr++;
1113
+ }
1114
+ const r = cinema.montarCinema({ roteiro, largura, altura, tempos: v.partes, narracao: path.relative(p.pasta, mp3).replace(/\\/g, '/'),
1115
+ sons: sonsC, trilha: trilhaC, fotos: fotosC, fortes });
1116
+ if (r.ok) projeto.anotar(p.id, 'plano', `estilo cinema: ${r.resumo.cenas} cenas, ${r.resumo.segundos}s`);
1117
+ return r;
1118
+ }
1119
+ if (modo === 'video') {
1120
+ const lista = partes.map((pt, i) => ({ texto: composicao.falaDe(pt), registro: composicao.registroDe(pt, i, partes.length) }));
1121
+ const arq = path.join(p.pasta, 'midia', `roteiro-${Date.now().toString(36)}.json`);
1122
+ fs.mkdirSync(path.dirname(arq), { recursive: true });
1123
+ fs.writeFileSync(arq, JSON.stringify(lista));
1124
+ const mp3 = path.join(p.pasta, 'midia', `narracao-${Date.now().toString(36)}.mp3`);
1125
+ // A voz de gente primeiro (Gemini, pelo servidor); o Edge é a reserva.
1126
+ let v = await narrarComGemini(lista, mp3, { voz: args.voz || roteiro.voz,
1127
+ direcao: estilo === 'impacto' ? 'Leia em português do Brasil como locutor de comercial agressivo e vendedor: energia alta, ritmo rápido, '
1128
+ + 'voz encorpada, ênfase forte nas palavras de impacto, pausas curtas e secas entre as frases, sorrindo na voz.' : undefined });
1129
+ if (!v.ok) v = await rodarPython('narrar.py', ['roteiro', '--arquivo', arq, '--saida', mp3, '--voz', String(args.voz || roteiro.voz || 'thalita')], { timeout: 10 * 60000 });
1130
+ if (!v.ok) return v;
1131
+ tempos = v.partes;
1132
+ narracao = path.relative(p.pasta, mp3).replace(/\\/g, '/');
1133
+ projeto.anotar(p.id, 'narracao', `${v.segundos}s em ${partes.length} partes, voz ${v.voz}`);
1134
+ sons = await edicao.prepararSons(p.pasta, video);
1135
+ try {
1136
+ const b = await assets.procurarMusica(roteiro.musica || args.trilha || (estilo === 'impacto' ? 'epic energetic' : 'inspiring corporate'), { quantas: 6, duracaoMin: 45 });
1137
+ for (const item of (b.ok ? b.sons : [])) {
1138
+ const d = await assets.baixar(item, { pasta: p.pasta, nome: 'trilha' });
1139
+ if (d.ok) { trilha = d.relativo; break; }
1140
+ }
1141
+ } catch { /* sem música */ }
1142
+ }
1143
+ if (estilo === 'impacto' && modo === 'video') {
1144
+ // Os emojis das palavras FALADAS também (a batida mostra a fala).
1145
+ const faladas = tempos.flatMap((t) => (t.palavras || []).map((w) => w.texto));
1146
+ for (const nome of new Set(faladas.map((w) => edicao.iconeDe(w)).filter(Boolean))) {
1147
+ if (iconesOk.has(nome)) continue;
1148
+ try { const r = await assets.icone(nome); if (r && r.ok) iconesOk.add(nome); } catch { /* sem */ }
1149
+ }
1150
+ const fotos = [
1151
+ ...acervo.fotos.map((f) => ({ src: f.src, tipo: 'imagem' })),
1152
+ ...(acervo.videos || []).map((v) => ({ src: v.src, tipo: 'video' })),
1153
+ ...Object.values(imagens),
1154
+ ];
1155
+ const r = impacto.montarImpacto({ roteiro, largura, altura, tempos, narracao, sons, trilha, fotos, iconesOk });
1156
+ if (r.ok) projeto.anotar(p.id, 'plano', `estilo impacto: ${r.resumo.cenas} batidas`);
1157
+ return r;
1158
+ }
1159
+ return composicao.comporRoteiro({ roteiro, modo, largura, altura, tempos, narracao, sons, trilha, imagens, iconesOk, fundos });
1160
+ }
1161
+
1162
+ /* Sem o tempo de cada palavra, cada trecho é repartido pelas palavras dele,
1163
+ proporcional ao tamanho de cada uma. */
1164
+ function repartirTrechos(trechos) {
1165
+ const out = [];
1166
+ for (const t of trechos) {
1167
+ const ws = String(t.texto || '').split(/\s+/).filter(Boolean);
1168
+ const letras = ws.reduce((n, w) => n + w.length, 0) || 1;
1169
+ let cur = t.de;
1170
+ for (const w of ws) {
1171
+ const d = (t.ate - t.de) * (w.length / letras);
1172
+ out.push({ de: cur, ate: cur + d, texto: w });
1173
+ cur += d;
1174
+ }
1175
+ }
1176
+ return out;
1177
+ }
1178
+
421
1179
  /**
422
1180
  * A transcrição com tempo — é ela que dá legenda sincronizada.
423
1181
  *
@@ -448,7 +1206,7 @@ async function transcrever(p, alvo, ctx) {
448
1206
  if (!r || r.ok === false) return { ok: false, error: (r && r.erro) || 'a transcrição falhou.' };
449
1207
  projeto.anotar(p.id, 'transcricao', `${(r.trechos || []).length} trechos`);
450
1208
  return {
451
- ok: true, texto: r.texto, trechos: r.trechos,
1209
+ ok: true, texto: r.texto, trechos: r.trechos, palavras: r.palavras || [],
452
1210
  comoUsar: 'cada trecho vira uma fala da legenda: {"de":…, "ate":…, "texto":…}. '
453
1211
  + 'O estilo da legenda (fonte, cor, peso, posição, animação) é escolha sua — não há padrão.',
454
1212
  };
@@ -518,6 +1276,17 @@ async function ferramentaRender(args, ctx = {}) {
518
1276
  });
519
1277
  if (!r.ok) return { ...r, faltaram };
520
1278
 
1279
+ /* O VOLUME FINAL: a mistura (voz, música, efeitos, depoimento) sai no
1280
+ padrão de Reels e YouTube, -14 LUFS. Medido em 25/09: o vídeo pronto
1281
+ saía com -23,6 dB de média. A imagem não é recodificada. */
1282
+ if (pl.audio && fs.existsSync(saida)) {
1283
+ const tmp = saida.replace(/\.mp4$/, '.norm.mp4');
1284
+ const ln = await video.rodar(video.ffmpeg(), ['-y', '-i', saida, '-c:v', 'copy', '-af', 'loudnorm=I=-14:TP=-1.2:LRA=10',
1285
+ '-c:a', 'aac', '-b:a', '256k', tmp], { timeout: 15 * 60000 });
1286
+ if (ln.ok && fs.existsSync(tmp)) fs.renameSync(tmp, saida);
1287
+ else { try { fs.unlinkSync(tmp); } catch { /* não havia */ } }
1288
+ }
1289
+
521
1290
  const bytes = fs.existsSync(saida) ? fs.statSync(saida).size : 0;
522
1291
  projeto.salvar(args.id, { renders: [...(p.renders || []), { quando: new Date().toISOString(), arquivo: saida, bytes }] });
523
1292
  projeto.anotar(args.id, 'render', `${(bytes / 1048576).toFixed(1)} MB, ${conferida.duracao}s`);
@@ -535,6 +1304,36 @@ async function ferramentaRender(args, ctx = {}) {
535
1304
 
536
1305
  // ── estudio_site ─────────────────────────────────────────────────────────
537
1306
 
1307
+ /* A PALETA INTEIRA do site, não só a primeira cor. O fundo é o neutro
1308
+ escuro que o site mais usa (#121218 na Paris Group); o destaque é a cor da
1309
+ marca que mais aparece sobre ele (o laranja, não o azul escuro que some); o
1310
+ apoio é a seguinte. A fonte serifada, quando existe, vai para citação. */
1311
+ function paletaDoSite(r) {
1312
+ 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]; };
1313
+ const luz = (h) => { const [a, b, c] = hexRgb(h); return (0.299 * a + 0.587 * b + 0.114 * c) / 255; };
1314
+ const sat = (h) => { const v = hexRgb(h); return (Math.max(...v) - Math.min(...v)) / 255; };
1315
+ const neutras = ((r.cores && (r.cores.neutras || r.cores.fundo)) || []).map((x) => x.cor || x).filter((c) => /^#/.test(c));
1316
+ const fundo = neutras.find((c) => luz(c) < 0.15 && luz(c) > 0.02) || neutras.find((c) => luz(c) < 0.2) || undefined;
1317
+ const base = fundo || '#121218';
1318
+ const marca = ((r.cores && r.cores.marca) || []).map((x) => x.cor).filter(Boolean);
1319
+ const nota = (c) => sat(c) * 1.2 + Math.abs(luz(c) - luz(base));
1320
+ const ordenadas = [...marca].sort((a, b) => nota(b) - nota(a));
1321
+ const destaque = ordenadas[0];
1322
+ // O apoio é OUTRA cor, de outro matiz: dois laranjas não fazem paleta.
1323
+ 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;
1324
+ 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; };
1325
+ const longe = (a, b) => { const d = Math.abs(matiz(a) - matiz(b)); return Math.min(d, 360 - d) > 60; };
1326
+ const apoio = ordenadas.find((c) => c !== destaque && longe(c, destaque) && sat(c) > 0.3) || ordenadas[1];
1327
+ const fontes = ((r.fontes && r.fontes.declaradas) || []).map((x) => x.fonte).filter((f) => !/mono|menlo|courier|system|arial|helvetica/i.test(f));
1328
+ return {
1329
+ cores: { ...(fundo ? { fundo } : {}), ...(destaque ? { destaque } : {}), ...(apoio ? { apoio } : {}) },
1330
+ fontes: {
1331
+ ...(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] } : {}),
1332
+ ...(fontes.find((f) => /serif/i.test(f) && !/sans/i.test(f)) ? { serifa: fontes.find((f) => /serif/i.test(f) && !/sans/i.test(f)) } : {}),
1333
+ },
1334
+ };
1335
+ }
1336
+
538
1337
  async function ferramentaSite(args) {
539
1338
  let alvo = String(args.url || args.site || '').trim();
540
1339
 
@@ -564,6 +1363,10 @@ async function ferramentaSite(args) {
564
1363
  uma pasta de projeto, é lixo que ninguém recolhe. */
565
1364
  const html = r.html;
566
1365
  delete r.html; // o HTML cru não vai para o modelo
1366
+ if (r.ok) {
1367
+ const marcaLida = paletaDoSite(r);
1368
+ ultimoSite = { url: r.url, html, quando: Date.now(), marca: marcaLida };
1369
+ }
567
1370
  if (r.ok && args.id && args.logo !== false) {
568
1371
  const p = projeto.ler(args.id);
569
1372
  if (p) {
@@ -580,11 +1383,57 @@ async function ferramentaSite(args) {
580
1383
  if (r.ok && args.id) {
581
1384
  projeto.anotar(args.id, 'referencia', `leu ${r.url}: ${(r.cores.marca[0] || {}).cor || 'sem cor'} · ${(r.fontes.declaradas[0] || {}).fonte || 'sem fonte'}`);
582
1385
  }
1386
+
1387
+ /* O ACERVO DA MARCA: as fotos e os vídeos reais do site, e o texto das
1388
+ páginas que combinam com o pedido (lib/estudio/acervo-site.js). As
1389
+ fotos ficam guardadas no projeto e o roteiro as usa sozinho; os fatos
1390
+ voltam para o modelo escrever o roteiro com dado de verdade. */
1391
+ if (r.ok && args.id && args.acervo !== false) {
1392
+ const p = projeto.ler(args.id);
1393
+ if (p) {
1394
+ try {
1395
+ const a = await acervoSite.colher({
1396
+ url: r.url, pedido: args.pedido || p.pedido || '', pasta: p.pasta, htmlInicial: html,
1397
+ buscar: identidade.buscar, baixar: assets.baixar,
1398
+ });
1399
+ const marca = { ...ultimoSite.marca, ...(r.logo && r.logo.ok ? { logo: r.logo.src } : {}) };
1400
+ /* O GRADIENTE da marca sai da própria logo, quando ela é SVG com
1401
+ gradiente (a da Paris Group vai de #2E81ED a #9567DB). */
1402
+ try {
1403
+ if (marca.logo && /\.svg$/i.test(marca.logo)) {
1404
+ const svg = fs.readFileSync(path.join(p.pasta, marca.logo), 'utf8');
1405
+ 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()))];
1406
+ if (stops.length >= 2) {
1407
+ marca.gradiente = stops.slice(0, 2);
1408
+ // A logo manda na identidade: o destaque é a cor dela, não a do CSS.
1409
+ marca.cores = { ...(marca.cores || {}), destaque: stops[1], apoio: stops[0] };
1410
+ }
1411
+ }
1412
+ } catch { /* sem gradiente */ }
1413
+ projeto.salvar(args.id, { acervo: { fotos: a.fotos, videos: a.videos, fatos: a.fatos, site: r.url }, marca });
1414
+ projeto.anotar(args.id, 'midia', `acervo do site: ${a.fotos.length} fotos, ${a.videos.length} vídeos, ${a.paginas.length} páginas lidas`);
1415
+ let orcamento = 4200;
1416
+ r.fatos = a.fatos.map((f) => {
1417
+ const texto = f.texto.slice(0, Math.max(0, Math.min(1400, orcamento)));
1418
+ orcamento -= texto.length;
1419
+ return { pagina: f.pagina, texto };
1420
+ }).filter((f) => f.texto);
1421
+ r.acervo = { fotos: a.fotos.length, videos: a.videos.length,
1422
+ exemplos: a.fotos.slice(0, 6).map((f) => f.alt).filter(Boolean) };
1423
+ r.comoUsar = 'Escreva o roteiro com os FATOS acima (datas, local, números, frases da marca) — nada '
1424
+ + 'inventado. As fotos e os vídeos reais da marca entram SOZINHOS no vídeo: não precisa pôr '
1425
+ + '"busca" nas partes de imagem (use tipo "imagem" com só o título). Use as cores, a fonte e '
1426
+ + 'a logo em roteiro.marca.';
1427
+ } catch (e) {
1428
+ r.acervo = { erro: e.message };
1429
+ }
1430
+ }
1431
+ }
583
1432
  return r;
584
1433
  }
585
1434
 
586
1435
  module.exports = {
587
1436
  ferramentaProjeto, ferramentaPlano, ferramentaMidia, ferramentaNarrar,
588
1437
  ferramentaVideo, ferramentaRender, ferramentaSite,
589
- projeto, plano, remotion, assets, video, identidade, slides, python, rodarPython,
1438
+ projeto, plano, remotion, assets, video, identidade, slides, python, rodarPython, alinharPartes,
590
1439
  };