primocode 9.8.0-beta.11 → 9.8.0-beta.12
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/claude-engine.js +25 -3
- package/lib/estudio/edicao.js +84 -14
- package/lib/estudio/index.js +36 -1
- package/lib/estudio/remotion.js +1 -0
- package/lib/local/motor-cli.js +1 -1
- package/package.json +1 -1
- package/studio/narrar.py +110 -10
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# PrimoCode v9.8.0-beta.
|
|
1
|
+
# PrimoCode v9.8.0-beta.12
|
|
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.12
|
|
13
13
|
▝▜█████▛▘ Bem-vindo de volta, Joel
|
|
14
14
|
▘▘ ▝▝ ~/primocode · /dir <pasta> muda
|
|
15
15
|
```
|
package/lib/claude-engine.js
CHANGED
|
@@ -61,6 +61,28 @@ function argsDoModelo() {
|
|
|
61
61
|
return MODELO_CLAUDE ? ['--model', MODELO_CLAUDE] : [];
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
+
/* O Claude Code ENXUTO, como motor. Sem isto cada passo do agente abria o
|
|
65
|
+
* Claude Code da pessoa inteiro: hooks, plugins, skills, CLAUDE.md dela e as
|
|
66
|
+
* ferramentas nativas — 36 mil tokens e ~6 s por chamada, e a resposta
|
|
67
|
+
* chegava com recado de hook pessoal ("context-sync pendente"). Medido em
|
|
68
|
+
* 25/09: com estes argumentos, a mesma chamada leva 473 tokens e ~3,5 s. Um
|
|
69
|
+
* pedido de 100 passos pagava aquilo 100 vezes — é a demora de 20 minutos.
|
|
70
|
+
*
|
|
71
|
+
* Nada disto tira o que o motor precisa: o protocolo vai inteiro no prompt
|
|
72
|
+
* (corpoDoProtocolo), e as ferramentas que valem são as do PrimoCode, não as
|
|
73
|
+
* do Claude Code. */
|
|
74
|
+
const ARGS_ENXUTOS = [
|
|
75
|
+
'--setting-sources', 'project',
|
|
76
|
+
'--strict-mcp-config',
|
|
77
|
+
'--disable-slash-commands',
|
|
78
|
+
'--tools', '',
|
|
79
|
+
'--no-session-persistence',
|
|
80
|
+
'--system-prompt', 'Você é o motor de decisão do PrimoCode. Siga à risca o protocolo que vem na mensagem e responda só com o bloco pedido.',
|
|
81
|
+
];
|
|
82
|
+
function argsDoMotor() {
|
|
83
|
+
return [...ARGS_ENXUTOS, ...argsDoModelo()];
|
|
84
|
+
}
|
|
85
|
+
|
|
64
86
|
/**
|
|
65
87
|
* Acha o ÚLTIMO bloco marcado no texto (pode vir ruído de terminal — prompt
|
|
66
88
|
* ANSI, texto solto — antes e depois) e devolve o JSON de dentro dele.
|
|
@@ -567,7 +589,7 @@ async function requestActViaHeadless(payload, onEvent) {
|
|
|
567
589
|
'-p',
|
|
568
590
|
'--output-format', 'json',
|
|
569
591
|
'--dangerously-skip-permissions',
|
|
570
|
-
...
|
|
592
|
+
...argsDoMotor(),
|
|
571
593
|
], { encoding: 'utf-8', timeout: 180000, input: prompt, maxBuffer: 32 * 1024 * 1024,
|
|
572
594
|
env: process.env, ...SEM_APARECER });
|
|
573
595
|
const r = JSON.parse(saida);
|
|
@@ -577,7 +599,7 @@ async function requestActViaHeadless(payload, onEvent) {
|
|
|
577
599
|
const pedirCorrecao = (aviso) => {
|
|
578
600
|
const s = execFileSync('claude', [
|
|
579
601
|
'-p', '--output-format', 'json', '--dangerously-skip-permissions',
|
|
580
|
-
...
|
|
602
|
+
...argsDoMotor(),
|
|
581
603
|
], {
|
|
582
604
|
encoding: 'utf-8', timeout: 120000, maxBuffer: 32 * 1024 * 1024,
|
|
583
605
|
env: process.env,
|
|
@@ -622,7 +644,7 @@ module.exports = {
|
|
|
622
644
|
// o que veio torto. Reimplementar aquilo no outro motor seria manter dois
|
|
623
645
|
// protocolos que divergem na primeira correção de um deles.
|
|
624
646
|
linhaDoHistorico, nomesDasTools, promptDoProtocolo, promptDeCorrecao,
|
|
625
|
-
MODELO_CLAUDE, argsDoModelo,
|
|
647
|
+
MODELO_CLAUDE, argsDoModelo, argsDoMotor,
|
|
626
648
|
SKILL_DIR, SKILL_PATH, garantirSkill,
|
|
627
649
|
temClaudeCode, temOrca, orcaAlcancavel, escolherCaminho,
|
|
628
650
|
garantirEngine, requestAct, emitirDecisao,
|
package/lib/estudio/edicao.js
CHANGED
|
@@ -85,6 +85,23 @@ const ICONES = [
|
|
|
85
85
|
[['surpre', 'chocad', 'absurd'], 'exploding-head'],
|
|
86
86
|
];
|
|
87
87
|
|
|
88
|
+
/* O termo de busca da IMAGEM DE APOIO, pelo emoji que a palavra ganhou. Em
|
|
89
|
+
inglês de propósito: os acervos abertos respondem muito melhor assim. */
|
|
90
|
+
const FOTO = {
|
|
91
|
+
robot: 'humanoid robot', brain: 'human brain', 'light-bulb': 'light bulb idea', 'money-bag': 'money cash',
|
|
92
|
+
stopwatch: 'clock time', 'chart-increasing': 'business growth', 'chart-decreasing': 'stock market fall',
|
|
93
|
+
'bar-chart': 'data analytics', rocket: 'rocket launch', fire: 'fire flames', 'mobile-phone': 'smartphone hand',
|
|
94
|
+
laptop: 'laptop programming', 'globe-showing-americas': 'earth from space', eyes: 'eye closeup',
|
|
95
|
+
'speaker-high-volume': 'sound waves', memo: 'writing notebook', 'musical-notes': 'music concert',
|
|
96
|
+
'dog-face': 'puppy', 'cat-face': 'cat', 'red-heart': 'heart love', 'busts-in-silhouette': 'people crowd',
|
|
97
|
+
house: 'modern house', automobile: 'car road', books: 'library books', warning: 'warning sign',
|
|
98
|
+
package: 'objects on table', 'puzzle-piece': 'puzzle pieces', 'thinking-face': 'person thinking',
|
|
99
|
+
'magnifying-glass-tilted-left': 'magnifying glass', 'speech-balloon': 'smart speaker', 'clapper-board': 'watching tv',
|
|
100
|
+
gear: 'circuit board', calendar: 'city street people', 'graduation-cap': 'students classroom', trophy: 'trophy',
|
|
101
|
+
'flexed-biceps': 'strength', handshake: 'handshake', 'shopping-cart': 'shopping', briefcase: 'office work',
|
|
102
|
+
locked: 'cyber security', 'party-popper': 'party celebration', 'exploding-head': 'surprised face',
|
|
103
|
+
};
|
|
104
|
+
|
|
88
105
|
function limpa(palavra) {
|
|
89
106
|
return String(palavra || '').normalize('NFD').replace(/[̀-ͯ]/g, '')
|
|
90
107
|
.toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
@@ -110,6 +127,33 @@ function peso(palavra) {
|
|
|
110
127
|
return w.length + (iconeDe(palavra) ? 6 : 0);
|
|
111
128
|
}
|
|
112
129
|
|
|
130
|
+
/** O termo de busca da imagem de apoio para esta palavra, ou null. */
|
|
131
|
+
function termoDeApoio(palavra) {
|
|
132
|
+
const ic = iconeDe(palavra);
|
|
133
|
+
return ic ? FOTO[ic.split(':')[1]] || null : null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Em quais cenas entra imagem de apoio: uma a cada três, a partir da segunda.
|
|
137
|
+
const temApoio = (i) => i % 3 === 2;
|
|
138
|
+
|
|
139
|
+
function forteDe(g) {
|
|
140
|
+
let forte = null;
|
|
141
|
+
for (const p of g) if (peso(p.texto) > (forte ? peso(forte.texto) : 0)) forte = p;
|
|
142
|
+
return forte;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Os termos das imagens de apoio que o plano vai pedir — para baixar antes. */
|
|
146
|
+
function termosDeApoio(palavras) {
|
|
147
|
+
const lista = (palavras || []).filter((p) => p && p.texto && p.ate > p.de).sort((a, b) => a.de - b.de);
|
|
148
|
+
const termos = [];
|
|
149
|
+
frases(lista).forEach((g, i) => {
|
|
150
|
+
const f = forteDe(g);
|
|
151
|
+
const t = f && temApoio(i) ? termoDeApoio(f.texto) : null;
|
|
152
|
+
if (t && !termos.includes(t)) termos.push(t);
|
|
153
|
+
});
|
|
154
|
+
return termos;
|
|
155
|
+
}
|
|
156
|
+
|
|
113
157
|
const fimDeFrase = (texto) => /[.!?…]$/.test(String(texto || '').trim());
|
|
114
158
|
const r2 = (n) => Math.round(n * 100) / 100;
|
|
115
159
|
|
|
@@ -164,8 +208,8 @@ function camera(i, dur) {
|
|
|
164
208
|
}
|
|
165
209
|
|
|
166
210
|
const ESTILOS = {
|
|
167
|
-
viral: { icones: true, letreiro: true, sons: true, barra: true, destaque: '#FFD400', forte: '#00E0A4' },
|
|
168
|
-
limpo: { icones: false, letreiro: false, sons: false, barra: false, destaque: '#FFFFFF', forte: '#FFD400' },
|
|
211
|
+
viral: { icones: true, letreiro: true, sons: true, barra: true, apoio: true, destaque: '#FFD400', forte: '#00E0A4' },
|
|
212
|
+
limpo: { icones: false, letreiro: false, sons: false, barra: false, apoio: true, destaque: '#FFFFFF', forte: '#FFD400' },
|
|
169
213
|
};
|
|
170
214
|
|
|
171
215
|
/**
|
|
@@ -181,7 +225,7 @@ const ESTILOS = {
|
|
|
181
225
|
* @param {object} op.sons { pop, whoosh } — caminhos na pasta do projeto, opcionais
|
|
182
226
|
* @param {Set} op.iconesOk os nomes de ícone que existem de verdade (sem isso, todos valem)
|
|
183
227
|
*/
|
|
184
|
-
function montarEdicao({ src, duracao, palavras, largura = 1080, altura = 1920, estilo = 'viral', sons = {}, iconesOk = null }) {
|
|
228
|
+
function montarEdicao({ src, duracao, palavras, largura = 1080, altura = 1920, estilo = 'viral', sons = {}, iconesOk = null, imagens = {}, trilha = null }) {
|
|
185
229
|
const e = ESTILOS[estilo] || ESTILOS.viral;
|
|
186
230
|
const lista = (palavras || []).filter((p) => p && p.texto && p.ate > p.de).sort((a, b) => a.de - b.de);
|
|
187
231
|
if (!lista.length) return { ok: false, error: 'a gravação não tem fala transcrita para editar.' };
|
|
@@ -202,16 +246,35 @@ function montarEdicao({ src, duracao, palavras, largura = 1080, altura = 1920, e
|
|
|
202
246
|
const local = (t) => r2(Math.max(0, t - ini));
|
|
203
247
|
|
|
204
248
|
// A palavra forte da frase: a que mais carrega a ideia.
|
|
205
|
-
|
|
206
|
-
for (const p of g) if (peso(p.texto) > (forte ? peso(forte.texto) : 0)) forte = p;
|
|
249
|
+
const forte = forteDe(g);
|
|
207
250
|
|
|
208
251
|
const elementos = [{
|
|
209
252
|
tipo: 'video', src, x: 0, y: 0, largura: '100%', altura: '100%',
|
|
210
253
|
deOrigem: r2(ini), ateOrigem: r2(ini + dur), volume: 1,
|
|
211
254
|
}];
|
|
212
255
|
|
|
256
|
+
/* A IMAGEM DE APOIO: na hora da palavra forte, a tela vira a imagem do
|
|
257
|
+
que está sendo dito, com um zoom lento, e a legenda continua por
|
|
258
|
+
cima. É o "b-roll" das edições de videomaker. */
|
|
259
|
+
const apoio = forte && e.apoio && temApoio(i) ? imagens[termoDeApoio(forte.texto)] : null;
|
|
260
|
+
if (apoio) {
|
|
261
|
+
const de = local(forte.de - 0.1);
|
|
262
|
+
const vida = Math.min(2.2, dur - de);
|
|
263
|
+
if (vida > 0.8) {
|
|
264
|
+
elementos.push({
|
|
265
|
+
tipo: 'imagem', src: apoio, x: 0, y: 0, largura: '100%', altura: '100%', ajuste: 'cover',
|
|
266
|
+
de, ate: r2(de + vida),
|
|
267
|
+
anima: {
|
|
268
|
+
escala: [{ t: 0, v: 1.12 }, { t: vida, v: 1.0, e: 'suave' }],
|
|
269
|
+
opacidade: [{ t: 0, v: 0 }, { t: 0.12, v: 1 }, { t: vida - 0.15, v: 1 }, { t: vida, v: 0 }],
|
|
270
|
+
},
|
|
271
|
+
});
|
|
272
|
+
if (e.sons && sons.whoosh) efeitos.push({ t: r2(Math.max(0, ini + de - 0.1)), src: sons.whoosh, volume: 0.55 });
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
213
276
|
// O ícone salta na hora da palavra; sem ícone, o letreiro da ideia.
|
|
214
|
-
const icone = forte && e.icones ? iconeDe(forte.texto) : null;
|
|
277
|
+
const icone = forte && e.icones && !apoio ? iconeDe(forte.texto) : null;
|
|
215
278
|
const temIcone = icone && (!iconesOk || iconesOk.has(icone));
|
|
216
279
|
const x = lado++ % 2 === 0 ? '78%' : '22%';
|
|
217
280
|
if (temIcone) {
|
|
@@ -229,7 +292,7 @@ function montarEdicao({ src, duracao, palavras, largura = 1080, altura = 1920, e
|
|
|
229
292
|
opacidade: [{ t: vida - 0.2, v: 1 }, { t: vida, v: 0 }],
|
|
230
293
|
},
|
|
231
294
|
});
|
|
232
|
-
if (e.sons && sons.pop) efeitos.push({ t: r2(ini + de), src: sons.pop, volume: 0.
|
|
295
|
+
if (e.sons && sons.pop) efeitos.push({ t: r2(ini + de), src: sons.pop, volume: 0.8 });
|
|
233
296
|
}
|
|
234
297
|
} else if (forte && e.letreiro && i % 2 === 0 && peso(forte.texto) >= 7) {
|
|
235
298
|
const de = local(forte.de);
|
|
@@ -245,12 +308,12 @@ function montarEdicao({ src, duracao, palavras, largura = 1080, altura = 1920, e
|
|
|
245
308
|
rotacao: [{ t: 0, v: -6 }, { t: 0.3, v: -3, e: 'saida' }],
|
|
246
309
|
},
|
|
247
310
|
});
|
|
248
|
-
if (e.sons && sons.pop) efeitos.push({ t: r2(ini + de), src: sons.pop, volume: 0.
|
|
311
|
+
if (e.sons && sons.pop) efeitos.push({ t: r2(ini + de), src: sons.pop, volume: 0.7 });
|
|
249
312
|
}
|
|
250
313
|
}
|
|
251
314
|
|
|
252
315
|
// A legenda palavra por palavra.
|
|
253
|
-
const fala = blocos(g,
|
|
316
|
+
const fala = blocos(g, 3).map((b, j, todos) => ({
|
|
254
317
|
de: local(b[0].de - 0.04),
|
|
255
318
|
ate: j < todos.length - 1 ? local(todos[j + 1][0].de - 0.04) : r2(dur),
|
|
256
319
|
texto: b.map((p) => p.texto).join(' '),
|
|
@@ -260,10 +323,10 @@ function montarEdicao({ src, duracao, palavras, largura = 1080, altura = 1920, e
|
|
|
260
323
|
})),
|
|
261
324
|
}));
|
|
262
325
|
elementos.push({
|
|
263
|
-
tipo: 'legenda', x: '50%', y: deitado ? '
|
|
326
|
+
tipo: 'legenda', x: '50%', y: deitado ? '76%' : '68%', ancora: 'centro', fala,
|
|
264
327
|
estilo: {
|
|
265
328
|
fonte: 'Montserrat', peso: 900, tamanho: Math.round(base * (deitado ? 0.078 : 0.072)),
|
|
266
|
-
destaque: e.destaque, corForte: e.forte, caixa: 'uppercase',
|
|
329
|
+
destaque: e.destaque, corForte: e.forte, caixa: 'uppercase', largura: '92%',
|
|
267
330
|
},
|
|
268
331
|
});
|
|
269
332
|
|
|
@@ -277,7 +340,7 @@ function montarEdicao({ src, duracao, palavras, largura = 1080, altura = 1920, e
|
|
|
277
340
|
}
|
|
278
341
|
|
|
279
342
|
// O whoosh entra no punch-in: é o corte que ele anuncia.
|
|
280
|
-
if (e.sons && sons.whoosh && i > 0 && i % 3 === 1) efeitos.push({ t: r2(Math.max(0, ini - 0.12)), src: sons.whoosh, volume: 0.
|
|
343
|
+
if (e.sons && sons.whoosh && i > 0 && i % 3 === 1) efeitos.push({ t: r2(Math.max(0, ini - 0.12)), src: sons.whoosh, volume: 0.6 });
|
|
281
344
|
|
|
282
345
|
cenas.push({ duracao: dur, camera: camera(i, dur), elementos, transicao: { tipo: 'corte' } });
|
|
283
346
|
});
|
|
@@ -288,11 +351,18 @@ function montarEdicao({ src, duracao, palavras, largura = 1080, altura = 1920, e
|
|
|
288
351
|
formato: { largura, altura, fps: 30 },
|
|
289
352
|
fundoGeral: '#000',
|
|
290
353
|
cenas,
|
|
291
|
-
...(efeitos.length ? { audio: {
|
|
354
|
+
...(efeitos.length || trilha ? { audio: {
|
|
355
|
+
...(efeitos.length ? { efeitos } : {}),
|
|
356
|
+
// A música fica BAIXA: quem fala para a câmera fala o vídeo
|
|
357
|
+
// inteiro, e a trilha é cama, não disputa.
|
|
358
|
+
...(trilha ? { trilha: { src: trilha, volume: 0.07, loop: true } } : {}),
|
|
359
|
+
} } : {}),
|
|
292
360
|
},
|
|
293
361
|
resumo: {
|
|
294
362
|
cenas: cenas.length,
|
|
295
363
|
icones: cenas.reduce((n, c) => n + c.elementos.filter((x) => x.tipo === 'icone').length, 0),
|
|
364
|
+
imagens: cenas.reduce((n, c) => n + c.elementos.filter((x) => x.tipo === 'imagem').length, 0),
|
|
365
|
+
musica: Boolean(trilha),
|
|
296
366
|
letreiros: cenas.reduce((n, c) => n + c.elementos.filter((x) => x.tipo === 'texto').length, 0),
|
|
297
367
|
efeitos: efeitos.length,
|
|
298
368
|
},
|
|
@@ -328,4 +398,4 @@ async function prepararSons(pasta, video) {
|
|
|
328
398
|
return sons;
|
|
329
399
|
}
|
|
330
400
|
|
|
331
|
-
module.exports = { montarEdicao, prepararSons, iconeDe, peso, frases, blocos, ESTILOS, ICONES };
|
|
401
|
+
module.exports = { montarEdicao, prepararSons, iconeDe, peso, frases, blocos, termosDeApoio, termoDeApoio, ESTILOS, ICONES, FOTO };
|
package/lib/estudio/index.js
CHANGED
|
@@ -413,6 +413,15 @@ async function ferramentaVideo(args, ctx = {}) {
|
|
|
413
413
|
const c = await video.cortarSilencio(alvo, saida, args);
|
|
414
414
|
if (c.ok && !c.semCorte) { fonte = saida; cortes = c.cortes || 0; }
|
|
415
415
|
}
|
|
416
|
+
/* A VOZ NO VOLUME CERTO. A gravação de celular sai baixa (medido em
|
|
417
|
+
25/09: -37,6 dB de média no vídeo pronto, e "está sem som"). O
|
|
418
|
+
loudnorm leva a fala a -15 LUFS, o volume de vídeo de internet; o
|
|
419
|
+
vídeo em si não é recodificado. */
|
|
420
|
+
const normal = path.join(p.pasta, 'midia', `voz-normal-${Date.now().toString(36)}.mp4`);
|
|
421
|
+
const ln = await video.rodar(video.ffmpeg(), ['-y', '-i', fonte, '-c:v', 'copy',
|
|
422
|
+
'-af', 'loudnorm=I=-15:TP=-1.5:LRA=11', '-c:a', 'aac', '-b:a', '192k', normal], { timeout: 10 * 60000 });
|
|
423
|
+
if (ln.ok) fonte = normal;
|
|
424
|
+
|
|
416
425
|
const m = await video.medir(fonte);
|
|
417
426
|
if (!m.ok) return m;
|
|
418
427
|
|
|
@@ -430,10 +439,36 @@ async function ferramentaVideo(args, ctx = {}) {
|
|
|
430
439
|
}
|
|
431
440
|
const sons = await edicao.prepararSons(p.pasta, video);
|
|
432
441
|
|
|
442
|
+
// As imagens de apoio: uma por termo, a primeira que baixar.
|
|
443
|
+
const imagens = {};
|
|
444
|
+
if ((args.estilo || 'viral') !== 'sem-apoio') {
|
|
445
|
+
for (const termo of edicao.termosDeApoio(palavras).slice(0, 8)) {
|
|
446
|
+
try {
|
|
447
|
+
const b = await assets.procurarImagens(termo, { quantas: 4 });
|
|
448
|
+
for (const item of (b.ok ? b.imagens : [])) {
|
|
449
|
+
const d = await assets.baixar(item, { pasta: p.pasta, nome: 'apoio-' + termo });
|
|
450
|
+
if (d.ok) { imagens[termo] = d.relativo; break; }
|
|
451
|
+
}
|
|
452
|
+
} catch { /* segue sem esta */ }
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// A música de fundo: uma trilha longa, de licença aberta.
|
|
457
|
+
let trilha = null;
|
|
458
|
+
if (args.musica !== false) {
|
|
459
|
+
try {
|
|
460
|
+
const b = await assets.procurarSom(args.trilha || 'upbeat corporate background', { quantas: 6, duracaoMin: 45 });
|
|
461
|
+
for (const item of (b.ok ? b.sons : [])) {
|
|
462
|
+
const d = await assets.baixar(item, { pasta: p.pasta, nome: 'trilha' });
|
|
463
|
+
if (d.ok) { trilha = d.relativo; break; }
|
|
464
|
+
}
|
|
465
|
+
} catch { /* segue sem música */ }
|
|
466
|
+
}
|
|
467
|
+
|
|
433
468
|
const montado = edicao.montarEdicao({
|
|
434
469
|
src: path.relative(p.pasta, fonte).replace(/\\/g, '/'),
|
|
435
470
|
duracao: m.segundos, palavras, largura: m.largura, altura: m.altura,
|
|
436
|
-
estilo: args.estilo || 'viral', sons, iconesOk,
|
|
471
|
+
estilo: args.estilo || 'viral', sons, iconesOk, imagens, trilha,
|
|
437
472
|
});
|
|
438
473
|
if (!montado.ok) return montado;
|
|
439
474
|
const salvo = await ferramentaPlano({ id: args.id, plano: montado.plano, motivo: `edição automática (${args.estilo || 'viral'})` });
|
package/lib/estudio/remotion.js
CHANGED
|
@@ -478,6 +478,7 @@ export const Video = () => {
|
|
|
478
478
|
<Audio
|
|
479
479
|
src={staticFile(plano.audio.trilha.src || plano.audio.trilha)}
|
|
480
480
|
volume={plano.audio.trilha.volume ?? 0.18}
|
|
481
|
+
loop={plano.audio.trilha.loop !== false}
|
|
481
482
|
/>
|
|
482
483
|
)}
|
|
483
484
|
{(plano.audio && plano.audio.efeitos ? plano.audio.efeitos : []).map((e, i) => (
|
package/lib/local/motor-cli.js
CHANGED
|
@@ -37,7 +37,7 @@ function motorDe(id) {
|
|
|
37
37
|
function perguntar(id, texto) {
|
|
38
38
|
if (id === 'codex') return String(motorDe('codex').chamarCodex(texto) || '').trim();
|
|
39
39
|
const claude = motorDe('claude-code');
|
|
40
|
-
const saida = execFileSync('claude', ['-p', '--output-format', 'json', ...claude.
|
|
40
|
+
const saida = execFileSync('claude', ['-p', '--output-format', 'json', ...claude.argsDoMotor()], {
|
|
41
41
|
encoding: 'utf-8', timeout: 120000, input: texto, maxBuffer: 8 * 1024 * 1024, ...SEM_APARECER,
|
|
42
42
|
});
|
|
43
43
|
try { return String(JSON.parse(saida).result || '').trim(); } catch { return String(saida || '').trim(); }
|
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.12",
|
|
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": {
|
package/studio/narrar.py
CHANGED
|
@@ -200,19 +200,22 @@ def falar_edge(texto, saida, voz, ritmo, tom):
|
|
|
200
200
|
|
|
201
201
|
def falar_elevenlabs(texto, saida, voz):
|
|
202
202
|
"""Só quando há chave. Sem ela, nem se tenta — e não é erro."""
|
|
203
|
-
chave
|
|
204
|
-
|
|
205
|
-
|
|
203
|
+
# A chave mora na variável OU em ~/.primocode/elevenlabs.key — antes só a
|
|
204
|
+
# variável era lida, e a função chamada (elevenlabs.falar) nem existia:
|
|
205
|
+
# a ElevenLabs nunca narrou nada, mesmo com a chave no lugar.
|
|
206
206
|
try:
|
|
207
207
|
sys.path.insert(0, str(AQUI))
|
|
208
|
-
import elevenlabs
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
208
|
+
import elevenlabs
|
|
209
|
+
if not elevenlabs.configurado():
|
|
210
|
+
return None
|
|
211
|
+
dados, segundos = elevenlabs.sintetizar(texto, {"voz": voz})
|
|
212
|
+
if not dados:
|
|
213
|
+
return None
|
|
214
|
+
Path(saida).write_bytes(dados)
|
|
215
|
+
return {"ok": True, "arquivo": str(saida), "motor": "elevenlabs", "segundos": segundos,
|
|
216
|
+
"falas": [{"de": 0, "ate": segundos, "texto": texto}]}
|
|
213
217
|
except Exception:
|
|
214
|
-
|
|
215
|
-
return None
|
|
218
|
+
return None
|
|
216
219
|
|
|
217
220
|
|
|
218
221
|
def comando_falar(args):
|
|
@@ -245,6 +248,96 @@ def comando_falar(args):
|
|
|
245
248
|
return falar_edge(texto, args.saida, v["voz"], ritmo, tom)
|
|
246
249
|
|
|
247
250
|
|
|
251
|
+
# ── o roteiro inteiro, com entonação por parte ─────────────────────────────
|
|
252
|
+
# "A voz está sem entonação." (25/09/2026). Uma chamada só, com o texto todo,
|
|
253
|
+
# sai no mesmo ritmo e no mesmo tom do começo ao fim. Aqui cada parte do
|
|
254
|
+
# roteiro é falada com o registro dela — o gancho mais aceso, o número mais
|
|
255
|
+
# devagar, o fecho mais alto — e as partes são costuradas com um respiro. O
|
|
256
|
+
# tempo de CADA PALAVRA volta junto (WordBoundary do edge-tts): é o que
|
|
257
|
+
# sincroniza a tipografia com a voz.
|
|
258
|
+
REGISTRO = {
|
|
259
|
+
"gancho": {"ritmo": "+6%", "tom": "+3Hz", "respiro": 0.35},
|
|
260
|
+
"numero": {"ritmo": "-7%", "tom": "-1Hz", "respiro": 0.45},
|
|
261
|
+
"contraste": {"ritmo": "+2%", "tom": "+1Hz", "respiro": 0.4},
|
|
262
|
+
"lista": {"ritmo": "+0%", "tom": "+0Hz", "respiro": 0.35},
|
|
263
|
+
"citacao": {"ritmo": "-10%", "tom": "-2Hz", "respiro": 0.5},
|
|
264
|
+
"cta": {"ritmo": "+4%", "tom": "+2Hz", "respiro": 0.2},
|
|
265
|
+
"normal": {"ritmo": "+0%", "tom": "+0Hz", "respiro": 0.3},
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _parte_edge(texto, voz, ritmo, tom, saida):
|
|
270
|
+
import asyncio
|
|
271
|
+
import edge_tts
|
|
272
|
+
|
|
273
|
+
async def rodar():
|
|
274
|
+
c = edge_tts.Communicate(texto, voz, rate=ritmo, pitch=tom, boundary="WordBoundary")
|
|
275
|
+
palavras = []
|
|
276
|
+
with open(saida, "wb") as fh:
|
|
277
|
+
async for ch in c.stream():
|
|
278
|
+
if ch["type"] == "audio":
|
|
279
|
+
fh.write(ch["data"])
|
|
280
|
+
elif ch["type"] == "WordBoundary":
|
|
281
|
+
de = ch["offset"] / 1e7
|
|
282
|
+
palavras.append({"de": round(de, 3), "ate": round(de + ch["duration"] / 1e7, 3),
|
|
283
|
+
"texto": ch["text"]})
|
|
284
|
+
return palavras
|
|
285
|
+
return asyncio.run(rodar())
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _duracao(arquivo):
|
|
289
|
+
r = subprocess.run(["ffprobe", "-v", "error", "-show_entries", "format=duration",
|
|
290
|
+
"-of", "csv=p=0", str(arquivo)], capture_output=True, text=True, timeout=30)
|
|
291
|
+
try:
|
|
292
|
+
return float(r.stdout.strip())
|
|
293
|
+
except ValueError:
|
|
294
|
+
return 0.0
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def comando_roteiro(args):
|
|
298
|
+
partes = json.loads(Path(args.arquivo).read_text(encoding="utf-8"))
|
|
299
|
+
if not partes:
|
|
300
|
+
return {"ok": False, "error": "o roteiro veio vazio"}
|
|
301
|
+
if not edge_disponivel() and not instalar_edge():
|
|
302
|
+
return {"ok": False, "error": "a narração precisa do pacote edge-tts",
|
|
303
|
+
"comoResolver": f"{sys.executable} -m pip install edge-tts"}
|
|
304
|
+
v = voz_de(args.voz)
|
|
305
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
306
|
+
lista, trechos, palavras, t = [], [], [], 0.0
|
|
307
|
+
for i, parte in enumerate(partes):
|
|
308
|
+
texto = re.sub(r"\s+", " ", str(parte.get("texto") or "")).strip()
|
|
309
|
+
if not texto:
|
|
310
|
+
trechos.append({"de": round(t, 3), "ate": round(t, 3), "palavras": []})
|
|
311
|
+
continue
|
|
312
|
+
reg = REGISTRO.get(parte.get("registro") or "normal", REGISTRO["normal"])
|
|
313
|
+
mp3 = Path(tmp) / f"p{i:02d}.mp3"
|
|
314
|
+
try:
|
|
315
|
+
ps = _parte_edge(texto, v["voz"], reg["ritmo"], reg["tom"], mp3)
|
|
316
|
+
except Exception as e:
|
|
317
|
+
return {"ok": False, "error": f"a voz falhou na parte {i + 1}: {str(e)[:200]}"}
|
|
318
|
+
dur = _duracao(mp3)
|
|
319
|
+
wav = Path(tmp) / f"p{i:02d}.wav"
|
|
320
|
+
# Cada parte vira wav com o respiro no fim: costurar mp3 direto
|
|
321
|
+
# deixa um clique e um silêncio de enchimento entre as partes.
|
|
322
|
+
subprocess.run(["ffmpeg", "-y", "-loglevel", "error", "-i", str(mp3), "-af",
|
|
323
|
+
f"apad=pad_dur={reg['respiro']}", "-ar", "44100", "-ac", "1", str(wav)],
|
|
324
|
+
capture_output=True, timeout=60)
|
|
325
|
+
lista.append(wav)
|
|
326
|
+
ws = [{"de": round(t + w["de"], 3), "ate": round(t + w["ate"], 3), "texto": w["texto"]} for w in ps]
|
|
327
|
+
palavras += ws
|
|
328
|
+
trechos.append({"de": round(t, 3), "ate": round(t + dur, 3), "palavras": ws})
|
|
329
|
+
t += dur + reg["respiro"]
|
|
330
|
+
roteiro_txt = Path(tmp) / "lista.txt"
|
|
331
|
+
roteiro_txt.write_text("".join(f"file '{w}'\n" for w in lista), encoding="utf-8")
|
|
332
|
+
r = subprocess.run(["ffmpeg", "-y", "-loglevel", "error", "-f", "concat", "-safe", "0",
|
|
333
|
+
"-i", str(roteiro_txt), "-c:a", "libmp3lame", "-q:a", "3", str(args.saida)],
|
|
334
|
+
capture_output=True, timeout=180)
|
|
335
|
+
if r.returncode != 0:
|
|
336
|
+
return {"ok": False, "error": (r.stderr or b"").decode("utf-8", "ignore")[-300:]}
|
|
337
|
+
return {"ok": True, "arquivo": str(args.saida), "motor": "edge", "voz": v["voz"],
|
|
338
|
+
"segundos": round(t, 3), "partes": trechos, "palavras": palavras}
|
|
339
|
+
|
|
340
|
+
|
|
248
341
|
def main():
|
|
249
342
|
p = argparse.ArgumentParser(description="Narração do PrimoCode Studio")
|
|
250
343
|
sub = p.add_subparsers(dest="comando", required=True)
|
|
@@ -258,11 +351,18 @@ def main():
|
|
|
258
351
|
f.add_argument("--ritmo", default="")
|
|
259
352
|
f.add_argument("--tom", default="")
|
|
260
353
|
|
|
354
|
+
r = sub.add_parser("roteiro")
|
|
355
|
+
r.add_argument("--arquivo", required=True)
|
|
356
|
+
r.add_argument("--saida", required=True)
|
|
357
|
+
r.add_argument("--voz", default="thalita")
|
|
358
|
+
|
|
261
359
|
sub.add_parser("vozes")
|
|
262
360
|
|
|
263
361
|
args = p.parse_args()
|
|
264
362
|
if args.comando == "vozes":
|
|
265
363
|
saida = {"ok": True, "vozes": VOZES, "estilos": list(ESTILOS.keys())}
|
|
364
|
+
elif args.comando == "roteiro":
|
|
365
|
+
saida = comando_roteiro(args)
|
|
266
366
|
else:
|
|
267
367
|
saida = comando_falar(args)
|
|
268
368
|
|