primocode 8.42.2 → 9.1.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/README.md +41 -37
- package/bin/primocode.js +598 -360
- package/lib/acervo.js +8 -1
- package/lib/act.js +205 -114
- package/lib/api.js +35 -4
- package/lib/boas-vindas.js +42 -195
- package/lib/catalogo.js +100 -61
- package/lib/config.js +1 -1
- package/lib/conta.js +3 -24
- package/lib/diff.js +161 -0
- package/lib/diretor.js +37 -47
- package/lib/entrada.js +576 -0
- package/lib/especialistas.js +2 -2
- package/lib/estudio/assets.js +343 -0
- package/lib/estudio/identidade.js +560 -0
- package/lib/estudio/index.js +565 -0
- package/lib/estudio/plano.js +327 -0
- package/lib/estudio/projeto.js +246 -0
- package/lib/estudio/remotion.js +644 -0
- package/lib/estudio/slides.js +190 -0
- package/lib/estudio/video.js +296 -0
- package/lib/fala.js +49 -6
- package/lib/marca.js +4 -1
- package/lib/markdown.js +376 -0
- package/lib/marketplace.js +189 -0
- package/lib/pasta.js +15 -4
- package/lib/permissao.js +137 -0
- package/lib/registro.js +229 -0
- package/lib/skills.js +329 -0
- package/lib/tools.js +106 -187
- package/lib/ui.js +7 -3
- package/lib/voz.js +239 -0
- package/package.json +12 -3
- package/skills/acessibilidade/SKILL.md +28 -0
- package/skills/api-que-nao-quebra/SKILL.md +27 -0
- package/skills/depurar-de-verdade/SKILL.md +29 -0
- package/skills/editar-meu-video/SKILL.md +59 -0
- package/skills/escrever-em-portugues/SKILL.md +28 -0
- package/skills/git-limpo/SKILL.md +28 -0
- package/skills/motion-de-primeira/SKILL.md +62 -0
- package/skills/pagina-que-converte/SKILL.md +28 -0
- package/skills/performance-web/SKILL.md +28 -0
- package/skills/revisao-antes-de-entregar/SKILL.md +27 -0
- package/skills/slides-que-prendem/SKILL.md +40 -0
- package/studio/edge.py +16 -66
- package/studio/narrar.py +274 -0
- package/studio/pessoa.py +199 -0
- package/voz/primo_voz/__init__.py +0 -0
- package/voz/primo_voz/__main__.py +383 -0
- package/voz/primo_voz/escuta.py +235 -0
- package/voz/primo_voz/orbe.py +216 -0
- package/lib/audio-livre.js +0 -177
- package/lib/chaves.js +0 -149
- package/lib/claude-engine.js +0 -631
- package/lib/codex-engine.js +0 -208
- package/lib/remotion.js +0 -663
- package/lib/studio.js +0 -960
- package/studio/PRIMOCODE.md +0 -58
- package/studio/README.md +0 -154
- package/studio/__pycache__/edge.cpython-311.pyc +0 -0
- package/studio/__pycache__/elevenlabs.cpython-311.pyc +0 -0
- package/studio/__pycache__/voz.cpython-311.pyc +0 -0
- package/studio/arquivos/corte-teste-de-camera-fb823.json +0 -57
- package/studio/ferramentas/00-protocolo.md +0 -125
- package/studio/ferramentas/01-modelos.md +0 -270
- package/studio/ferramentas/README.md +0 -32
- package/studio/ferramentas/corte.md +0 -919
- package/studio/ferramentas/grade.md +0 -92
- package/studio/ferramentas/prisma.md +0 -107
- package/studio/ferramentas/prosa.md +0 -86
- package/studio/ferramentas/tela.md +0 -93
- package/studio/ferramentas/traco.md +0 -79
- package/studio/server.py +0 -2254
- package/studio/studio.log +0 -57
- package/studio/voz.py +0 -388
- package/studio/web/analise.html +0 -52
- package/studio/web/core.css +0 -1167
- package/studio/web/fontes/bebas.woff2 +0 -0
- package/studio/web/fontes/grotesk-700.woff2 +0 -0
- package/studio/web/fontes/inter-400.woff2 +0 -0
- package/studio/web/fontes/inter-700.woff2 +0 -0
- package/studio/web/fontes/jetbrains-400.woff2 +0 -0
- package/studio/web/fontes/playfair-700.woff2 +0 -0
- package/studio/web/fontes.css +0 -23
- package/studio/web/home.html +0 -217
- package/studio/web/js/analise.js +0 -510
- package/studio/web/js/audio.js +0 -607
- package/studio/web/js/editor.js +0 -1802
- package/studio/web/js/exportar.js +0 -222
- package/studio/web/js/ferramentas.js +0 -108
- package/studio/web/js/icones.js +0 -120
- package/studio/web/js/midia.js +0 -157
- package/studio/web/js/narracao.js +0 -325
- package/studio/web/js/player.js +0 -903
- package/studio/web/js/render-grade.js +0 -243
- package/studio/web/js/render-prosa.js +0 -104
- package/studio/web/js/render.js +0 -1852
- package/studio/web/js/sfx.js +0 -233
- package/studio/web/js/video.js +0 -464
- package/studio/web/js/voz-neural.js +0 -152
- package/studio/web/render.css +0 -1352
- package/studio/web/tf.html +0 -15
- package/studio/web/tool.html +0 -78
- package/studio/web/view.html +0 -27
package/studio/server.py
DELETED
|
@@ -1,2254 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""
|
|
3
|
-
PrimoCode Studio — suíte de criação operada por IA.
|
|
4
|
-
|
|
5
|
-
python3 server.py # http://localhost:7777
|
|
6
|
-
python3 server.py 8080
|
|
7
|
-
|
|
8
|
-
Só biblioteca padrão do Python. Nada para instalar.
|
|
9
|
-
"""
|
|
10
|
-
|
|
11
|
-
import base64
|
|
12
|
-
import hashlib
|
|
13
|
-
import functools
|
|
14
|
-
import json
|
|
15
|
-
import mimetypes
|
|
16
|
-
import os
|
|
17
|
-
import re
|
|
18
|
-
import sys
|
|
19
|
-
import threading
|
|
20
|
-
import time
|
|
21
|
-
import unicodedata
|
|
22
|
-
import uuid
|
|
23
|
-
import wave
|
|
24
|
-
from datetime import datetime, timezone
|
|
25
|
-
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
|
26
|
-
from urllib.parse import urlparse, unquote, urlencode
|
|
27
|
-
import urllib.request
|
|
28
|
-
|
|
29
|
-
RAIZ = os.path.dirname(os.path.abspath(__file__))
|
|
30
|
-
sys.path.insert(0, RAIZ)
|
|
31
|
-
try:
|
|
32
|
-
import voz as voz_lib # noqa: E402 (precisa da RAIZ no path antes)
|
|
33
|
-
except Exception as _e: # instalação parcial, ou voz.py de uma versão velha
|
|
34
|
-
# Narração é acabamento; o Studio inteiro não pode morrer por causa dela.
|
|
35
|
-
# Sem o módulo, a corrente começa direto no Google e tudo mais funciona.
|
|
36
|
-
sys.stderr.write("[voz] módulo indisponível (%s); narração vai só pelo Google\n" % _e)
|
|
37
|
-
|
|
38
|
-
class _SemVoz:
|
|
39
|
-
@staticmethod
|
|
40
|
-
def pronta(*a, **k):
|
|
41
|
-
return False
|
|
42
|
-
|
|
43
|
-
@staticmethod
|
|
44
|
-
def sintetizar(*a, **k):
|
|
45
|
-
return None
|
|
46
|
-
|
|
47
|
-
@staticmethod
|
|
48
|
-
def instalar(*a, **k):
|
|
49
|
-
return {"pronta": False, "erro": "módulo de voz indisponível"}
|
|
50
|
-
|
|
51
|
-
@staticmethod
|
|
52
|
-
def estado():
|
|
53
|
-
return {"pronta": False, "vozes": [], "erro": "módulo de voz indisponível"}
|
|
54
|
-
|
|
55
|
-
@staticmethod
|
|
56
|
-
def apagar():
|
|
57
|
-
return {"ok": True}
|
|
58
|
-
|
|
59
|
-
@staticmethod
|
|
60
|
-
def bloqueado():
|
|
61
|
-
return "módulo de voz indisponível"
|
|
62
|
-
|
|
63
|
-
voz_lib = _SemVoz()
|
|
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
|
-
|
|
114
|
-
WEB = os.path.join(RAIZ, "web")
|
|
115
|
-
ARQUIVOS = os.path.join(RAIZ, "arquivos")
|
|
116
|
-
FERRAMENTAS = os.path.join(RAIZ, "ferramentas")
|
|
117
|
-
MIDIA = os.path.join(RAIZ, "midia")
|
|
118
|
-
PORTA = int(sys.argv[1]) if len(sys.argv) > 1 else 7777
|
|
119
|
-
|
|
120
|
-
def _assinatura():
|
|
121
|
-
"""SHA-1 do código do Studio, lido NA SUBIDA. Se o npm trocar o código no
|
|
122
|
-
disco depois, o processo no ar continua respondendo a assinatura antiga —
|
|
123
|
-
e é exatamente essa diferença que o CLI usa para saber que precisa
|
|
124
|
-
reiniciar o Studio em vez de servir a versão velha para sempre.
|
|
125
|
-
|
|
126
|
-
Entra todo módulo do Studio, não só este arquivo: uma correção que mexa
|
|
127
|
-
apenas na voz também precisa derrubar o processo velho."""
|
|
128
|
-
h = hashlib.sha1()
|
|
129
|
-
try:
|
|
130
|
-
for nome in ("server.py", "voz.py"):
|
|
131
|
-
caminho = os.path.join(RAIZ, nome)
|
|
132
|
-
if os.path.exists(caminho):
|
|
133
|
-
with open(caminho, "rb") as fh:
|
|
134
|
-
h.update(fh.read())
|
|
135
|
-
return h.hexdigest()[:12]
|
|
136
|
-
except Exception:
|
|
137
|
-
return "?"
|
|
138
|
-
|
|
139
|
-
ASSINATURA = _assinatura()
|
|
140
|
-
|
|
141
|
-
# 60 MB por arquivo: cobre faixa longa em mp3 e foto de câmera sem susto
|
|
142
|
-
LIMITE_MIDIA = 60 * 1024 * 1024
|
|
143
|
-
# Vídeo do usuário é outra escala: um celular grava 100 MB por minuto. O teto
|
|
144
|
-
# de 60 MB recusava quase toda gravação real — e "edite meu vídeo" morria na
|
|
145
|
-
# primeira ordem. Só vale para o caminho bruto, que não passa por base64.
|
|
146
|
-
LIMITE_VIDEO = 2 * 1024 * 1024 * 1024
|
|
147
|
-
|
|
148
|
-
os.makedirs(ARQUIVOS, exist_ok=True)
|
|
149
|
-
os.makedirs(MIDIA, exist_ok=True)
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
# ------------------------------------------------------------- ferramentas --
|
|
153
|
-
|
|
154
|
-
FERRAMENTAS_INFO = [
|
|
155
|
-
{
|
|
156
|
-
"slug": "prisma",
|
|
157
|
-
"nome": "Prisma",
|
|
158
|
-
"papel": "Apresentações",
|
|
159
|
-
"equivale": "PowerPoint, Keynote, Canva Apresentações",
|
|
160
|
-
"modelo": "cena",
|
|
161
|
-
"resumo": "Decks com layouts automáticos, temas, animação de entrada, notas do apresentador e export em PDF.",
|
|
162
|
-
"cor": "#FF5A36",
|
|
163
|
-
},
|
|
164
|
-
{
|
|
165
|
-
"slug": "tela",
|
|
166
|
-
"nome": "Tela",
|
|
167
|
-
"papel": "Design gráfico",
|
|
168
|
-
"equivale": "Photoshop, Figma, Canva",
|
|
169
|
-
"modelo": "cena",
|
|
170
|
-
"resumo": "Artes em canvas livre com camadas, formas, texto, imagens, gradientes e sombras. Post, capa, banner, thumbnail.",
|
|
171
|
-
"cor": "#8B5CF6",
|
|
172
|
-
},
|
|
173
|
-
{
|
|
174
|
-
"slug": "prosa",
|
|
175
|
-
"nome": "Prosa",
|
|
176
|
-
"papel": "Documentos",
|
|
177
|
-
"equivale": "Word, Google Docs, Notion",
|
|
178
|
-
"modelo": "documento",
|
|
179
|
-
"resumo": "Textos longos com hierarquia, citações, listas, tabelas, capa e sumário. Export em HTML e Markdown.",
|
|
180
|
-
"cor": "#2E9E6B",
|
|
181
|
-
},
|
|
182
|
-
{
|
|
183
|
-
"slug": "grade",
|
|
184
|
-
"nome": "Grade",
|
|
185
|
-
"papel": "Planilhas",
|
|
186
|
-
"equivale": "Excel, Google Sheets",
|
|
187
|
-
"modelo": "planilha",
|
|
188
|
-
"resumo": "Tabelas com fórmulas, formatação condicional, totais e gráficos. Export em CSV.",
|
|
189
|
-
"cor": "#1F8FE0",
|
|
190
|
-
},
|
|
191
|
-
{
|
|
192
|
-
"slug": "corte",
|
|
193
|
-
"nome": "Corte",
|
|
194
|
-
"papel": "Vídeo",
|
|
195
|
-
"equivale": "CapCut, Premiere, After Effects",
|
|
196
|
-
"modelo": "cena",
|
|
197
|
-
"resumo": "Montagem por cenas com duração, transições e animação. Reproduz no navegador e grava .webm.",
|
|
198
|
-
"cor": "#E0397A",
|
|
199
|
-
},
|
|
200
|
-
{
|
|
201
|
-
"slug": "traco",
|
|
202
|
-
"nome": "Traço",
|
|
203
|
-
"papel": "Diagramas",
|
|
204
|
-
"equivale": "Miro, Whimsical, Lucidchart",
|
|
205
|
-
"modelo": "cena",
|
|
206
|
-
"resumo": "Fluxos, mapas mentais, arquiteturas e organogramas com nós e conectores que se ligam sozinhos.",
|
|
207
|
-
"cor": "#D9A21B",
|
|
208
|
-
},
|
|
209
|
-
]
|
|
210
|
-
|
|
211
|
-
POR_SLUG = {f["slug"]: f for f in FERRAMENTAS_INFO}
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
# -------------------------------------------------------------- utilidades --
|
|
215
|
-
|
|
216
|
-
def _normaliza(texto, tamanho):
|
|
217
|
-
"""NFKD → ASCII → não-alfanumérico vira hífen → minúsculo → corta.
|
|
218
|
-
Compartilhada por slugify() e nome_seguro(), que faziam a mesma conta
|
|
219
|
-
com um corte de tamanho diferente cada uma."""
|
|
220
|
-
texto = unicodedata.normalize("NFKD", texto or "").encode("ascii", "ignore").decode()
|
|
221
|
-
texto = re.sub(r"[^a-zA-Z0-9]+", "-", texto).strip("-").lower()
|
|
222
|
-
return (texto or "arquivo")[:tamanho]
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
def slugify(texto):
|
|
226
|
-
return _normaliza(texto, 40)
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
def caminho(arq_id):
|
|
230
|
-
if not re.fullmatch(r"[a-z0-9\-]{1,72}", arq_id or ""):
|
|
231
|
-
return None
|
|
232
|
-
return os.path.join(ARQUIVOS, arq_id + ".json")
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
def ler(arq_id):
|
|
236
|
-
c = caminho(arq_id)
|
|
237
|
-
if not c or not os.path.exists(c):
|
|
238
|
-
return None
|
|
239
|
-
with open(c, encoding="utf-8") as fh:
|
|
240
|
-
return json.load(fh)
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
def gravar(arq):
|
|
244
|
-
arq["atualizado"] = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
|
245
|
-
destino = caminho(arq["id"])
|
|
246
|
-
# Escrita atômica: o servidor é multi-thread (autosave do editor a cada
|
|
247
|
-
# 600 ms + a thread de narração gravando o mesmo doc). Escrever direto
|
|
248
|
-
# podia deixar um JSON pela metade se duas gravações se cruzassem ou o
|
|
249
|
-
# processo caísse no meio. Com tmp + replace, ou o arquivo antigo está
|
|
250
|
-
# inteiro, ou o novo está inteiro — nunca metade.
|
|
251
|
-
tmp = destino + ".tmp"
|
|
252
|
-
with open(tmp, "w", encoding="utf-8") as fh:
|
|
253
|
-
json.dump(arq, fh, ensure_ascii=False, indent=2)
|
|
254
|
-
os.replace(tmp, destino)
|
|
255
|
-
return arq
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
def _duracao_wav(dados):
|
|
259
|
-
"""Segundos de um WAV em bytes; None se não der para medir."""
|
|
260
|
-
try:
|
|
261
|
-
import io
|
|
262
|
-
with wave.open(io.BytesIO(dados), "rb") as w:
|
|
263
|
-
fr = w.getframerate()
|
|
264
|
-
return (w.getnframes() / fr) if fr else None
|
|
265
|
-
except Exception:
|
|
266
|
-
return None
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
# ------------------------------------------------------------------ versões --
|
|
270
|
-
#
|
|
271
|
-
# Um pedido de ajuste apagou o trabalho de um usuário: ele tinha um vídeo de
|
|
272
|
-
# oito cenas, com narração e trilha, pediu "aplique a identidade visual" e o
|
|
273
|
-
# agente mandou um PUT com doc={"tema":"..."}. O handler trocava o documento
|
|
274
|
-
# INTEIRO pelo que chegou — as oito cenas, a narração e a trilha viraram um
|
|
275
|
-
# documento de uma chave só. Não havia como voltar.
|
|
276
|
-
#
|
|
277
|
-
# Daí saíram duas defesas. `mesclar` faz o pedido parcial ser parcial de
|
|
278
|
-
# verdade, que é o conserto. Este histórico é o que sobra quando o conserto
|
|
279
|
-
# não basta: guarda o estado anterior a cada gravação, para que sobrescrever
|
|
280
|
-
# nunca mais seja definitivo. Nada aqui apaga o arquivo vivo; o pior caso é
|
|
281
|
-
# ocupar disco, e vinte versões de um documento de texto não dão um megabyte.
|
|
282
|
-
|
|
283
|
-
VERSOES = os.path.join(ARQUIVOS, ".versoes")
|
|
284
|
-
MAX_VERSOES = 20
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
_seq_versao = 0
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
def guardar_versao(arq):
|
|
291
|
-
"""Copia o estado atual para .versoes/<id>/<carimbo>.json antes de trocar.
|
|
292
|
-
|
|
293
|
-
O carimbo é ordenável como texto, e por isso precisa de milissegundo: o
|
|
294
|
-
editor do navegador salva a cada 600 ms, e duas versões do mesmo segundo
|
|
295
|
-
desempatadas por sorteio fariam "desfaz a última mudança" desfazer a
|
|
296
|
-
penúltima. O contador no fim resolve o empate que sobra."""
|
|
297
|
-
global _seq_versao
|
|
298
|
-
try:
|
|
299
|
-
pasta = os.path.join(VERSOES, arq["id"])
|
|
300
|
-
os.makedirs(pasta, exist_ok=True)
|
|
301
|
-
_seq_versao = (_seq_versao + 1) % 10000
|
|
302
|
-
agora = datetime.now(timezone.utc)
|
|
303
|
-
carimbo = "%s%03d-%04d" % (agora.strftime("%Y%m%dT%H%M%S"),
|
|
304
|
-
agora.microsecond // 1000, _seq_versao)
|
|
305
|
-
with open(os.path.join(pasta, carimbo + ".json"), "w", encoding="utf-8") as fh:
|
|
306
|
-
json.dump(arq, fh, ensure_ascii=False)
|
|
307
|
-
antigas = sorted(n for n in os.listdir(pasta) if n.endswith(".json"))
|
|
308
|
-
for velha in antigas[:-MAX_VERSOES]:
|
|
309
|
-
os.remove(os.path.join(pasta, velha))
|
|
310
|
-
return carimbo
|
|
311
|
-
except Exception:
|
|
312
|
-
# Histórico é rede de proteção, não requisito: se o disco recusar, a
|
|
313
|
-
# gravação principal segue. Falhar aqui seria trocar perder o passado
|
|
314
|
-
# por perder o presente.
|
|
315
|
-
return None
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
def versoes(arq_id):
|
|
319
|
-
"""As versões guardadas, da mais nova para a mais velha."""
|
|
320
|
-
if not caminho(arq_id):
|
|
321
|
-
return []
|
|
322
|
-
pasta = os.path.join(VERSOES, arq_id)
|
|
323
|
-
if not os.path.isdir(pasta):
|
|
324
|
-
return []
|
|
325
|
-
saida = []
|
|
326
|
-
for nome in sorted(os.listdir(pasta), reverse=True):
|
|
327
|
-
if not nome.endswith(".json"):
|
|
328
|
-
continue
|
|
329
|
-
try:
|
|
330
|
-
with open(os.path.join(pasta, nome), encoding="utf-8") as fh:
|
|
331
|
-
a = json.load(fh)
|
|
332
|
-
except Exception:
|
|
333
|
-
continue
|
|
334
|
-
doc = a.get("doc") or {}
|
|
335
|
-
saida.append({
|
|
336
|
-
"versao": nome[:-5],
|
|
337
|
-
"titulo": a.get("titulo", ""),
|
|
338
|
-
"pecas": len(doc.get("cenas") or doc.get("blocos") or doc.get("abas") or []),
|
|
339
|
-
"atualizado": a.get("atualizado", ""),
|
|
340
|
-
})
|
|
341
|
-
return saida
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
def ler_versao(arq_id, versao):
|
|
345
|
-
if not caminho(arq_id) or not re.fullmatch(r"[0-9A-Za-z\-]{1,40}", versao or ""):
|
|
346
|
-
return None
|
|
347
|
-
c = os.path.join(VERSOES, arq_id, versao + ".json")
|
|
348
|
-
if not os.path.exists(c):
|
|
349
|
-
return None
|
|
350
|
-
with open(c, encoding="utf-8") as fh:
|
|
351
|
-
return json.load(fh)
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
def _tem_narracao(doc):
|
|
355
|
-
if not isinstance(doc, dict):
|
|
356
|
-
return False
|
|
357
|
-
if isinstance(doc.get("narracao"), str) and doc["narracao"].strip():
|
|
358
|
-
return True
|
|
359
|
-
for c in doc.get("cenas") or []:
|
|
360
|
-
if isinstance(c, dict) and isinstance(c.get("narracao"), str) and c["narracao"].strip():
|
|
361
|
-
return True
|
|
362
|
-
return False
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
def preparar_voz(doc):
|
|
366
|
-
"""Documento com narração chegou: começa a baixar a voz agora.
|
|
367
|
-
|
|
368
|
-
O download é de ~90 MB e leva algum tempo. Fazê-lo quando o usuário
|
|
369
|
-
aperta play seria tarde — ele ouviria a voz robótica do Google. Fazê-lo
|
|
370
|
-
aqui, no instante em que o roteiro é gravado, dá ao download a mesma
|
|
371
|
-
janela que o agente gasta escrevendo o resto: quando alguém toca o vídeo,
|
|
372
|
-
a voz boa já está na máquina. Se não der tempo, a corrente cobre.
|
|
373
|
-
|
|
374
|
-
Com o ElevenLabs configurado, não há nada para baixar: a síntese é
|
|
375
|
-
imediata e os 90 MB do Piper ficam para o dia em que faltarem chave ou
|
|
376
|
-
internet."""
|
|
377
|
-
if _tem_narracao(doc) and not elevenlabs_lib.configurado():
|
|
378
|
-
# O Edge (pip, segundos) e o Piper (90 MB, minutos) começam JUNTOS.
|
|
379
|
-
# Antes só o Piper era preparado aqui, e o Edge — a melhor voz grátis —
|
|
380
|
-
# só era instalado quando a primeira cena já estava pedindo áudio: a
|
|
381
|
-
# narração caía no Google enquanto o pip rodava, e o dono ouviu a
|
|
382
|
-
# voz de robô no vídeo inteiro.
|
|
383
|
-
try:
|
|
384
|
-
edge_lib.instalar()
|
|
385
|
-
except Exception:
|
|
386
|
-
pass
|
|
387
|
-
if not voz_lib.pronta():
|
|
388
|
-
try:
|
|
389
|
-
voz_lib.instalar()
|
|
390
|
-
except Exception:
|
|
391
|
-
pass
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
# ------------------------------------------------------- narração em arquivo --
|
|
395
|
-
#
|
|
396
|
-
# O usuário testou e disse: "as vozes tá a mesma merda". Estava certo, e a
|
|
397
|
-
# causa era tempo, não motor. A narração era sintetizada NA HORA DE TOCAR:
|
|
398
|
-
# quem apertava play antes de o Piper terminar de baixar ouvia o motor de
|
|
399
|
-
# reserva — a voz robótica de sempre. Como o download leva minutos e o vídeo
|
|
400
|
-
# fica pronto em segundos, era o que acontecia SEMPRE na primeira vez.
|
|
401
|
-
#
|
|
402
|
-
# Agora a narração vira ARQUIVO assim que o roteiro é gravado. O trabalho
|
|
403
|
-
# roda no fundo, espera o motor bom ficar pronto (esperar é de graça: ninguém
|
|
404
|
-
# está olhando) e grava um .wav por cena. O player já preferia
|
|
405
|
-
# `cena.narracaoAudio` quando existe — passa a existir sempre.
|
|
406
|
-
#
|
|
407
|
-
# De quebra, conserta duas coisas que ninguém tinha ligado: o vídeo gravado
|
|
408
|
-
# em MP4 carrega a voz boa, e tocar de novo não re-sintetiza nada.
|
|
409
|
-
|
|
410
|
-
_narrando = set()
|
|
411
|
-
_tranca_narracao = threading.Lock()
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
def _assinatura_voz(texto, voz):
|
|
415
|
-
corpo = "%s|%s|%s|%s" % (texto, voz.get("voz") or "", voz.get("grave") or "",
|
|
416
|
-
voz.get("velocidade") or "")
|
|
417
|
-
return hashlib.sha1(corpo.encode("utf-8")).hexdigest()[:10]
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
def _google_tts(texto, idioma="pt-BR"):
|
|
421
|
-
"""Google Translate TTS em MP3, no nível do módulo, para o pré-render
|
|
422
|
-
também ter o mesmo piso do endpoint: uma voz que responde sempre. Robótica,
|
|
423
|
-
mas garante que NENHUMA cena fique sem áudio. Devolve b"" se falhar."""
|
|
424
|
-
try:
|
|
425
|
-
pedacos = []
|
|
426
|
-
for t in _picar(texto, 190):
|
|
427
|
-
url = "https://translate.google.com/translate_tts?" + urlencode({
|
|
428
|
-
"ie": "UTF-8", "q": t, "tl": idioma, "client": "tw-ob", "total": 1, "idx": 0,
|
|
429
|
-
})
|
|
430
|
-
req = urllib.request.Request(url, headers={
|
|
431
|
-
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
|
|
432
|
-
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0 Safari/537.36",
|
|
433
|
-
"Referer": "https://translate.google.com/",
|
|
434
|
-
})
|
|
435
|
-
with urllib.request.urlopen(req, timeout=20) as r:
|
|
436
|
-
pedacos.append(r.read())
|
|
437
|
-
return b"".join(pedacos)
|
|
438
|
-
except Exception as e:
|
|
439
|
-
sys.stderr.write("[voz] Google TTS falhou: %s\n" % e)
|
|
440
|
-
return b""
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
def _gravar_narracao(arq_id):
|
|
444
|
-
"""Sintetiza a narração de cada cena e guarda em midia/."""
|
|
445
|
-
try:
|
|
446
|
-
# Com ElevenLabs (chave) ou Edge (grátis, neural, sem download) já há
|
|
447
|
-
# voz de gente na hora — não se espera o Piper. Só quando NENHUM dos
|
|
448
|
-
# dois responde vale aguardar o Piper baixar, porque a alternativa é
|
|
449
|
-
# o Google, que soa a robô no vídeo que a pessoa vai apresentar.
|
|
450
|
-
if not elevenlabs_lib.configurado() and not edge_lib.disponivel():
|
|
451
|
-
edge_lib.instalar() # tenta trazer o Edge em paralelo
|
|
452
|
-
for _ in range(120):
|
|
453
|
-
if voz_lib.pronta() or edge_lib.disponivel():
|
|
454
|
-
break
|
|
455
|
-
# Máquina onde o Piper nunca vai rodar (binário de outra
|
|
456
|
-
# arquitetura): esperar 10 minutos aqui era esperar por nada.
|
|
457
|
-
if voz_lib.bloqueado():
|
|
458
|
-
sys.stderr.write("[voz] Piper bloqueado: %s\n" % voz_lib.bloqueado())
|
|
459
|
-
break
|
|
460
|
-
voz_lib.instalar()
|
|
461
|
-
time.sleep(5)
|
|
462
|
-
# Nem Piper nem Edge subiram: ainda assim seguimos — o Google, lá
|
|
463
|
-
# embaixo na síntese, garante que nenhuma cena fique sem áudio.
|
|
464
|
-
|
|
465
|
-
a = ler(arq_id)
|
|
466
|
-
if not a:
|
|
467
|
-
return
|
|
468
|
-
doc = a.get("doc") or {}
|
|
469
|
-
voz = doc.get("voz") if isinstance(doc.get("voz"), dict) else {}
|
|
470
|
-
mudou = False
|
|
471
|
-
|
|
472
|
-
for i, c in enumerate(doc.get("cenas") or []):
|
|
473
|
-
if not isinstance(c, dict):
|
|
474
|
-
continue
|
|
475
|
-
texto = c.get("narracao")
|
|
476
|
-
if not isinstance(texto, str) or not texto.strip():
|
|
477
|
-
continue
|
|
478
|
-
assinatura = _assinatura_voz(texto, voz)
|
|
479
|
-
# Já gravada e com o mesmo texto e a mesma voz: nada a fazer.
|
|
480
|
-
if c.get("narracaoVoz") == assinatura and c.get("narracaoAudio"):
|
|
481
|
-
continue
|
|
482
|
-
# Gravação do próprio usuário nunca é sobrescrita — a voz dele
|
|
483
|
-
# vale mais que qualquer síntese.
|
|
484
|
-
if c.get("narracaoAudio") and not c.get("narracaoVoz"):
|
|
485
|
-
continue
|
|
486
|
-
audio, ext, dur = None, ".wav", None
|
|
487
|
-
motor_usado = None
|
|
488
|
-
# A CORRENTE, na ordem de qualidade. Cada motor cai no seguinte
|
|
489
|
-
# quando não devolve áudio — e o Google, no fim, garante que a
|
|
490
|
-
# cena nunca fique muda. É o que faz o pré-render nunca parar.
|
|
491
|
-
|
|
492
|
-
# 1) ElevenLabs: a voz que o usuário aprovou. MP3 CBR — a duração
|
|
493
|
-
# do módulo já vem medida.
|
|
494
|
-
if elevenlabs_lib.configurado():
|
|
495
|
-
audio, dur = elevenlabs_lib.sintetizar(texto, voz)
|
|
496
|
-
if audio:
|
|
497
|
-
ext, motor_usado = ".mp3", "elevenlabs"
|
|
498
|
-
|
|
499
|
-
# 2) Edge TTS: neural, grátis, sem chave. MP3, duração medida pelo
|
|
500
|
-
# VTT que o próprio edge escreve. Ninguém está olhando este
|
|
501
|
-
# laço: se o pip ainda roda, espera por ele (até 2 min) — é de
|
|
502
|
-
# graça, e é o que separa voz de gente de voz de robô.
|
|
503
|
-
if not audio:
|
|
504
|
-
if not edge_lib.disponivel():
|
|
505
|
-
edge_lib.instalar(esperar=120)
|
|
506
|
-
if edge_lib.disponivel():
|
|
507
|
-
dados, edur = edge_lib.sintetizar(texto, voz)
|
|
508
|
-
if dados:
|
|
509
|
-
audio, dur, ext, motor_usado = dados, edur, ".mp3", "edge"
|
|
510
|
-
|
|
511
|
-
# 3) Piper: neural e offline, quando baixado e compatível. WAV.
|
|
512
|
-
if not audio:
|
|
513
|
-
try:
|
|
514
|
-
audio = voz_lib.sintetizar(
|
|
515
|
-
texto, voz.get("voz"),
|
|
516
|
-
grave=bool(voz.get("grave")),
|
|
517
|
-
velocidade=float(voz.get("velocidade") or 1))
|
|
518
|
-
except Exception as e:
|
|
519
|
-
sys.stderr.write("[voz] Piper cena %d falhou: %s\n" % (i + 1, e))
|
|
520
|
-
audio = None
|
|
521
|
-
if audio:
|
|
522
|
-
dur, ext, motor_usado = _duracao_wav(audio), ".wav", "piper"
|
|
523
|
-
|
|
524
|
-
# 4) Google: última reserva. Robótica, mas responde. MP3 — a
|
|
525
|
-
# duração sai do tamanho (CBR de 24 kbps ≈ 3000 bytes/s).
|
|
526
|
-
# Vai MARCADA na cena (`narracaoMotor`): o agente lê e avisa a
|
|
527
|
-
# pessoa, em vez de ela descobrir ouvindo.
|
|
528
|
-
if not audio:
|
|
529
|
-
dados = _google_tts(texto)
|
|
530
|
-
if dados:
|
|
531
|
-
audio, ext, motor_usado = dados, ".mp3", "google"
|
|
532
|
-
dur = round(len(dados) / 3000.0, 2)
|
|
533
|
-
sys.stderr.write("[voz] RESERVA na cena %d: Google (edge-tts: %s)\n"
|
|
534
|
-
% (i + 1, edge_lib.ultimo_erro() or "não instalado"))
|
|
535
|
-
|
|
536
|
-
if not audio:
|
|
537
|
-
continue
|
|
538
|
-
nome = "narr-%s-%d-%s%s" % (arq_id[:28], i + 1, assinatura, ext)
|
|
539
|
-
with open(os.path.join(MIDIA, nome), "wb") as fh:
|
|
540
|
-
fh.write(audio)
|
|
541
|
-
c["narracaoAudio"] = "/midia/" + nome
|
|
542
|
-
c["narracaoVoz"] = assinatura
|
|
543
|
-
c["narracaoMotor"] = motor_usado
|
|
544
|
-
# A duração medida vai junto: com ela o player sabe quanto a cena
|
|
545
|
-
# REALMENTE dura (a fala inteira + respiro) e a linha do tempo
|
|
546
|
-
# vira determinística — sem ela, o relógio congela na beirada da
|
|
547
|
-
# cena esperando a voz, e a duração do vídeo muda a cada
|
|
548
|
-
# reprodução.
|
|
549
|
-
if dur:
|
|
550
|
-
c["narracaoDur"] = round(dur, 2)
|
|
551
|
-
mudou = True
|
|
552
|
-
|
|
553
|
-
if mudou:
|
|
554
|
-
# Relê antes de gravar: o agente pode ter mexido no documento
|
|
555
|
-
# enquanto a síntese rodava, e o trabalho dele vale mais.
|
|
556
|
-
atual = ler(arq_id)
|
|
557
|
-
if atual:
|
|
558
|
-
cenas = (atual.get("doc") or {}).get("cenas") or []
|
|
559
|
-
for i, c in enumerate(doc.get("cenas") or []):
|
|
560
|
-
if i < len(cenas) and c.get("narracaoAudio") \
|
|
561
|
-
and cenas[i].get("narracao") == c.get("narracao"):
|
|
562
|
-
cenas[i]["narracaoAudio"] = c["narracaoAudio"]
|
|
563
|
-
cenas[i]["narracaoVoz"] = c["narracaoVoz"]
|
|
564
|
-
if c.get("narracaoMotor"):
|
|
565
|
-
cenas[i]["narracaoMotor"] = c["narracaoMotor"]
|
|
566
|
-
if c.get("narracaoDur"):
|
|
567
|
-
cenas[i]["narracaoDur"] = c["narracaoDur"]
|
|
568
|
-
gravar(atual)
|
|
569
|
-
except Exception as e:
|
|
570
|
-
sys.stderr.write("[voz] narração em arquivo falhou: %s\n" % e)
|
|
571
|
-
finally:
|
|
572
|
-
with _tranca_narracao:
|
|
573
|
-
_narrando.discard(arq_id)
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
def narrar_em_arquivo(arq_id, doc):
|
|
577
|
-
if not _tem_narracao(doc):
|
|
578
|
-
return
|
|
579
|
-
with _tranca_narracao:
|
|
580
|
-
if arq_id in _narrando:
|
|
581
|
-
return
|
|
582
|
-
_narrando.add(arq_id)
|
|
583
|
-
threading.Thread(target=_gravar_narracao, args=(arq_id,), daemon=True).start()
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
def mesclar(antigo, novo):
|
|
587
|
-
"""O documento novo por cima do antigo, chave a chave.
|
|
588
|
-
|
|
589
|
-
Quem só quer trocar o tema manda {"tema":"papel"} — e é a coisa natural a
|
|
590
|
-
mandar, porque o pedido era esse. Trocar o documento inteiro por isso
|
|
591
|
-
apagava cenas, narração e trilha. Mesclando, o que não foi citado fica
|
|
592
|
-
onde estava e o que foi citado vale: mandar cenas novas ainda troca as
|
|
593
|
-
cenas, que é o ajuste de verdade."""
|
|
594
|
-
if not isinstance(antigo, dict) or not isinstance(novo, dict):
|
|
595
|
-
return novo
|
|
596
|
-
saida = dict(antigo)
|
|
597
|
-
saida.update(novo)
|
|
598
|
-
return saida
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
def listar():
|
|
602
|
-
saida = []
|
|
603
|
-
for nome in os.listdir(ARQUIVOS):
|
|
604
|
-
if not nome.endswith(".json"):
|
|
605
|
-
continue
|
|
606
|
-
try:
|
|
607
|
-
with open(os.path.join(ARQUIVOS, nome), encoding="utf-8") as fh:
|
|
608
|
-
a = json.load(fh)
|
|
609
|
-
doc = a.get("doc") or {}
|
|
610
|
-
saida.append({
|
|
611
|
-
"id": a["id"],
|
|
612
|
-
"titulo": a.get("titulo", "Sem título"),
|
|
613
|
-
"ferramenta": a.get("ferramenta", "prisma"),
|
|
614
|
-
"pecas": len(doc.get("cenas") or doc.get("blocos") or doc.get("abas") or []),
|
|
615
|
-
"atualizado": a.get("atualizado", ""),
|
|
616
|
-
})
|
|
617
|
-
except Exception:
|
|
618
|
-
pass
|
|
619
|
-
return sorted(saida, key=lambda a: a["atualizado"], reverse=True)
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
# ------------------------------------------------------------------ mídia --
|
|
623
|
-
|
|
624
|
-
TIPOS_OK = {
|
|
625
|
-
".png": "imagem", ".jpg": "imagem", ".jpeg": "imagem", ".gif": "imagem",
|
|
626
|
-
".webp": "imagem", ".svg": "imagem", ".avif": "imagem",
|
|
627
|
-
".mp3": "audio", ".m4a": "audio", ".wav": "audio", ".ogg": "audio",
|
|
628
|
-
".aac": "audio", ".flac": "audio", ".opus": "audio", ".weba": "audio",
|
|
629
|
-
".mp4": "video", ".webm": "video", ".mov": "video",
|
|
630
|
-
}
|
|
631
|
-
|
|
632
|
-
# .weba é WebM só com áudio — é o que sai da gravação de voz pelo microfone
|
|
633
|
-
mimetypes.add_type("audio/webm", ".weba")
|
|
634
|
-
mimetypes.add_type("audio/mp4", ".m4a")
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
def nome_seguro(nome):
|
|
638
|
-
base, ext = os.path.splitext(nome or "arquivo")
|
|
639
|
-
return _normaliza(base, 48), ext.lower()
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
def listar_midia():
|
|
643
|
-
fora = []
|
|
644
|
-
for nome in sorted(os.listdir(MIDIA)):
|
|
645
|
-
ext = os.path.splitext(nome)[1].lower()
|
|
646
|
-
if ext not in TIPOS_OK:
|
|
647
|
-
continue
|
|
648
|
-
caminho_arq = os.path.join(MIDIA, nome)
|
|
649
|
-
fora.append({
|
|
650
|
-
"nome": nome,
|
|
651
|
-
"url": "/midia/" + nome,
|
|
652
|
-
"tipo": TIPOS_OK[ext],
|
|
653
|
-
"bytes": os.path.getsize(caminho_arq),
|
|
654
|
-
})
|
|
655
|
-
return sorted(fora, key=lambda m: m["nome"])
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
def _nome_livre(base, ext):
|
|
659
|
-
nome = base + ext
|
|
660
|
-
if os.path.exists(os.path.join(MIDIA, nome)):
|
|
661
|
-
nome = base + "-" + uuid.uuid4().hex[:4] + ext
|
|
662
|
-
return nome
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
def guardar_midia_bruta(nome_pedido, fluxo, tamanho):
|
|
666
|
-
"""Grava o arquivo lendo o corpo direto do socket, sem passar por base64.
|
|
667
|
-
|
|
668
|
-
Um vídeo gravado no celular tem centenas de MB. Em base64 ele cresce um
|
|
669
|
-
terço, vira uma linha só de JSON e precisa caber INTEIRO na memória três
|
|
670
|
-
vezes — no texto, no decodificado e no que o json guarda. O estúdio
|
|
671
|
-
engasgava antes de gravar um byte, e o dono só via "falhou".
|
|
672
|
-
|
|
673
|
-
Aqui o corpo desce em pedaços de 1 MB direto para o disco: o consumo não
|
|
674
|
-
depende do tamanho do arquivo.
|
|
675
|
-
"""
|
|
676
|
-
base, ext = nome_seguro(nome_pedido)
|
|
677
|
-
if ext not in TIPOS_OK:
|
|
678
|
-
return {"erro": "formato não aceito", "aceitos": sorted(TIPOS_OK)}, 400
|
|
679
|
-
if tamanho <= 0:
|
|
680
|
-
return {"erro": "corpo vazio"}, 400
|
|
681
|
-
limite = LIMITE_VIDEO if TIPOS_OK[ext] == "video" else LIMITE_MIDIA
|
|
682
|
-
if tamanho > limite:
|
|
683
|
-
return {"erro": "arquivo acima de %d MB" % (limite // 1048576)}, 413
|
|
684
|
-
|
|
685
|
-
nome = _nome_livre(base, ext)
|
|
686
|
-
destino = os.path.join(MIDIA, nome)
|
|
687
|
-
escrito = 0
|
|
688
|
-
try:
|
|
689
|
-
with open(destino, "wb") as fh:
|
|
690
|
-
while escrito < tamanho:
|
|
691
|
-
pedaco = fluxo.read(min(1048576, tamanho - escrito))
|
|
692
|
-
if not pedaco:
|
|
693
|
-
break
|
|
694
|
-
fh.write(pedaco)
|
|
695
|
-
escrito += len(pedaco)
|
|
696
|
-
except Exception as erro:
|
|
697
|
-
try: os.remove(destino)
|
|
698
|
-
except OSError: pass
|
|
699
|
-
return {"erro": "não consegui gravar: %s" % erro}, 500
|
|
700
|
-
|
|
701
|
-
if escrito != tamanho:
|
|
702
|
-
# Meio arquivo é pior que nenhum: o <video> abre, mostra o começo e
|
|
703
|
-
# corta no meio da frase, e nada denuncia de onde veio.
|
|
704
|
-
try: os.remove(destino)
|
|
705
|
-
except OSError: pass
|
|
706
|
-
return {"erro": "a transferência parou em %d de %d bytes" % (escrito, tamanho)}, 400
|
|
707
|
-
|
|
708
|
-
return {"nome": nome, "url": "/midia/" + nome,
|
|
709
|
-
"tipo": TIPOS_OK[ext], "bytes": escrito}, 201
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
def guardar_midia(dados):
|
|
713
|
-
"""Recebe {nome, dados:"data:...;base64,..."} e grava em midia/."""
|
|
714
|
-
bruto = dados.get("dados") or ""
|
|
715
|
-
m = re.match(r"^data:([^;]*);base64,(.*)$", bruto, re.S)
|
|
716
|
-
if not m:
|
|
717
|
-
return {"erro": "envie 'dados' como data URI base64"}, 400
|
|
718
|
-
|
|
719
|
-
base, ext = nome_seguro(dados.get("nome"))
|
|
720
|
-
if ext not in TIPOS_OK:
|
|
721
|
-
adivinhado = mimetypes.guess_extension(m.group(1) or "") or ""
|
|
722
|
-
ext = adivinhado.lower()
|
|
723
|
-
if ext not in TIPOS_OK:
|
|
724
|
-
return {"erro": "formato não aceito", "aceitos": sorted(TIPOS_OK)}, 400
|
|
725
|
-
|
|
726
|
-
try:
|
|
727
|
-
conteudo = base64.b64decode(m.group(2), validate=True)
|
|
728
|
-
except Exception:
|
|
729
|
-
return {"erro": "base64 inválido"}, 400
|
|
730
|
-
|
|
731
|
-
limite = LIMITE_VIDEO if TIPOS_OK[ext] == "video" else LIMITE_MIDIA
|
|
732
|
-
if len(conteudo) > limite:
|
|
733
|
-
return {"erro": "arquivo acima de %d MB" % (limite // 1048576)}, 413
|
|
734
|
-
|
|
735
|
-
nome = _nome_livre(base, ext)
|
|
736
|
-
with open(os.path.join(MIDIA, nome), "wb") as fh:
|
|
737
|
-
fh.write(conteudo)
|
|
738
|
-
|
|
739
|
-
return {"nome": nome, "url": "/midia/" + nome,
|
|
740
|
-
"tipo": TIPOS_OK[ext], "bytes": len(conteudo)}, 201
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
# ------------------------------------------------------------ análise de áudio --
|
|
744
|
-
#
|
|
745
|
-
# "Ele vai cortar as pausas demoradas no vídeo."
|
|
746
|
-
#
|
|
747
|
-
# Para cortar as pausas é preciso SABER onde elas estão, e ninguém aqui sabe
|
|
748
|
-
# ler o áudio de um .mp4: o PrimoCode não tem dependência nenhuma, e o Python
|
|
749
|
-
# da máquina do usuário lê WAV e mais nada. ffmpeg resolveria — e seria mais
|
|
750
|
-
# uma instalação de 80 MB para quem só quer editar um vídeo.
|
|
751
|
-
#
|
|
752
|
-
# Quem sabe decodificar MP4, MOV e webm já está aberto na mesa: o navegador.
|
|
753
|
-
# `decodeAudioData` faz isso desde sempre, em qualquer sistema, sem instalar
|
|
754
|
-
# coisa alguma. Então a conta roda lá, e o servidor só segura o pedido e a
|
|
755
|
-
# resposta enquanto isso — é um quadro de recados entre o agente e a aba.
|
|
756
|
-
#
|
|
757
|
-
# Fica na memória de propósito: é resultado de trabalho, não trabalho. Se o
|
|
758
|
-
# estúdio reiniciar, pedir de novo custa segundos.
|
|
759
|
-
ANALISES = {}
|
|
760
|
-
LIMITE_ANALISES = 40
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
def criar_analise(dados):
|
|
764
|
-
arquivo = (dados or {}).get("arquivo") or ""
|
|
765
|
-
if not arquivo.startswith("/midia/"):
|
|
766
|
-
return {"erro": 'passe "arquivo" como a URL que /api/midia devolveu (ex.: "/midia/x.mp4")'}, 400
|
|
767
|
-
nome = unquote(arquivo[len("/midia/"):])
|
|
768
|
-
if nome != os.path.basename(nome) or not os.path.exists(os.path.join(MIDIA, nome)):
|
|
769
|
-
return {"erro": "não achei %s em midia/" % arquivo}, 404
|
|
770
|
-
|
|
771
|
-
# Podar antes de inserir: sem isto uma sessão longa acumula análises que
|
|
772
|
-
# ninguém mais vai ler.
|
|
773
|
-
if len(ANALISES) >= LIMITE_ANALISES:
|
|
774
|
-
for velho in sorted(ANALISES, key=lambda k: ANALISES[k]["criado"])[:len(ANALISES) - LIMITE_ANALISES + 1]:
|
|
775
|
-
ANALISES.pop(velho, None)
|
|
776
|
-
|
|
777
|
-
# `manter` e `repetida` viajam SEM valor padrão aqui, e isso é de propósito:
|
|
778
|
-
# quem decide quanto de pausa fica é a página (ela conhece o tamanho de cada
|
|
779
|
-
# uma), e um padrão escrito também deste lado seria um segundo dono da mesma
|
|
780
|
-
# escolha — os dois divergem na primeira mudança e o que vale passa a ser o
|
|
781
|
-
# que ninguém lembra de olhar.
|
|
782
|
-
pedido = dados or {}
|
|
783
|
-
ident = uuid.uuid4().hex[:10]
|
|
784
|
-
ANALISES[ident] = {
|
|
785
|
-
"id": ident, "arquivo": arquivo, "estado": "pedido",
|
|
786
|
-
"silencio": float(pedido.get("silencio") or 0.45),
|
|
787
|
-
"pausa": float(pedido.get("pausa") or 0.6),
|
|
788
|
-
"manter": None if pedido.get("manter") is None else float(pedido["manter"]),
|
|
789
|
-
"repetida": pedido.get("repetida") is not False,
|
|
790
|
-
"criado": time.time(), "resultado": None,
|
|
791
|
-
}
|
|
792
|
-
return {"id": ident, "pagina": "/analise#" + ident, "estado": "pedido"}, 201
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
def gravar_analise(ident, dados):
|
|
796
|
-
tarefa = ANALISES.get(ident)
|
|
797
|
-
if not tarefa:
|
|
798
|
-
return {"erro": "análise não encontrada"}, 404
|
|
799
|
-
if (dados or {}).get("erro"):
|
|
800
|
-
tarefa["estado"] = "erro"
|
|
801
|
-
tarefa["resultado"] = {"erro": str(dados["erro"])[:300]}
|
|
802
|
-
else:
|
|
803
|
-
tarefa["estado"] = "pronto"
|
|
804
|
-
# ESTE DICIONÁRIO É UMA PENEIRA, e campo que não está aqui morre em
|
|
805
|
-
# silêncio. Foi assim que a lista de falas repetidas sumiu no dia em que
|
|
806
|
-
# a página passou a devolvê-la: o vídeo saía com o tropeço já cortado e
|
|
807
|
-
# nada na tela dizia o que tinha sido tirado. Campo novo na página entra
|
|
808
|
-
# AQUI junto, ou não existe.
|
|
809
|
-
tarefa["resultado"] = {
|
|
810
|
-
"duracao": dados.get("duracao"),
|
|
811
|
-
"trechos": dados.get("trechos") or [],
|
|
812
|
-
"pausas": dados.get("pausas") or [],
|
|
813
|
-
"repetidos": dados.get("repetidos") or [],
|
|
814
|
-
"cortado": dados.get("cortado"),
|
|
815
|
-
}
|
|
816
|
-
return {"ok": True, "estado": tarefa["estado"]}, 200
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
# ------------------------------------------------------- revisão de monotonia --
|
|
820
|
-
#
|
|
821
|
-
# "Ele não alterna entre os temas, usa um tema só pra todos os vídeos, e o
|
|
822
|
-
# vídeo sempre tem a mesma estrutura. Fica tudo genérico."
|
|
823
|
-
#
|
|
824
|
-
# O estúdio tem doze temas, onze fundos, oito transições e seis trilhas. O
|
|
825
|
-
# agente usa uma fatia estreita e repete — porque a spec tem UM exemplo
|
|
826
|
-
# completo e modelo ancora em exemplo. Conselho escrito na spec ajuda pouco
|
|
827
|
-
# quando ela nem sempre é relida.
|
|
828
|
-
#
|
|
829
|
-
# Então quem revisa é o próprio estúdio, na hora de gravar. Isto NÃO recusa
|
|
830
|
-
# nada: um vídeo monótono continua sendo um vídeo, e recusar conteúdo válido
|
|
831
|
-
# seria pior que o problema. O que ele faz é devolver o que dá para melhorar,
|
|
832
|
-
# em texto que o agente lê como resultado da tool — do mesmo jeito que
|
|
833
|
-
# validar_doc ensina o formato quando o tipo está errado.
|
|
834
|
-
def revisar_monotonia(doc, ferramenta):
|
|
835
|
-
if ferramenta not in ("corte", "prisma") or not isinstance(doc, dict):
|
|
836
|
-
return []
|
|
837
|
-
cenas = [c for c in (doc.get("cenas") or []) if isinstance(c, dict)]
|
|
838
|
-
if len(cenas) < 3:
|
|
839
|
-
return [] # peça curta demais para julgar
|
|
840
|
-
|
|
841
|
-
avisos = []
|
|
842
|
-
|
|
843
|
-
fundos = [c.get("fundo") for c in cenas]
|
|
844
|
-
distintos = {f if isinstance(f, str) else "?" for f in fundos if f}
|
|
845
|
-
if len(distintos) <= 1:
|
|
846
|
-
avisos.append(
|
|
847
|
-
"todas as %d cenas usam o mesmo fundo%s. São onze: malha, brilho, grade, "
|
|
848
|
-
"pontos, suave, vinheta, aurora, nevoa, ondas, raios, xadrez. Fundo igual "
|
|
849
|
-
"em tudo é o que faz o vídeo parecer template."
|
|
850
|
-
% (len(cenas), " (" + list(distintos)[0] + ")" if distintos else " — nenhum"))
|
|
851
|
-
|
|
852
|
-
trs = [c.get("transicao") for c in cenas[1:] if c.get("transicao")]
|
|
853
|
-
if len(trs) >= 2 and len(set(trs)) == 1:
|
|
854
|
-
avisos.append(
|
|
855
|
-
'toda transição é "%s". São oito: corte, fade, deslizar, subir, zoom, '
|
|
856
|
-
"limpar, persiana, flash. Tudo igual parece slide, não vídeo." % trs[0])
|
|
857
|
-
|
|
858
|
-
if ferramenta == "corte" and not doc.get("trilha"):
|
|
859
|
-
avisos.append(
|
|
860
|
-
"o vídeo está sem trilha. Oito estilos gerados existem (pulso, ambiente, "
|
|
861
|
-
"lofi, epico, alegre, tenso, suave, foco) e nenhum custa arquivo nem licença. "
|
|
862
|
-
"Se alguém FALA no vídeo, use suave ou foco: as duas foram feitas para ficar "
|
|
863
|
-
"embaixo de uma voz. Escolha "
|
|
864
|
-
"pelo assunto.")
|
|
865
|
-
|
|
866
|
-
duracoes = [c.get("duracao") for c in cenas if isinstance(c.get("duracao"), (int, float))]
|
|
867
|
-
if len(duracoes) >= 4 and len(set(duracoes)) == 1:
|
|
868
|
-
avisos.append(
|
|
869
|
-
"todas as cenas duram %s s. Ritmo igual do começo ao fim cansa: gancho "
|
|
870
|
-
"curto, argumento mais longo, fecho curto." % duracoes[0])
|
|
871
|
-
|
|
872
|
-
anims = []
|
|
873
|
-
for c in cenas:
|
|
874
|
-
for e in (c.get("elementos") or []):
|
|
875
|
-
if isinstance(e, dict) and e.get("anim"):
|
|
876
|
-
anims.append(e["anim"])
|
|
877
|
-
if len(anims) >= 4 and len(set(anims)) == 1:
|
|
878
|
-
avisos.append(
|
|
879
|
-
'toda animação de entrada é "%s". Varie: subir, surgir, esquerda, direita, '
|
|
880
|
-
"zoom, desfoque, cair, girar, saltar, revelar, cortina, elastico." % anims[0])
|
|
881
|
-
|
|
882
|
-
# ── O QUE FALTA, E NÃO SÓ O QUE SE REPETE ───────────────────────────
|
|
883
|
-
#
|
|
884
|
-
# "Falta de efeitos visuais: não adiciona efeitos, não insere ícones ou
|
|
885
|
-
# elementos visuais de apoio no vídeo final."
|
|
886
|
-
#
|
|
887
|
-
# Os avisos acima cobram VARIEDADE — eles só disparam quando alguma coisa
|
|
888
|
-
# foi usada e usada sempre igual. Um vídeo que não usa NADA passava por
|
|
889
|
-
# todos eles calado, e era esse o vídeo do relato: texto sobre fundo, do
|
|
890
|
-
# primeiro segundo ao último. O que falta não aparece contando repetição;
|
|
891
|
-
# aparece contando ausência.
|
|
892
|
-
todos = [e for c in cenas for e in (c.get("elementos") or []) if isinstance(e, dict)]
|
|
893
|
-
tipos = {e.get("tipo") for e in todos}
|
|
894
|
-
|
|
895
|
-
if not doc.get("acabamento") and not any(c.get("acabamento") for c in cenas):
|
|
896
|
-
avisos.append(
|
|
897
|
-
"nenhuma cena tem acabamento. É a textura por cima do quadro inteiro "
|
|
898
|
-
"(grao, vinheta, scanlines, luz, brilho, moldura) e é o que tira a cara "
|
|
899
|
-
'de template: "acabamento": "grao" no documento vale para o vídeo todo.')
|
|
900
|
-
|
|
901
|
-
apoio = {"icone", "selo", "forma", "grafico", "progresso", "dispositivo",
|
|
902
|
-
"conversa", "passos", "opcoes", "numeros", "avatar", "citacao"}
|
|
903
|
-
if not (tipos & apoio):
|
|
904
|
-
avisos.append(
|
|
905
|
-
"o vídeo é só texto e imagem: nenhum elemento de apoio em %d cenas. "
|
|
906
|
-
'Um "icone" ao lado do argumento (raio, alvo, foguete, escudo, relogio, '
|
|
907
|
-
"lampada, cadeado, crescimento, pessoas, dinheiro, globo, fogo… ou um "
|
|
908
|
-
'DESENHADO por você, com "caminho": path SVG numa caixa 24x24), um '
|
|
909
|
-
'"selo" de estado, um "numeros" com "contar": true — é isto que faz a '
|
|
910
|
-
"tela parecer produzida em vez de um slide com legenda." % len(cenas))
|
|
911
|
-
|
|
912
|
-
movimento = any(e.get("chaves") or e.get("movimento") for e in todos)
|
|
913
|
-
if not movimento and not anims:
|
|
914
|
-
avisos.append(
|
|
915
|
-
"nada se mexe: nenhum elemento tem anim, chaves ou movimento. Imagem "
|
|
916
|
-
'parada em vídeo denuncia preguiça — toda "imagem" leva "movimento" '
|
|
917
|
-
"(aproximar, afastar, esquerda, direita, cima), e uma ou duas peças por "
|
|
918
|
-
"vídeo levam chaves de animação.")
|
|
919
|
-
|
|
920
|
-
# ── ANÚNCIO DE MARCA SEM PRODUTO E SEM ASSINATURA ───────────────────
|
|
921
|
-
#
|
|
922
|
-
# "Ainda não tá bom, tá genérico. Quero nesse nível." — o dono, sobre um
|
|
923
|
-
# comercial de fintech mandado como referência.
|
|
924
|
-
#
|
|
925
|
-
# O que separava a peça daqui daquela não era tema nem cor: era não ter o
|
|
926
|
-
# PRODUTO na tela e não assinar no fim. Quem declarou uma `marca` está
|
|
927
|
-
# fazendo peça de marca, e aí estas duas ausências são o defeito inteiro.
|
|
928
|
-
if doc.get("marca") and len(cenas) >= 4:
|
|
929
|
-
if not (tipos & {"cartao", "app", "vitrine", "dispositivo", "imagem", "clipe"}):
|
|
930
|
-
avisos.append(
|
|
931
|
-
"esta peça tem marca declarada e nenhuma cena mostra o PRODUTO — são %d cenas "
|
|
932
|
-
"de texto sobre fundo. Um anúncio de cartão mostra o cartão (`cartao`), o app "
|
|
933
|
-
"funcionando dentro do celular (`dispositivo` + `app`) e a linha de produtos "
|
|
934
|
-
"(`vitrine`). É essa ausência que se lê como genérico." % len(cenas))
|
|
935
|
-
if "assinatura" not in tipos:
|
|
936
|
-
avisos.append(
|
|
937
|
-
'o anúncio acaba sem assinar. A última cena de uma peça de marca é '
|
|
938
|
-
'{"tipo":"assinatura","nome":"…","sobre":"marca"} — o logo sozinho, grande, '
|
|
939
|
-
"sobre a cor da casa. Sem ela a peça termina numa frase e ninguém fica "
|
|
940
|
-
"sabendo quem falou.")
|
|
941
|
-
|
|
942
|
-
# Só a voz com tela montada: existe e quase nunca é lembrado, porque é o
|
|
943
|
-
# contrário do `mudo` e o contrário não estava no vocabulário até agora.
|
|
944
|
-
clipes = [e for e in todos if e.get("tipo") == "clipe"]
|
|
945
|
-
if clipes and len(clipes) >= 3 and not any(
|
|
946
|
-
e.get("apenas") == "som" or e.get("som") is True for e in clipes):
|
|
947
|
-
avisos.append(
|
|
948
|
-
"todos os %d clipes mostram a gravação. Se o que vale é o que ele DIZ, "
|
|
949
|
-
'"apenas": "som" no clipe mantém a voz e libera o quadro para a tela que '
|
|
950
|
-
"você montar por cima — é o vídeo dele virando narração da sua peça. "
|
|
951
|
-
"Vale para a parte em que ele explica, não para a em que ele aparece."
|
|
952
|
-
% len(clipes))
|
|
953
|
-
|
|
954
|
-
return avisos
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
def criar(dados):
|
|
958
|
-
# O cliente é um agente de IA falando JSON cru — um tipo errado aqui
|
|
959
|
-
# (titulo como lista, por exemplo) não pode virar AttributeError sem
|
|
960
|
-
# resposta HTTP nenhuma; melhor um 400 com o motivo.
|
|
961
|
-
ferramenta_bruta = dados.get("ferramenta")
|
|
962
|
-
if ferramenta_bruta is not None and not isinstance(ferramenta_bruta, str):
|
|
963
|
-
raise ValueError("ferramenta precisa ser texto")
|
|
964
|
-
ferramenta = (ferramenta_bruta or "prisma").lower()
|
|
965
|
-
if ferramenta not in POR_SLUG:
|
|
966
|
-
return None
|
|
967
|
-
|
|
968
|
-
titulo_bruto = dados.get("titulo")
|
|
969
|
-
if titulo_bruto is not None and not isinstance(titulo_bruto, str):
|
|
970
|
-
raise ValueError("titulo precisa ser texto")
|
|
971
|
-
titulo = (titulo_bruto or "Sem título").strip()
|
|
972
|
-
|
|
973
|
-
doc = dados.get("doc")
|
|
974
|
-
if doc is not None and not isinstance(doc, dict):
|
|
975
|
-
raise ValueError("doc precisa ser um objeto")
|
|
976
|
-
|
|
977
|
-
# ── O doc torto não pode virar arquivo ───────────────────────────────
|
|
978
|
-
# Quem cria é um agente de IA, e quando ele não viu a spec ele INVENTA a
|
|
979
|
-
# estrutura. Antes o arquivo era gravado assim mesmo: o player não achava
|
|
980
|
-
# cena nenhuma e o "vídeo" saía uma tela preta — o usuário viu exatamente
|
|
981
|
-
# isso. Recusar com o formato certo na mensagem transforma o erro mudo em
|
|
982
|
-
# correção na rodada seguinte.
|
|
983
|
-
if doc:
|
|
984
|
-
problema = validar_doc(POR_SLUG[ferramenta]["modelo"], doc)
|
|
985
|
-
if problema:
|
|
986
|
-
raise ValueError(problema)
|
|
987
|
-
|
|
988
|
-
preparar_voz(doc)
|
|
989
|
-
arq_id = ferramenta + "-" + slugify(titulo) + "-" + uuid.uuid4().hex[:5]
|
|
990
|
-
salvo = gravar({
|
|
991
|
-
"id": arq_id,
|
|
992
|
-
"ferramenta": ferramenta,
|
|
993
|
-
"titulo": titulo,
|
|
994
|
-
"doc": doc or exemplo(ferramenta),
|
|
995
|
-
})
|
|
996
|
-
narrar_em_arquivo(arq_id, salvo.get("doc") or {})
|
|
997
|
-
return salvo
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
# Frases que só existem nos EXEMPLOS das specs. Documento que chega com uma
|
|
1001
|
-
# delas é o agente copiando o exemplo em vez de escrever o conteúdo do pedido
|
|
1002
|
-
# — um usuário pediu um vídeo da empresa dele e recebeu cenas sobre "bandeira
|
|
1003
|
-
# tarifária", o assunto fictício do exemplo do corte.
|
|
1004
|
-
# Só frases INCONFUNDÍVEIS entram aqui: "energia solar no brasil" seria um
|
|
1005
|
-
# pedido legítimo possível, e bloquear pedido legítimo é pior que deixar um
|
|
1006
|
-
# plágio passar. Estas seis não existem fora do exemplo.
|
|
1007
|
-
FRASES_DE_EXEMPLO = [
|
|
1008
|
-
"bandeira tarifária em 12 meses", "bandeira tarifaria em 12 meses",
|
|
1009
|
-
"não foi o seu chuveiro", "nao foi o seu chuveiro",
|
|
1010
|
-
"saturadas no nordeste", "usina gerando sem escoar",
|
|
1011
|
-
"segue para entender a próxima", "segue para entender a proxima",
|
|
1012
|
-
]
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
def _texto_do_doc(doc):
|
|
1016
|
-
"""Todo texto visível do documento, achatado, para inspeção."""
|
|
1017
|
-
pedacos = []
|
|
1018
|
-
|
|
1019
|
-
def anda(x):
|
|
1020
|
-
if isinstance(x, dict):
|
|
1021
|
-
for v in x.values():
|
|
1022
|
-
anda(v)
|
|
1023
|
-
elif isinstance(x, list):
|
|
1024
|
-
for v in x:
|
|
1025
|
-
anda(v)
|
|
1026
|
-
elif isinstance(x, str):
|
|
1027
|
-
pedacos.append(x)
|
|
1028
|
-
|
|
1029
|
-
anda(doc)
|
|
1030
|
-
return " ".join(pedacos).lower()
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
# Os temas que o render.css de fato define. Fora desta lista não existe cor
|
|
1034
|
-
# nenhuma — foi o que aconteceu com "noite", que passou como se fosse tema.
|
|
1035
|
-
TEMAS_VALIDOS = {"claro", "meia-noite", "papel", "neon", "brasa", "floresta",
|
|
1036
|
-
"retro", "oceano", "doce", "mono", "ambar", "grafite"}
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
def _tema_invalido(tema, onde=""):
|
|
1040
|
-
if tema is None:
|
|
1041
|
-
return None
|
|
1042
|
-
if isinstance(tema, str) and tema in TEMAS_VALIDOS:
|
|
1043
|
-
return None
|
|
1044
|
-
return ('tema %r%s não existe — a peça sairia sem cor nenhuma. Os temas são: %s. '
|
|
1045
|
-
'Para escuro use "meia-noite".'
|
|
1046
|
-
% (tema, onde, ", ".join(sorted(TEMAS_VALIDOS))))
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
def _clipe_invalido(e, i, j):
|
|
1050
|
-
"""Um `clipe` é um TRECHO da gravação do usuário: arquivo, começo e fim.
|
|
1051
|
-
|
|
1052
|
-
Os três erros abaixo saem calados. `src` faltando ou apontando para o
|
|
1053
|
-
disco da pessoa dá <video> que nunca carrega — cena preta, sem aviso.
|
|
1054
|
-
`ate` antes de `de` dá trecho de duração negativa, que vira cena de zero
|
|
1055
|
-
segundo. E `de`/`ate` em texto ("00:12") faz a conta de duração virar
|
|
1056
|
-
concatenação, que foi como já saiu um vídeo de 00:00 daqui.
|
|
1057
|
-
"""
|
|
1058
|
-
onde = "elemento %d da cena %d" % (j + 1, i + 1)
|
|
1059
|
-
src = e.get("src")
|
|
1060
|
-
if not isinstance(src, str) or not src.strip():
|
|
1061
|
-
return ('%s é "clipe" mas está sem "src". O caminho é a URL que /api/midia devolveu '
|
|
1062
|
-
'(ex.: "/midia/gravacao.mp4"). Envie o arquivo primeiro; caminho do disco '
|
|
1063
|
-
'("/Users/...", "C:\\\\...") o navegador não abre.' % onde)
|
|
1064
|
-
if re.match(r"^([a-zA-Z]:[\\/]|/(Users|home|mnt|var|tmp)/|file:)", src.strip()):
|
|
1065
|
-
return ('%s aponta para o disco (%r). O navegador não lê caminho de arquivo: envie o vídeo '
|
|
1066
|
-
'para /api/midia e use a URL "/midia/..." que ele devolve.' % (onde, src[:60]))
|
|
1067
|
-
for campo in ("de", "ate"):
|
|
1068
|
-
v = e.get(campo)
|
|
1069
|
-
if v is None:
|
|
1070
|
-
continue
|
|
1071
|
-
if isinstance(v, bool) or not isinstance(v, (int, float)):
|
|
1072
|
-
return ('%s tem "%s": %r, que não é número. São SEGUNDOS do arquivo original: '
|
|
1073
|
-
'"de": 12.5, "ate": 19.' % (onde, campo, v))
|
|
1074
|
-
if v < 0:
|
|
1075
|
-
return '%s tem "%s": %r — segundo negativo não existe.' % (onde, campo, v)
|
|
1076
|
-
# "apenas": "som" entrega a VOZ da gravação com a tela montada por cima.
|
|
1077
|
-
# Junto com "mudo": true ele não entrega nada: sem imagem porque foi
|
|
1078
|
-
# escondido, sem som porque foi silenciado. A cena sairia com a duração do
|
|
1079
|
-
# trecho e o conteúdo de um silêncio — que é exatamente o tipo de defeito
|
|
1080
|
-
# que só aparece quando alguém assiste o vídeo pronto.
|
|
1081
|
-
if (e.get("apenas") == "som" or e.get("som") is True) and e.get("mudo"):
|
|
1082
|
-
return ('%s tem "apenas": "som" com "mudo": true. Um cancela o outro: o clipe some da '
|
|
1083
|
-
'tela E fica sem áudio, e a cena vira um silêncio do tamanho do trecho. Para a '
|
|
1084
|
-
'voz dele com a sua tela por cima, tire o "mudo".' % onde)
|
|
1085
|
-
de, ate = e.get("de"), e.get("ate")
|
|
1086
|
-
if isinstance(de, (int, float)) and isinstance(ate, (int, float)) and ate <= de:
|
|
1087
|
-
return ('%s tem "ate": %r antes ou igual a "de": %r. O trecho ficaria com duração zero ou '
|
|
1088
|
-
'negativa e a cena sairia vazia.' % (onde, ate, de))
|
|
1089
|
-
return None
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
def validar_doc(modelo, doc):
|
|
1093
|
-
"""None quando o doc serve; senão, a frase que ENSINA o formato certo.
|
|
1094
|
-
|
|
1095
|
-
A mensagem repete a estrutura mínima de propósito: ela volta para o agente
|
|
1096
|
-
como resultado da tool, e é a única chance de ele se corrigir sem o
|
|
1097
|
-
usuário ver um vídeo preto."""
|
|
1098
|
-
texto = _texto_do_doc(doc)
|
|
1099
|
-
for frase in FRASES_DE_EXEMPLO:
|
|
1100
|
-
if frase in texto:
|
|
1101
|
-
return ('o documento contém "%s" — isso é texto do EXEMPLO da spec, não do pedido '
|
|
1102
|
-
'do usuário. O exemplo ensina o FORMATO; o conteúdo é seu: reescreva cada '
|
|
1103
|
-
'cena com os fatos da tarefa (a pesquisa que você fez) e crie de novo.' % frase)
|
|
1104
|
-
|
|
1105
|
-
# Os tipos que o render.js de fato desenha. Elemento de tipo inventado
|
|
1106
|
-
# ("titulo", "subtitulo", "video"...) passava na validação e caía no
|
|
1107
|
-
# default do renderizador: texto cru de 16px num canvas de 1920 — ou nada,
|
|
1108
|
-
# quando o texto estava noutro campo. Era o "vídeo com música mas tudo
|
|
1109
|
-
# preto": documento válido na forma, invisível na tela.
|
|
1110
|
-
# O kit de anúncio (cartao, app, vitrine, assinatura) entra na lista abaixo.
|
|
1111
|
-
#
|
|
1112
|
-
# A LISTA É LIDA POR UM TESTE (`vocabulario.test.js`), que a recorta com uma
|
|
1113
|
-
# expressão regular e pega as palavras — então comentário DENTRO dela vira
|
|
1114
|
-
# "tipo que o render precisa desenhar", e o teste passa a cobrar `produto`,
|
|
1115
|
-
# `linha` e `fim`. Texto sobre os tipos mora aqui fora.
|
|
1116
|
-
TIPOS_VALIDOS = {"texto", "lista", "cartoes", "numeros", "tabela", "codigo",
|
|
1117
|
-
"citacao", "forma", "imagem", "icone", "grafico", "no", "conector",
|
|
1118
|
-
"objeto3d", "clipe",
|
|
1119
|
-
"cartao", "app", "vitrine", "assinatura",
|
|
1120
|
-
"selo", "progresso", "avatar", "dispositivo", "conversa",
|
|
1121
|
-
"passos", "opcoes"}
|
|
1122
|
-
# Os sete últimos são o vocabulário do vídeo de referência. O comentário
|
|
1123
|
-
# mora aqui fora porque test/vocabulario.test.js lê esta lista com
|
|
1124
|
-
# expressão regular, comparando com a do render.js — palavra de comentário
|
|
1125
|
-
# dentro das chaves entrava na lista como se fosse um tipo válido.
|
|
1126
|
-
|
|
1127
|
-
# Tema inventado tinha o mesmo destino silencioso do tipo inventado: o CSS
|
|
1128
|
-
# não casa com [data-tema="noite"], nenhuma cor é definida e a peça sai
|
|
1129
|
-
# sem identidade nenhuma. Recusar aqui devolve a lista certa ao agente.
|
|
1130
|
-
problema = _tema_invalido(doc.get("tema"))
|
|
1131
|
-
if problema:
|
|
1132
|
-
return problema
|
|
1133
|
-
|
|
1134
|
-
if modelo == "cena":
|
|
1135
|
-
cenas = doc.get("cenas")
|
|
1136
|
-
if not isinstance(cenas, list) or not cenas:
|
|
1137
|
-
return ('doc sem "cenas". O formato é: {"tema":"...", "cenas":[{"layout":"centro",'
|
|
1138
|
-
'"duracao":3,"elementos":[{"tipo":"texto","texto":"..."}]}]}. '
|
|
1139
|
-
'Leia studio_spec da ferramenta e refaça.')
|
|
1140
|
-
for i, c in enumerate(cenas):
|
|
1141
|
-
if not isinstance(c, dict):
|
|
1142
|
-
return "cena %d não é um objeto" % (i + 1)
|
|
1143
|
-
els = c.get("elementos")
|
|
1144
|
-
if not isinstance(els, list) or not els:
|
|
1145
|
-
return ('cena %d sem "elementos". Cada cena precisa de elementos:[...] — '
|
|
1146
|
-
'ex.: {"tipo":"texto","texto":"..."}. Leia studio_spec e refaça.' % (i + 1))
|
|
1147
|
-
problema = _tema_invalido(c.get("tema"), " (cena %d)" % (i + 1))
|
|
1148
|
-
if problema:
|
|
1149
|
-
return problema
|
|
1150
|
-
dur = c.get("duracao")
|
|
1151
|
-
if dur is not None and (isinstance(dur, bool) or not isinstance(dur, (int, float))):
|
|
1152
|
-
return ('cena %d com "duracao" que não é número (veio %r). Use número de segundos: '
|
|
1153
|
-
'"duracao": 3' % (i + 1, dur))
|
|
1154
|
-
# Duração zero passava: era número, afinal. E um vídeo cujas cenas
|
|
1155
|
-
# somam zero é o "vídeo de 0 segundos" que o usuário viu mais de
|
|
1156
|
-
# uma vez — arquivo válido, conteúdo inalcançável.
|
|
1157
|
-
if isinstance(dur, (int, float)) and not isinstance(dur, bool) and dur <= 0:
|
|
1158
|
-
return ('cena %d tem "duracao": %r. Zero (ou negativo) faz o vídeo inteiro durar '
|
|
1159
|
-
'nada — foi assim que saiu um vídeo de 0 segundos. Use pelo menos 1.'
|
|
1160
|
-
% (i + 1, dur))
|
|
1161
|
-
for j, e in enumerate(els):
|
|
1162
|
-
if not isinstance(e, dict) or not e.get("tipo"):
|
|
1163
|
-
return 'elemento %d da cena %d sem "tipo" (texto, imagem, lista, numeros, forma...)' % (j + 1, i + 1)
|
|
1164
|
-
if e.get("tipo") not in TIPOS_VALIDOS:
|
|
1165
|
-
return ('elemento %d da cena %d tem tipo "%s", que NÃO existe. Os tipos válidos são: %s. '
|
|
1166
|
-
'Para título use {"tipo":"texto","papel":"titulo","texto":"..."}.'
|
|
1167
|
-
% (j + 1, i + 1, e.get("tipo"), ", ".join(sorted(TIPOS_VALIDOS))))
|
|
1168
|
-
if e.get("tipo") == "texto" and not (e.get("texto") or "").strip():
|
|
1169
|
-
return ('elemento %d da cena %d é "texto" mas o campo "texto" está vazio — o conteúdo '
|
|
1170
|
-
'vai SEMPRE no campo "texto".' % (j + 1, i + 1))
|
|
1171
|
-
# Clipe: o trecho da gravação do usuário. Erra-se em silêncio de
|
|
1172
|
-
# três formas, e as três dão vídeo preto ou corte que não corta.
|
|
1173
|
-
if e.get("tipo") == "clipe":
|
|
1174
|
-
problema = _clipe_invalido(e, i, j)
|
|
1175
|
-
if problema:
|
|
1176
|
-
return problema
|
|
1177
|
-
elif modelo == "documento":
|
|
1178
|
-
blocos = doc.get("blocos")
|
|
1179
|
-
if not isinstance(blocos, list) or not blocos:
|
|
1180
|
-
return ('doc sem "blocos". O formato é: {"tema":"papel","blocos":[{"tipo":"titulo",'
|
|
1181
|
-
'"texto":"..."},{"tipo":"paragrafo","texto":"..."}]}. Leia studio_spec e refaça.')
|
|
1182
|
-
elif modelo == "planilha":
|
|
1183
|
-
abas = doc.get("abas")
|
|
1184
|
-
if not isinstance(abas, list) or not abas:
|
|
1185
|
-
return ('doc sem "abas". O formato é: {"abas":[{"nome":"...","colunas":[...],'
|
|
1186
|
-
'"linhas":[[...]]}]}. Leia studio_spec e refaça.')
|
|
1187
|
-
return None
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
def exemplo(ferramenta):
|
|
1191
|
-
"""Documento inicial de cada ferramenta — o mínimo que já fica bonito."""
|
|
1192
|
-
if ferramenta == "prosa":
|
|
1193
|
-
return {
|
|
1194
|
-
"tema": "papel",
|
|
1195
|
-
"blocos": [
|
|
1196
|
-
{"tipo": "titulo", "texto": "Título do documento"},
|
|
1197
|
-
{"tipo": "paragrafo", "texto": "Comece a escrever por aqui."},
|
|
1198
|
-
],
|
|
1199
|
-
}
|
|
1200
|
-
if ferramenta == "grade":
|
|
1201
|
-
return {
|
|
1202
|
-
"tema": "claro",
|
|
1203
|
-
"abas": [{
|
|
1204
|
-
"nome": "Planilha 1",
|
|
1205
|
-
"colunas": ["Item", "Valor"],
|
|
1206
|
-
"linhas": [["Exemplo", 100]],
|
|
1207
|
-
}],
|
|
1208
|
-
}
|
|
1209
|
-
if ferramenta == "tela":
|
|
1210
|
-
return {
|
|
1211
|
-
"tema": "meia-noite", "largura": 1080, "altura": 1080,
|
|
1212
|
-
"cenas": [{"layout": "livre", "fundo": "malha", "elementos": [
|
|
1213
|
-
{"tipo": "texto", "x": 100, "y": 440, "l": 880, "a": 200,
|
|
1214
|
-
"texto": "Sua arte", "tamanho": 120, "peso": 700, "alinhar": "centro"},
|
|
1215
|
-
]}],
|
|
1216
|
-
}
|
|
1217
|
-
if ferramenta == "corte":
|
|
1218
|
-
return {
|
|
1219
|
-
"tema": "meia-noite", "largura": 1920, "altura": 1080,
|
|
1220
|
-
"cenas": [
|
|
1221
|
-
{"layout": "capa", "duracao": 3, "fundo": "malha",
|
|
1222
|
-
"elementos": [{"tipo": "texto", "papel": "titulo", "texto": "Primeira cena"}]},
|
|
1223
|
-
{"layout": "capa", "duracao": 3, "fundo": "brilho",
|
|
1224
|
-
"elementos": [{"tipo": "texto", "papel": "titulo", "texto": "Segunda cena"}]},
|
|
1225
|
-
],
|
|
1226
|
-
}
|
|
1227
|
-
if ferramenta == "traco":
|
|
1228
|
-
return {
|
|
1229
|
-
"tema": "meia-noite", "largura": 1920, "altura": 1080,
|
|
1230
|
-
"cenas": [{"layout": "livre", "elementos": [
|
|
1231
|
-
{"id": "a", "tipo": "no", "x": 300, "y": 460, "l": 340, "a": 160, "texto": "Início"},
|
|
1232
|
-
{"id": "b", "tipo": "no", "x": 1100, "y": 460, "l": 340, "a": 160, "texto": "Fim"},
|
|
1233
|
-
{"tipo": "conector", "de": "a", "para": "b"},
|
|
1234
|
-
]}],
|
|
1235
|
-
}
|
|
1236
|
-
return {
|
|
1237
|
-
"tema": "meia-noite", "largura": 1920, "altura": 1080,
|
|
1238
|
-
"cenas": [
|
|
1239
|
-
{"layout": "capa", "fundo": "malha", "elementos": [
|
|
1240
|
-
{"tipo": "texto", "papel": "chapeu", "texto": "PrimoCode Studio"},
|
|
1241
|
-
{"tipo": "texto", "papel": "titulo", "texto": "Título da apresentação"},
|
|
1242
|
-
{"tipo": "texto", "papel": "apoio", "texto": "Uma frase que explica do que se trata."},
|
|
1243
|
-
]},
|
|
1244
|
-
{"layout": "titulo", "elementos": [
|
|
1245
|
-
{"tipo": "texto", "papel": "titulo", "texto": "Uma ideia por cena"},
|
|
1246
|
-
{"tipo": "lista", "itens": ["Primeiro ponto", "Segundo ponto", "Terceiro ponto"]},
|
|
1247
|
-
]},
|
|
1248
|
-
],
|
|
1249
|
-
}
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
# ------------------------------------------------------------------ manual --
|
|
1253
|
-
|
|
1254
|
-
def manual():
|
|
1255
|
-
"""Junta ferramentas/*.md — a pasta é a fonte da verdade para a IA."""
|
|
1256
|
-
ordem = ["00-protocolo", "01-modelos"] + [f["slug"] for f in FERRAMENTAS_INFO]
|
|
1257
|
-
partes = ["PRIMOCODE STUDIO — MANUAL PARA AGENTES",
|
|
1258
|
-
"Servidor: http://localhost:%d" % PORTA, ""]
|
|
1259
|
-
for nome in ordem:
|
|
1260
|
-
p = os.path.join(FERRAMENTAS, nome + ".md")
|
|
1261
|
-
if os.path.exists(p):
|
|
1262
|
-
with open(p, encoding="utf-8") as fh:
|
|
1263
|
-
partes.append(fh.read().rstrip())
|
|
1264
|
-
partes.append("\n" + "=" * 70 + "\n")
|
|
1265
|
-
return "\n".join(partes)
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
# ----------------------------------------------------------------- handler --
|
|
1269
|
-
|
|
1270
|
-
def seguro(metodo):
|
|
1271
|
-
"""Qualquer exceção não prevista na rota vira 500 em JSON, não conexão
|
|
1272
|
-
fechada sem resposta nenhuma — era o comportamento padrão do
|
|
1273
|
-
BaseHTTPRequestHandler para exceção não tratada num do_*, e o cliente (o
|
|
1274
|
-
agente de IA) não tem como interpretar uma conexão que só morre."""
|
|
1275
|
-
@functools.wraps(metodo)
|
|
1276
|
-
def envolto(self, *a, **kw):
|
|
1277
|
-
try:
|
|
1278
|
-
return metodo(self, *a, **kw)
|
|
1279
|
-
except Exception as e:
|
|
1280
|
-
try:
|
|
1281
|
-
self.js({"erro": "erro interno: %s" % e}, 500)
|
|
1282
|
-
except Exception:
|
|
1283
|
-
pass
|
|
1284
|
-
return envolto
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
def _picar(texto, limite):
|
|
1288
|
-
"""Corta o texto em pedaços que o TTS aceita, sempre numa fronteira de
|
|
1289
|
-
palavra — cortar no meio da palavra faz a voz gaguejar na emenda."""
|
|
1290
|
-
palavras, atual, saida = texto.split(), "", []
|
|
1291
|
-
for w in palavras:
|
|
1292
|
-
if len(atual) + len(w) + 1 > limite and atual:
|
|
1293
|
-
saida.append(atual)
|
|
1294
|
-
atual = w
|
|
1295
|
-
else:
|
|
1296
|
-
atual = (atual + " " + w).strip()
|
|
1297
|
-
if atual:
|
|
1298
|
-
saida.append(atual)
|
|
1299
|
-
return saida or [texto[:limite]]
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
class Handler(SimpleHTTPRequestHandler):
|
|
1303
|
-
server_version = "PrimoCode/1.0"
|
|
1304
|
-
|
|
1305
|
-
def __init__(self, *a, **kw):
|
|
1306
|
-
super().__init__(*a, directory=WEB, **kw)
|
|
1307
|
-
|
|
1308
|
-
def log_message(self, fmt, *args):
|
|
1309
|
-
if "/api/" in (self.path or ""):
|
|
1310
|
-
sys.stderr.write(" %s %s\n" % (self.command, self.path))
|
|
1311
|
-
|
|
1312
|
-
def end_headers(self):
|
|
1313
|
-
self.send_header("Cache-Control", "no-store, max-age=0")
|
|
1314
|
-
super().end_headers()
|
|
1315
|
-
|
|
1316
|
-
# -------------------------------------------------------------- saídas --
|
|
1317
|
-
|
|
1318
|
-
def js(self, obj, status=200):
|
|
1319
|
-
corpo = json.dumps(obj, ensure_ascii=False).encode("utf-8")
|
|
1320
|
-
self.send_response(status)
|
|
1321
|
-
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
1322
|
-
self.send_header("Content-Length", str(len(corpo)))
|
|
1323
|
-
self.end_headers()
|
|
1324
|
-
self.wfile.write(corpo)
|
|
1325
|
-
|
|
1326
|
-
def txt(self, texto, ctype="text/plain; charset=utf-8", status=200, baixar=None):
|
|
1327
|
-
corpo = texto.encode("utf-8")
|
|
1328
|
-
self.send_response(status)
|
|
1329
|
-
self.send_header("Content-Type", ctype)
|
|
1330
|
-
self.send_header("Content-Length", str(len(corpo)))
|
|
1331
|
-
if baixar:
|
|
1332
|
-
self.send_header("Content-Disposition", 'attachment; filename="%s"' % baixar)
|
|
1333
|
-
self.end_headers()
|
|
1334
|
-
self.wfile.write(corpo)
|
|
1335
|
-
|
|
1336
|
-
def pagina(self, nome):
|
|
1337
|
-
with open(os.path.join(WEB, nome), encoding="utf-8") as fh:
|
|
1338
|
-
self.txt(fh.read(), "text/html; charset=utf-8")
|
|
1339
|
-
|
|
1340
|
-
def corpo(self):
|
|
1341
|
-
n = int(self.headers.get("Content-Length") or 0)
|
|
1342
|
-
if not n:
|
|
1343
|
-
return {}
|
|
1344
|
-
try:
|
|
1345
|
-
return json.loads(self.rfile.read(n).decode("utf-8"))
|
|
1346
|
-
except Exception:
|
|
1347
|
-
return {}
|
|
1348
|
-
|
|
1349
|
-
# Achado de segurança: POST/PUT/DELETE sem checagem de Origin é CSRF — uma
|
|
1350
|
-
# página de terceiro (aberta no MESMO navegador, sem relação nenhuma com o
|
|
1351
|
-
# Studio) consegue disparar um fetch() com Content-Type: text/plain (que
|
|
1352
|
-
# não exige preflight de CORS) contra http://127.0.0.1:7777 e o pedido
|
|
1353
|
-
# chega igual a um legítimo. Bloqueia quando o Origin existe e não é o
|
|
1354
|
-
# próprio servidor — sem Origin nenhum (cliente não-navegador, como o CLI
|
|
1355
|
-
# do PrimoCode via http nativo do Node) continua liberado, já que isso
|
|
1356
|
-
# nunca é o vetor de um navegador sendo usado contra o usuário.
|
|
1357
|
-
def origem_ok(self):
|
|
1358
|
-
origem = self.headers.get("Origin")
|
|
1359
|
-
if not origem:
|
|
1360
|
-
return True
|
|
1361
|
-
esperados = {
|
|
1362
|
-
"http://127.0.0.1:%d" % PORTA,
|
|
1363
|
-
"http://localhost:%d" % PORTA,
|
|
1364
|
-
}
|
|
1365
|
-
return origem in esperados
|
|
1366
|
-
|
|
1367
|
-
# -------------------------------------------------------------- rotas --
|
|
1368
|
-
|
|
1369
|
-
@seguro
|
|
1370
|
-
def do_GET(self):
|
|
1371
|
-
p = urlparse(self.path).path
|
|
1372
|
-
|
|
1373
|
-
if p == "/":
|
|
1374
|
-
return self.pagina("home.html")
|
|
1375
|
-
if re.fullmatch(r"/t/[^/]+/[^/]+", p):
|
|
1376
|
-
return self.pagina("tool.html")
|
|
1377
|
-
if re.fullmatch(r"/v/[^/]+", p):
|
|
1378
|
-
return self.pagina("view.html")
|
|
1379
|
-
if p == "/analise":
|
|
1380
|
-
return self.pagina("analise.html")
|
|
1381
|
-
|
|
1382
|
-
if p == "/api/manual":
|
|
1383
|
-
return self.txt(manual())
|
|
1384
|
-
if p == "/api/ferramentas":
|
|
1385
|
-
return self.js(FERRAMENTAS_INFO)
|
|
1386
|
-
|
|
1387
|
-
m = re.fullmatch(r"/api/ferramentas/([a-z]+)", p)
|
|
1388
|
-
if m:
|
|
1389
|
-
info = POR_SLUG.get(m.group(1))
|
|
1390
|
-
if not info:
|
|
1391
|
-
return self.js({"erro": "ferramenta desconhecida"}, 404)
|
|
1392
|
-
doc = os.path.join(FERRAMENTAS, m.group(1) + ".md")
|
|
1393
|
-
texto = ""
|
|
1394
|
-
if os.path.exists(doc):
|
|
1395
|
-
with open(doc, encoding="utf-8") as fh:
|
|
1396
|
-
texto = fh.read()
|
|
1397
|
-
return self.txt(texto or json.dumps(info, ensure_ascii=False, indent=2))
|
|
1398
|
-
|
|
1399
|
-
if p == "/api/versao":
|
|
1400
|
-
return self.js({"assinatura": ASSINATURA})
|
|
1401
|
-
|
|
1402
|
-
m = re.fullmatch(r"/api/analise/([a-z0-9]+)", p)
|
|
1403
|
-
if m:
|
|
1404
|
-
tarefa = ANALISES.get(m.group(1))
|
|
1405
|
-
if not tarefa:
|
|
1406
|
-
return self.js({"erro": "análise não encontrada"}, 404)
|
|
1407
|
-
return self.js(tarefa)
|
|
1408
|
-
|
|
1409
|
-
if p == "/api/voz":
|
|
1410
|
-
return self.voz(urlparse(self.path).query)
|
|
1411
|
-
if p == "/api/voz/status":
|
|
1412
|
-
return self.voz_status()
|
|
1413
|
-
|
|
1414
|
-
if p == "/api/midia":
|
|
1415
|
-
return self.js(listar_midia())
|
|
1416
|
-
if p.startswith("/midia/"):
|
|
1417
|
-
return self.servir_midia(unquote(p[len("/midia/"):]))
|
|
1418
|
-
|
|
1419
|
-
if p == "/api/arquivos":
|
|
1420
|
-
return self.js(listar())
|
|
1421
|
-
|
|
1422
|
-
m = re.fullmatch(r"/api/arquivos/([^/]+)", p)
|
|
1423
|
-
if m:
|
|
1424
|
-
a = ler(m.group(1))
|
|
1425
|
-
return self.js(a) if a else self.js({"erro": "não encontrado"}, 404)
|
|
1426
|
-
|
|
1427
|
-
m = re.fullmatch(r"/api/arquivos/([^/]+)/versoes", p)
|
|
1428
|
-
if m:
|
|
1429
|
-
if not ler(m.group(1)):
|
|
1430
|
-
return self.js({"erro": "não encontrado"}, 404)
|
|
1431
|
-
return self.js({"versoes": versoes(m.group(1))})
|
|
1432
|
-
|
|
1433
|
-
m = re.fullmatch(r"/api/arquivos/([^/]+)/export", p)
|
|
1434
|
-
if m:
|
|
1435
|
-
a = ler(m.group(1))
|
|
1436
|
-
if not a:
|
|
1437
|
-
return self.js({"erro": "não encontrado"}, 404)
|
|
1438
|
-
return self.exportar(a)
|
|
1439
|
-
|
|
1440
|
-
return super().do_GET()
|
|
1441
|
-
|
|
1442
|
-
# ── Voz ────────────────────────────────────────────────────────────
|
|
1443
|
-
# A narração inteira é decidida AQUI, no Python, e não no navegador.
|
|
1444
|
-
#
|
|
1445
|
-
# Antes o navegador tentava a voz neural do Edge por WebSocket e, quando
|
|
1446
|
-
# ela não subia, caía no speechSynthesis — o TTS da própria máquina, que
|
|
1447
|
-
# soa a computador. A voz do Edge parou de subir (a Microsoft responde
|
|
1448
|
-
# 403 na síntese), então na prática TODO vídeo saía com voz de robô. Era
|
|
1449
|
-
# o que o usuário estava ouvindo.
|
|
1450
|
-
#
|
|
1451
|
-
# A corrente agora é esta, e o navegador só consome o resultado:
|
|
1452
|
-
# 1. PIPER neural, roda na máquina, offline, ilimitado, com escolha
|
|
1453
|
-
# de voz — inclusive graves de verdade (voz.py mede e diz
|
|
1454
|
-
# a frequência de cada uma). É o bom.
|
|
1455
|
-
# 2. VOICEBOX se o usuário tiver o app: voz clonável.
|
|
1456
|
-
# 3. GOOGLE uma voz só, robótica, mas responde na hora sem instalar
|
|
1457
|
-
# nada — é o que cobre enquanto o Piper baixa.
|
|
1458
|
-
#
|
|
1459
|
-
# O endpoint do Google recusa texto comprido, então a frase é cortada em
|
|
1460
|
-
# pedaços de até 190 caracteres, sempre numa fronteira de palavra, e os
|
|
1461
|
-
# MP3 são emendados. MP3 aceita concatenação de quadros: é feio, mas toca.
|
|
1462
|
-
def voz(self, query):
|
|
1463
|
-
from urllib.parse import parse_qs
|
|
1464
|
-
q = parse_qs(query or "")
|
|
1465
|
-
texto = (q.get("texto", [""])[0] or "").strip()
|
|
1466
|
-
idioma = (q.get("idioma", ["pt-BR"])[0] or "pt-BR")[:8]
|
|
1467
|
-
if not texto:
|
|
1468
|
-
return self.js({"erro": "sem texto"}, 400)
|
|
1469
|
-
if len(texto) > 4000:
|
|
1470
|
-
texto = texto[:4000]
|
|
1471
|
-
|
|
1472
|
-
voz_pedida = (q.get("voz", [""])[0] or "").strip()
|
|
1473
|
-
instrucao = (q.get("instrucao", [""])[0] or "").strip()
|
|
1474
|
-
motor = (q.get("motor", [""])[0] or "").strip()
|
|
1475
|
-
grave = (q.get("grave", [""])[0] or "").lower() in ("1", "true", "sim")
|
|
1476
|
-
try:
|
|
1477
|
-
velocidade = float(q.get("velocidade", ["1"])[0])
|
|
1478
|
-
except ValueError:
|
|
1479
|
-
velocidade = 1.0
|
|
1480
|
-
|
|
1481
|
-
tipo = "audio/mpeg"
|
|
1482
|
-
audio = b""
|
|
1483
|
-
|
|
1484
|
-
# 0) ElevenLabs: a voz que o usuário aprovou nos vídeos. Só entra na
|
|
1485
|
-
# corrente quando há chave configurada — e continua caindo para os
|
|
1486
|
-
# motores locais se falhar, porque narração nunca fica muda.
|
|
1487
|
-
if motor in ("", "elevenlabs") and elevenlabs_lib.configurado():
|
|
1488
|
-
dados, _ = elevenlabs_lib.sintetizar(texto, {"voz": voz_pedida})
|
|
1489
|
-
if dados:
|
|
1490
|
-
audio = dados
|
|
1491
|
-
tipo = "audio/mpeg"
|
|
1492
|
-
elif motor == "elevenlabs":
|
|
1493
|
-
return self.js({"erro": "ElevenLabs não devolveu áudio — chave válida? cota?"}, 502)
|
|
1494
|
-
|
|
1495
|
-
# 0.5) Edge TTS: neural, grátis, sem chave. Entra logo depois do
|
|
1496
|
-
# ElevenLabs porque é a melhor voz gratuita da corrente — vozes
|
|
1497
|
-
# da Microsoft, muito acima do Google. Precisa de internet.
|
|
1498
|
-
if not audio and motor in ("", "edge"):
|
|
1499
|
-
# Pacote ainda não instalado? ESPERA o pip (até 60 s) em vez de
|
|
1500
|
-
# cair para o Google. Quem pediu voz tem internet — é o que o Edge
|
|
1501
|
-
# precisa — e meio minuto é menos ruim que a voz de robô no vídeo.
|
|
1502
|
-
if not edge_lib.disponivel():
|
|
1503
|
-
edge_lib.instalar(esperar=60)
|
|
1504
|
-
dados, _ = edge_lib.sintetizar(texto, {"voz": voz_pedida,
|
|
1505
|
-
"grave": grave, "velocidade": velocidade})
|
|
1506
|
-
if dados:
|
|
1507
|
-
audio = dados
|
|
1508
|
-
tipo = "audio/mpeg"
|
|
1509
|
-
elif motor == "edge":
|
|
1510
|
-
return self.js({"erro": "Edge TTS não devolveu áudio — sem internet ou pacote ausente?"}, 502)
|
|
1511
|
-
|
|
1512
|
-
# 1) Piper: o motor local. Se ainda não baixou, começa a baixar agora
|
|
1513
|
-
# e segue para o próximo — quem pediu narração hoje ouve o Google,
|
|
1514
|
-
# e a partir da próxima já é voz de verdade.
|
|
1515
|
-
if not audio and motor not in ("google", "voicebox", "elevenlabs", "edge"):
|
|
1516
|
-
try:
|
|
1517
|
-
if voz_lib.pronta(voz_pedida or None):
|
|
1518
|
-
audio = voz_lib.sintetizar(texto, voz_pedida or None,
|
|
1519
|
-
grave=grave, velocidade=velocidade) or b""
|
|
1520
|
-
if audio:
|
|
1521
|
-
tipo = "audio/wav"
|
|
1522
|
-
else:
|
|
1523
|
-
voz_lib.instalar(voz_pedida or None)
|
|
1524
|
-
except Exception as e:
|
|
1525
|
-
sys.stderr.write("[voz] Piper falhou (%s); seguindo a corrente\n" % e)
|
|
1526
|
-
audio = b""
|
|
1527
|
-
|
|
1528
|
-
# 2) Voicebox, se o usuário tiver instalado: voz clonável, ilimitada e
|
|
1529
|
-
# sem depender de nada na internet.
|
|
1530
|
-
if not audio and motor != "google" and self._voicebox_vivo():
|
|
1531
|
-
try:
|
|
1532
|
-
audio, tipo = self._voz_voicebox(texto, voz_pedida, instrucao)
|
|
1533
|
-
except Exception as e:
|
|
1534
|
-
sys.stderr.write("[voz] Voicebox falhou (%s); indo de Google\n" % e)
|
|
1535
|
-
audio = b""
|
|
1536
|
-
# 3) Google: uma voz só, mas responde sempre. É a EXCEÇÃO, e vai
|
|
1537
|
-
# marcada: o cabeçalho diz qual motor falou, para o player e o
|
|
1538
|
-
# agente saberem que a voz saiu pela reserva — em vez de a pessoa
|
|
1539
|
-
# descobrir ouvindo.
|
|
1540
|
-
motor_usado = "elevenlabs" if (audio and tipo == "audio/mpeg" and elevenlabs_lib.configurado()) else \
|
|
1541
|
-
("edge" if audio and tipo == "audio/mpeg" else ("piper" if audio and tipo == "audio/wav" else "voicebox" if audio else "google"))
|
|
1542
|
-
if not audio:
|
|
1543
|
-
tipo = "audio/mpeg"
|
|
1544
|
-
try:
|
|
1545
|
-
audio = b"".join(self._trecho_de_voz(t, idioma) for t in _picar(texto, 190))
|
|
1546
|
-
except Exception as e: # rede, bloqueio, mudança do serviço
|
|
1547
|
-
return self.js({"erro": "voz indisponível: %s" % e}, 502)
|
|
1548
|
-
sys.stderr.write("[voz] RESERVA: a narração saiu pelo Google (edge-tts: %s)\n"
|
|
1549
|
-
% (edge_lib.ultimo_erro() or "não instalado"))
|
|
1550
|
-
if not audio:
|
|
1551
|
-
return self.js({"erro": "voz não devolveu áudio"}, 502)
|
|
1552
|
-
|
|
1553
|
-
self.send_response(200)
|
|
1554
|
-
self.send_header("X-Voz-Motor", motor_usado)
|
|
1555
|
-
self.send_header("Content-Type", tipo)
|
|
1556
|
-
self.send_header("Content-Length", str(len(audio)))
|
|
1557
|
-
self.send_header("Cache-Control", "no-store")
|
|
1558
|
-
self.end_headers()
|
|
1559
|
-
self.wfile.write(audio)
|
|
1560
|
-
|
|
1561
|
-
# ── Voicebox ───────────────────────────────────────────────────────
|
|
1562
|
-
# https://voicebox.sh — app local, código aberto, de graça e sem limite,
|
|
1563
|
-
# com clonagem de voz. É a melhor voz que existe para este uso e não
|
|
1564
|
-
# depende de serviço nenhum na internet: roda na máquina do usuário.
|
|
1565
|
-
#
|
|
1566
|
-
# A ponte é FEITA AQUI, e não no navegador, por causa do CORS: o Voicebox
|
|
1567
|
-
# só libera as portas dele (5173 e 17493), e o Studio serve em 7777 — do
|
|
1568
|
-
# navegador o pedido morreria antes de sair.
|
|
1569
|
-
#
|
|
1570
|
-
# Não está instalado? A rota cai para o Google, e ninguém fica sem voz.
|
|
1571
|
-
VOICEBOX = "http://127.0.0.1:17493"
|
|
1572
|
-
|
|
1573
|
-
def voz_status(self):
|
|
1574
|
-
"""O que está disponível para narrar, para a interface não oferecer o
|
|
1575
|
-
que não existe — e para ela poder mostrar o download em vez de fingir
|
|
1576
|
-
que já está tudo pronto."""
|
|
1577
|
-
vivo = self._voicebox_vivo()
|
|
1578
|
-
vozes = []
|
|
1579
|
-
if vivo:
|
|
1580
|
-
try:
|
|
1581
|
-
with urllib.request.urlopen(self.VOICEBOX + "/profiles", timeout=6) as r:
|
|
1582
|
-
vozes = [{"id": v.get("id"), "nome": v.get("name"), "idioma": v.get("language")}
|
|
1583
|
-
for v in json.loads(r.read().decode("utf-8"))]
|
|
1584
|
-
except Exception:
|
|
1585
|
-
vozes = []
|
|
1586
|
-
return self.js({
|
|
1587
|
-
"elevenlabs": elevenlabs_lib.estado(),
|
|
1588
|
-
"edge": edge_lib.estado(),
|
|
1589
|
-
"piper": voz_lib.estado(),
|
|
1590
|
-
"voicebox": {"ativo": vivo, "vozes": vozes, "site": "https://voicebox.sh"},
|
|
1591
|
-
"google": {"ativo": True},
|
|
1592
|
-
})
|
|
1593
|
-
|
|
1594
|
-
def _voicebox_vivo(self):
|
|
1595
|
-
try:
|
|
1596
|
-
with urllib.request.urlopen(self.VOICEBOX + "/health", timeout=2):
|
|
1597
|
-
return True
|
|
1598
|
-
except Exception:
|
|
1599
|
-
return False
|
|
1600
|
-
|
|
1601
|
-
def _voicebox_perfil(self, pedido):
|
|
1602
|
-
"""A voz a usar: a que o usuário pediu pelo nome, ou a primeira que
|
|
1603
|
-
houver. Sem perfil não há geração — o Voicebox exige um."""
|
|
1604
|
-
req = urllib.request.Request(self.VOICEBOX + "/profiles")
|
|
1605
|
-
with urllib.request.urlopen(req, timeout=8) as r:
|
|
1606
|
-
perfis = json.loads(r.read().decode("utf-8"))
|
|
1607
|
-
if not perfis:
|
|
1608
|
-
raise RuntimeError("Voicebox está no ar mas sem nenhuma voz criada")
|
|
1609
|
-
if pedido:
|
|
1610
|
-
alvo = pedido.strip().lower()
|
|
1611
|
-
for p in perfis:
|
|
1612
|
-
if alvo in (p.get("name") or "").lower() or alvo == p.get("id"):
|
|
1613
|
-
return p
|
|
1614
|
-
# Sem escolha explícita, prefere uma voz em português.
|
|
1615
|
-
for p in perfis:
|
|
1616
|
-
if (p.get("language") or "").startswith("pt"):
|
|
1617
|
-
return p
|
|
1618
|
-
return perfis[0]
|
|
1619
|
-
|
|
1620
|
-
def _voz_voicebox(self, texto, voz, instrucao):
|
|
1621
|
-
perfil = self._voicebox_perfil(voz)
|
|
1622
|
-
corpo = json.dumps({
|
|
1623
|
-
"profile_id": perfil["id"],
|
|
1624
|
-
"text": texto[:50000],
|
|
1625
|
-
"language": "pt",
|
|
1626
|
-
# A instrução de entrega é o que dá voz de cinema em vez de voz de
|
|
1627
|
-
# leitura. O Voicebox aceita em linguagem natural, até 500 chars.
|
|
1628
|
-
"instruct": (instrucao or "Narração institucional: voz grave, ritmo pausado, tom seguro.")[:500],
|
|
1629
|
-
"max_chunk_chars": 800,
|
|
1630
|
-
"crossfade_ms": 50,
|
|
1631
|
-
"normalize": True,
|
|
1632
|
-
}).encode("utf-8")
|
|
1633
|
-
req = urllib.request.Request(self.VOICEBOX + "/generate", data=corpo,
|
|
1634
|
-
headers={"Content-Type": "application/json"})
|
|
1635
|
-
with urllib.request.urlopen(req, timeout=30) as r:
|
|
1636
|
-
g = json.loads(r.read().decode("utf-8"))
|
|
1637
|
-
gid = g.get("id")
|
|
1638
|
-
if not gid:
|
|
1639
|
-
raise RuntimeError("Voicebox não devolveu id de geração")
|
|
1640
|
-
|
|
1641
|
-
# /generate volta na hora com status "generating" e sintetiza em
|
|
1642
|
-
# segundo plano — então é preciso esperar de verdade.
|
|
1643
|
-
prazo = time.time() + 180
|
|
1644
|
-
estado = g.get("status")
|
|
1645
|
-
while estado in (None, "generating", "pending", "processing"):
|
|
1646
|
-
if time.time() > prazo:
|
|
1647
|
-
raise RuntimeError("Voicebox demorou demais")
|
|
1648
|
-
time.sleep(1.0)
|
|
1649
|
-
with urllib.request.urlopen(self.VOICEBOX + "/generate/%s/status" % gid, timeout=8) as r:
|
|
1650
|
-
estado = (json.loads(r.read().decode("utf-8")) or {}).get("status")
|
|
1651
|
-
if estado != "completed":
|
|
1652
|
-
raise RuntimeError("Voicebox falhou (%s)" % estado)
|
|
1653
|
-
|
|
1654
|
-
with urllib.request.urlopen(self.VOICEBOX + "/audio/%s" % gid, timeout=30) as r:
|
|
1655
|
-
return r.read(), (r.headers.get("Content-Type") or "audio/wav")
|
|
1656
|
-
|
|
1657
|
-
def _trecho_de_voz(self, texto, idioma):
|
|
1658
|
-
url = "https://translate.google.com/translate_tts?" + urlencode({
|
|
1659
|
-
"ie": "UTF-8", "q": texto, "tl": idioma, "client": "tw-ob", "total": 1, "idx": 0,
|
|
1660
|
-
})
|
|
1661
|
-
req = urllib.request.Request(url, headers={
|
|
1662
|
-
# Sem cara de navegador o serviço devolve 403.
|
|
1663
|
-
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
|
|
1664
|
-
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0 Safari/537.36",
|
|
1665
|
-
"Referer": "https://translate.google.com/",
|
|
1666
|
-
})
|
|
1667
|
-
with urllib.request.urlopen(req, timeout=20) as r:
|
|
1668
|
-
return r.read()
|
|
1669
|
-
|
|
1670
|
-
def servir_midia(self, nome):
|
|
1671
|
-
"""Serve com suporte a Range — sem isso o áudio não busca no <audio>."""
|
|
1672
|
-
if "/" in nome or ".." in nome:
|
|
1673
|
-
return self.js({"erro": "caminho inválido"}, 400)
|
|
1674
|
-
caminho_arq = os.path.join(MIDIA, nome)
|
|
1675
|
-
if not os.path.isfile(caminho_arq):
|
|
1676
|
-
return self.js({"erro": "não encontrado"}, 404)
|
|
1677
|
-
|
|
1678
|
-
tamanho = os.path.getsize(caminho_arq)
|
|
1679
|
-
ctype = mimetypes.guess_type(caminho_arq)[0] or "application/octet-stream"
|
|
1680
|
-
faixa = self.headers.get("Range")
|
|
1681
|
-
inicio, fim = 0, tamanho - 1
|
|
1682
|
-
status = 200
|
|
1683
|
-
|
|
1684
|
-
if faixa:
|
|
1685
|
-
m = re.match(r"bytes=(\d*)-(\d*)", faixa)
|
|
1686
|
-
if m:
|
|
1687
|
-
if not m.group(1) and m.group(2):
|
|
1688
|
-
# "bytes=-500" é sufixo HTTP válido: os ÚLTIMOS 500 bytes,
|
|
1689
|
-
# não os primeiros 500 — sem este caso à parte, group(2)
|
|
1690
|
-
# virava fim absoluto e o pedido servia o pedaço errado.
|
|
1691
|
-
inicio = max(0, tamanho - int(m.group(2)))
|
|
1692
|
-
fim = tamanho - 1
|
|
1693
|
-
else:
|
|
1694
|
-
if m.group(1):
|
|
1695
|
-
inicio = int(m.group(1))
|
|
1696
|
-
if m.group(2):
|
|
1697
|
-
fim = min(int(m.group(2)), tamanho - 1)
|
|
1698
|
-
if inicio > fim or inicio >= tamanho:
|
|
1699
|
-
self.send_response(416)
|
|
1700
|
-
self.send_header("Content-Range", "bytes */%d" % tamanho)
|
|
1701
|
-
self.end_headers()
|
|
1702
|
-
return
|
|
1703
|
-
status = 206
|
|
1704
|
-
|
|
1705
|
-
self.send_response(status)
|
|
1706
|
-
self.send_header("Content-Type", ctype)
|
|
1707
|
-
self.send_header("Accept-Ranges", "bytes")
|
|
1708
|
-
self.send_header("Content-Length", str(fim - inicio + 1))
|
|
1709
|
-
if status == 206:
|
|
1710
|
-
self.send_header("Content-Range", "bytes %d-%d/%d" % (inicio, fim, tamanho))
|
|
1711
|
-
self.end_headers()
|
|
1712
|
-
|
|
1713
|
-
with open(caminho_arq, "rb") as fh:
|
|
1714
|
-
fh.seek(inicio)
|
|
1715
|
-
restante = fim - inicio + 1
|
|
1716
|
-
while restante > 0:
|
|
1717
|
-
pedaco = fh.read(min(262144, restante))
|
|
1718
|
-
if not pedaco:
|
|
1719
|
-
break
|
|
1720
|
-
self.wfile.write(pedaco)
|
|
1721
|
-
restante -= len(pedaco)
|
|
1722
|
-
|
|
1723
|
-
@seguro
|
|
1724
|
-
def do_POST(self):
|
|
1725
|
-
if not self.origem_ok():
|
|
1726
|
-
return self.js({"erro": "origem não permitida"}, 403)
|
|
1727
|
-
rota = urlparse(self.path).path
|
|
1728
|
-
if rota == "/api/midia":
|
|
1729
|
-
corpo, status = guardar_midia(self.corpo())
|
|
1730
|
-
return self.js(corpo, status)
|
|
1731
|
-
|
|
1732
|
-
if rota == "/api/analise":
|
|
1733
|
-
corpo, status = criar_analise(self.corpo())
|
|
1734
|
-
return self.js(corpo, status)
|
|
1735
|
-
|
|
1736
|
-
if rota == "/api/voz/instalar":
|
|
1737
|
-
pedido = self.corpo()
|
|
1738
|
-
if pedido.get("apagar"):
|
|
1739
|
-
return self.js(voz_lib.apagar())
|
|
1740
|
-
return self.js(voz_lib.instalar(pedido.get("voz"),
|
|
1741
|
-
esperar=min(60, int(pedido.get("esperar") or 0))))
|
|
1742
|
-
|
|
1743
|
-
if rota == "/api/voz/elevenlabs":
|
|
1744
|
-
# Grava (ou apaga, com chave vazia) a chave do ElevenLabs. A chave
|
|
1745
|
-
# nunca volta em resposta nenhuma — só o "configurado" volta.
|
|
1746
|
-
return self.js(elevenlabs_lib.gravar_chave(self.corpo().get("chave")))
|
|
1747
|
-
|
|
1748
|
-
if rota == "/api/sair":
|
|
1749
|
-
# Só o CLI chama, e só quando o código no disco mudou (update do
|
|
1750
|
-
# npm). Sem isto o processo antigo ficava no ar servindo o bug que
|
|
1751
|
-
# o update tinha acabado de corrigir. Responde antes de morrer,
|
|
1752
|
-
# para quem pediu não ver erro de conexão.
|
|
1753
|
-
self.js({"ok": True, "tchau": ASSINATURA})
|
|
1754
|
-
import threading
|
|
1755
|
-
threading.Thread(target=lambda: (time.sleep(0.3), os._exit(0)), daemon=True).start()
|
|
1756
|
-
return
|
|
1757
|
-
|
|
1758
|
-
m = re.fullmatch(r"/api/arquivos/([^/]+)/restaurar", rota)
|
|
1759
|
-
if m:
|
|
1760
|
-
a = ler(m.group(1))
|
|
1761
|
-
if not a:
|
|
1762
|
-
return self.js({"erro": "não encontrado"}, 404)
|
|
1763
|
-
pedido = self.corpo()
|
|
1764
|
-
disponiveis = versoes(m.group(1))
|
|
1765
|
-
# Sem versão dita, a anterior — que é o que "desfaz" quer dizer
|
|
1766
|
-
# quando alguém acabou de estragar o arquivo.
|
|
1767
|
-
alvo = pedido.get("versao") or (disponiveis[0]["versao"] if disponiveis else None)
|
|
1768
|
-
velha = ler_versao(m.group(1), alvo) if alvo else None
|
|
1769
|
-
if not velha:
|
|
1770
|
-
return self.js({"erro": "versão não encontrada",
|
|
1771
|
-
"versoes": disponiveis}, 404)
|
|
1772
|
-
# Restaurar também guarda versão: desfazer o desfazer tem de valer.
|
|
1773
|
-
guardar_versao(a)
|
|
1774
|
-
a["titulo"] = velha.get("titulo", a.get("titulo"))
|
|
1775
|
-
a["doc"] = velha.get("doc") or a.get("doc")
|
|
1776
|
-
gravar(a)
|
|
1777
|
-
doc = a.get("doc") or {}
|
|
1778
|
-
return self.js({
|
|
1779
|
-
"ok": True, "id": a["id"], "restaurado": alvo,
|
|
1780
|
-
"titulo": a.get("titulo"),
|
|
1781
|
-
"pecas": len(doc.get("cenas") or doc.get("blocos") or doc.get("abas") or []),
|
|
1782
|
-
})
|
|
1783
|
-
|
|
1784
|
-
if rota == "/api/arquivos":
|
|
1785
|
-
try:
|
|
1786
|
-
a = criar(self.corpo())
|
|
1787
|
-
except ValueError as e:
|
|
1788
|
-
return self.js({"erro": str(e)}, 400)
|
|
1789
|
-
if not a:
|
|
1790
|
-
return self.js({"erro": "ferramenta inválida",
|
|
1791
|
-
"validas": list(POR_SLUG)}, 400)
|
|
1792
|
-
base = "http://localhost:%d" % PORTA
|
|
1793
|
-
corpo = {
|
|
1794
|
-
"id": a["id"],
|
|
1795
|
-
"ferramenta": a["ferramenta"],
|
|
1796
|
-
"titulo": a["titulo"],
|
|
1797
|
-
"editar": "%s/t/%s/%s" % (base, a["ferramenta"], a["id"]),
|
|
1798
|
-
"ver": "%s/v/%s" % (base, a["id"]),
|
|
1799
|
-
}
|
|
1800
|
-
revisao = revisar_monotonia(a.get("doc") or {}, a.get("ferramenta"))
|
|
1801
|
-
if revisao:
|
|
1802
|
-
corpo["podeMelhorar"] = revisao
|
|
1803
|
-
return self.js(corpo, 201)
|
|
1804
|
-
return self.js({"erro": "não encontrado"}, 404)
|
|
1805
|
-
|
|
1806
|
-
@seguro
|
|
1807
|
-
def do_PUT(self):
|
|
1808
|
-
if not self.origem_ok():
|
|
1809
|
-
return self.js({"erro": "origem não permitida"}, 403)
|
|
1810
|
-
|
|
1811
|
-
# Envio bruto: PUT /api/midia/<nome> com o arquivo no corpo, sem JSON
|
|
1812
|
-
# e sem base64. É por onde passa a gravação do usuário, que é grande
|
|
1813
|
-
# demais para virar texto.
|
|
1814
|
-
m = re.fullmatch(r"/api/midia/([^/]+)", urlparse(self.path).path)
|
|
1815
|
-
if m:
|
|
1816
|
-
corpo, status = guardar_midia_bruta(
|
|
1817
|
-
unquote(m.group(1)), self.rfile,
|
|
1818
|
-
int(self.headers.get("Content-Length") or 0))
|
|
1819
|
-
return self.js(corpo, status)
|
|
1820
|
-
|
|
1821
|
-
m = re.fullmatch(r"/api/analise/([a-z0-9]+)", urlparse(self.path).path)
|
|
1822
|
-
if m:
|
|
1823
|
-
corpo, status = gravar_analise(m.group(1), self.corpo())
|
|
1824
|
-
return self.js(corpo, status)
|
|
1825
|
-
|
|
1826
|
-
m = re.fullmatch(r"/api/arquivos/([^/]+)", urlparse(self.path).path)
|
|
1827
|
-
if not m:
|
|
1828
|
-
return self.js({"erro": "não encontrado"}, 404)
|
|
1829
|
-
a = ler(m.group(1))
|
|
1830
|
-
if not a:
|
|
1831
|
-
return self.js({"erro": "não encontrado"}, 404)
|
|
1832
|
-
dados = self.corpo()
|
|
1833
|
-
# Mesma validação de criar(): sem isso, um PUT com titulo/doc do tipo
|
|
1834
|
-
# errado grava sujeira que só quebra depois, no consumo (export etc).
|
|
1835
|
-
if "titulo" in dados and dados["titulo"] is not None and not isinstance(dados["titulo"], str):
|
|
1836
|
-
return self.js({"erro": "titulo precisa ser texto"}, 400)
|
|
1837
|
-
if "doc" in dados and dados["doc"] is not None and not isinstance(dados["doc"], dict):
|
|
1838
|
-
return self.js({"erro": "doc precisa ser um objeto"}, 400)
|
|
1839
|
-
|
|
1840
|
-
antes = a.get("doc") or {}
|
|
1841
|
-
novo = a.get("doc")
|
|
1842
|
-
if "doc" in dados and dados["doc"] is not None:
|
|
1843
|
-
# Mesclar em vez de trocar. Era aqui que o trabalho sumia: um doc
|
|
1844
|
-
# parcial substituía o inteiro. Quem quiser mesmo jogar fora o que
|
|
1845
|
-
# existe manda "substituir": true e assume.
|
|
1846
|
-
novo = dados["doc"] if dados.get("substituir") else mesclar(antes, dados["doc"])
|
|
1847
|
-
|
|
1848
|
-
# O agente precisa da validação: é a única forma de ele saber que
|
|
1849
|
-
# errou o formato. O editor do navegador, não — ali há uma pessoa
|
|
1850
|
-
# digitando, e uma cena momentaneamente vazia no meio da edição não
|
|
1851
|
-
# pode fazer o salvamento falhar calado.
|
|
1852
|
-
if self.headers.get("X-Primo-Agente"):
|
|
1853
|
-
problema = validar_doc(
|
|
1854
|
-
POR_SLUG.get(a.get("ferramenta"), {}).get("modelo"), novo)
|
|
1855
|
-
if problema:
|
|
1856
|
-
# As frases de validar_doc foram escritas para a CRIAÇÃO:
|
|
1857
|
-
# elas terminam em "crie de novo" e "refaça". Devolver isso
|
|
1858
|
-
# numa ATUALIZAÇÃO manda o agente fazer exatamente a coisa
|
|
1859
|
-
# errada — e foi o que aconteceu: ele recebia o erro, jogava
|
|
1860
|
-
# fora o vídeo pronto e recomeçava do zero, às vezes saindo
|
|
1861
|
-
# pior. Aqui a instrução é a oposta, e vem por último para
|
|
1862
|
-
# ser a última coisa que ele lê.
|
|
1863
|
-
problema = re.sub(r"(crie de novo|e refaça)\.?", "", problema).strip()
|
|
1864
|
-
return self.js({
|
|
1865
|
-
"erro": problema
|
|
1866
|
-
+ ' Isto é uma ATUALIZAÇÃO: NÃO crie outro arquivo e NÃO recomece do zero. '
|
|
1867
|
-
'Corrija só o que está errado acima e chame studio_atualizar de novo '
|
|
1868
|
-
'com o MESMO id "%s". O arquivo continua intacto — nada foi perdido.' % a["id"],
|
|
1869
|
-
"id": a["id"],
|
|
1870
|
-
}, 400)
|
|
1871
|
-
|
|
1872
|
-
preparar_voz(novo)
|
|
1873
|
-
versao = guardar_versao(a)
|
|
1874
|
-
if "titulo" in dados:
|
|
1875
|
-
a["titulo"] = dados["titulo"]
|
|
1876
|
-
if "doc" in dados:
|
|
1877
|
-
a["doc"] = novo
|
|
1878
|
-
salvo = gravar(a)
|
|
1879
|
-
narrar_em_arquivo(a["id"], salvo.get("doc") or {})
|
|
1880
|
-
if versao:
|
|
1881
|
-
# O agente vê isto no resultado da tool: se ele estragou algo, o
|
|
1882
|
-
# caminho de volta está na resposta, não numa doc que ele não leu.
|
|
1883
|
-
salvo = dict(salvo, versao_anterior=versao)
|
|
1884
|
-
return self.js(salvo)
|
|
1885
|
-
|
|
1886
|
-
@seguro
|
|
1887
|
-
def do_DELETE(self):
|
|
1888
|
-
if not self.origem_ok():
|
|
1889
|
-
return self.js({"erro": "origem não permitida"}, 403)
|
|
1890
|
-
rota = urlparse(self.path).path
|
|
1891
|
-
m = re.fullmatch(r"/api/midia/([^/]+)", rota)
|
|
1892
|
-
if m:
|
|
1893
|
-
nome = unquote(m.group(1))
|
|
1894
|
-
alvo = os.path.join(MIDIA, nome)
|
|
1895
|
-
if "/" not in nome and ".." not in nome and os.path.isfile(alvo):
|
|
1896
|
-
os.remove(alvo)
|
|
1897
|
-
return self.js({"ok": True})
|
|
1898
|
-
return self.js({"erro": "não encontrado"}, 404)
|
|
1899
|
-
|
|
1900
|
-
m = re.fullmatch(r"/api/arquivos/([^/]+)", rota)
|
|
1901
|
-
c = caminho(m.group(1)) if m else None
|
|
1902
|
-
if c and os.path.exists(c):
|
|
1903
|
-
os.remove(c)
|
|
1904
|
-
return self.js({"ok": True})
|
|
1905
|
-
return self.js({"erro": "não encontrado"}, 404)
|
|
1906
|
-
|
|
1907
|
-
# ------------------------------------------------------------- export --
|
|
1908
|
-
|
|
1909
|
-
def exportar(self, a):
|
|
1910
|
-
ferramenta = a.get("ferramenta")
|
|
1911
|
-
doc = a.get("doc") or {}
|
|
1912
|
-
|
|
1913
|
-
if ferramenta == "grade":
|
|
1914
|
-
linhas = []
|
|
1915
|
-
for aba in doc.get("abas", []):
|
|
1916
|
-
linhas.append(",".join(csv_celula(c) for c in aba.get("colunas", [])))
|
|
1917
|
-
for lin in calcular_aba(aba):
|
|
1918
|
-
linhas.append(",".join(csv_celula(c) for c in lin))
|
|
1919
|
-
linhas.append("")
|
|
1920
|
-
return self.txt("\n".join(linhas), "text/csv; charset=utf-8",
|
|
1921
|
-
baixar=a["id"] + ".csv")
|
|
1922
|
-
|
|
1923
|
-
if ferramenta == "prosa":
|
|
1924
|
-
return self.txt(prosa_markdown(doc), "text/markdown; charset=utf-8",
|
|
1925
|
-
baixar=a["id"] + ".md")
|
|
1926
|
-
|
|
1927
|
-
return self.txt(html_autonomo(a), "text/html; charset=utf-8",
|
|
1928
|
-
baixar=a["id"] + ".html")
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
def csv_celula(valor):
|
|
1932
|
-
if isinstance(valor, float):
|
|
1933
|
-
valor = round(valor, 2)
|
|
1934
|
-
if valor == int(valor):
|
|
1935
|
-
valor = int(valor)
|
|
1936
|
-
s = "" if valor is None else str(valor)
|
|
1937
|
-
return '"%s"' % s.replace('"', '""') if re.search(r'[",\n\r]', s) else s
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
# ------------------------------------------------- fórmulas (espelha o JS) --
|
|
1941
|
-
|
|
1942
|
-
def _mediana(v):
|
|
1943
|
-
if not v:
|
|
1944
|
-
return 0
|
|
1945
|
-
o = sorted(v)
|
|
1946
|
-
m = len(o) // 2
|
|
1947
|
-
return o[m] if len(o) % 2 else (o[m - 1] + o[m]) / 2
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
_FUNCOES = {
|
|
1951
|
-
"SOMA": sum,
|
|
1952
|
-
"MEDIA": lambda v: sum(v) / len(v) if v else 0,
|
|
1953
|
-
"MIN": lambda v: min(v) if v else 0,
|
|
1954
|
-
"MAX": lambda v: max(v) if v else 0,
|
|
1955
|
-
"CONT": len,
|
|
1956
|
-
"ARRED": lambda v: round(v[0], int(v[1])) if len(v) > 1 else round(v[0] if v else 0),
|
|
1957
|
-
"ABS": lambda v: abs(v[0] if v else 0),
|
|
1958
|
-
"RAIZ": lambda v: abs(v[0] if v else 0) ** 0.5,
|
|
1959
|
-
"POT": lambda v: (v[0] if v else 0) ** (v[1] if len(v) > 1 else 2),
|
|
1960
|
-
"TETO": lambda v: -(-(v[0] if v else 0) // 1),
|
|
1961
|
-
"PISO": lambda v: (v[0] if v else 0) // 1,
|
|
1962
|
-
"MEDIANA": _mediana,
|
|
1963
|
-
"MAIOR": lambda v: sorted(v[:-1], reverse=True)[max(0, int(v[-1]) - 1)] if len(v) > 1 else 0,
|
|
1964
|
-
"MENOR": lambda v: sorted(v[:-1])[max(0, int(v[-1]) - 1)] if len(v) > 1 else 0,
|
|
1965
|
-
"SE": lambda v: (v[1] if len(v) > 1 else 1) if v and v[0] else (v[2] if len(v) > 2 else 0),
|
|
1966
|
-
}
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
def _criterio(texto):
|
|
1970
|
-
m = re.match(r'^(<=|>=|<>|!=|<|>|=)?\s*(-?[\d.,]+)$', str(texto).strip().strip('"\''))
|
|
1971
|
-
if not m:
|
|
1972
|
-
return lambda x: False
|
|
1973
|
-
op, alvo = m.group(1) or "=", float(m.group(2).replace(",", "."))
|
|
1974
|
-
return {
|
|
1975
|
-
">": lambda x: x > alvo, "<": lambda x: x < alvo,
|
|
1976
|
-
">=": lambda x: x >= alvo, "<=": lambda x: x <= alvo,
|
|
1977
|
-
"<>": lambda x: x != alvo, "!=": lambda x: x != alvo,
|
|
1978
|
-
}.get(op, lambda x: x == alvo)
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
def _col(letras):
|
|
1982
|
-
n = 0
|
|
1983
|
-
for c in letras:
|
|
1984
|
-
n = n * 26 + (ord(c) - 64)
|
|
1985
|
-
return n - 1
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
def _num(v):
|
|
1989
|
-
if isinstance(v, (int, float)):
|
|
1990
|
-
return v
|
|
1991
|
-
if v in (None, ""):
|
|
1992
|
-
return 0
|
|
1993
|
-
try:
|
|
1994
|
-
return float(re.sub(r"[R$\s%.]", "", str(v)).replace(",", "."))
|
|
1995
|
-
except ValueError:
|
|
1996
|
-
return 0
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
def calcular_aba(aba):
|
|
2000
|
-
"""Resolve as fórmulas da aba e devolve a matriz de valores."""
|
|
2001
|
-
linhas = [list(l) for l in aba.get("linhas", [])]
|
|
2002
|
-
|
|
2003
|
-
def celula(c, l, prof):
|
|
2004
|
-
# l/c negativos (referência tipo "A0", que vira índice -1) não podem
|
|
2005
|
-
# passar: índice negativo do Python "funciona" contando do fim da
|
|
2006
|
-
# lista, e a última linha da planilha aparecia como resposta de uma
|
|
2007
|
-
# referência inválida em vez de virar 0.
|
|
2008
|
-
if prof > 24 or l < 0 or c < 0 or l >= len(linhas) or c >= len(linhas[l]):
|
|
2009
|
-
return 0
|
|
2010
|
-
bruto = linhas[l][c]
|
|
2011
|
-
if isinstance(bruto, str) and bruto.startswith("="):
|
|
2012
|
-
return avaliar(bruto[1:], prof + 1)
|
|
2013
|
-
return bruto
|
|
2014
|
-
|
|
2015
|
-
def faixa(ref, prof):
|
|
2016
|
-
m = re.fullmatch(r"([A-Z]+)(\d+):([A-Z]+)(\d+)", ref)
|
|
2017
|
-
if not m:
|
|
2018
|
-
return []
|
|
2019
|
-
c1, l1, c2, l2 = _col(m.group(1)), int(m.group(2)) - 1, _col(m.group(3)), int(m.group(4)) - 1
|
|
2020
|
-
return [_num(celula(c, l, prof))
|
|
2021
|
-
for l in range(min(l1, l2), max(l1, l2) + 1)
|
|
2022
|
-
for c in range(min(c1, c2), max(c1, c2) + 1)]
|
|
2023
|
-
|
|
2024
|
-
def ref_simples(ref, prof):
|
|
2025
|
-
m = re.fullmatch(r"([A-Z]+)(\d+)", ref)
|
|
2026
|
-
return celula(_col(m.group(1)), int(m.group(2)) - 1, prof) if m else 0
|
|
2027
|
-
|
|
2028
|
-
def avaliar(expr, prof):
|
|
2029
|
-
def fn(m):
|
|
2030
|
-
nome = m.group(1)
|
|
2031
|
-
partes = [p.strip() for p in m.group(2).split(";")]
|
|
2032
|
-
|
|
2033
|
-
if nome in ("SOMASE", "CONTSE"):
|
|
2034
|
-
base = faixa(partes[0], prof) if re.fullmatch(r"[A-Z]+\d+:[A-Z]+\d+", partes[0]) else []
|
|
2035
|
-
passou = [x for x in base if _criterio(partes[1] if len(partes) > 1 else "")(x)]
|
|
2036
|
-
return repr(sum(passou) if nome == "SOMASE" else len(passou))
|
|
2037
|
-
|
|
2038
|
-
f = _FUNCOES.get(nome)
|
|
2039
|
-
if not f:
|
|
2040
|
-
return "0"
|
|
2041
|
-
vals = []
|
|
2042
|
-
for parte in partes:
|
|
2043
|
-
if re.fullmatch(r"[A-Z]+\d+:[A-Z]+\d+", parte):
|
|
2044
|
-
vals += faixa(parte, prof)
|
|
2045
|
-
elif parte:
|
|
2046
|
-
# qualquer outro argumento é uma expressão: resolve antes
|
|
2047
|
-
vals.append(_num(avaliar(parte, prof)))
|
|
2048
|
-
try:
|
|
2049
|
-
# MAIOR/MENOR estouram IndexError com k maior que o intervalo;
|
|
2050
|
-
# POT estoura OverflowError com base/expoente grandes demais.
|
|
2051
|
-
# Uma fórmula ruim vira "#ERRO" NESSA célula, não derruba a
|
|
2052
|
-
# exportação inteira (mesma regra do eval() logo abaixo).
|
|
2053
|
-
return repr(f(vals))
|
|
2054
|
-
except Exception:
|
|
2055
|
-
return "#ERRO"
|
|
2056
|
-
|
|
2057
|
-
# repete até esgotar: resolve de dentro para fora em fórmula aninhada
|
|
2058
|
-
e, voltas = expr, 0
|
|
2059
|
-
while re.search(r"[A-Z]+\(", e) and voltas < 12:
|
|
2060
|
-
e = re.sub(r"([A-Z]+)\(([^()]*)\)", fn, e)
|
|
2061
|
-
voltas += 1
|
|
2062
|
-
e = re.sub(r"\b[A-Z]+\d+\b", lambda m: repr(_num(ref_simples(m.group(0), prof))), e)
|
|
2063
|
-
# comparações são permitidas para o SE funcionar
|
|
2064
|
-
if not re.fullmatch(r"[-+*/().\d\s,<>=!]*", e):
|
|
2065
|
-
return "#ERRO"
|
|
2066
|
-
try:
|
|
2067
|
-
pronto = e.replace(",", ".").replace("<>", "!=")
|
|
2068
|
-
pronto = re.sub(r"([^<>!=])=(?!=)", r"\1==", pronto) or "0"
|
|
2069
|
-
r = eval(pronto, {"__builtins__": {}}, {})
|
|
2070
|
-
if isinstance(r, bool):
|
|
2071
|
-
return 1 if r else 0
|
|
2072
|
-
return round(r, 6) if isinstance(r, float) else r
|
|
2073
|
-
except Exception:
|
|
2074
|
-
return "#ERRO"
|
|
2075
|
-
|
|
2076
|
-
return [[celula(c, l, 0) for c in range(len(linhas[l]))] for l in range(len(linhas))]
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
def prosa_markdown(doc):
|
|
2080
|
-
fora = []
|
|
2081
|
-
capa = doc.get("capa")
|
|
2082
|
-
if capa:
|
|
2083
|
-
if capa.get("chapeu"):
|
|
2084
|
-
fora += ["*%s*" % capa["chapeu"], ""]
|
|
2085
|
-
if capa.get("titulo"):
|
|
2086
|
-
fora += ["# " + capa["titulo"], ""]
|
|
2087
|
-
if capa.get("apoio"):
|
|
2088
|
-
fora += [capa["apoio"], ""]
|
|
2089
|
-
assinatura = " · ".join(x for x in (capa.get("autor"), capa.get("data")) if x)
|
|
2090
|
-
if assinatura:
|
|
2091
|
-
fora += ["_%s_" % assinatura, ""]
|
|
2092
|
-
fora += ["---", ""]
|
|
2093
|
-
|
|
2094
|
-
for b in doc.get("blocos", []):
|
|
2095
|
-
t, texto = b.get("tipo"), b.get("texto", "")
|
|
2096
|
-
if t == "titulo":
|
|
2097
|
-
fora.append("# " + texto)
|
|
2098
|
-
elif t == "secao":
|
|
2099
|
-
fora.append("## " + texto)
|
|
2100
|
-
elif t == "subsecao":
|
|
2101
|
-
fora.append("### " + texto)
|
|
2102
|
-
elif t == "chapeu":
|
|
2103
|
-
fora.append("*" + texto + "*")
|
|
2104
|
-
elif t == "apoio":
|
|
2105
|
-
fora.append("_" + texto + "_")
|
|
2106
|
-
elif t == "destaque":
|
|
2107
|
-
fora.append("> **%s** " % b.get("titulo", "Nota"))
|
|
2108
|
-
fora.append("> " + texto)
|
|
2109
|
-
elif t == "sumario":
|
|
2110
|
-
fora.append("## " + (texto or "Sumário"))
|
|
2111
|
-
fora += ["%d. %s" % (i + 1, s.get("texto", ""))
|
|
2112
|
-
for i, s in enumerate([x for x in doc.get("blocos", []) if x.get("tipo") == "secao"])]
|
|
2113
|
-
elif t == "citacao":
|
|
2114
|
-
fora.append("> " + texto)
|
|
2115
|
-
if b.get("autor"):
|
|
2116
|
-
fora += [">", "> — " + b["autor"]]
|
|
2117
|
-
elif t == "lista":
|
|
2118
|
-
fora += ["- " + i for i in b.get("itens", [])]
|
|
2119
|
-
elif t == "numerada":
|
|
2120
|
-
fora += ["%d. %s" % (i + 1, v) for i, v in enumerate(b.get("itens", []))]
|
|
2121
|
-
elif t == "codigo":
|
|
2122
|
-
fora.append("```\n" + texto + "\n```")
|
|
2123
|
-
elif t == "divisor":
|
|
2124
|
-
fora.append("---")
|
|
2125
|
-
elif t == "tabela":
|
|
2126
|
-
linhas = b.get("linhas", [])
|
|
2127
|
-
if linhas:
|
|
2128
|
-
fora.append("| " + " | ".join(map(str, linhas[0])) + " |")
|
|
2129
|
-
fora.append("|" + "---|" * len(linhas[0]))
|
|
2130
|
-
for l in linhas[1:]:
|
|
2131
|
-
fora.append("| " + " | ".join(map(str, l)) + " |")
|
|
2132
|
-
else:
|
|
2133
|
-
fora.append(texto)
|
|
2134
|
-
fora.append("")
|
|
2135
|
-
return "\n".join(fora)
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
def embutir_midia(texto, limite=24 * 1024 * 1024):
|
|
2139
|
-
"""Troca /midia/x por data URI para o export abrir sem servidor nenhum."""
|
|
2140
|
-
def troca(m):
|
|
2141
|
-
nome = unquote(m.group(1))
|
|
2142
|
-
alvo = os.path.join(MIDIA, nome)
|
|
2143
|
-
if "/" in nome or ".." in nome or not os.path.isfile(alvo):
|
|
2144
|
-
return m.group(0)
|
|
2145
|
-
if os.path.getsize(alvo) > limite:
|
|
2146
|
-
return m.group(0) # grande demais: mantém o link
|
|
2147
|
-
tipo = mimetypes.guess_type(alvo)[0] or "application/octet-stream"
|
|
2148
|
-
with open(alvo, "rb") as fh:
|
|
2149
|
-
return "data:%s;base64,%s" % (tipo, base64.b64encode(fh.read()).decode())
|
|
2150
|
-
|
|
2151
|
-
return re.sub(r"/midia/([A-Za-z0-9._%\-]+)", troca, texto)
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
def html_autonomo(a):
|
|
2155
|
-
"""Arquivo único: CSS, renderizadores, áudio, mídia e documento embutidos."""
|
|
2156
|
-
def leia(*partes):
|
|
2157
|
-
with open(os.path.join(WEB, *partes), encoding="utf-8") as fh:
|
|
2158
|
-
return fh.read()
|
|
2159
|
-
|
|
2160
|
-
js = "\n".join(leia("js", n) for n in
|
|
2161
|
-
("icones.js", "audio.js", "sfx.js", "render.js", "render-prosa.js", "render-grade.js"))
|
|
2162
|
-
|
|
2163
|
-
dados = json.dumps(a, ensure_ascii=False).replace("</script", "<\\/script")
|
|
2164
|
-
|
|
2165
|
-
return """<!doctype html>
|
|
2166
|
-
<html lang="pt-BR"><head><meta charset="utf-8">
|
|
2167
|
-
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
2168
|
-
<title>%(titulo)s</title>
|
|
2169
|
-
<style>%(css)s</style></head>
|
|
2170
|
-
<body class="modo-ver">
|
|
2171
|
-
<div id="palco" class="palco"></div>
|
|
2172
|
-
<script id="dados" type="application/json">%(dados)s</script>
|
|
2173
|
-
<script>%(js)s</script>
|
|
2174
|
-
<script>%(player)s</script>
|
|
2175
|
-
</body></html>
|
|
2176
|
-
""" % {
|
|
2177
|
-
"titulo": (a.get("titulo") or "PrimoCode").replace("<", "<"),
|
|
2178
|
-
"css": leia("core.css") + leia("render.css"),
|
|
2179
|
-
"dados": embutir_midia(dados),
|
|
2180
|
-
"js": js,
|
|
2181
|
-
"player": leia("js", "player.js"),
|
|
2182
|
-
}
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
# -------------------------------------------------------------------- main --
|
|
2186
|
-
|
|
2187
|
-
def _abrir_log():
|
|
2188
|
-
"""Diagnóstico que sobrevive ao `stdio: 'ignore'` do CLI.
|
|
2189
|
-
|
|
2190
|
-
O CLI sobe este servidor descartando stdout/stderr — e era exatamente
|
|
2191
|
-
para lá que iam os erros de voz e o traceback de porta ocupada. Ninguém
|
|
2192
|
-
via nada: o Studio "não subia" em silêncio. Espelhar stdout/stderr num
|
|
2193
|
-
arquivo ao lado do código dá onde olhar. Rotação simples: passou de
|
|
2194
|
-
2 MB, o log atual vira `.old` e começa outro.
|
|
2195
|
-
"""
|
|
2196
|
-
try:
|
|
2197
|
-
destino = os.path.join(RAIZ, "studio.log")
|
|
2198
|
-
if os.path.exists(destino) and os.path.getsize(destino) > 2 * 1024 * 1024:
|
|
2199
|
-
os.replace(destino, destino + ".old")
|
|
2200
|
-
fh = open(destino, "a", encoding="utf-8", buffering=1)
|
|
2201
|
-
|
|
2202
|
-
class _Tee:
|
|
2203
|
-
def __init__(self, original):
|
|
2204
|
-
self.original = original
|
|
2205
|
-
def write(self, s):
|
|
2206
|
-
try:
|
|
2207
|
-
self.original.write(s)
|
|
2208
|
-
except Exception:
|
|
2209
|
-
pass
|
|
2210
|
-
try:
|
|
2211
|
-
fh.write(s)
|
|
2212
|
-
except Exception:
|
|
2213
|
-
pass
|
|
2214
|
-
def flush(self):
|
|
2215
|
-
for alvo in (self.original, fh):
|
|
2216
|
-
try:
|
|
2217
|
-
alvo.flush()
|
|
2218
|
-
except Exception:
|
|
2219
|
-
pass
|
|
2220
|
-
|
|
2221
|
-
sys.stdout = _Tee(sys.stdout)
|
|
2222
|
-
sys.stderr = _Tee(sys.stderr)
|
|
2223
|
-
print("[studio] %s — iniciando na porta %d" %
|
|
2224
|
-
(datetime.now(timezone.utc).isoformat(timespec="seconds"), PORTA))
|
|
2225
|
-
except Exception:
|
|
2226
|
-
pass # sem log ainda é melhor que sem servidor
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
if __name__ == "__main__":
|
|
2230
|
-
_abrir_log()
|
|
2231
|
-
url = "http://localhost:%d" % PORTA
|
|
2232
|
-
print("""
|
|
2233
|
-
██ PrimoCode Studio
|
|
2234
|
-
────────────────────────────────────────────────────────
|
|
2235
|
-
Estúdio %s
|
|
2236
|
-
Manual da IA %s/api/manual
|
|
2237
|
-
Ferramentas %s
|
|
2238
|
-
────────────────────────────────────────────────────────
|
|
2239
|
-
Ctrl+C para parar
|
|
2240
|
-
""" % (url, url, ", ".join(f["nome"] for f in FERRAMENTAS_INFO)))
|
|
2241
|
-
|
|
2242
|
-
try:
|
|
2243
|
-
ThreadingHTTPServer(("127.0.0.1", PORTA), Handler).serve_forever()
|
|
2244
|
-
except KeyboardInterrupt:
|
|
2245
|
-
print("\n Encerrado.\n")
|
|
2246
|
-
except OSError as e:
|
|
2247
|
-
# Porta ocupada saía como traceback — e com stdio descartado pelo
|
|
2248
|
-
# CLI, saía para lugar nenhum. Mensagem clara + código de saída
|
|
2249
|
-
# próprio: quem supervisiona sabe o que houve sem ler stack.
|
|
2250
|
-
if getattr(e, "errno", None) in (48, 98, 10048): # mac, linux, windows
|
|
2251
|
-
print("\n A porta %d já está em uso — outro Studio no ar?\n"
|
|
2252
|
-
" Feche-o (POST /api/sair) ou use PRIMOCODE_STUDIO_PORT.\n" % PORTA)
|
|
2253
|
-
sys.exit(2)
|
|
2254
|
-
raise
|