primocode 9.8.0-beta.9 → 9.8.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.
@@ -17,7 +17,7 @@
17
17
  *
18
18
  * ── A IDEIA ──────────────────────────────────────────────────────────────
19
19
  * Ninguém precisa das 45 ferramentas para fazer um slide. Quem pede um vídeo
20
- * não usa `desktop_click`; quem conserta um bug não usa `studio_criar`. Então
20
+ * não usa `desktop_click`; quem conserta um bug não usa `estudio_render`. Então
21
21
  * cada pedido leva só o que aquele pedido pode usar — ferramentas E seções do
22
22
  * prompt — e o que sobra do orçamento vira espaço para a conversa.
23
23
  *
@@ -74,10 +74,19 @@ const GRUPOS = [
74
74
  },
75
75
  {
76
76
  nome: 'estudio',
77
- tools: ['studio_manual', 'studio_spec', 'studio_criar', 'studio_ler',
78
- 'studio_atualizar', 'studio_versoes', 'studio_listar', 'studio_midia'],
79
- // O que o estúdio produz, e os apps que ele substitui.
80
- gatilho: /\b(slides?|apresenta[çc][ãa]o|deck|v[íi]deo|reels?|anima[çc][ãa]o|post|banner|capa|thumbnail|arte|cartaz|logo|planilha|tabela|or[çc]amento|documento|relat[óo]rio|artigo|proposta|contrato|diagrama|fluxograma|organograma|mapa mental|narra[çc][ãa]o|trilha|est[úu]dio|canva|powerpoint|keynote|word|docs|excel|sheets|photoshop|figma|capcut|premiere|miro)\b/i,
77
+ tools: ['estudio_projeto', 'estudio_plano', 'estudio_midia', 'estudio_narrar',
78
+ 'estudio_render', 'estudio_site', 'planejar_producao'],
79
+ // O que o estúdio produz, e os apps que ele substitui. Documento,
80
+ // planilha e PDF saíram: o Estúdio novo faz vídeo, slides e arte, e o
81
+ // resto é arquivo escrito com código.
82
+ gatilho: /\b(slides?|apresenta[çc][ãa]o|deck|v[íi]deo|reels?|anima[çc][ãa]o|motion|vinheta|teaser|an[úu]ncio|post|banner|capa|thumbnail|arte|cartaz|narra[çc][ãa]o|trilha|legenda|grava[çc][ãa]o|est[úu]dio|canva|powerpoint|keynote|photoshop|figma|capcut|premiere)\b/i,
83
+ },
84
+ {
85
+ /* A GRAVAÇÃO do usuário é outra ferramenta, e só entra quando o pedido
86
+ fala dela: "faz um vídeo" não precisa cortar silêncio de ninguém. */
87
+ nome: 'gravacao',
88
+ tools: ['estudio_video'],
89
+ gatilho: /\b(grava[çc][ãa]o|gravei|filmagem|filmei|meu v[íi]deo|edit\w* (o |a |meu |minha |esse |essa |este |esta )?(v[íi]deo|grava[çc][ãa]o)|\.mp4|\.mov|legendas?|cort(a|ar|e) (o )?sil[êe]ncio|recort(a|ar|e) (a )?pessoa|transcrev\w*|importa\w* (o )?v[íi]deo)\b/i,
81
90
  },
82
91
  {
83
92
  nome: 'navegador',
@@ -118,7 +127,7 @@ const GRUPOS = [
118
127
  quando o pedido fala do arquivo — é o que mantém o pedido de vídeo
119
128
  dentro do teto do minuto (medido: ela custa ~180 tokens). */
120
129
  nome: 'exportar',
121
- tools: ['studio_exportar'],
130
+ tools: ['estudio_render'],
122
131
  gatilho: /\b(mp4|webm|baix[ae]r?|download|exporta\w*|salv[ae]r? (o )?v[íi]deo|arquivo (do|de) v[íi]deo|me manda o v[íi]deo|manda o arquivo|renderiza\w*)\b/i,
123
132
  },
124
133
  {
@@ -196,7 +205,8 @@ function jaAprendeu(messages) {
196
205
  if (m && m.role === 'tool' && nomePorId.has(m.tool_call_id)) feitas.add(nomePorId.get(m.tool_call_id));
197
206
  }
198
207
  const dispensaveis = new Set();
199
- if (feitas.has('studio_spec') || feitas.has('studio_manual')) dispensaveis.add('estudio');
208
+ // O Estúdio novo não tem spec para ler: a seção dele é o único mapa do
209
+ // fluxo, e fica até o fim.
200
210
  if (feitas.has('pesquisar_referencia')) dispensaveis.add('pesquisa');
201
211
  return dispensaveis;
202
212
  }
@@ -253,7 +263,7 @@ function relatorio({ prompt, ferramentas, messages }) {
253
263
  // O CLI já protege as mesmas mensagens antes de mandar (lib/repeticao.js), mas
254
264
  // quem falava por último aqui era `caberNoTeto`, que não sabia disso e cortava
255
265
  // assim mesmo. Dois compactadores brigando, e o do servidor ganhava.
256
- const REFERENCIAS = new Set(['studio_manual', 'studio_spec']);
266
+ const REFERENCIAS = new Set(['planejar_producao', 'estudio_site']);
257
267
 
258
268
  // Referência tem teto GENEROSO, não infinito.
259
269
  //
@@ -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.9",
3
+ "version": "9.8.0",
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": {
@@ -8,7 +8,7 @@
8
8
  },
9
9
  "scripts": {
10
10
  "start": "node bin/primocode.js",
11
- "test": "node test/tools.test.js && node test/ui.test.js && node test/entrada.test.js && node test/transparencia.test.js && node test/permissao.test.js && node test/compatibilidade.test.js && node test/skills.test.js && node test/skills-de-fora.test.js && node test/skills-novas.test.js && node test/comandos.test.js && node test/estudio.test.js && node test/voz.test.js && node test/voz-conversa.test.js && node test/modo.test.js && node test/pasta.test.js && node test/projeto.test.js && node test/nome-projeto.test.js && node test/api-propria.test.js && node test/claude-engine.test.js && node test/codex.test.js && node test/conferir.test.js && node test/catalogo.test.js && node test/desktop.test.js && node test/seta.test.js && node test/effort.test.js && node test/regressao.test.js && node test/memoria-conversa.test.js && node test/repeticao.test.js && node test/autonomia.test.js && node test/desfazer.test.js && node test/desfazer-git.test.js && node test/referencia.test.js && node test/parar.test.js && node test/continuar.test.js && node test/pipeline.test.js && node test/app.test.js && node test/app-janela.test.js && node test/aplicativo.test.js && node test/conta.test.mjs && node test/nuvem.test.js && node test/prazo.test.js && node test/primeira-vez.test.js && node test/janela.test.js && node test/fala.test.js && node test/especialistas.test.js && node test/empurrao.test.js && node test/terminal-novo.test.js && node test/markdown.test.js && node test/coerencia.test.js && node test/contexto.test.js && node test/perigo.test.js && node test/rapidez.test.js && node test/mascotes.test.js && node test/comandos-extra.test.js && node test/offline.test.js"
11
+ "test": "node test/tools.test.js && node test/ui.test.js && node test/entrada.test.js && node test/transparencia.test.js && node test/permissao.test.js && node test/compatibilidade.test.js && node test/skills.test.js && node test/skills-de-fora.test.js && node test/skills-novas.test.js && node test/comandos.test.js && node test/estudio.test.js && node test/voz.test.js && node test/voz-conversa.test.js && node test/modo.test.js && node test/pasta.test.js && node test/projeto.test.js && node test/nome-projeto.test.js && node test/api-propria.test.js && node test/claude-engine.test.js && node test/codex.test.js && node test/conferir.test.js && node test/catalogo.test.js && node test/desktop.test.js && node test/seta.test.js && node test/effort.test.js && node test/regressao.test.js && node test/memoria-conversa.test.js && node test/repeticao.test.js && node test/autonomia.test.js && node test/desfazer.test.js && node test/desfazer-git.test.js && node test/referencia.test.js && node test/parar.test.js && node test/continuar.test.js && node test/pipeline.test.js && node test/app.test.js && node test/app-janela.test.js && node test/aplicativo.test.js && node test/conta.test.mjs && node test/nuvem.test.js && node test/prazo.test.js && node test/primeira-vez.test.js && node test/janela.test.js && node test/fala.test.js && node test/especialistas.test.js && node test/empurrao.test.js && node test/terminal-novo.test.js && node test/markdown.test.js && node test/coerencia.test.js && node test/contexto.test.js && node test/perigo.test.js && node test/rapidez.test.js && node test/mascotes.test.js && node test/comandos-extra.test.js && node test/offline.test.js && node test/edicao.test.js && node test/composicao.test.js && node test/acervo-site.test.js && node test/impacto.test.js && node test/cinema.test.js"
12
12
  },
13
13
  "engines": {
14
14
  "node": ">=18.17.0"
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,97 @@ 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), "-af", "loudnorm=I=-14:TP=-1.5:LRA=9",
334
+ "-c:a", "libmp3lame", "-q:a", "3", str(args.saida)],
335
+ capture_output=True, timeout=180)
336
+ if r.returncode != 0:
337
+ return {"ok": False, "error": (r.stderr or b"").decode("utf-8", "ignore")[-300:]}
338
+ return {"ok": True, "arquivo": str(args.saida), "motor": "edge", "voz": v["voz"],
339
+ "segundos": round(t, 3), "partes": trechos, "palavras": palavras}
340
+
341
+
248
342
  def main():
249
343
  p = argparse.ArgumentParser(description="Narração do PrimoCode Studio")
250
344
  sub = p.add_subparsers(dest="comando", required=True)
@@ -258,11 +352,18 @@ def main():
258
352
  f.add_argument("--ritmo", default="")
259
353
  f.add_argument("--tom", default="")
260
354
 
355
+ r = sub.add_parser("roteiro")
356
+ r.add_argument("--arquivo", required=True)
357
+ r.add_argument("--saida", required=True)
358
+ r.add_argument("--voz", default="thalita")
359
+
261
360
  sub.add_parser("vozes")
262
361
 
263
362
  args = p.parse_args()
264
363
  if args.comando == "vozes":
265
364
  saida = {"ok": True, "vozes": VOZES, "estilos": list(ESTILOS.keys())}
365
+ elif args.comando == "roteiro":
366
+ saida = comando_roteiro(args)
266
367
  else:
267
368
  saida = comando_falar(args)
268
369
 
@@ -0,0 +1,52 @@
1
+ #!/usr/bin/env python3
2
+ """rostos.py — onde estão as pessoas em cada foto.
3
+
4
+ "Aparecem três homens ali, ela está cortada, não dá pra ver ninguém."
5
+ (25/09/2026)
6
+
7
+ Foto deitada num vídeo em pé perde dois terços da largura. Recortar pelo
8
+ centro corta as pessoas. Aqui se acha o retângulo que cobre todos os rostos
9
+ da foto (MediaPipe, o mesmo do recorte da pessoa), e o vídeo enquadra nele —
10
+ ou, quando os rostos não cabem no recorte, mostra a foto inteira.
11
+
12
+ Uso:
13
+ python3 rostos.py foto1.webp foto2.jpg …
14
+ Saída (JSON): {"ok": true, "fotos": {"foto1.webp": {"largura", "altura", "n",
15
+ "cx", "cy", "x0", "x1", "y0", "y1"}}} (tudo de 0 a 1)
16
+ """
17
+
18
+ import json
19
+ import sys
20
+
21
+
22
+ def main():
23
+ try:
24
+ import cv2
25
+ import mediapipe as mp
26
+ except Exception as e: # sem as bibliotecas, ninguém é enquadrado — e não é erro
27
+ print(json.dumps({"ok": False, "error": f"sem mediapipe: {e}"}))
28
+ return
29
+ det = mp.solutions.face_detection.FaceDetection(model_selection=1, min_detection_confidence=0.45)
30
+ fotos = {}
31
+ for caminho in sys.argv[1:]:
32
+ img = cv2.imread(caminho)
33
+ if img is None:
34
+ continue
35
+ h, w = img.shape[:2]
36
+ r = det.process(cv2.cvtColor(img, cv2.COLOR_BGR2RGB))
37
+ caixas = []
38
+ for d in (r.detections or []):
39
+ b = d.location_data.relative_bounding_box
40
+ caixas.append((max(0, b.xmin), max(0, b.ymin), min(1, b.xmin + b.width), min(1, b.ymin + b.height)))
41
+ item = {"largura": w, "altura": h, "n": len(caixas)}
42
+ if caixas:
43
+ x0 = min(c[0] for c in caixas); y0 = min(c[1] for c in caixas)
44
+ x1 = max(c[2] for c in caixas); y1 = max(c[3] for c in caixas)
45
+ item.update({"x0": round(x0, 3), "y0": round(y0, 3), "x1": round(x1, 3), "y1": round(y1, 3),
46
+ "cx": round((x0 + x1) / 2, 3), "cy": round((y0 + y1) / 2, 3)})
47
+ fotos[caminho] = item
48
+ print(json.dumps({"ok": True, "fotos": fotos}))
49
+
50
+
51
+ if __name__ == "__main__":
52
+ main()