primocode 10.11.1 → 10.13.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.
@@ -510,7 +510,9 @@ function tituloDaFala(fala) {
510
510
 
511
511
  function conteudoDe(p, marca = {}) {
512
512
  const titulo = p.titulo || p.texto || tituloDaFala(p.fala);
513
- const itens = itensDe(p);
513
+ // O item que repete o título ("Sem troco" / "sem troco", 15/10) sai: o quadro dizia a mesma coisa duas vezes.
514
+ const igual = (a, b) => tokens(a).join(' ') === tokens(b).join(' ');
515
+ const itens = itensDe(p).filter((it) => !igual(it, titulo));
514
516
  if (p.tipo === 'numero' && numeroDe(p.valor)) return { tipo: 'numero', valor: String(p.valor), rotulo: p.rotulo || titulo, sub: p.sub || '' };
515
517
  if (itens.length >= 2) return { tipo: 'lista', titulo: p.tipo === 'cores' ? titulo : titulo, itens: itens.slice(0, 5), numerada: p.tipo === 'passos' };
516
518
  if (p.tipo === 'contraste' && (p.nao || p.sim)) return { tipo: 'contraste', nao: p.nao || '', sim: p.sim || titulo };
@@ -528,7 +530,7 @@ const textoDoConteudo = (c) => [c.titulo, c.sub, c.valor, c.rotulo, c.nao, c.sim
528
530
  const COMPOSICOES = ['gigante', 'pilha', 'divisao', 'contador', 'cascata', 'parede', 'citacao', 'marca', 'palavras', 'produto'];
529
531
  const COMPORTAMENTOS = ['carimbo', 'mascara', 'onda', 'decodifica', 'peso', 'largura', 'tracking', 'desfoque', 'flap', 'pontos', 'contorno', 'monta'];
530
532
  const SAIDAS = ['pontos', 'evapora', 'atravessa', 'desliza', 'comprime', 'explode', 'chicote', 'inunda', 'corte'];
531
- const ACENTOS = ['ponto', 'sublinha', 'anel', 'risco', 'barra', 'bala', 'marca', 'moldura', 'seta'];
533
+ const ACENTOS = ['ponto', 'sublinha', 'anel', 'risco', 'barra', 'bala', 'marca', 'moldura', 'seta', 'visto'];
532
534
 
533
535
  function pesosDe(c, { curto, palavras, primeira, ultima, tem3d, pergunta, exclama, numeroNoTitulo }) {
534
536
  switch (c.tipo) {
@@ -1055,13 +1057,18 @@ function partituraDe({ roteiro, largura = 1920, altura = 1080, tempos = [], icon
1055
1057
  const M = Math.round(W * (deitado ? 0.085 : 0.095));
1056
1058
  const larguraUtil = W - 2 * M;
1057
1059
  const base = Math.min(W, H) / 1080;
1058
- const tamRotulo = Math.round((deitado ? 24 : 30) * base);
1060
+ /* (15/10, as referências) O rótulo de 24–30 px (1,6% da altura no 9:16)
1061
+ não se lia no celular ("NO COADOR", "pra você, de graça"). O rótulo
1062
+ fica em ~2% da altura, e a segunda voz (o sub, a frase de apoio) em
1063
+ 3–4%, como a anotação das referências. */
1064
+ const tamRotulo = Math.round(Math.max((deitado ? 24 : 30) * base, H * (deitado ? 0.026 : 0.02)));
1059
1065
  const tem3d = !!roteiro.modelo3d;
1060
1066
  const ws = temposDasPartes(partes, tempos);
1061
1067
 
1062
1068
  // ── os limites de cada parte na linha do tempo ────────────────────────
1063
1069
  const inicio = [];
1064
- partes.forEach((_, i) => inicio.push(i === 0 ? 0 : r3(Math.max(inicio[i - 1] + 1.4, ws[i].de - 0.12))));
1070
+ // 0,9 s (eram 1,4): nas referências a cena dura 1 a 2 s; com fala rápida a parte segue a voz.
1071
+ partes.forEach((_, i) => inicio.push(i === 0 ? 0 : r3(Math.max(inicio[i - 1] + 0.9, ws[i].de - 0.12))));
1065
1072
  const ultimaFala = ws[ws.length - 1];
1066
1073
  const duracao = r1(Math.max(inicio[inicio.length - 1] + 2.0, (ultimaFala.ate || 0) + 1.15));
1067
1074
  const fimParte = partes.map((_, i) => (i < partes.length - 1 ? inicio[i + 1] : duracao));
@@ -1544,14 +1551,18 @@ function partituraDe({ roteiro, largura = 1920, altura = 1080, tempos = [], icon
1544
1551
  resto); no fim, cada gesto vira o seu par limpo. */
1545
1552
  /* O substituto é um limpo que não aparece nas 3 batidas de cada lado: a
1546
1553
  passagem e o gesto da tese não voltam antes de 3 batidas. */
1547
- const TORTOS = ['decodifica', 'flap', 'onda', 'monta'];
1548
- const GESTOS_LIMPOS = ['mascara', 'desfoque', 'tracking', 'pontos', 'contorno', 'carimbo'];
1549
- const SAIDAS_LIMPAS = ['evapora', 'desliza', 'comprime', 'pontos', 'atravessa'];
1554
+ /* (15/10, as referências) Os pontos, o evapora e o comprime também
1555
+ espalhavam as letras ("dad|o", "· p o n d e", parecia glitch). Nas
1556
+ referências a frase sai inteira: desliza, atravessa ou corta seco. */
1557
+ const TORTOS = ['decodifica', 'flap', 'onda', 'monta', 'pontos'];
1558
+ const GESTOS_LIMPOS = ['mascara', 'desfoque', 'tracking', 'contorno', 'carimbo'];
1559
+ const SAIDAS_LIMPAS = ['desliza', 'atravessa', 'corte'];
1560
+ const SAIDAS_SUJAS = ['explode', 'chicote', 'pontos', 'evapora', 'comprime'];
1550
1561
  const vizinhas = (i) => batidas.filter((_, j) => j !== i && Math.abs(j - i) <= 3);
1551
1562
  const principal = (b) => (b.linhas || []).find((l) => (l.papel === 'tese' || l.papel === 'numero') && !l.marca);
1552
1563
  const comGesto = batidas.filter(principal);
1553
1564
  batidas.forEach((b, i) => {
1554
- if (b.saida === 'explode' || b.saida === 'chicote') {
1565
+ if (SAIDAS_SUJAS.includes(b.saida)) {
1555
1566
  const perto = vizinhas(i).map((x) => x.saida);
1556
1567
  b.saida = SAIDAS_LIMPAS.find((x) => !perto.includes(x)) || 'corte';
1557
1568
  if (batidas[i + 1]) batidas[i + 1].entrada = b.saida;
@@ -1626,6 +1637,11 @@ function molduraDe(roteiro, E, { W, H, deitado, marca, secoes }) {
1626
1637
  const pesoDe = (fonte) => (VARIAVEIS[fonte] && VARIAVEIS[fonte].eixos.wght) || [400, 400];
1627
1638
 
1628
1639
  // ── as composições: de conteúdo + contexto, uma batida ─────────────────
1640
+ /** A segunda voz (sub, a frase de apoio): 3–4% da altura, como a anotação das referências. */
1641
+ function tamSegunda({ H, deitado, tamRotulo }) {
1642
+ return Math.round(Math.max(tamRotulo, H * (deitado ? 0.042 : 0.03)));
1643
+ }
1644
+
1629
1645
  function compor(comp, br, ctx) {
1630
1646
  const { s, W, H, M, deitado, base, larguraUtil, tamRotulo, fontes } = ctx;
1631
1647
  const c = br.conteudo;
@@ -1739,6 +1755,8 @@ function compor(comp, br, ctx) {
1739
1755
  else {
1740
1756
  const lt = comTese || teses[teses.length - 1];
1741
1757
  b.acento.push({ forma, alvo: { linha: lt.id, palavra: comTese ? lt.palavras.findIndex((p) => p.papel === 'tese') : null } });
1758
+ // O círculo de caneta na tese ganha o ✓ (15/10, as referências), sem gastar sorteio.
1759
+ if (forma === 'anel' && comTese) b.acento.push({ forma: 'visto', alvo: { linha: lt.id, palavra: lt.palavras.findIndex((p) => p.papel === 'tese') } });
1742
1760
  }
1743
1761
  }
1744
1762
  // O ThreeCanvas só aceita pixel inteiro; ele ocupa a altura toda, para o chão não cortar.
@@ -1759,7 +1777,13 @@ function compor(comp, br, ctx) {
1759
1777
  }
1760
1778
  for (const t of fit.linhas) { const l = linha(t, 'apoio', { tamanho: fit.tamanho, eixos: { wght: 560 } }); misturarPesos(l, { maiorTese: 1.14 }); }
1761
1779
  const lt = b.linhas.find((l) => l.palavras.some((p) => p.papel === 'tese'));
1762
- if (lt) { tese(lt); b.acento.push({ forma: s.chance(0.6) ? 'sublinha' : 'anel', alvo: { linha: lt.id, palavra: lt.palavras.findIndex((p) => p.papel === 'tese') } }); }
1780
+ if (lt) {
1781
+ tese(lt);
1782
+ const alvo = { linha: lt.id, palavra: lt.palavras.findIndex((p) => p.papel === 'tese') };
1783
+ const forma = s.chance(0.6) ? 'sublinha' : 'anel';
1784
+ b.acento.push({ forma, alvo });
1785
+ if (forma === 'anel') b.acento.push({ forma: 'visto', alvo: { ...alvo } });
1786
+ }
1763
1787
  else pontoFinal(b.linhas[b.linhas.length - 1]);
1764
1788
  break;
1765
1789
  }
@@ -1774,7 +1798,7 @@ function compor(comp, br, ctx) {
1774
1798
  const l = linha(c.valor, 'numero', { fonte, tamanho: tam, eixos });
1775
1799
  const tRot = Math.max(30 * base, Math.min(tam * 0.22, 64 * base));
1776
1800
  if (c.rotulo) linha(c.rotulo, 'apoio', { tamanho: tRot, eixos: { wght: 600 }, fonte: fontes.display });
1777
- if (c.sub) linha(c.sub, 'rotulo', { fonte: fontes.mono, tamanho: tamRotulo, tracking: 0.08, eixos: { wght: 400 } });
1801
+ if (c.sub) linha(c.sub, 'rotulo', { fonte: fontes.mono, tamanho: tamSegunda(ctx), tracking: 0.02, eixos: { wght: 400 } });
1778
1802
  b.contador = { linha: l.id, ...n, dur: 0.9 };
1779
1803
  b.acento.push({ forma: c.valor.length <= 4 ? 'anel' : 'sublinha', alvo: { linha: l.id } });
1780
1804
  break;
@@ -1844,7 +1868,7 @@ function compor(comp, br, ctx) {
1844
1868
  const tam = tamanhoQueCabe(nome, lg, (deitado ? 0.3 : 0.16) * H, { fonte, wght: 800 });
1845
1869
  b.bloco = { x: W / 2 + tam * 0.3, y: H * 0.5, alinha: 'centro', vert: 'meio', largura: lg, gap: 0.2 };
1846
1870
  linha(nome, 'tese', { fonte, tamanho: tam, eixos: { wght: Math.min(800, pesoDe(fonte)[1]) }, comportamento: 'contorno' }).marca = true;
1847
- if (c.sub || c.link) linha(c.sub || c.link, 'rotulo', { fonte: fontes.mono, tamanho: tamRotulo, tracking: 0.2, maiusc: true, eixos: { wght: 400 } });
1871
+ if (c.sub || c.link) linha(c.sub || c.link, 'rotulo', { fonte: fontes.mono, tamanho: tamSegunda(ctx), tracking: 0.06, eixos: { wght: 400 } });
1848
1872
  b.acento.push({ forma: 'ponto', alvo: { linha: b.linhas[0].id, antes: true } });
1849
1873
  break;
1850
1874
  }
@@ -1952,9 +1976,10 @@ function tempoDaBatida(b, palavras, ctx) {
1952
1976
  l.t = r3(Math.max(i === 0 ? b.de + 0.1 : t + REGRAS.stagger[0] * Q, falado != null ? falado : (i === 0 ? b.de + 0.12 : t + passo)));
1953
1977
  t = l.t;
1954
1978
  });
1955
- /* Quadro morto não: a primeira coisa da batida entra em até 0,35 s, mesmo
1956
- que a voz ainda não a tenha dito (o resto segue a voz). */
1957
- if (ordem[0] && ordem[0].t > b.de + 0.35) ordem[0].t = r3(b.de + 0.35);
1979
+ /* Quadro morto não: a primeira coisa da batida entra em até 0,12 s, mesmo
1980
+ que a voz ainda não a tenha dito (o resto segue a voz). Eram 0,35 s, e
1981
+ com a saída da anterior o teste de 15/10 tinha 23% de quadro vazio. */
1982
+ if (ordem[0] && ordem[0].t > b.de + 0.12) ordem[0].t = r3(b.de + 0.12);
1958
1983
  for (let i = 1; i < ordem.length; i++) if (ordem[i].t < ordem[i - 1].t + REGRAS.stagger[0] * Q) ordem[i].t = r3(ordem[i - 1].t + REGRAS.stagger[0] * Q);
1959
1984
  // Nada entra tarde demais para ser lido: de trás para frente, com o stagger mínimo.
1960
1985
  for (let i = ordem.length - 1; i >= 0; i--) {
@@ -1985,7 +2010,11 @@ function tempoDaBatida(b, palavras, ctx) {
1985
2010
  let tw = l.t;
1986
2011
  l.palavras.forEach((p, j) => {
1987
2012
  const alvo = qs[j] != null ? qs[j] : (j === 0 ? l.t : tw + 2 * Q);
1988
- p.t = r3(Math.min(Math.max(j === 0 ? l.t : tw + 2 * Q, alvo), Math.max(l.t, fim)));
2013
+ /* Quadro morto não, também na palavra (15/10): a voz pode dizer
2014
+ outra coisa antes ("Aponta a câmera, paga, e o pão…"); a primeira
2015
+ palavra da batida entra com a linha, as outras seguem a voz. */
2016
+ const abre = j === 0 && l === ordem[0];
2017
+ p.t = r3(abre ? l.t : Math.min(Math.max(j === 0 ? l.t : tw + 2 * Q, alvo), Math.max(l.t, fim)));
1989
2018
  tw = p.t;
1990
2019
  });
1991
2020
  }
@@ -65,6 +65,7 @@ const gerativo = require('./gerativo.js');
65
65
  const fluido = require('./fluido.js');
66
66
  const vox = require('./vox.js');
67
67
  const kokoro = require('./kokoro.js');
68
+ const voicestudio = require('./voicestudio.js');
68
69
  const lancamento = require('./lancamento.js');
69
70
  const glifos = require('./glifos.js');
70
71
  const personagem = require('./personagem.js');
@@ -77,6 +78,7 @@ const colecao = require('./colecao.js');
77
78
  const { escolherEstilo, LEVES } = require('./estilo.js');
78
79
  const sonsLeves = require('./sons-leves.js');
79
80
  const semNarracao = require('./sem-narracao.js');
81
+ const camadas = require('./camadas.js');
80
82
  const ende = require('./ende.js');
81
83
  const producao = require('./producao.js');
82
84
 
@@ -862,6 +864,80 @@ async function ferramentaVideo(args, ctx = {}) {
862
864
  };
863
865
  }
864
866
 
867
+ /* O VÍDEO EM CAMADAS (camadas.js, 14/10: "quero uma edição nesse nível"):
868
+ a pessoa só gravou a fala; a IA passa as batidas (forma do rosto, cena
869
+ de motion, destaque) e o Estúdio monta o bolo por cima. Sem batidas,
870
+ devolve uma proposta pela transcrição — a IA aceita ou muda. */
871
+ if (acao === 'camadas') {
872
+ const m = await video.medir(alvo);
873
+ if (!m.ok) return m;
874
+ // A transcrição fica guardada: a proposta e a montagem não pagam duas vezes.
875
+ const guardada = path.join(p.pasta, 'midia', `transcricao-${path.basename(alvo).replace(/\.[^.]+$/, '')}.json`);
876
+ let palavras = Array.isArray(args.palavras) && args.palavras.length ? args.palavras : null;
877
+ if (palavras) { try { fs.writeFileSync(guardada, JSON.stringify(palavras)); } catch { /* segue sem guardar */ } }
878
+ if (!palavras && fs.existsSync(guardada)) { try { palavras = JSON.parse(fs.readFileSync(guardada, 'utf8')); } catch { palavras = null; } }
879
+ if (!palavras) {
880
+ const tr = await transcrever(p, alvo, ctx);
881
+ if (!tr.ok) return tr;
882
+ palavras = (tr.palavras && tr.palavras.length) ? tr.palavras : repartirTrechos(tr.trechos || []);
883
+ try { fs.writeFileSync(guardada, JSON.stringify(palavras)); } catch { /* segue sem guardar */ }
884
+ }
885
+ const batidas = Array.isArray(args.batidas) && args.batidas.length ? args.batidas : null;
886
+ if (!batidas) {
887
+ return {
888
+ ok: true, proposta: camadas.propor(palavras, m.segundos), duracao: m.segundos,
889
+ comoUsar: 'é uma SUGESTÃO pela transcrição. Decida você: mude as formas, junte ou separe batidas, '
890
+ + 'escreva a cena de motion de cada ideia (componentes/x.jsx, recebe {el, t, fps}; el.dados traz largura/altura do espaço livre, '
891
+ + 'palavras com t, destaque, cores e fontes; a LEGENDA da fala já está no quadro, então a cena NÃO escreve a fala: desenha a ideia) ou use um efeito de fábrica pelo nome (estudio_midia acao:"efeitos"), '
892
+ + 'e chame de novo com batidas:[{de, ate, rosto, cena?, destaque?, canto?, dados?}].',
893
+ };
894
+ }
895
+ // A cena: arquivo da pasta (conferido antes do render) ou efeito de fábrica pelo nome.
896
+ const problemas = [];
897
+ const prontas = batidas.map((b, i) => {
898
+ if (!b || !b.cena) return b;
899
+ const cena = String(b.cena).trim();
900
+ if (/[\\/]|\.(jsx|tsx|js)$/i.test(cena)) {
901
+ const cheio = dentro(cena);
902
+ if (!cheio || !fs.existsSync(cheio)) { problemas.push(`batidas[${i}]: a cena "${cena}" não está na pasta do projeto.`); return { ...b, cena: null }; }
903
+ const conf = conferirCena(cheio);
904
+ if (!conf.ok) { problemas.push(`batidas[${i}]: ${cena} — ${conf.erro}`); return { ...b, cena: null }; }
905
+ return { ...b, cena };
906
+ }
907
+ const r = efeitos.reusar({ pasta: p.pasta, nome: cena });
908
+ if (!r.ok) { problemas.push(`batidas[${i}]: ${r.error}`); return { ...b, cena: null }; }
909
+ return { ...b, cena: r.src };
910
+ });
911
+ if (problemas.length) return { ok: false, error: 'as cenas têm problema — conserte e mande de novo.', problemas };
912
+
913
+ /* A voz no volume de vídeo de internet (-15 LUFS), como na edição
914
+ automática; o vídeo em si não é recodificado. */
915
+ let fonte = alvo;
916
+ if (args.normalizar !== false) {
917
+ const normal = path.join(p.pasta, 'midia', `voz-normal-${path.basename(alvo).replace(/\.[^.]+$/, '')}.mp4`);
918
+ if (!fs.existsSync(normal)) {
919
+ await video.rodar(video.ffmpeg(), ['-y', '-i', alvo, '-c:v', 'copy',
920
+ '-af', 'loudnorm=I=-15:TP=-1.5:LRA=11', '-c:a', 'aac', '-b:a', '192k', normal], { timeout: 10 * 60000 });
921
+ }
922
+ if (fs.existsSync(normal)) fonte = normal;
923
+ }
924
+ const deitado = m.largura > m.altura;
925
+ const montado = camadas.montar({
926
+ src: path.relative(p.pasta, fonte).replace(/\\/g, '/'), duracao: m.segundos, palavras, batidas: prontas,
927
+ largura: deitado ? 1920 : 1080, altura: deitado ? 1080 : 1920, fps: 30,
928
+ origem: { largura: m.largura, altura: m.altura }, rosto: args.rosto || {}, acento: args.acento,
929
+ sons: sonsLeves.preparar(p.pasta),
930
+ });
931
+ if (!montado.ok) return montado;
932
+ const salvo = await ferramentaPlano({ id: args.id, plano: montado.plano, motivo: `vídeo em camadas (${montado.resumo.batidas} batidas)` });
933
+ if (!salvo.ok) return salvo;
934
+ projeto.anotar(p.id, 'edicao', `camadas: ${montado.resumo.batidas} batidas, ${montado.resumo.cenas} cenas de motion, ${montado.resumo.trocasDeForma} trocas de forma`);
935
+ return {
936
+ ok: true, ...montado.resumo, batidas: montado.batidas, duracao: salvo.duracao,
937
+ proximo: 'estudio_render com este id. Para mudar uma batida, chame camadas de novo com a lista inteira.',
938
+ };
939
+ }
940
+
865
941
  if (acao === 'recortar-pessoa' || acao === 'pessoa') {
866
942
  const saida = path.join(p.pasta, 'midia', `pessoa-${Date.now().toString(36)}.webm`);
867
943
  const r = await rodarPessoa([
@@ -1102,6 +1178,35 @@ async function narrarComGemini(lista, mp3, { voz, direcao } = {}) {
1102
1178
  }
1103
1179
  }
1104
1180
 
1181
+ /* A VOZ DO VOICESTUDIO (15/10): o app local fala o roteiro inteiro, e o resto
1182
+ é o caminho do Gemini — volume de Reels, a transcrição para achar as partes.
1183
+ Fechado ou falhando, devolve ok:false e a cadeia segue para o Gemini. */
1184
+ async function narrarComVoiceStudio(lista, mp3, { voz } = {}) {
1185
+ if (process.env.PRIMOCODE_SEM_VOICESTUDIO) return { ok: false, indisponivel: true, error: 'voicestudio desligado' };
1186
+ try {
1187
+ const texto = lista.map((x) => x.texto).join('\n\n');
1188
+ // A voz do Gemini ("Kore", "Charon") não existe lá: só um perfil do próprio app.
1189
+ const perfil = /^vs:/i.test(String(voz || '')) ? String(voz).slice(3) : undefined;
1190
+ const r = await voicestudio.falar(texto, { voz: perfil });
1191
+ if (!r.ok) return r;
1192
+ const bruto = mp3.replace(/\.mp3$/, '-vs.mp3');
1193
+ fs.writeFileSync(bruto, r.audio);
1194
+ const c = await video.rodar(video.ffmpeg(), ['-y', '-i', bruto, '-af', 'loudnorm=I=-14:TP=-1.5:LRA=9', '-c:a', 'libmp3lame', '-q:a', '3', mp3], { timeout: 120000 });
1195
+ try { fs.unlinkSync(bruto); } catch { /* fica */ }
1196
+ if (!c.ok) return { ok: false, error: 'voicestudio: não consegui converter a voz' };
1197
+ const med = await video.medir(mp3);
1198
+ const chars = lista.reduce((n, x) => n + x.texto.length, 0);
1199
+ if (med.segundos < chars / 22) return { ok: false, error: `voicestudio: a voz veio cortada (${med.segundos.toFixed(1)} s para ${chars} caracteres)` };
1200
+ const cfg = require('../config').loadConfig();
1201
+ const tr = await require('../api').requestTranscricao(cfg.server, { token: cfg.token, audio: fs.readFileSync(mp3).toString('base64'), nome: 'voz.mp3', idioma: 'pt' });
1202
+ const palavras = (tr && tr.palavras) || [];
1203
+ if (!palavras.length) return { ok: false, error: 'voicestudio: sem a transcrição da voz para achar as partes' };
1204
+ return { ok: true, voz: `voicestudio ${perfil || 'default'}`, segundos: med.segundos, partes: alinharPartes(lista, palavras, med.segundos) };
1205
+ } catch (e) {
1206
+ return { ok: false, error: 'voicestudio: ' + e.message };
1207
+ }
1208
+ }
1209
+
1105
1210
  /* Onde estão os rostos em cada foto (studio/rostos.py, pelo uv). Guardado no
1106
1211
  projeto: a mesma foto não é analisada duas vezes. */
1107
1212
  async function acharRostos(p, srcs) {
@@ -1195,7 +1300,9 @@ async function narrarComCache(lista, mp3, { voz, direcao } = {}) {
1195
1300
  if (fs.existsSync(cm) && fs.existsSync(cj)) { fs.copyFileSync(cm, mp3); return { ok: true, ...JSON.parse(fs.readFileSync(cj, 'utf8')) }; }
1196
1301
  // A voz local pedida (voz: 'kokoro'): direto nela, sem gastar a cota do Gemini.
1197
1302
  if (kokoro.querKokoro(voz)) return kokoro.narrar(lista, mp3, { voz, uv: acharUv() });
1198
- const v = await narrarComGemini(lista, mp3, { voz, direcao });
1303
+ // O VoiceStudio local primeiro (15/10); fechado ou falhando, o Gemini.
1304
+ let v = await narrarComVoiceStudio(lista, mp3, { voz });
1305
+ if (!v.ok) v = await narrarComGemini(lista, mp3, { voz, direcao });
1199
1306
  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 */ } }
1200
1307
  return v;
1201
1308
  }
@@ -1214,6 +1321,7 @@ async function produzirNecessidade(p, roteiro, pedido, provedores) {
1214
1321
  direção dele), o Kokoro local e o Edge. A falha de uma passa para a próxima. */
1215
1322
  function provedoresDeVoz(lista, mp3, arq, voz, vozEdge, { soEdge = false, gemini, escolha } = {}) {
1216
1323
  const todos = {
1324
+ voicestudio: () => narrarComVoiceStudio(lista, mp3, { voz }),
1217
1325
  gemini: () => gemini(),
1218
1326
  kokoro: () => kokoro.narrar(lista, mp3, { voz, uv: acharUv() }),
1219
1327
  edge: () => {
@@ -1232,7 +1340,7 @@ function provedoresDeVoz(lista, mp3, arq, voz, vozEdge, { soEdge = false, gemini
1232
1340
  /* A VOZ DE RESERVA, quando o Gemini falha: o Kokoro (local, sem cota) e
1233
1341
  depois o Edge. A falha de uma passa para a próxima, nunca derruba. */
1234
1342
  function narrarReserva(lista, mp3, arq, voz, vozEdge, { soEdge = false, semKokoro = false } = {}) {
1235
- const passos = kokoro.ordem(voz, process.env, { soEdge }).filter((n) => n !== 'gemini' && !(semKokoro && n === 'kokoro'));
1343
+ const passos = kokoro.ordem(voz, process.env, { soEdge }).filter((n) => n !== 'gemini' && n !== 'voicestudio' && !(semKokoro && n === 'kokoro'));
1236
1344
  return kokoro.corrente(passos, {
1237
1345
  kokoro: () => kokoro.narrar(lista, mp3, { voz, uv: acharUv() }),
1238
1346
  edge: () => rodarPython('narrar.py', ['roteiro', '--arquivo', arq, '--saida', mp3, '--voz', kokoro.vozNoEdge(voz, vozEdge)], { timeout: 10 * 60000 }),
@@ -295,6 +295,21 @@ function geometria(a, b, L, P) {
295
295
  for (let i = 0; i < N; i++) { const q = -2.3 + (i / (N - 1)) * Math.PI * 2 * 1.1; const e = 1 + (i / N) * 0.07; pts.push([ccx + Math.cos(q) * rx * e, ccy + Math.sin(q) * ry * e]); }
296
296
  return { pts, larg: Math.max(4 * base, cx.s * 0.03), alfa: 1, frente: true, cx: ccx, cy: ccy };
297
297
  }
298
+ if (f === 'visto') {
299
+ /* O ✓ de caneta (15/10, as referências): depois do círculo, ao lado da
300
+ palavra, em dois traços; se não cabe à direita, encosta na margem. */
301
+ const pw = alvoPalavra();
302
+ const s = cx.s, cy0 = cx.y + cx.h * 0.5;
303
+ const A = [pw.x + pw.w + s * 0.16, cy0], B = [pw.x + pw.w + s * 0.27, cy0 + s * 0.2], C = [pw.x + pw.w + s * 0.52, cy0 - s * 0.3];
304
+ const dx = Math.min(0, P.largura - 24 * base - C[0]);
305
+ const q = 0.3;
306
+ for (let i = 0; i < N; i++) {
307
+ const k = i / (N - 1);
308
+ const [p0, p1, u] = k < q ? [A, B, k / q] : [B, C, (k - q) / (1 - q)];
309
+ pts.push([lerp(p0[0], p1[0], u) + dx, lerp(p0[1], p1[1], u)]);
310
+ }
311
+ return { pts, larg: Math.max(5 * base, s * 0.045), alfa: 1, frente: true, tampa: 'round', cx: B[0] + dx, cy: B[1] };
312
+ }
298
313
  if (f === 'barra') {
299
314
  // O vão da barra é o respiro do vídeo (margem.js): colada, ela riscava a primeira letra.
300
315
  // A deriva da vida abre as letras até 3% da linha para fora: a barra recua junto.
@@ -12,6 +12,7 @@ import { FLUIDO } from './fluido';
12
12
  import { PERSONAGEM } from './personagem';
13
13
  import { VOXEL } from './voxel';
14
14
  import { ANIMACAO } from './animacao';
15
+ import { ROSTO } from './rosto';
15
16
 
16
- export const KIT = { ...VOX, ...MARCA, ...TRES, ...GERATIVO, ...FLUIDO, ...PERSONAGEM, ...VOXEL, ...ANIMACAO, papel: Papel };
17
+ export const KIT = { ...VOX, ...MARCA, ...TRES, ...GERATIVO, ...FLUIDO, ...PERSONAGEM, ...VOXEL, ...ANIMACAO, ...ROSTO, papel: Papel };
17
18
  export { Filtros, Acabamento, Hud, CURVAS_KIT, bolhaCaminho } from './base';
@@ -0,0 +1,108 @@
1
+ /* kit/rosto.jsx — o rosto de quem fala, que muda de forma (camadas.js).
2
+ *
3
+ * "o vídeo da pessoa é contínuo, mas o ROSTO muda de forma: tela cheia,
4
+ * cartão com borda que brilha, bolinha no canto, some" (Joel, 14/10)
5
+ *
6
+ * Duas peças:
7
+ * - palco: o fundo escuro com brilho que respira e grão (o que aparece
8
+ * quando o rosto não ocupa a tela);
9
+ * - rosto: a gravação dentro de uma caixa que vai de `antes` para `agora`
10
+ * com mola (kit/natural.js): recua um tico (antecipação), vai, passa do
11
+ * ponto e assenta. A gravação começa em `deOrigem` e NÃO reinicia: cada
12
+ * cena pega do ponto em que a anterior parou, e o áudio dela é a fala do
13
+ * vídeo. Quando "some", o vídeo continua tocando, só invisível.
14
+ * A geometria (onde fica cada caixa) vem pronta do camadas.js; aqui só se
15
+ * interpola. Nada de sorteio: o mesmo quadro sai igual sempre. */
16
+ import React from 'react';
17
+ import { AbsoluteFill, Video, staticFile, useVideoConfig } from 'remotion';
18
+ import { mola, ruido } from './natural';
19
+
20
+ const lerp = (a, b, k) => a + (b - a) * k;
21
+ const clamp = (x, a, b) => Math.max(a, Math.min(b, x));
22
+ const CAMPOS = ['x', 'y', 'w', 'h', 'raio', 'anel', 'zoom', 'fx', 'fy'];
23
+
24
+ /** A caixa no instante t (s) da cena. */
25
+ export function caixaNoTempo(el, t) {
26
+ const B = el.agora || el.antes || {};
27
+ const A = el.antes || B;
28
+ if (!el.mudou) return { ...B };
29
+ const s = t - 0.05;
30
+ // A mola com peso: um pouco de sobra, e assenta em ~0,6 s.
31
+ const k = mola(s, { freq: 1.55, amort: 0.62 });
32
+ const kc = clamp(k, 0, 1);
33
+ // A antecipação: antes de ir, a caixa encolhe um tico (o "respiro" do gesto).
34
+ const prep = s > 0 && s < 0.14 ? Math.sin(Math.PI * (s / 0.14)) * 0.035 : 0;
35
+ const o = {};
36
+ for (const c of CAMPOS) o[c] = lerp(A[c] ?? B[c] ?? 0, B[c] ?? A[c] ?? 0, k);
37
+ o.raio = Math.max(0, o.raio);
38
+ o.anel = Math.max(0, o.anel);
39
+ o.w = Math.max(1, o.w); o.h = Math.max(1, o.h);
40
+ o.opacidade = lerp(A.opacidade ?? 1, B.opacidade ?? 1, kc);
41
+ o.escala = Math.max(0, lerp(A.escala ?? 1, B.escala ?? 1, k)) * (1 - prep);
42
+ return o;
43
+ }
44
+
45
+ export function Rosto({ el, t, fps }) {
46
+ const c = caixaNoTempo(el, t);
47
+ const ac = el.acento || '#8B7CF6';
48
+ const origem = el.origem || {};
49
+ const sw = origem.largura || 1080, sh = origem.altura || 1920;
50
+ // A gravação cobre a caixa (cover) com o zoom, e o rosto (fx, fy) vai para o centro, acima do meio (o queixo longe da legenda).
51
+ const s = Math.max(c.w / sw, c.h / sh) * Math.max(1, c.zoom || 1);
52
+ const vw = sw * s, vh = sh * s;
53
+ const left = clamp(c.w * 0.5 - (c.fx ?? 0.5) * vw, c.w - vw, 0);
54
+ const top = clamp(c.h * 0.38 - (c.fy ?? 0.4) * vh, c.h - vh, 0);
55
+ // A bolinha e o cartão flutuam de leve: parado de todo parece colado.
56
+ const flutua = el.forma === 'bolinha' || el.forma === 'cartao' ? ruido(t * 0.35, 11) * 4 : 0;
57
+ const anel = c.anel;
58
+ const brilho = anel > 0
59
+ ? `0 0 ${Math.round(18 + anel * 5)}px ${ac}88, 0 0 ${Math.round(60 + anel * 10)}px ${ac}33, 0 26px 70px rgba(0,0,0,.6)`
60
+ : 'none';
61
+ return (
62
+ <div style={{
63
+ position: 'absolute', left: c.x, top: c.y + flutua, width: c.w, height: c.h,
64
+ transform: `scale(${c.escala})`, transformOrigin: '50% 50%', opacity: c.opacidade,
65
+ borderRadius: c.raio, padding: anel,
66
+ // A borda que brilha: um degradê do acento ao branco, por trás do vídeo.
67
+ background: anel > 0 ? `linear-gradient(150deg, #ffffffcc, ${ac} 38%, ${ac}aa 70%, #ffffff88)` : 'transparent',
68
+ boxShadow: brilho, boxSizing: 'border-box',
69
+ }}>
70
+ <div style={{ position: 'relative', width: '100%', height: '100%', overflow: 'hidden', borderRadius: Math.max(0, c.raio - anel), background: '#000' }}>
71
+ <Video
72
+ src={staticFile(el.src)}
73
+ startFrom={el.deOrigem ? Math.round(el.deOrigem * fps) : undefined}
74
+ volume={el.volume ?? 1}
75
+ style={{ position: 'absolute', left, top, width: vw, height: vh, maxWidth: 'none' }}
76
+ />
77
+ </div>
78
+ </div>
79
+ );
80
+ }
81
+
82
+ /** O palco: preto azulado, dois brilhos que derivam devagar, vinheta e grão. */
83
+ export function Palco({ el, t }) {
84
+ const { width: W, height: H } = useVideoConfig();
85
+ const ac = el.acento || '#8B7CF6';
86
+ const dx = ruido(t * 0.12, 3 + (el.semente || 0)) * 7;
87
+ const dy = ruido(t * 0.1, 7 + (el.semente || 0)) * 5;
88
+ const quadro = Math.floor(t * 24);
89
+ const id = 'grao-' + (el.semente || 0);
90
+ return (
91
+ <AbsoluteFill style={{ background: '#07060c', overflow: 'hidden' }}>
92
+ <div style={{ position: 'absolute', inset: 0, background:
93
+ `radial-gradient(55% 38% at ${50 + dx}% ${58 + dy}%, ${ac}40, transparent 72%),`
94
+ + `radial-gradient(45% 30% at ${16 - dx}% ${16 - dy}%, #4f46e526, transparent 70%),`
95
+ + `radial-gradient(70% 45% at ${80 + dy}% ${96}%, #1d4ed838, transparent 72%)` }} />
96
+ <div style={{ position: 'absolute', inset: 0, background: 'radial-gradient(120% 80% at 50% 45%, transparent 55%, rgba(0,0,0,.55) 100%)' }} />
97
+ <svg width={W} height={H} style={{ position: 'absolute', inset: 0, opacity: el.grao ?? 0.13, mixBlendMode: 'screen' }}>
98
+ <filter id={id}>
99
+ <feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" seed={quadro % 61} stitchTiles="stitch" />
100
+ <feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1.1 -0.45" />
101
+ </filter>
102
+ <rect width="100%" height="100%" filter={`url(#${id})`} />
103
+ </svg>
104
+ </AbsoluteFill>
105
+ );
106
+ }
107
+
108
+ export const ROSTO = { rosto: Rosto, palco: Palco };
@@ -65,9 +65,12 @@ const PECAS = {
65
65
  voxel: { precisa: ['partitura'] },
66
66
  // A animação pronta da biblioteca (kit/animacao.jsx, colecao.js): o Lottie tocado quadro a quadro.
67
67
  animacao: { precisa: ['src'] },
68
+ // O vídeo em camadas (kit/rosto.jsx, camadas.js): a gravação que muda de forma, e o palco escuro atrás dela.
69
+ rosto: { precisa: ['src'] },
70
+ palco: {},
68
71
  };
69
72
 
70
- const ARQUIVOS = ['base.jsx', 'vox.jsx', 'marca.jsx', 'tres.jsx', 'gerativo.jsx', 'fluido.jsx', 'personagem.jsx', 'mundo.jsx', 'voxel.jsx', 'animacao.jsx', 'natural.js', 'index.jsx'];
73
+ const ARQUIVOS = ['base.jsx', 'vox.jsx', 'marca.jsx', 'tres.jsx', 'gerativo.jsx', 'fluido.jsx', 'personagem.jsx', 'mundo.jsx', 'voxel.jsx', 'animacao.jsx', 'rosto.jsx', 'natural.js', 'index.jsx'];
71
74
 
72
75
  /** Os arquivos do kit, para o projeto Remotion: [{nome: 'kit/base.jsx', conteudo}]. */
73
76
  function arquivos() {
@@ -94,7 +94,9 @@ async function narrar(lista, mp3, { voz, uv, espeak = acharEspeak(), executar =
94
94
  roteiro pula as outras. */
95
95
  function ordem(voz, env = process.env, { soEdge = false } = {}) {
96
96
  if (soEdge) return ['edge'];
97
- return querKokoro(voz, env) ? ['kokoro', 'edge'] : ['gemini', 'edge', 'kokoro'];
97
+ if (querKokoro(voz, env)) return ['kokoro', 'edge'];
98
+ // O VoiceStudio local vem primeiro (15/10); fechado, a cadeia segue para o Gemini na hora.
99
+ return env.PRIMOCODE_SEM_VOICESTUDIO ? ['gemini', 'edge', 'kokoro'] : ['voicestudio', 'gemini', 'edge', 'kokoro'];
98
100
  }
99
101
 
100
102
  /* A voz do Gemini no Edge: o narrar.py só conhece antonio, francisca e
@@ -108,9 +108,9 @@ import { KIT, CURVAS_KIT, Filtros } from './kit';
108
108
  import { COMPONENTES } from './componentes';
109
109
 
110
110
  /* ── SUAVIZAÇÃO ──────────────────────────────────────────────────────────
111
- * Cada curva é uma função de 0..1 em 0..1. "suave" é o padrão porque
112
- * movimento linear é o que mais denuncia animação feita por máquina: nada no
113
- * mundo físico começa e para na mesma velocidade. */
111
+ * Cada curva é uma função de 0..1 em 0..1. A "editorial" é o padrão (15/10):
112
+ * movimento linear denuncia máquina, e o entra-e-sai devagar da "suave" lia
113
+ * como fade; as referências atacam rápido e desaceleram forte. */
114
114
  const CURVAS = {
115
115
  linear: (t) => t,
116
116
  suave: (t) => (t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2),
@@ -143,7 +143,9 @@ function valorEm(trilha, t, padrao) {
143
143
  const a = trilha[i], b = trilha[i + 1];
144
144
  if (t >= a.t && t <= b.t) {
145
145
  const bruto = b.t === a.t ? 1 : (t - a.t) / (b.t - a.t);
146
- const curva = CURVAS[b.e || a.e || 'suave'] || CURVAS.suave;
146
+ /* (15/10, as referências) Sem curva dita, a editorial: ataque rápido e
147
+ desaceleração forte. A "suave" (entra e sai devagar) lia como fade. */
148
+ const curva = CURVAS[b.e || a.e || 'editorial'] || CURVAS.editorial;
147
149
  const k = curva(bruto);
148
150
  /* Texto no lugar do número: é COR ("#ff6a00") ou CAMINHO (o "d" de um
149
151
  SVG). A cor mistura; o caminho se deforma (morfar.js): a bolinha
@@ -16,6 +16,8 @@
16
16
  * avisa a mesma sequência de elementos em 4+ cenas seguidas (cara de template)
17
17
  * avisa imagem sem a pessoa pedir, ou imagem em mais da metade das cenas
18
18
  * conserta efeito sonoro demais (mais de 1 a cada 1,5 s) → ficam os mais altos
19
+ * avisa texto pequeno demais para o celular (< 2% da altura no 9:16)
20
+ * avisa texto com contraste abaixo de 3:1 contra o fundo da cena
19
21
  * Puro: o plano entra, o plano consertado e a lista saem.
20
22
  */
21
23
 
@@ -34,6 +36,62 @@ const palavras = (t) => String(t || '').split(/\s+/).filter(Boolean).length;
34
36
  // Imagem ou vídeo DE FORA: o vídeo da própria gravação (deOrigem) e o recorte da pessoa não contam.
35
37
  const imagemDeFora = (el) => el && (el.tipo === 'imagem' || (el.tipo === 'video' && el.deOrigem == null && !/pessoa-/.test(el.src || '')));
36
38
 
39
+ /* O TEXTO QUE NÃO SE LÊ (15/10, as referências): rótulo de 1,6% da altura
40
+ ("NO COADOR") e laranja sobre bege. Nas referências a palavra principal
41
+ ocupa 55–85% da largura e a segunda voz ~5% da altura, sempre em contraste
42
+ duro. Contraste é o da WCAG para texto grande (3:1). */
43
+ const hex6 = (c) => {
44
+ const m = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(String(c || '').trim());
45
+ if (!m) return null;
46
+ const h = m[1].length === 3 ? m[1].split('').map((x) => x + x).join('') : m[1];
47
+ return [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16) / 255);
48
+ };
49
+ const luminancia = (c) => {
50
+ const v = hex6(c);
51
+ if (!v) return null;
52
+ const [r, g, b] = v.map((x) => (x <= 0.03928 ? x / 12.92 : Math.pow((x + 0.055) / 1.055, 2.4)));
53
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
54
+ };
55
+ function contraste(a, b) {
56
+ const la = luminancia(a), lb = luminancia(b);
57
+ if (la == null || lb == null) return null;
58
+ return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
59
+ }
60
+ const corDoFundo = (f) => (typeof f === 'string' ? f : f && (f.valor || f.cor)) || null;
61
+
62
+ function revisarLeitura(plano, avisos) {
63
+ const W = Number(plano.formato && plano.formato.largura) || 1920, H = Number(plano.formato && plano.formato.altura) || 1080;
64
+ const piso = H * (W > H ? 0.024 : 0.019);
65
+ const curto = (t) => String(t || '').slice(0, 24);
66
+ const pequenos = [], apagados = [];
67
+ (plano.cenas || []).forEach((c, i) => {
68
+ const fundo = corDoFundo(c.fundo) || plano.fundoGeral;
69
+ for (const e of c.elementos || []) {
70
+ if (!e) continue;
71
+ if (e.tipo === 'texto') {
72
+ const tam = Number(e.tamanho || (e.estilo && e.estilo.tamanho));
73
+ if (tam && tam < piso) pequenos.push(`cena ${i + 1} "${curto(e.texto)}" ${r2((tam / H) * 100)}%`);
74
+ const k = contraste(e.cor || (e.estilo && e.estilo.cor), fundo);
75
+ if (k != null && k < 3) apagados.push(`cena ${i + 1} "${curto(e.texto)}" ${r2(k)}:1`);
76
+ }
77
+ if (e.tipo === 'kit' && e.peca === 'gerativo' && e.partitura) {
78
+ const Hp = Number(e.partitura.altura) || H;
79
+ const pisoP = Hp * ((Number(e.partitura.largura) || W) > Hp ? 0.024 : 0.019);
80
+ for (const b of e.partitura.batidas || []) {
81
+ const cores = b.cores || {};
82
+ for (const l of b.linhas || []) {
83
+ if (l.tamanho && l.tamanho < pisoP) pequenos.push(`batida ${r2(b.de)}s "${curto(l.texto)}" ${r2((l.tamanho / Hp) * 100)}%`);
84
+ }
85
+ const k = contraste(cores.texto, cores.fundo);
86
+ if (k != null && k < 3) apagados.push(`batida ${r2(b.de)}s ${cores.texto} sobre ${cores.fundo} ${r2(k)}:1`);
87
+ }
88
+ }
89
+ }
90
+ });
91
+ if (pequenos.length) avisos.push(`texto pequeno demais para o celular (piso ~2% da altura): ${pequenos.slice(0, 3).join('; ')}${pequenos.length > 3 ? ` e mais ${pequenos.length - 3}` : ''}`);
92
+ if (apagados.length) avisos.push(`texto com contraste abaixo de 3:1 contra o fundo: ${apagados.slice(0, 3).join('; ')}${apagados.length > 3 ? ` e mais ${apagados.length - 3}` : ''}`);
93
+ }
94
+
37
95
  function revisar(plano, { semImagem = false } = {}) {
38
96
  const consertos = [], avisos = [];
39
97
  const cenas = (plano && plano.cenas) || [];
@@ -114,6 +172,9 @@ function revisar(plano, { semImagem = false } = {}) {
114
172
  if (semImagem && comImagem) avisos.push(`${comImagem} cena(s) com imagem de fora, mas a pessoa não pediu imagem`);
115
173
  else if (cenas.length >= 4 && comImagem / cenas.length > 0.5) avisos.push(`imagem em ${comImagem} de ${cenas.length} cenas — a imagem é um momento, não o vídeo inteiro`);
116
174
 
175
+ // 7. O texto que não se lê: pequeno demais ou sem contraste.
176
+ revisarLeitura(plano, avisos);
177
+
117
178
  return { plano, consertos, avisos };
118
179
  }
119
180