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 CHANGED
@@ -1,4 +1,4 @@
1
- # PrimoCode v9.8.0-beta.11
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.11
12
+ ▐▛███▜▌ Primo Code v9.8.0-beta.12
13
13
  ▝▜█████▛▘ Bem-vindo de volta, Joel
14
14
  ▘▘ ▝▝ ~/primocode · /dir <pasta> muda
15
15
  ```
@@ -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
- ...argsDoModelo(),
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
- ...argsDoModelo(),
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,
@@ -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
- let forte = null;
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.45 });
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.35 });
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, deitado ? 4 : 3).map((b, j, todos) => ({
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 ? '80%' : '70%', ancora: 'centro', fala,
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.3 });
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: { efeitos } } : {}),
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 };
@@ -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'})` });
@@ -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) => (
@@ -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.argsDoModelo()], {
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.11",
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 = os.environ.get("ELEVENLABS_API_KEY", "").strip()
204
- if not chave:
205
- return None
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 # o módulo que já existia no Studio
209
- r = elevenlabs.falar(texto, str(saida), voz=voz, chave=chave)
210
- if r and r.get("ok"):
211
- r["motor"] = "elevenlabs"
212
- return r
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
- pass
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