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 +2 -2
- package/lib/estudio/cinema.js +38 -3
- package/lib/estudio/index.js +73 -19
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# PrimoCode v9.8.0-beta.
|
|
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.
|
|
12
|
+
▐▛███▜▌ Primo Code v9.8.0-beta.23
|
|
13
13
|
▝▜█████▛▘ Bem-vindo de volta, Joel
|
|
14
14
|
▘▘ ▝▝ ~/primocode · /dir <pasta> muda
|
|
15
15
|
```
|
package/lib/estudio/cinema.js
CHANGED
|
@@ -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
|
|
42
|
-
|
|
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 };
|
package/lib/estudio/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
707
|
-
|
|
708
|
-
|
|
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
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
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
|
-
|
|
796
|
-
const iniW = ws.find((w) => w.de >= c.de -
|
|
797
|
-
|
|
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 (
|
|
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.
|
|
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.
|
|
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
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
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.
|
|
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": {
|