primocode 8.20.1 → 8.22.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', 'edge.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.22.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": {
package/studio/edge.py ADDED
@@ -0,0 +1,221 @@
1
+ """Narração neural grátis, via Edge TTS (vozes online da Microsoft).
2
+
3
+ ── POR QUE ESTE MOTOR EXISTE ─────────────────────────────────────────────
4
+ A corrente de voz tinha um buraco entre o ElevenLabs (premium, com chave e
5
+ cota) e o Google (uma voz só, robótica). No meio ficava o Piper — neural e
6
+ offline, mas que baixa 90 MB e, em Mac ARM, vinha com binário x86 e travava.
7
+ Quando o Piper não sobe, a narração caía direto no Google e o vídeo saía com
8
+ voz de robô.
9
+
10
+ O Edge TTS preenche esse buraco: são as MESMAS vozes neurais do serviço da
11
+ Microsoft (Antonio, Francisca, Thalita em pt-BR), de graça, SEM chave e SEM
12
+ conta. Precisa de internet — mas quem está gerando vídeo já está online. A
13
+ qualidade fica logo abaixo do ElevenLabs e muito acima do Google.
14
+
15
+ ── COMO ENTRA NA CORRENTE ────────────────────────────────────────────────
16
+ 1. ELEVENLABS premium, se houver chave com crédito.
17
+ 2. EDGE TTS neural, grátis, sem chave. ← ESTE
18
+ 3. PIPER neural, offline, ilimitado.
19
+ 4. VOICEBOX se o app estiver instalado.
20
+ 5. GOOGLE última reserva; nunca fica mudo.
21
+
22
+ ── SÍNTESE ───────────────────────────────────────────────────────────────
23
+ Rodamos o edge-tts por SUBPROCESSO (`python3 -m edge_tts`), não pela API
24
+ async: o servidor do Studio é stdlib síncrono, e enfiar um event loop dentro
25
+ de uma thread dele é fonte de travamento. O subprocesso é isolado, tem
26
+ timeout de verdade, e devolve o MP3 pronto.
27
+
28
+ A DURAÇÃO sai das legendas que o próprio edge-tts escreve (`--write-subtitles`):
29
+ o último timestamp do VTT é o fim da fala, medido — não estimado. Com isso a
30
+ cena sabe quanto dura de fato e a linha do tempo fica determinística.
31
+
32
+ O pacote `edge-tts` não vem no PrimoCode (é pip, não stdlib). Se faltar, este
33
+ motor se declara indisponível e a corrente segue para o Piper — nada trava.
34
+ `instalar()` tenta trazê-lo em segundo plano, uma vez, sem bloquear ninguém.
35
+ """
36
+
37
+ import os
38
+ import re
39
+ import subprocess
40
+ import sys
41
+ import tempfile
42
+ import threading
43
+
44
+ # Catálogo pt-BR do Edge. Só três vozes existem no serviço da Microsoft para
45
+ # português do Brasil; não há por que inventar mais. A padrão é a Antonio,
46
+ # masculina e de locução, que é o registro de abertura de vídeo — a mesma
47
+ # lógica da voz grave padrão do Piper.
48
+ CATALOGO = [
49
+ {"id": "antonio", "voz": "pt-BR-AntonioNeural", "nome": "Antonio", "genero": "m", "padrao": True},
50
+ {"id": "francisca", "voz": "pt-BR-FranciscaNeural", "nome": "Francisca", "genero": "f"},
51
+ {"id": "thalita", "voz": "pt-BR-ThalitaMultilingualNeural","nome": "Thalita", "genero": "f"},
52
+ ]
53
+ POR_ID = {v["id"]: v for v in CATALOGO}
54
+ PADRAO = next(v["voz"] for v in CATALOGO if v.get("padrao"))
55
+
56
+ # Apelidos: as vozes do ElevenLabs que o usuário talvez peça no documento
57
+ # mapeiam para o timbre mais próximo aqui, para o mesmo documento render
58
+ # igual com qualquer motor ativo. Masculinas → Antonio; femininas → Francisca.
59
+ APELIDOS = {
60
+ "brian": "pt-BR-AntonioNeural", "daniel": "pt-BR-AntonioNeural",
61
+ "adam": "pt-BR-AntonioNeural", "bill": "pt-BR-AntonioNeural",
62
+ "liam": "pt-BR-AntonioNeural", "jeff": "pt-BR-AntonioNeural",
63
+ "cadu": "pt-BR-AntonioNeural", "edresson": "pt-BR-AntonioNeural",
64
+ "faber": "pt-BR-AntonioNeural",
65
+ "sarah": "pt-BR-FranciscaNeural", "alice": "pt-BR-FranciscaNeural",
66
+ }
67
+
68
+ _verificado = None # cache do disponivel(): None = ainda não checou
69
+ _instalando = False
70
+ _tranca = threading.Lock()
71
+
72
+
73
+ def _voz_de(voz_cfg):
74
+ """Resolve o id/nome pedido no documento para um nome de voz do Edge."""
75
+ pedido = ""
76
+ if isinstance(voz_cfg, dict):
77
+ pedido = str(voz_cfg.get("voz") or "").strip()
78
+ elif isinstance(voz_cfg, str):
79
+ pedido = voz_cfg.strip()
80
+ if not pedido:
81
+ return PADRAO
82
+ baixo = pedido.lower()
83
+ if baixo in POR_ID:
84
+ return POR_ID[baixo]["voz"]
85
+ if baixo in APELIDOS:
86
+ return APELIDOS[baixo]
87
+ # Nome cru do Edge ("pt-BR-...Neural") passa direto.
88
+ if re.match(r"^[a-z]{2}-[A-Z]{2}-\w+$", pedido):
89
+ return pedido
90
+ return PADRAO
91
+
92
+
93
+ def _taxa(velocidade):
94
+ """velocidade do documento (1.0 = normal) vira o --rate do edge (+N%)."""
95
+ try:
96
+ v = float(velocidade)
97
+ except (TypeError, ValueError):
98
+ v = 1.0
99
+ v = max(0.5, min(2.0, v))
100
+ pct = int(round((v - 1.0) * 100))
101
+ return ("+%d%%" % pct) if pct >= 0 else ("%d%%" % pct)
102
+
103
+
104
+ def disponivel():
105
+ """O edge-tts está utilizável nesta máquina? Resultado é cacheado: a
106
+ checagem roda o CLI e não vale repetir a cada narração."""
107
+ global _verificado
108
+ if _verificado is not None:
109
+ return _verificado
110
+ try:
111
+ r = subprocess.run(
112
+ [sys.executable, "-m", "edge_tts", "--help"],
113
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=20)
114
+ _verificado = (r.returncode == 0)
115
+ except Exception:
116
+ _verificado = False
117
+ return _verificado
118
+
119
+
120
+ def _rotina_instalar():
121
+ global _instalando, _verificado
122
+ try:
123
+ subprocess.run(
124
+ [sys.executable, "-m", "pip", "install", "--user", "--quiet", "edge-tts"],
125
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=180)
126
+ except Exception as e:
127
+ sys.stderr.write("[edge] pip install falhou: %s\n" % e)
128
+ finally:
129
+ _verificado = None # força nova checagem
130
+ with _tranca:
131
+ _instalando = False
132
+
133
+
134
+ def instalar():
135
+ """Tenta trazer o pacote em segundo plano, uma vez. Não bloqueia: quem
136
+ pediu narração agora ouve o próximo motor da corrente, e a partir da
137
+ próxima vez o Edge já responde."""
138
+ global _instalando
139
+ if disponivel():
140
+ return
141
+ with _tranca:
142
+ if _instalando:
143
+ return
144
+ _instalando = True
145
+ threading.Thread(target=_rotina_instalar, daemon=True).start()
146
+
147
+
148
+ def _dur_do_vtt(caminho):
149
+ """Último timestamp do VTT = fim da fala, em segundos. Medido, não
150
+ estimado. Formato do edge-tts: `HH:MM:SS,mmm --> HH:MM:SS,mmm`."""
151
+ try:
152
+ with open(caminho, "r", encoding="utf-8") as fh:
153
+ texto = fh.read()
154
+ except OSError:
155
+ return None
156
+ fins = re.findall(r"-->\s*(\d\d):(\d\d):(\d\d)[,.](\d\d\d)", texto)
157
+ if not fins:
158
+ return None
159
+ h, m, s, ms = fins[-1]
160
+ return int(h) * 3600 + int(m) * 60 + int(s) + int(ms) / 1000.0
161
+
162
+
163
+ def sintetizar(texto, voz_cfg=None, timeout=90):
164
+ """O texto vira (mp3_bytes, duracao_em_s). Devolve (None, None) quando o
165
+ Edge não está disponível ou falha — e NUNCA levanta exceção, para a
166
+ corrente de voz cair no motor seguinte sem tratamento especial."""
167
+ if not disponivel():
168
+ instalar()
169
+ return None, None
170
+
171
+ # Uma fala por vez: quebras viram uma frase só, com respiro de pontuação.
172
+ limpo = re.sub(r"\s+", " ", str(texto or "")).strip()
173
+ if not limpo:
174
+ return None, None
175
+
176
+ grave = bool(isinstance(voz_cfg, dict) and voz_cfg.get("grave"))
177
+ velocidade = (voz_cfg.get("velocidade") if isinstance(voz_cfg, dict) else None) or 1.0
178
+ voz = _voz_de(voz_cfg)
179
+ taxa = _taxa(velocidade)
180
+
181
+ pasta = tempfile.mkdtemp(prefix="primo-edge-")
182
+ try:
183
+ mp3 = os.path.join(pasta, "voz.mp3")
184
+ vtt = os.path.join(pasta, "voz.vtt")
185
+ try:
186
+ r = subprocess.run(
187
+ [sys.executable, "-m", "edge_tts",
188
+ "--voice", voz, "--rate", taxa, "--text", limpo,
189
+ "--write-media", mp3, "--write-subtitles", vtt],
190
+ stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, timeout=timeout)
191
+ except subprocess.TimeoutExpired:
192
+ sys.stderr.write("[edge] tempo esgotado (%ss)\n" % timeout)
193
+ return None, None
194
+ if r.returncode != 0 or not os.path.exists(mp3) or os.path.getsize(mp3) < 800:
195
+ msg = (r.stderr or b"").decode("utf-8", "replace")[-200:]
196
+ sys.stderr.write("[edge] síntese falhou: %s\n" % msg)
197
+ return None, None
198
+ with open(mp3, "rb") as fh:
199
+ dados = fh.read()
200
+ dur = _dur_do_vtt(vtt)
201
+ return dados, (round(dur, 2) if dur else None)
202
+ except Exception as e:
203
+ sys.stderr.write("[edge] erro inesperado: %s\n" % e)
204
+ return None, None
205
+ finally:
206
+ import shutil
207
+ shutil.rmtree(pasta, ignore_errors=True)
208
+
209
+
210
+ def estado():
211
+ """O que a interface mostra sobre este motor."""
212
+ with _tranca:
213
+ inst = _instalando
214
+ return {
215
+ "motor": "edge",
216
+ "disponivel": disponivel(),
217
+ "instalando": inst,
218
+ "precisa_internet": True,
219
+ "vozes": [{"id": v["id"], "nome": v["nome"], "genero": v["genero"],
220
+ "padrao": bool(v.get("padrao"))} for v in CATALOGO],
221
+ }
@@ -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,11 +141,33 @@ 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
- A síntese é neural e roda na máquina do usuário — offline, sem limite. As
148
- vozes pt-BR, com a frequência de cada uma medida (menor = mais grave):
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
+
155
+ **A corrente de voz, em ordem de qualidade** (cada motor cai no seguinte
156
+ quando não responde — a narração nunca fica muda):
157
+
158
+ 1. **ElevenLabs** — premium, se houver chave com crédito.
159
+ 2. **Edge TTS** — vozes neurais da Microsoft, **grátis e sem chave**, só
160
+ pede internet. É a melhor voz gratuita: `antonio` (masculina, de locução
161
+ — **a padrão gratuita**), `francisca` e `thalita` (femininas). Instala-se
162
+ sozinho na primeira vez (`pip install edge-tts`, ~1 min) e, enquanto isso,
163
+ a narração sai por outro motor. Não há por que citar "voz de robô": com
164
+ internet, o padrão já é voz neural de gente.
165
+ 3. **Piper** — neural e **offline**, sem limite (detalhado abaixo).
166
+ 4. **Voicebox** — se o app estiver instalado (voz clonável).
167
+ 5. **Google** — última reserva, robótica, mas responde sempre.
168
+
169
+ O **Piper** roda na máquina do usuário — offline, sem limite. As vozes
170
+ pt-BR, com a frequência de cada uma medida (menor = mais grave):
149
171
 
150
172
  | id | timbre | Hz |
151
173
  |---|---|---|
package/studio/server.py CHANGED
@@ -56,8 +56,61 @@ 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
+
89
+ try:
90
+ import edge as edge_lib # noqa: E402
91
+ except Exception as _e:
92
+ sys.stderr.write("[voz] edge indisponível (%s)\n" % _e)
93
+
94
+ class _SemEdge:
95
+ @staticmethod
96
+ def disponivel():
97
+ return False
98
+
99
+ @staticmethod
100
+ def sintetizar(*a, **k):
101
+ return None, None
102
+
103
+ @staticmethod
104
+ def instalar():
105
+ return None
106
+
107
+ @staticmethod
108
+ def estado():
109
+ return {"motor": "edge", "disponivel": False, "vozes": [],
110
+ "erro": "módulo indisponível"}
111
+
112
+ edge_lib = _SemEdge()
113
+
61
114
  WEB = os.path.join(RAIZ, "web")
62
115
  ARQUIVOS = os.path.join(RAIZ, "arquivos")
63
116
  FERRAMENTAS = os.path.join(RAIZ, "ferramentas")
@@ -312,8 +365,12 @@ def preparar_voz(doc):
312
365
  aperta play seria tarde — ele ouviria a voz robótica do Google. Fazê-lo
313
366
  aqui, no instante em que o roteiro é gravado, dá ao download a mesma
314
367
  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():
368
+ a voz boa já está na máquina. Se não der tempo, a corrente cobre.
369
+
370
+ Com o ElevenLabs configurado, não há nada para baixar: a síntese é
371
+ imediata e os 90 MB do Piper ficam para o dia em que faltarem chave ou
372
+ internet."""
373
+ if _tem_narracao(doc) and not elevenlabs_lib.configurado() and not voz_lib.pronta():
317
374
  try:
318
375
  voz_lib.instalar()
319
376
  except Exception:
@@ -346,24 +403,50 @@ def _assinatura_voz(texto, voz):
346
403
  return hashlib.sha1(corpo.encode("utf-8")).hexdigest()[:10]
347
404
 
348
405
 
406
+ def _google_tts(texto, idioma="pt-BR"):
407
+ """Google Translate TTS em MP3, no nível do módulo, para o pré-render
408
+ também ter o mesmo piso do endpoint: uma voz que responde sempre. Robótica,
409
+ mas garante que NENHUMA cena fique sem áudio. Devolve b"" se falhar."""
410
+ try:
411
+ pedacos = []
412
+ for t in _picar(texto, 190):
413
+ url = "https://translate.google.com/translate_tts?" + urlencode({
414
+ "ie": "UTF-8", "q": t, "tl": idioma, "client": "tw-ob", "total": 1, "idx": 0,
415
+ })
416
+ req = urllib.request.Request(url, headers={
417
+ "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
418
+ "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0 Safari/537.36",
419
+ "Referer": "https://translate.google.com/",
420
+ })
421
+ with urllib.request.urlopen(req, timeout=20) as r:
422
+ pedacos.append(r.read())
423
+ return b"".join(pedacos)
424
+ except Exception as e:
425
+ sys.stderr.write("[voz] Google TTS falhou: %s\n" % e)
426
+ return b""
427
+
428
+
349
429
  def _gravar_narracao(arq_id):
350
430
  """Sintetiza a narração de cada cena e guarda em midia/."""
351
431
  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())
362
- return
363
- voz_lib.instalar()
364
- time.sleep(5)
365
- if not voz_lib.pronta():
366
- return
432
+ # Com ElevenLabs (chave) ou Edge (grátis, neural, sem download) já há
433
+ # voz de gente na hora — não se espera o Piper. Só quando NENHUM dos
434
+ # dois responde vale aguardar o Piper baixar, porque a alternativa é
435
+ # o Google, que soa a robô no vídeo que a pessoa vai apresentar.
436
+ if not elevenlabs_lib.configurado() and not edge_lib.disponivel():
437
+ edge_lib.instalar() # tenta trazer o Edge em paralelo
438
+ for _ in range(120):
439
+ if voz_lib.pronta() or edge_lib.disponivel():
440
+ break
441
+ # Máquina onde o Piper nunca vai rodar (binário de outra
442
+ # arquitetura): esperar 10 minutos aqui era esperar por nada.
443
+ if voz_lib.bloqueado():
444
+ sys.stderr.write("[voz] Piper bloqueado: %s\n" % voz_lib.bloqueado())
445
+ break
446
+ voz_lib.instalar()
447
+ time.sleep(5)
448
+ # Nem Piper nem Edge subiram: ainda assim seguimos — o Google, lá
449
+ # embaixo na síntese, garante que nenhuma cena fique sem áudio.
367
450
 
368
451
  a = ler(arq_id)
369
452
  if not a:
@@ -386,27 +469,58 @@ def _gravar_narracao(arq_id):
386
469
  # vale mais que qualquer síntese.
387
470
  if c.get("narracaoAudio") and not c.get("narracaoVoz"):
388
471
  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
472
+ audio, ext, dur = None, ".wav", None
473
+ # A CORRENTE, na ordem de qualidade. Cada motor cai no seguinte
474
+ # quando não devolve áudio — e o Google, no fim, garante que a
475
+ # cena nunca fique muda. É o que faz o pré-render nunca parar.
476
+
477
+ # 1) ElevenLabs: a voz que o usuário aprovou. MP3 CBR — a duração
478
+ # do módulo já vem medida.
479
+ if elevenlabs_lib.configurado():
480
+ audio, dur = elevenlabs_lib.sintetizar(texto, voz)
481
+ if audio:
482
+ ext = ".mp3"
483
+
484
+ # 2) Edge TTS: neural, grátis, sem chave. MP3, duração medida pelo
485
+ # VTT que o próprio edge escreve.
486
+ if not audio and edge_lib.disponivel():
487
+ dados, edur = edge_lib.sintetizar(texto, voz)
488
+ if dados:
489
+ audio, dur, ext = dados, edur, ".mp3"
490
+
491
+ # 3) Piper: neural e offline, quando baixado e compatível. WAV.
492
+ if not audio:
493
+ try:
494
+ audio = voz_lib.sintetizar(
495
+ texto, voz.get("voz"),
496
+ grave=bool(voz.get("grave")),
497
+ velocidade=float(voz.get("velocidade") or 1))
498
+ except Exception as e:
499
+ sys.stderr.write("[voz] Piper cena %d falhou: %s\n" % (i + 1, e))
500
+ audio = None
501
+ if audio:
502
+ dur, ext = _duracao_wav(audio), ".wav"
503
+
504
+ # 4) Google: última reserva. Robótica, mas responde. MP3 — a
505
+ # duração sai do tamanho (CBR de 24 kbps ≈ 3000 bytes/s).
506
+ if not audio:
507
+ dados = _google_tts(texto)
508
+ if dados:
509
+ audio, ext = dados, ".mp3"
510
+ dur = round(len(dados) / 3000.0, 2)
511
+
397
512
  if not audio:
398
513
  continue
399
- nome = "narr-%s-%d-%s.wav" % (arq_id[:28], i + 1, assinatura)
514
+ nome = "narr-%s-%d-%s%s" % (arq_id[:28], i + 1, assinatura, ext)
400
515
  with open(os.path.join(MIDIA, nome), "wb") as fh:
401
516
  fh.write(audio)
402
517
  c["narracaoAudio"] = "/midia/" + nome
403
518
  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)
519
+ # A duração medida vai junto: com ela o player sabe quanto a cena
520
+ # REALMENTE dura (a fala inteira + respiro) e a linha do tempo
521
+ # vira determinística — sem ela, o relógio congela na beirada da
522
+ # cena esperando a voz, e a duração do vídeo muda a cada
523
+ # reprodução.
410
524
  if dur:
411
525
  c["narracaoDur"] = round(dur, 2)
412
526
  mudou = True
@@ -1002,10 +1116,33 @@ class Handler(SimpleHTTPRequestHandler):
1002
1116
  tipo = "audio/mpeg"
1003
1117
  audio = b""
1004
1118
 
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"):
1119
+ # 0) ElevenLabs: a voz que o usuário aprovou nos vídeos. Só entra na
1120
+ # corrente quando há chave configurada — e continua caindo para os
1121
+ # motores locais se falhar, porque narração nunca fica muda.
1122
+ if motor in ("", "elevenlabs") and elevenlabs_lib.configurado():
1123
+ dados, _ = elevenlabs_lib.sintetizar(texto, {"voz": voz_pedida})
1124
+ if dados:
1125
+ audio = dados
1126
+ tipo = "audio/mpeg"
1127
+ elif motor == "elevenlabs":
1128
+ return self.js({"erro": "ElevenLabs não devolveu áudio — chave válida? cota?"}, 502)
1129
+
1130
+ # 0.5) Edge TTS: neural, grátis, sem chave. Entra logo depois do
1131
+ # ElevenLabs porque é a melhor voz gratuita da corrente — vozes
1132
+ # da Microsoft, muito acima do Google. Precisa de internet.
1133
+ if not audio and motor in ("", "edge"):
1134
+ dados, _ = edge_lib.sintetizar(texto, {"voz": voz_pedida,
1135
+ "grave": grave, "velocidade": velocidade})
1136
+ if dados:
1137
+ audio = dados
1138
+ tipo = "audio/mpeg"
1139
+ elif motor == "edge":
1140
+ return self.js({"erro": "Edge TTS não devolveu áudio — sem internet ou pacote ausente?"}, 502)
1141
+
1142
+ # 1) Piper: o motor local. Se ainda não baixou, começa a baixar agora
1143
+ # e segue para o próximo — quem pediu narração hoje ouve o Google,
1144
+ # e a partir da próxima já é voz de verdade.
1145
+ if not audio and motor not in ("google", "voicebox", "elevenlabs", "edge"):
1009
1146
  try:
1010
1147
  if voz_lib.pronta(voz_pedida or None):
1011
1148
  audio = voz_lib.sintetizar(texto, voz_pedida or None,
@@ -1069,6 +1206,8 @@ class Handler(SimpleHTTPRequestHandler):
1069
1206
  except Exception:
1070
1207
  vozes = []
1071
1208
  return self.js({
1209
+ "elevenlabs": elevenlabs_lib.estado(),
1210
+ "edge": edge_lib.estado(),
1072
1211
  "piper": voz_lib.estado(),
1073
1212
  "voicebox": {"ativo": vivo, "vozes": vozes, "site": "https://voicebox.sh"},
1074
1213
  "google": {"ativo": True},
@@ -1219,6 +1358,11 @@ class Handler(SimpleHTTPRequestHandler):
1219
1358
  return self.js(voz_lib.instalar(pedido.get("voz"),
1220
1359
  esperar=min(60, int(pedido.get("esperar") or 0))))
1221
1360
 
1361
+ if rota == "/api/voz/elevenlabs":
1362
+ # Grava (ou apaga, com chave vazia) a chave do ElevenLabs. A chave
1363
+ # nunca volta em resposta nenhuma — só o "configurado" volta.
1364
+ return self.js(elevenlabs_lib.gravar_chave(self.corpo().get("chave")))
1365
+
1222
1366
  if rota == "/api/sair":
1223
1367
  # Só o CLI chama, e só quando o código no disco mudou (update do
1224
1368
  # 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() {
@@ -178,25 +178,43 @@ function aplicarChaves(no, e, ctx) {
178
178
  return _numero(a.t, 0) - _numero(b.t, 0);
179
179
  });
180
180
 
181
+ // Um campo OMITIDO numa chave herda o valor da chave anterior — não pula
182
+ // para um padrão fixo. Sem isto, uma chave que só mexe em `opacidade`
183
+ // zerava a `escala` implícita: o elemento nascia com scale(0) e sumia da
184
+ // tela, e a animação parecia "bugada e atrasada". Herdar é o que a pessoa
185
+ // espera: "o que eu não toquei, fica como estava". O primeiro valor de
186
+ // cada campo, quando nem a primeira chave o declara, é o neutro (escala e
187
+ // opacidade em 1, deslocamento e giro em 0) — nunca zero para escala.
188
+ var NEUTRO = { x: 0, y: 0, escala: 1, giro: 0, opacidade: 1, desfoque: 0 };
189
+ var CAMPOS = ["x", "y", "escala", "giro", "opacidade", "desfoque"];
190
+ var herdado = {};
191
+ CAMPOS.forEach(function (c) {
192
+ // semente: primeiro valor declarado em qualquer chave, senão o neutro.
193
+ for (var i = 0; i < chaves.length; i++) {
194
+ if (typeof chaves[i][c] === "number" && isFinite(chaves[i][c])) { herdado[c] = chaves[i][c]; return; }
195
+ }
196
+ herdado[c] = NEUTRO[c];
197
+ });
198
+
181
199
  var nome = "ch" + (++_nChave);
182
200
  var passos = chaves.map(function (k) {
201
+ CAMPOS.forEach(function (c) {
202
+ if (typeof k[c] === "number" && isFinite(k[c])) herdado[c] = k[c];
203
+ });
183
204
  var pct = Math.max(0, Math.min(1, _numero(k.t, 0))) * 100;
184
205
  var mov = [];
185
206
  // x e y de uma chave são DESLOCAMENTO a partir de onde o elemento está.
186
207
  // Assim a chave continua valendo se a pessoa arrastar o elemento no
187
208
  // palco depois — o contrário obrigaria a refazer todas as chaves.
188
- var dx = _numero(k.x, 0), dy = _numero(k.y, 0);
209
+ var dx = herdado.x, dy = herdado.y;
189
210
  if (dx || dy) mov.push("translate(" + dx + "px," + dy + "px)");
190
- var esc = _numero(k.escala, 1);
191
- if (esc !== 1) mov.push("scale(" + esc + ")");
192
- var giro = _numero(k.giro, 0);
193
- if (giro) mov.push("rotate(" + giro + "deg)");
211
+ if (herdado.escala !== 1) mov.push("scale(" + herdado.escala + ")");
212
+ if (herdado.giro) mov.push("rotate(" + herdado.giro + "deg)");
194
213
 
195
214
  var linhas = ["transform:" + (mov.length ? mov.join(" ") : "none")];
196
- var op = _numero(k.opacidade, 1);
197
- linhas.push("opacity:" + op);
198
- if (typeof k.desfoque === "number" && k.desfoque > 0) {
199
- linhas.push("filter:blur(" + k.desfoque + "px)");
215
+ linhas.push("opacity:" + herdado.opacidade);
216
+ if (herdado.desfoque > 0) {
217
+ linhas.push("filter:blur(" + herdado.desfoque + "px)");
200
218
  }
201
219
  return pct.toFixed(3) + "%{" + linhas.join(";") + "}";
202
220
  }).join("");