primocode 8.20.0 → 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/act.js +17 -1
- package/lib/studio.js +1 -1
- package/package.json +1 -1
- package/studio/elevenlabs.py +142 -0
- package/studio/ferramentas/corte.md +9 -1
- package/studio/server.py +96 -35
- package/studio/web/core.css +135 -0
- package/studio/web/home.html +2 -1
- package/studio/web/js/player.js +80 -3
package/lib/act.js
CHANGED
|
@@ -377,6 +377,7 @@ async function runAct(userPrompt, opts) {
|
|
|
377
377
|
// usuário — que não tem prompt nenhum para ajustar. É o modelo tropeçando
|
|
378
378
|
// ao escrever o JSON da chamada, e quase sempre acerta na segunda.
|
|
379
379
|
let funcoesQuebradas = 0;
|
|
380
|
+
let respostasVazias = 0;
|
|
380
381
|
// O teto de tokens é POR MINUTO, somando TODAS as chamadas — o plano, o
|
|
381
382
|
// resumo e cada rodada do loop dividem a mesma cota. Um pedido perfeito
|
|
382
383
|
// estoura se a rodada anterior já gastou o minuto. Encolher não resolve
|
|
@@ -457,7 +458,11 @@ async function runAct(userPrompt, opts) {
|
|
|
457
458
|
// isto o 400 cru do provedor ("Failed to call a function. Please
|
|
458
459
|
// adjust your prompt.") caía na tela de quem não tem prompt nenhum
|
|
459
460
|
// para ajustar.
|
|
460
|
-
|
|
461
|
+
// "failed to parse tool call arguments" é a variante nova do mesmo
|
|
462
|
+
// tropeço: o provedor recusou o JSON que o MODELO gerou. Medido em
|
|
463
|
+
// produção num "crie um vídeo" — o documento grande saía torto e o
|
|
464
|
+
// erro cru parava a tarefa com a corrente inteira ainda de pé.
|
|
465
|
+
if (streamError && /failed to call a function|failed to parse tool call|failed_generation|tool_use_failed/i.test(String(streamError))
|
|
461
466
|
&& funcoesQuebradas < 4 && !PARAR) {
|
|
462
467
|
funcoesQuebradas++;
|
|
463
468
|
if (!subagent) console.log(ident(profundidade) + c.dim(' ⎿ o modelo tropeçou na chamada — tentando de novo'));
|
|
@@ -506,12 +511,23 @@ async function runAct(userPrompt, opts) {
|
|
|
506
511
|
// Sem tool_calls: o modelo respondeu e acabou.
|
|
507
512
|
if (!toolCalls.length) {
|
|
508
513
|
finalText = assistantText.trim();
|
|
514
|
+
// Resposta VAZIA não é resposta: sem tool, sem texto e sem erro é
|
|
515
|
+
// o modelo grátis engasgando. Cair direto no "parei sem concluir"
|
|
516
|
+
// jogava a tarefa fora quando repetir a MESMA rodada quase sempre
|
|
517
|
+
// resolve — visto em produção no primeiro pedido de vídeo.
|
|
518
|
+
if (!finalText && respostasVazias < 2 && !PARAR) {
|
|
519
|
+
respostasVazias++;
|
|
520
|
+
if (!subagent) console.log(ident(profundidade) + c.dim(' ⎿ o modelo respondeu vazio — tentando de novo'));
|
|
521
|
+
iteration--;
|
|
522
|
+
continue;
|
|
523
|
+
}
|
|
509
524
|
if (!subagent) {
|
|
510
525
|
if (finalText && !streaming) console.log('\n' + c.white(finalText));
|
|
511
526
|
else if (streaming) process.stdout.write('\n');
|
|
512
527
|
}
|
|
513
528
|
break;
|
|
514
529
|
}
|
|
530
|
+
respostasVazias = 0;
|
|
515
531
|
|
|
516
532
|
if (streaming && !subagent) process.stdout.write('\n');
|
|
517
533
|
|
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.
|
|
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": "
|
|
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
|
-
|
|
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
|
-
#
|
|
353
|
-
#
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
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
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
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
|
|
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
|
|
405
|
-
#
|
|
406
|
-
#
|
|
407
|
-
#
|
|
408
|
-
#
|
|
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
|
-
#
|
|
1006
|
-
#
|
|
1007
|
-
#
|
|
1008
|
-
if motor
|
|
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
|
package/studio/web/core.css
CHANGED
|
@@ -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
|
+
}
|
package/studio/web/home.html
CHANGED
|
@@ -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
|
|
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>
|
package/studio/web/js/player.js
CHANGED
|
@@ -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
|
|
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 =
|
|
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 = "
|
|
600
|
+
if (b) { b.classList.remove("gravando"); b.textContent = "Baixar ▾"; }
|
|
524
601
|
}
|
|
525
602
|
|
|
526
603
|
function baixarGravacao() {
|