primocode 9.8.0-beta.21 → 9.8.0-beta.23

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.
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # PrimoCode v9.8.0-beta.21
1
+ # PrimoCode v9.8.0-beta.23
2
2
 
3
3
  Agente de engenharia brasileiro para o terminal, no estilo Claude Code.
4
4
  Cria arquivos de verdade, roda comandos, **controla o navegador e o desktop** —
@@ -9,7 +9,7 @@ OpenRouter, e cada apelido (`top`/`main`/`fast`) é uma **corrente**: se um mode
9
9
  está no limite de pedidos, o próximo atende. Você não vê o erro, e não paga nada.
10
10
 
11
11
  ```
12
- ▐▛███▜▌ Primo Code v9.8.0-beta.21
12
+ ▐▛███▜▌ Primo Code v9.8.0-beta.23
13
13
  ▝▜█████▛▘ Bem-vindo de volta, Joel
14
14
  ▘▘ ▝▝ ~/primocode · /dir <pasta> muda
15
15
  ```
@@ -35,11 +35,46 @@ const tokens = (t) => String(t || '').normalize('NFD').replace(/[̀-ͯ]/g, '').t
35
35
  const leitura = (t) => 0.55 + 0.22 * String(t || '').split(/\s+/).filter(Boolean).length;
36
36
 
37
37
  /** A fala desta parte (o que a voz diz): a "fala", ou os textos da tela. */
38
+ /* A voz lê EXATAMENTE o que está na tela, na ordem da tela ("a narração tem
39
+ que ser a mesma dos textos", 25/09). Caixa alta vira frase normal (senão a
40
+ voz soletra), "IA" fica "IA", o separador vira pausa e "/SC" sai da fala. */
41
+ function paraFala(x) {
42
+ let t = String(x || '').replace(/\b0(\d)\b/g, '$1').replace(/[·→]/g, ',').replace(/\/(SC|SP|RJ|PR|RS|MG)\b/g, '').replace(/\s+,/g, ',').trim();
43
+ if (t && t === t.toUpperCase()) {
44
+ // Sigla de até 3 letras fica sigla ("AI" minúsculo a voz lê como "ai").
45
+ const SIGLAS = new Set(['IA', 'AI', 'TI', 'RH', 'CEO', 'KPI', 'BI', 'CRM', 'ERP', 'SC', 'SP', 'PR', 'RS', 'RJ', 'MG']);
46
+ t = t.split(/(\s+)/).map((w) => (SIGLAS.has(w.replace(/[:.,!?]$/, '')) ? w : w.toLowerCase())).join('')
47
+ .replace(/(^|[.!?:]\s+)(\p{L})/gu, (m, a, b) => a + b.toUpperCase());
48
+ }
49
+ t = t.replace(/[,;:\s]+$/, '');
50
+ if (!/[.!?]$/.test(t)) t += '.';
51
+ return t;
52
+ }
38
53
  function falaDe(p) {
39
54
  if (p.fala) return p.fala;
40
55
  if (p.tipo === 'depoimento') return '';
41
- const partes = [p.antes, ...(p.linhas || []), ...(p.itens || []), p.num ? `Dia ${p.num}.` : '', p.titulo, p.final];
42
- return partes.filter(Boolean).map((x) => String(x).replace(/[·→]/g, ',')).join(' ');
56
+ const tela = p.tipo === 'dia' ? [`Dia ${p.num}`, p.titulo, ...(p.linhas || [])]
57
+ : p.tipo === 'escada' ? [...(p.itens || []), p.final]
58
+ : p.tipo === 'numeros' ? [p.antes, ...(p.itens || [])]
59
+ : (p.linhas || []);
60
+ /* A linha só emenda na seguinte quando não termina em pontuação E a
61
+ seguinte começa em minúscula ("E se 3 dias fossem suficientes" / "para
62
+ mudar…"); o resto é frase inteira. A frase repetida em seguida
63
+ ("CONSTRUIR" / "Construir.") a voz diz uma vez só. */
64
+ const linhas = tela.filter(Boolean).map((x) => String(x).trim());
65
+ const frases = [];
66
+ let atual = '';
67
+ linhas.forEach((bruto, k) => {
68
+ const prox = linhas[k + 1];
69
+ const emenda = !/[.!?…·→:]$/.test(bruto) && prox && /^[a-zà-ú]/.test(prox);
70
+ let pedaco = paraFala(bruto);
71
+ if (emenda) pedaco = pedaco.replace(/\.$/, '');
72
+ atual = atual ? `${atual} ${pedaco.charAt(0).toLowerCase()}${pedaco.slice(1)}` : pedaco;
73
+ if (!emenda) { frases.push(atual); atual = ''; }
74
+ });
75
+ if (atual) frases.push(atual);
76
+ const chave = (f) => f.toLowerCase().replace(/[^\p{L}\p{N}]/gu, '');
77
+ return frases.filter((f, k) => k === 0 || chave(f) !== chave(frases[k - 1])).join(' ');
43
78
  }
44
79
 
45
80
  /* Quando cada texto começa, pelo tempo das palavras da voz: procura a
@@ -351,4 +386,4 @@ function montarCinema({ roteiro, largura = 1920, altura = 1080, tempos = [], nar
351
386
  };
352
387
  }
353
388
 
354
- module.exports = { montarCinema, falaDe, quando, leitura };
389
+ module.exports = { montarCinema, falaDe, paraFala, quando, leitura };
@@ -701,11 +701,21 @@ function alinharPartes(lista, palavras, total) {
701
701
  for (let i = 1; i < lista.length; i++) {
702
702
  acum += chars[i - 1];
703
703
  const esperado = Math.round(ws.length * acum / soma);
704
- const alvo = tokens(lista[i].texto).find((w) => w.length >= 3) || tokens(lista[i].texto)[0];
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);
705
708
  let achado = -1;
706
- // Procura perto de onde a proporção diz que a parte começa.
707
- for (let j = Math.max(cursor + 1, esperado - 12); j < Math.min(ws.length, esperado + 12); j++) {
708
- if (ws[j].k === alvo) { achado = j; break; }
709
+ 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--;
709
719
  }
710
720
  const idx = achado > cursor ? achado : Math.max(cursor + 1, Math.min(ws.length - 1, esperado));
711
721
  inicios.push(idx);
@@ -723,11 +733,22 @@ async function narrarComGemini(lista, mp3, { voz, direcao } = {}) {
723
733
  try {
724
734
  const cfg = require('../config').loadConfig();
725
735
  const api = require('../api');
726
- const r = await api.request(cfg.server, '/api/voz', {
727
- texto: lista.map((x) => x.texto).join('\n\n'),
728
- voz: VOZ_GEMINI[String(voz || '').toLowerCase()] || voz || 'Charon',
729
- ...(direcao ? { direcao } : {}),
730
- }, cfg.token);
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
+ }
731
752
  if (!r || !r.ok) return { ok: false, error: (r && r.erro) || 'sem voz no servidor' };
732
753
  const pcm = mp3.replace(/\.mp3$/, '.pcm');
733
754
  fs.writeFileSync(pcm, Buffer.from(r.audio, 'base64'));
@@ -791,20 +812,30 @@ async function prepararDepoimento(p, c) {
791
812
  try { fs.unlinkSync(amostra); } catch { /* fica */ }
792
813
  const base0 = Math.max(0, c.de - 1.5);
793
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. */
794
818
  const alvo = tokens(c.frase || '');
795
- // A primeira palavra da frase depois do começo pedido, e a última antes do fim pedido.
796
- const iniW = ws.find((w) => w.de >= c.de - 0.6 && (!alvo.length || alvo.includes(tokens(w.texto)[0])));
797
- const fimWs = ws.filter((w) => w.ate <= c.ate + 0.8);
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);
798
824
  if (iniW) de = Math.max(0, iniW.de - 0.08);
799
- if (fimWs.length) ate = fimWs[fimWs.length - 1].ate;
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
+ if (ditas.split(/\s+/).length >= 3) c.fraseDita = ditas.replace(/^./, (x) => x.toUpperCase()).replace(/[,;]$/, '.');
800
830
  } catch { /* fica o trecho pedido */ }
801
- const dur = ate - de + 0.7; // o respiro depois da última palavra
831
+ const dur = ate - de + 0.45; // um respiro curto depois da última palavra
802
832
  const saida = path.join(p.pasta, 'midia', `depo-${path.basename(c.src, path.extname(c.src))}-${Math.round(de)}.mp4`);
803
833
  const r = await video.rodar(video.ffmpeg(), ['-y', '-ss', String(de), '-t', String(dur), '-i', origem,
804
- '-af', `loudnorm=I=-14:TP=-1.5:LRA=9,afade=t=in:d=0.12,afade=t=out:st=${Math.max(0, dur - 0.55).toFixed(2)}:d=0.55`,
834
+ '-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`,
805
835
  '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '18', '-c:a', 'aac', '-b:a', '192k', saida], { timeout: 5 * 60000 });
806
836
  if (!r.ok) return {};
807
- return { src: path.relative(p.pasta, saida).replace(/\\/g, '/'), de: 0, ate: Math.round((dur - 0.3) * 100) / 100, pronto: true };
837
+ return { src: path.relative(p.pasta, saida).replace(/\\/g, '/'), de: 0, ate: Math.round((dur - 0.3) * 100) / 100, pronto: true,
838
+ origem: { src: c.src, de: c.de, ate: c.ate, frase: c.frase }, ...(c.fraseDita ? { frase: c.fraseDita } : {}) };
808
839
  }
809
840
 
810
841
  /* A narração com cache (a mesma fala, na mesma direção, não é gerada duas vezes). */
@@ -946,6 +977,17 @@ async function prepararRoteiro(p, args) {
946
977
  if (fs.existsSync(cacheMp3) && fs.existsSync(cacheJson)) {
947
978
  fs.copyFileSync(cacheMp3, mp3);
948
979
  v = { ok: true, ...JSON.parse(fs.readFileSync(cacheJson, 'utf8')), doCache: true };
980
+ // Cache sem as palavras (vindo de um plano antigo): transcreve de novo e realinha — sem gastar voz.
981
+ if (!(v.partes || []).some((pt) => (pt.palavras || []).length)) {
982
+ try {
983
+ const cfg = require('../config').loadConfig();
984
+ const tr = await require('../api').requestTranscricao(cfg.server, { token: cfg.token, audio: fs.readFileSync(mp3).toString('base64'), nome: 'voz.mp3', idioma: 'pt' });
985
+ if (tr && (tr.palavras || []).length) {
986
+ v.partes = alinharPartes(lista, tr.palavras, v.segundos);
987
+ fs.writeFileSync(cacheJson, JSON.stringify({ voz: v.voz, segundos: v.segundos, partes: v.partes }));
988
+ }
989
+ } catch { /* fica o alinhamento guardado */ }
990
+ }
949
991
  } else {
950
992
  v = await narrarComGemini(lista, mp3, { voz: args.voz || roteiro.voz,
951
993
  direcao: 'Leia em português do Brasil como narrador de trailer de marca premium: voz grave, confiante e humana, '
@@ -997,6 +1039,15 @@ async function prepararRoteiro(p, args) {
997
1039
  }
998
1040
  } catch { /* o próximo termo */ }
999
1041
  }
1042
+ /* A MÚSICA mais baixa na origem: a faixa vem masterizada alta e comprimida,
1043
+ e mesmo a 30% ela cobria a voz e o depoimento ("a música tá muito
1044
+ alta, não dá pra ouvir as pessoas", 25/09). Ela vai a -28 LUFS antes
1045
+ da mistura; os volumes do plano partem daí. */
1046
+ if (trilhaC) {
1047
+ const norm = path.join(p.pasta, 'midia', 'trilha-baixa-' + path.basename(trilhaC).replace(/\.[a-z0-9]+$/i, '.mp3'));
1048
+ 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 });
1049
+ if (c.ok) trilhaC = path.relative(p.pasta, norm).replace(/\\/g, '/');
1050
+ }
1000
1051
  /* AS FOTOS, separadas e enquadradas. "Uma sala, 20 cadeiras" com foto de
1001
1052
  dashboard "não tem nada a ver" (25/09): foto de gente e print de tela
1002
1053
  são coisas diferentes, e a miniatura de depoimento fica de fora. E
@@ -1019,9 +1070,12 @@ async function prepararRoteiro(p, args) {
1019
1070
  if (pt.tipo === 'depoimento') continue;
1020
1071
  if (pt.falaForte) {
1021
1072
  const arqF = path.join(p.pasta, 'midia', `voz-forte-${i + 1}-${Date.now().toString(36)}.mp3`);
1022
- const vf = await narrarComCache([{ texto: pt.falaForte }], arqF, { voz: args.voz || roteiro.voz,
1023
- direcao: pt.entonacao || 'Leia em português do Brasil como a VIRADA de um trailer: voz grave, intensa e muito firme, '
1024
- + 'com peso em cada palavra, pausa dramática no meio, sem gritar.' });
1073
+ // falaForte: true → a voz forte lê o texto da tela.
1074
+ const textoForte = pt.falaForte === true ? cinema.falaDe({ ...pt, fala: undefined }) : pt.falaForte;
1075
+ const vf = await narrarComCache([{ texto: textoForte }], arqF, { voz: args.voz || roteiro.voz,
1076
+ // 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).
1077
+ direcao: pt.entonacao || 'Leia em português do Brasil com a mesma voz e o mesmo ritmo de um narrador de trailer de marca: '
1078
+ + 'confiante e natural, só um pouco mais de ênfase nas palavras principais. Sem dramatizar.' });
1025
1079
  if (vf.ok) fortes[iNarr] = { src: path.relative(p.pasta, arqF).replace(/\\/g, '/'), segundos: vf.segundos, palavras: (vf.partes[0] || {}).palavras || [] };
1026
1080
  }
1027
1081
  iNarr++;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "primocode",
3
- "version": "9.8.0-beta.21",
3
+ "version": "9.8.0-beta.23",
4
4
  "description": "PrimoCode — agente de engenharia com IA e cursor próprio. Requer conta Conecta Primo AI (Premium ou Super). Cria arquivos, roda comandos, controla navegador e desktop: abre apps, clica em botões e ícones pelo nome, digita e usa atalhos.",
5
5
  "main": "bin/primocode.js",
6
6
  "bin": {