primocode 8.20.1 → 8.21.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.
package/lib/studio.js CHANGED
@@ -42,7 +42,7 @@ const BASE = `http://127.0.0.1:${PORTA}`;
42
42
  // voz.py é módulo do servidor, não enfeite: sem ele o server.py não importa
43
43
  // e o Studio inteiro deixa de subir. Todo arquivo .py do Studio precisa
44
44
  // entrar aqui.
45
- const COPIAVEIS = new Set(['server.py', 'voz.py', 'web', 'ferramentas', 'README.md', 'PRIMOCODE.md']);
45
+ const COPIAVEIS = new Set(['server.py', 'voz.py', 'elevenlabs.py', 'web', 'ferramentas', 'README.md', 'PRIMOCODE.md']);
46
46
 
47
47
  /**
48
48
  * Onde está o Python 3 desta máquina.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "primocode",
3
- "version": "8.20.1",
3
+ "version": "8.21.0",
4
4
  "description": "PrimoCode — agente de engenharia com IA e cursor próprio. Modelos grátis. 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": {
@@ -0,0 +1,142 @@
1
+ """Voz ElevenLabs — o topo da corrente de narração, quando há chave.
2
+
3
+ ── POR QUE ELA EXISTE ────────────────────────────────────────────────────
4
+ O usuário ouviu a diferença: a narração que ele aprovou nos vídeos veio do
5
+ ElevenLabs, e nenhum motor local chega perto. Este módulo coloca essa voz
6
+ DENTRO do estúdio: com a chave configurada, toda narração — do player, do
7
+ editor e do pré-render em arquivo — sai neural de verdade. Sem chave, nada
8
+ muda: a corrente continua (Piper → Voicebox → Google) como sempre.
9
+
10
+ ── A CHAVE ───────────────────────────────────────────────────────────────
11
+ Duas formas, nesta ordem:
12
+ 1. variável de ambiente ELEVENLABS_API_KEY
13
+ 2. arquivo ~/.primocode/elevenlabs.key (uma linha, só a chave)
14
+ O endpoint POST /api/voz/elevenlabs grava o arquivo pela interface.
15
+
16
+ ── AS VOZES ──────────────────────────────────────────────────────────────
17
+ Catálogo enxuto das vozes prontas da casa (IDs públicos e estáveis), com
18
+ o timbre descrito honestamente. A padrão é a Brian — grave, de locução —
19
+ porque abertura de vídeo pede peso. Todas falam português no modelo
20
+ multilingual. `doc.voz.voz` escolhe pelo id daqui OU por um voice_id cru
21
+ do ElevenLabs (quem tem voz clonada usa o ID dela direto).
22
+ """
23
+
24
+ import json
25
+ import os
26
+ import urllib.request
27
+
28
+ ARQUIVO_CHAVE = os.path.join(os.path.expanduser("~"), ".primocode", "elevenlabs.key")
29
+ API = "https://api.elevenlabs.io/v1"
30
+ MODELO = "eleven_multilingual_v2"
31
+ # mp3_44100_128 é CBR: a duração sai do tamanho (bytes/16000), sem depender
32
+ # de biblioteca de áudio para ler metadados.
33
+ FORMATO = "mp3_44100_128"
34
+ BYTES_POR_SEGUNDO = 16000.0
35
+
36
+ CATALOGO = [
37
+ {"id": "brian", "nome": "Brian", "tom": "grave, de locução — é a padrão", "grave": True,
38
+ "voice_id": "nPczCjzI2devNBz1zQrb"},
39
+ {"id": "daniel", "nome": "Daniel", "tom": "grave, de noticiário", "grave": True,
40
+ "voice_id": "onwK4e9ZLuTAKqWW03F9"},
41
+ {"id": "bill", "nome": "Bill", "tom": "maduro, acolhedor",
42
+ "voice_id": "pqHfZKP75CvOlQylNhV4"},
43
+ {"id": "liam", "nome": "Liam", "tom": "jovem, energia de redes sociais",
44
+ "voice_id": "TX3LPaxmHKxFdv7VOQHJ"},
45
+ {"id": "adam", "nome": "Adam", "tom": "firme, dominante", "grave": True,
46
+ "voice_id": "pNInz6obpgDQGcFmaJgB"},
47
+ {"id": "sarah", "nome": "Sarah", "tom": "feminina, suave e profissional",
48
+ "voice_id": "EXAVITQu4vr4xnSDxMaL"},
49
+ {"id": "alice", "nome": "Alice", "tom": "feminina, clara e confiante",
50
+ "voice_id": "Xb7hH8MSUJpSbSDYk0k2"},
51
+ ]
52
+ POR_ID = {v["id"]: v for v in CATALOGO}
53
+ PADRAO = "brian"
54
+
55
+
56
+ def chave():
57
+ k = (os.environ.get("ELEVENLABS_API_KEY") or "").strip()
58
+ if k:
59
+ return k
60
+ try:
61
+ with open(ARQUIVO_CHAVE, encoding="utf-8") as fh:
62
+ return fh.read().strip() or None
63
+ except OSError:
64
+ return None
65
+
66
+
67
+ def configurado():
68
+ return bool(chave())
69
+
70
+
71
+ def gravar_chave(k):
72
+ """Grava (ou apaga, com string vazia) a chave da interface."""
73
+ os.makedirs(os.path.dirname(ARQUIVO_CHAVE), exist_ok=True)
74
+ k = str(k or "").strip()
75
+ if not k:
76
+ try:
77
+ os.remove(ARQUIVO_CHAVE)
78
+ except OSError:
79
+ pass
80
+ return {"ok": True, "configurado": False}
81
+ with open(ARQUIVO_CHAVE, "w", encoding="utf-8") as fh:
82
+ fh.write(k)
83
+ try:
84
+ os.chmod(ARQUIVO_CHAVE, 0o600) # a chave é segredo do usuário
85
+ except OSError:
86
+ pass
87
+ return {"ok": True, "configurado": True}
88
+
89
+
90
+ def _voice_id(pedido):
91
+ """id do catálogo, voice_id cru do ElevenLabs, ou a padrão."""
92
+ p = str(pedido or "").strip()
93
+ if p.lower() in POR_ID:
94
+ return POR_ID[p.lower()]["voice_id"]
95
+ # voice_id cru: 20 caracteres alfanuméricos é a cara dos IDs deles —
96
+ # deixa passar qualquer coisa nesse formato (vozes clonadas do usuário).
97
+ if len(p) >= 16 and p.isalnum():
98
+ return p
99
+ return POR_ID[PADRAO]["voice_id"]
100
+
101
+
102
+ def sintetizar(texto, voz_cfg=None, timeout=90):
103
+ """Texto → (bytes de MP3, duração em segundos) — ou (None, None).
104
+
105
+ None manda a corrente seguir para o próximo motor; nunca levanta para o
106
+ chamador, porque narração com um motor a menos ainda é narração.
107
+ """
108
+ k = chave()
109
+ limpo = " ".join(str(texto or "").split())
110
+ if not k or not limpo:
111
+ return None, None
112
+ voz_cfg = voz_cfg if isinstance(voz_cfg, dict) else {}
113
+ url = "%s/text-to-speech/%s?output_format=%s" % (
114
+ API, _voice_id(voz_cfg.get("voz")), FORMATO)
115
+ corpo = json.dumps({
116
+ "text": limpo[:4900],
117
+ "model_id": MODELO,
118
+ "voice_settings": {"stability": 0.5, "similarity_boost": 0.75},
119
+ }).encode("utf-8")
120
+ req = urllib.request.Request(url, data=corpo, headers={
121
+ "xi-api-key": k,
122
+ "Content-Type": "application/json",
123
+ })
124
+ try:
125
+ with urllib.request.urlopen(req, timeout=timeout) as r:
126
+ dados = r.read()
127
+ if not dados or len(dados) < 400:
128
+ return None, None
129
+ return dados, round(len(dados) / BYTES_POR_SEGUNDO, 2)
130
+ except Exception:
131
+ return None, None
132
+
133
+
134
+ def estado():
135
+ return {
136
+ "ativo": configurado(),
137
+ "vozes": [{"id": v["id"], "nome": v["nome"], "tom": v["tom"],
138
+ "grave": bool(v.get("grave"))} for v in CATALOGO],
139
+ "padrao": PADRAO,
140
+ "como_configurar": "POST /api/voz/elevenlabs {\"chave\":\"...\"} "
141
+ "ou variável ELEVENLABS_API_KEY",
142
+ }
@@ -141,9 +141,17 @@ na hora de tocar — sem gravar nada, sem arquivo:
141
141
  Escolha da voz no **documento** (opcional):
142
142
 
143
143
  ```json
144
- "voz": { "voz": "jeff", "grave": true, "velocidade": 1 }
144
+ "voz": { "voz": "brian", "grave": true, "velocidade": 1 }
145
145
  ```
146
146
 
147
+ **Com o ElevenLabs configurado** (chave em `POST /api/voz/elevenlabs` ou na
148
+ variável `ELEVENLABS_API_KEY`), a narração inteira sobe de nível e as vozes
149
+ passam a ser: `brian` (grave, de locução — padrão) · `daniel` (grave, de
150
+ noticiário) · `adam` (firme) · `bill` (maduro) · `liam` (jovem, redes
151
+ sociais) · `sarah` e `alice` (femininas). Também aceita um voice_id cru do
152
+ ElevenLabs — é assim que a voz clonada do próprio usuário entra. Consulte
153
+ `GET /api/voz/status` para saber o que está ativo nesta máquina.
154
+
147
155
  A síntese é neural e roda na máquina do usuário — offline, sem limite. As
148
156
  vozes pt-BR, com a frequência de cada uma medida (menor = mais grave):
149
157
 
package/studio/server.py CHANGED
@@ -56,8 +56,36 @@ except Exception as _e: # instalação parcial, ou voz.py de uma versão velh
56
56
  def apagar():
57
57
  return {"ok": True}
58
58
 
59
+ @staticmethod
60
+ def bloqueado():
61
+ return "módulo de voz indisponível"
62
+
59
63
  voz_lib = _SemVoz()
60
64
 
65
+ try:
66
+ import elevenlabs as elevenlabs_lib # noqa: E402
67
+ except Exception as _e:
68
+ sys.stderr.write("[voz] elevenlabs indisponível (%s)\n" % _e)
69
+
70
+ class _SemEleven:
71
+ @staticmethod
72
+ def configurado():
73
+ return False
74
+
75
+ @staticmethod
76
+ def sintetizar(*a, **k):
77
+ return None, None
78
+
79
+ @staticmethod
80
+ def estado():
81
+ return {"ativo": False, "vozes": [], "erro": "módulo indisponível"}
82
+
83
+ @staticmethod
84
+ def gravar_chave(*a, **k):
85
+ return {"ok": False, "erro": "módulo indisponível"}
86
+
87
+ elevenlabs_lib = _SemEleven()
88
+
61
89
  WEB = os.path.join(RAIZ, "web")
62
90
  ARQUIVOS = os.path.join(RAIZ, "arquivos")
63
91
  FERRAMENTAS = os.path.join(RAIZ, "ferramentas")
@@ -312,8 +340,12 @@ def preparar_voz(doc):
312
340
  aperta play seria tarde — ele ouviria a voz robótica do Google. Fazê-lo
313
341
  aqui, no instante em que o roteiro é gravado, dá ao download a mesma
314
342
  janela que o agente gasta escrevendo o resto: quando alguém toca o vídeo,
315
- a voz boa já está na máquina. Se não der tempo, a corrente cobre."""
316
- if _tem_narracao(doc) and not voz_lib.pronta():
343
+ a voz boa já está na máquina. Se não der tempo, a corrente cobre.
344
+
345
+ Com o ElevenLabs configurado, não há nada para baixar: a síntese é
346
+ imediata e os 90 MB do Piper ficam para o dia em que faltarem chave ou
347
+ internet."""
348
+ if _tem_narracao(doc) and not elevenlabs_lib.configurado() and not voz_lib.pronta():
317
349
  try:
318
350
  voz_lib.instalar()
319
351
  except Exception:
@@ -349,21 +381,24 @@ def _assinatura_voz(texto, voz):
349
381
  def _gravar_narracao(arq_id):
350
382
  """Sintetiza a narração de cada cena e guarda em midia/."""
351
383
  try:
352
- # Esperar o motor: são minutos, mas ninguém está esperando na frente
353
- # da tela — e o resultado é a diferença entre voz de gente e voz de
354
- # robô no vídeo que a pessoa vai apresentar.
355
- for _ in range(120):
356
- if voz_lib.pronta():
357
- break
358
- # Máquina onde o Piper nunca vai rodar (binário de outra
359
- # arquitetura): esperar 10 minutos aqui era esperar por nada.
360
- if voz_lib.bloqueado():
361
- sys.stderr.write("[voz] motor neural bloqueado: %s\n" % voz_lib.bloqueado())
384
+ # Com o ElevenLabs configurado não há o que esperar: a síntese é
385
+ # imediata e o Piper vira só a reserva do dia sem internet.
386
+ if not elevenlabs_lib.configurado():
387
+ # Esperar o motor: são minutos, mas ninguém está esperando na
388
+ # frente da tela — e o resultado é a diferença entre voz de gente
389
+ # e voz de robô no vídeo que a pessoa vai apresentar.
390
+ for _ in range(120):
391
+ if voz_lib.pronta():
392
+ break
393
+ # Máquina onde o Piper nunca vai rodar (binário de outra
394
+ # arquitetura): esperar 10 minutos aqui era esperar por nada.
395
+ if voz_lib.bloqueado():
396
+ sys.stderr.write("[voz] motor neural bloqueado: %s\n" % voz_lib.bloqueado())
397
+ return
398
+ voz_lib.instalar()
399
+ time.sleep(5)
400
+ if not voz_lib.pronta():
362
401
  return
363
- voz_lib.instalar()
364
- time.sleep(5)
365
- if not voz_lib.pronta():
366
- return
367
402
 
368
403
  a = ler(arq_id)
369
404
  if not a:
@@ -386,27 +421,36 @@ def _gravar_narracao(arq_id):
386
421
  # vale mais que qualquer síntese.
387
422
  if c.get("narracaoAudio") and not c.get("narracaoVoz"):
388
423
  continue
389
- try:
390
- audio = voz_lib.sintetizar(
391
- texto, voz.get("voz"),
392
- grave=bool(voz.get("grave")),
393
- velocidade=float(voz.get("velocidade") or 1))
394
- except Exception as e:
395
- sys.stderr.write("[voz] cena %d falhou: %s\n" % (i + 1, e))
396
- continue
424
+ audio, ext, dur = None, ".wav", None
425
+ # ElevenLabs primeiro, quando há chave: é a voz que o usuário
426
+ # aprovou. O MP3 dele é CBR — a duração sai do tamanho.
427
+ if elevenlabs_lib.configurado():
428
+ audio, dur = elevenlabs_lib.sintetizar(texto, voz)
429
+ if audio:
430
+ ext = ".mp3"
431
+ if not audio:
432
+ try:
433
+ audio = voz_lib.sintetizar(
434
+ texto, voz.get("voz"),
435
+ grave=bool(voz.get("grave")),
436
+ velocidade=float(voz.get("velocidade") or 1))
437
+ except Exception as e:
438
+ sys.stderr.write("[voz] cena %d falhou: %s\n" % (i + 1, e))
439
+ continue
440
+ if audio:
441
+ dur = _duracao_wav(audio)
397
442
  if not audio:
398
443
  continue
399
- nome = "narr-%s-%d-%s.wav" % (arq_id[:28], i + 1, assinatura)
444
+ nome = "narr-%s-%d-%s%s" % (arq_id[:28], i + 1, assinatura, ext)
400
445
  with open(os.path.join(MIDIA, nome), "wb") as fh:
401
446
  fh.write(audio)
402
447
  c["narracaoAudio"] = "/midia/" + nome
403
448
  c["narracaoVoz"] = assinatura
404
- # A duração medida do WAV vai junto: com ela o player sabe quanto
405
- # a cena REALMENTE dura (a fala inteira + respiro) e a linha do
406
- # tempo vira determinística — sem ela, o relógio congela na
407
- # beirada da cena esperando a voz, e a duração do vídeo muda a
408
- # cada reprodução.
409
- dur = _duracao_wav(audio)
449
+ # A duração medida vai junto: com ela o player sabe quanto a cena
450
+ # REALMENTE dura (a fala inteira + respiro) e a linha do tempo
451
+ # vira determinística — sem ela, o relógio congela na beirada da
452
+ # cena esperando a voz, e a duração do vídeo muda a cada
453
+ # reprodução.
410
454
  if dur:
411
455
  c["narracaoDur"] = round(dur, 2)
412
456
  mudou = True
@@ -1002,10 +1046,21 @@ class Handler(SimpleHTTPRequestHandler):
1002
1046
  tipo = "audio/mpeg"
1003
1047
  audio = b""
1004
1048
 
1005
- # 1) Piper: o motor bom. Se ainda não baixou, começa a baixar agora e
1006
- # segue para o próximo — quem pediu narração hoje ouve o Google, e
1007
- # a partir da próxima já é voz de verdade.
1008
- if motor not in ("google", "voicebox"):
1049
+ # 0) ElevenLabs: a voz que o usuário aprovou nos vídeos. Só entra na
1050
+ # corrente quando há chave configurada — e continua caindo para os
1051
+ # motores locais se falhar, porque narração nunca fica muda.
1052
+ if motor in ("", "elevenlabs") and elevenlabs_lib.configurado():
1053
+ dados, _ = elevenlabs_lib.sintetizar(texto, {"voz": voz_pedida})
1054
+ if dados:
1055
+ audio = dados
1056
+ tipo = "audio/mpeg"
1057
+ elif motor == "elevenlabs":
1058
+ return self.js({"erro": "ElevenLabs não devolveu áudio — chave válida? cota?"}, 502)
1059
+
1060
+ # 1) Piper: o motor local. Se ainda não baixou, começa a baixar agora
1061
+ # e segue para o próximo — quem pediu narração hoje ouve o Google,
1062
+ # e a partir da próxima já é voz de verdade.
1063
+ if not audio and motor not in ("google", "voicebox", "elevenlabs"):
1009
1064
  try:
1010
1065
  if voz_lib.pronta(voz_pedida or None):
1011
1066
  audio = voz_lib.sintetizar(texto, voz_pedida or None,
@@ -1069,6 +1124,7 @@ class Handler(SimpleHTTPRequestHandler):
1069
1124
  except Exception:
1070
1125
  vozes = []
1071
1126
  return self.js({
1127
+ "elevenlabs": elevenlabs_lib.estado(),
1072
1128
  "piper": voz_lib.estado(),
1073
1129
  "voicebox": {"ativo": vivo, "vozes": vozes, "site": "https://voicebox.sh"},
1074
1130
  "google": {"ativo": True},
@@ -1219,6 +1275,11 @@ class Handler(SimpleHTTPRequestHandler):
1219
1275
  return self.js(voz_lib.instalar(pedido.get("voz"),
1220
1276
  esperar=min(60, int(pedido.get("esperar") or 0))))
1221
1277
 
1278
+ if rota == "/api/voz/elevenlabs":
1279
+ # Grava (ou apaga, com chave vazia) a chave do ElevenLabs. A chave
1280
+ # nunca volta em resposta nenhuma — só o "configurado" volta.
1281
+ return self.js(elevenlabs_lib.gravar_chave(self.corpo().get("chave")))
1282
+
1222
1283
  if rota == "/api/sair":
1223
1284
  # Só o CLI chama, e só quando o código no disco mudou (update do
1224
1285
  # npm). Sem isto o processo antigo ficava no ar servindo o bug que
@@ -852,3 +852,138 @@ body:has(.transporte) .aviso { bottom: 80px; }
852
852
  font: 500 12px/1 var(--sans); cursor: pointer;
853
853
  }
854
854
  .presets button:hover { background: var(--sup3); color: var(--texto); border-color: var(--primo); }
855
+
856
+ /* ==========================================================================
857
+ Refresh 2026 — o estúdio deixa de parecer template.
858
+ Um bloco só, no fim, sobrepondo o essencial: profundidade no fundo,
859
+ gradiente na marca, cartões com vida e o transporte flutuando de vidro.
860
+ ========================================================================== */
861
+
862
+ :root {
863
+ --primo2: #ffb03a; /* o par do laranja no gradiente */
864
+ --grad-primo: linear-gradient(120deg, #ff5a36 0%, #ff7a3c 55%, #ffb03a 100%);
865
+ --brilho-primo: 0 8px 32px rgba(255, 90, 54, .25);
866
+ }
867
+
868
+ body {
869
+ background:
870
+ radial-gradient(ellipse 1000px 640px at 18% -10%, rgba(255, 90, 54, .07), transparent 60%),
871
+ radial-gradient(ellipse 900px 600px at 92% 108%, rgba(124, 92, 246, .05), transparent 60%),
872
+ var(--fundo);
873
+ }
874
+
875
+ /* marca com gradiente e cubo aceso */
876
+ .topo .marca .cubo {
877
+ background: var(--grad-primo);
878
+ box-shadow: var(--brilho-primo);
879
+ }
880
+ .topo .marca em {
881
+ background: var(--grad-primo);
882
+ -webkit-background-clip: text;
883
+ background-clip: text;
884
+ color: transparent;
885
+ font-style: normal;
886
+ }
887
+
888
+ /* hero maior, título com peso de capa */
889
+ .home .hero h1 {
890
+ font-size: 42px;
891
+ letter-spacing: -.02em;
892
+ background: linear-gradient(120deg, #fff 30%, #ffd9cd 70%, var(--primo2) 100%);
893
+ -webkit-background-clip: text;
894
+ background-clip: text;
895
+ color: transparent;
896
+ }
897
+
898
+ /* cartões de ferramenta: vidro, glow no hover, levanta */
899
+ .cartao-ferr {
900
+ border-radius: 16px;
901
+ background: linear-gradient(180deg, rgba(255, 255, 255, .035), rgba(255, 255, 255, .012));
902
+ transition: transform .18s ease, border-color .18s ease, box-shadow .18s ease;
903
+ }
904
+ .cartao-ferr:hover {
905
+ transform: translateY(-3px);
906
+ border-color: var(--linha2);
907
+ box-shadow: 0 14px 44px rgba(0, 0, 0, .45), 0 0 0 1px rgba(255, 90, 54, .12);
908
+ }
909
+ .cartao-ferr .icone {
910
+ border-radius: 12px;
911
+ box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .06);
912
+ }
913
+
914
+ /* botão principal: gradiente da marca */
915
+ .btn.primo,
916
+ .transporte .tp-btn.texto.primo {
917
+ background: var(--grad-primo);
918
+ border: none;
919
+ color: #fff;
920
+ font-weight: 600;
921
+ box-shadow: var(--brilho-primo);
922
+ }
923
+ .btn.primo:hover,
924
+ .transporte .tp-btn.texto.primo:hover {
925
+ filter: brightness(1.08);
926
+ }
927
+
928
+ /* itens da lista de arquivos: respiro e hover */
929
+ .item-arq {
930
+ border-radius: 14px;
931
+ transition: background .15s ease, border-color .15s ease;
932
+ }
933
+ .item-arq:hover { background: var(--sup2); border-color: var(--linha2); }
934
+
935
+ /* transporte do player: vidro flutuante */
936
+ .transporte {
937
+ background: rgba(16, 16, 20, .78);
938
+ backdrop-filter: blur(14px);
939
+ -webkit-backdrop-filter: blur(14px);
940
+ border: 1px solid var(--linha);
941
+ border-radius: 16px;
942
+ margin: 0 14px 12px;
943
+ width: calc(100% - 28px);
944
+ box-shadow: 0 12px 40px rgba(0, 0, 0, .5);
945
+ }
946
+ .transporte .tp-cheio { background: var(--grad-primo); }
947
+
948
+ /* menu do botão Baixar (vídeo / áudio) */
949
+ .menu-baixar {
950
+ position: fixed;
951
+ right: 22px;
952
+ bottom: 84px;
953
+ z-index: 60;
954
+ display: flex;
955
+ flex-direction: column;
956
+ gap: 4px;
957
+ padding: 6px;
958
+ border-radius: 14px;
959
+ border: 1px solid var(--linha2);
960
+ background: rgba(18, 18, 23, .96);
961
+ backdrop-filter: blur(16px);
962
+ -webkit-backdrop-filter: blur(16px);
963
+ box-shadow: 0 18px 60px rgba(0, 0, 0, .6);
964
+ animation: menu-sobe .16s ease;
965
+ }
966
+ @keyframes menu-sobe {
967
+ from { opacity: 0; transform: translateY(8px); }
968
+ to { opacity: 1; transform: none; }
969
+ }
970
+ .menu-baixar button {
971
+ display: flex;
972
+ flex-direction: column;
973
+ align-items: flex-start;
974
+ gap: 2px;
975
+ min-width: 250px;
976
+ padding: 11px 14px;
977
+ border: none;
978
+ border-radius: 10px;
979
+ background: transparent;
980
+ color: var(--texto);
981
+ font: 600 13.5px/1.2 var(--sans);
982
+ text-align: left;
983
+ cursor: pointer;
984
+ }
985
+ .menu-baixar button:hover { background: var(--primo-suave); }
986
+ .menu-baixar button span {
987
+ font: 400 11.5px/1.2 var(--sans);
988
+ color: var(--fraco);
989
+ }
@@ -37,7 +37,8 @@
37
37
  <h1>O estúdio que a IA opera</h1>
38
38
  <p>Seis ferramentas nativas com um modelo de documento em comum. A IA escreve o
39
39
  documento, o estúdio desenha — apresentação, arte, texto, planilha, vídeo ou diagrama —
40
- e você refina à mão no editor.</p>
40
+ e você refina à mão no editor. Vídeo sai com narração neural, trilha, efeitos
41
+ sonoros e download direto em MP4 ou áudio.</p>
41
42
  </section>
42
43
 
43
44
  <section class="grade-ferr" id="ferramentas"></section>
@@ -219,6 +219,7 @@
219
219
  pintar();
220
220
  aviso("fim", 1200);
221
221
  if (gravador && gravador.state === "recording") setTimeout(pararGravacao, 600);
222
+ if (gravadorAudio && gravadorAudio.state === "recording") setTimeout(pararGravacao, 600);
222
223
  }
223
224
 
224
225
  /* A narração da cena ainda está no ar? */
@@ -388,12 +389,12 @@
388
389
  '<div class="tp-tempo" id="tp-total">00:00</div>' +
389
390
  '<button class="tp-btn pequeno" id="tp-som" title="Mudo (M)"></button>' +
390
391
  '<input class="tp-vol" id="tp-vol" type="range" min="0" max="100" value="70">' +
391
- '<button class="tp-btn texto primo" id="tp-gravar" title="Baixar este vídeo como arquivo">Baixar MP4</button>';
392
+ '<button class="tp-btn texto primo" id="tp-gravar" title="Baixar vídeo ou áudio">Baixar ▾</button>';
392
393
  document.body.appendChild(transporte);
393
394
 
394
395
  document.getElementById("tp-tocar").onclick = function () { tocando ? pausar() : tocar(); };
395
396
  document.getElementById("tp-som").onclick = alternarMudo;
396
- document.getElementById("tp-gravar").onclick = alternarGravacao;
397
+ document.getElementById("tp-gravar").onclick = menuBaixar;
397
398
  document.getElementById("tp-vol").oninput = function () {
398
399
  if (trilha) trilha.volume(this.value / 100);
399
400
  };
@@ -444,6 +445,81 @@
444
445
  iniciarGravacao();
445
446
  }
446
447
 
448
+ /* Menu do botão Baixar: vídeo completo ou só o áudio.
449
+
450
+ O áudio sai DIRETO do grafo (trilha + narração + SFX, já mixados com
451
+ ducking) — sem pedir permissão de tela nenhuma, porque não há tela:
452
+ é o MediaRecorder pendurado no fluxo da Web Audio. */
453
+ function menuBaixar() {
454
+ if ((gravador && gravador.state === "recording") ||
455
+ (gravadorAudio && gravadorAudio.state === "recording")) {
456
+ return pararGravacao();
457
+ }
458
+ var velho = document.getElementById("menu-baixar");
459
+ if (velho) { velho.remove(); return; }
460
+ var m = document.createElement("div");
461
+ m.id = "menu-baixar";
462
+ m.className = "menu-baixar";
463
+ m.innerHTML =
464
+ '<button data-acao="mp4">🎬 Vídeo completo <span>' + (gravaMp4() ? "MP4" : "WebM") + " · imagem + som</span></button>" +
465
+ '<button data-acao="audio">🎧 Só o áudio <span>narração + trilha + efeitos</span></button>';
466
+ document.body.appendChild(m);
467
+ m.onclick = function (ev) {
468
+ var b = ev.target.closest("button");
469
+ if (!b) return;
470
+ m.remove();
471
+ if (b.dataset.acao === "mp4") iniciarGravacao();
472
+ else iniciarGravacaoAudio();
473
+ };
474
+ setTimeout(function () {
475
+ document.addEventListener("click", function fecha(e) {
476
+ if (!m.contains(e.target)) { m.remove(); document.removeEventListener("click", fecha); }
477
+ });
478
+ }, 0);
479
+ }
480
+
481
+ /* ------------------------------------------------ gravação só de áudio -- */
482
+
483
+ var gravadorAudio = null, pedacosAudio = [];
484
+
485
+ var FORMATOS_AUDIO = [
486
+ "audio/mp4", // AAC → .m4a: toca em tudo, importa em tudo
487
+ "audio/webm;codecs=opus",
488
+ "audio/webm",
489
+ ];
490
+
491
+ function iniciarGravacaoAudio() {
492
+ if (!trilha || !trilha.fluxo) return aviso("áudio indisponível neste navegador", 2500);
493
+ var tipo = "";
494
+ for (var i = 0; i < FORMATOS_AUDIO.length; i++) {
495
+ if (MediaRecorder.isTypeSupported(FORMATOS_AUDIO[i])) { tipo = FORMATOS_AUDIO[i]; break; }
496
+ }
497
+ pedacosAudio = [];
498
+ gravadorAudio = new MediaRecorder(trilha.fluxo, tipo ? { mimeType: tipo, audioBitsPerSecond: 192000 } : undefined);
499
+ gravadorAudio.ondataavailable = function (ev) { if (ev.data.size) pedacosAudio.push(ev.data); };
500
+ gravadorAudio.onstop = baixarAudio;
501
+ gravadorAudio.start();
502
+ var b = document.getElementById("tp-gravar");
503
+ if (b) { b.classList.add("gravando"); b.textContent = "Parar"; }
504
+ buscar(0);
505
+ tocar();
506
+ aviso("gravando o áudio — para sozinho no fim", 2600);
507
+ }
508
+
509
+ function baixarAudio() {
510
+ var mime = (gravadorAudio && gravadorAudio.mimeType) || "audio/webm";
511
+ var ext = /mp4/.test(mime) ? ".m4a" : ".weba";
512
+ var blob = new Blob(pedacosAudio, { type: mime.split(";")[0] });
513
+ var url = URL.createObjectURL(blob);
514
+ var a = document.createElement("a");
515
+ a.href = url;
516
+ a.download = (arq.titulo || "audio").replace(/[^\w\-]+/g, "-").toLowerCase() + ext;
517
+ a.click();
518
+ setTimeout(function () { URL.revokeObjectURL(url); }, 4000);
519
+ aviso("áudio salvo em " + ext, 2600);
520
+ gravadorAudio = null;
521
+ }
522
+
447
523
  /* O melhor formato que este navegador sabe gravar.
448
524
  *
449
525
  * A ordem importa: H.264 com AAC é o MP4 que toca em qualquer lugar —
@@ -518,9 +594,10 @@ function gravaMp4() { return /mp4/.test(escolherFormato()); }
518
594
 
519
595
  function pararGravacao() {
520
596
  if (gravador && gravador.state === "recording") gravador.stop();
597
+ if (gravadorAudio && gravadorAudio.state === "recording") gravadorAudio.stop();
521
598
  if (fluxoTela) fluxoTela.getTracks().forEach(function (f) { f.stop(); });
522
599
  var b = document.getElementById("tp-gravar");
523
- if (b) { b.classList.remove("gravando"); b.textContent = "Gravar"; }
600
+ if (b) { b.classList.remove("gravando"); b.textContent = "Baixar ▾"; }
524
601
  }
525
602
 
526
603
  function baixarGravacao() {