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.
Files changed (104) hide show
  1. package/README.md +41 -37
  2. package/bin/primocode.js +598 -360
  3. package/lib/acervo.js +8 -1
  4. package/lib/act.js +205 -114
  5. package/lib/api.js +35 -4
  6. package/lib/boas-vindas.js +42 -195
  7. package/lib/catalogo.js +100 -61
  8. package/lib/config.js +1 -1
  9. package/lib/conta.js +3 -24
  10. package/lib/diff.js +161 -0
  11. package/lib/diretor.js +37 -47
  12. package/lib/entrada.js +576 -0
  13. package/lib/especialistas.js +2 -2
  14. package/lib/estudio/assets.js +343 -0
  15. package/lib/estudio/identidade.js +560 -0
  16. package/lib/estudio/index.js +565 -0
  17. package/lib/estudio/plano.js +327 -0
  18. package/lib/estudio/projeto.js +246 -0
  19. package/lib/estudio/remotion.js +644 -0
  20. package/lib/estudio/slides.js +190 -0
  21. package/lib/estudio/video.js +296 -0
  22. package/lib/fala.js +49 -6
  23. package/lib/marca.js +4 -1
  24. package/lib/markdown.js +376 -0
  25. package/lib/marketplace.js +189 -0
  26. package/lib/pasta.js +15 -4
  27. package/lib/permissao.js +137 -0
  28. package/lib/registro.js +229 -0
  29. package/lib/skills.js +329 -0
  30. package/lib/tools.js +106 -187
  31. package/lib/ui.js +7 -3
  32. package/lib/voz.js +239 -0
  33. package/package.json +12 -3
  34. package/skills/acessibilidade/SKILL.md +28 -0
  35. package/skills/api-que-nao-quebra/SKILL.md +27 -0
  36. package/skills/depurar-de-verdade/SKILL.md +29 -0
  37. package/skills/editar-meu-video/SKILL.md +59 -0
  38. package/skills/escrever-em-portugues/SKILL.md +28 -0
  39. package/skills/git-limpo/SKILL.md +28 -0
  40. package/skills/motion-de-primeira/SKILL.md +62 -0
  41. package/skills/pagina-que-converte/SKILL.md +28 -0
  42. package/skills/performance-web/SKILL.md +28 -0
  43. package/skills/revisao-antes-de-entregar/SKILL.md +27 -0
  44. package/skills/slides-que-prendem/SKILL.md +40 -0
  45. package/studio/edge.py +16 -66
  46. package/studio/narrar.py +274 -0
  47. package/studio/pessoa.py +199 -0
  48. package/voz/primo_voz/__init__.py +0 -0
  49. package/voz/primo_voz/__main__.py +383 -0
  50. package/voz/primo_voz/escuta.py +235 -0
  51. package/voz/primo_voz/orbe.py +216 -0
  52. package/lib/audio-livre.js +0 -177
  53. package/lib/chaves.js +0 -149
  54. package/lib/claude-engine.js +0 -631
  55. package/lib/codex-engine.js +0 -208
  56. package/lib/remotion.js +0 -663
  57. package/lib/studio.js +0 -960
  58. package/studio/PRIMOCODE.md +0 -58
  59. package/studio/README.md +0 -154
  60. package/studio/__pycache__/edge.cpython-311.pyc +0 -0
  61. package/studio/__pycache__/elevenlabs.cpython-311.pyc +0 -0
  62. package/studio/__pycache__/voz.cpython-311.pyc +0 -0
  63. package/studio/arquivos/corte-teste-de-camera-fb823.json +0 -57
  64. package/studio/ferramentas/00-protocolo.md +0 -125
  65. package/studio/ferramentas/01-modelos.md +0 -270
  66. package/studio/ferramentas/README.md +0 -32
  67. package/studio/ferramentas/corte.md +0 -919
  68. package/studio/ferramentas/grade.md +0 -92
  69. package/studio/ferramentas/prisma.md +0 -107
  70. package/studio/ferramentas/prosa.md +0 -86
  71. package/studio/ferramentas/tela.md +0 -93
  72. package/studio/ferramentas/traco.md +0 -79
  73. package/studio/server.py +0 -2254
  74. package/studio/studio.log +0 -57
  75. package/studio/voz.py +0 -388
  76. package/studio/web/analise.html +0 -52
  77. package/studio/web/core.css +0 -1167
  78. package/studio/web/fontes/bebas.woff2 +0 -0
  79. package/studio/web/fontes/grotesk-700.woff2 +0 -0
  80. package/studio/web/fontes/inter-400.woff2 +0 -0
  81. package/studio/web/fontes/inter-700.woff2 +0 -0
  82. package/studio/web/fontes/jetbrains-400.woff2 +0 -0
  83. package/studio/web/fontes/playfair-700.woff2 +0 -0
  84. package/studio/web/fontes.css +0 -23
  85. package/studio/web/home.html +0 -217
  86. package/studio/web/js/analise.js +0 -510
  87. package/studio/web/js/audio.js +0 -607
  88. package/studio/web/js/editor.js +0 -1802
  89. package/studio/web/js/exportar.js +0 -222
  90. package/studio/web/js/ferramentas.js +0 -108
  91. package/studio/web/js/icones.js +0 -120
  92. package/studio/web/js/midia.js +0 -157
  93. package/studio/web/js/narracao.js +0 -325
  94. package/studio/web/js/player.js +0 -903
  95. package/studio/web/js/render-grade.js +0 -243
  96. package/studio/web/js/render-prosa.js +0 -104
  97. package/studio/web/js/render.js +0 -1852
  98. package/studio/web/js/sfx.js +0 -233
  99. package/studio/web/js/video.js +0 -464
  100. package/studio/web/js/voz-neural.js +0 -152
  101. package/studio/web/render.css +0 -1352
  102. package/studio/web/tf.html +0 -15
  103. package/studio/web/tool.html +0 -78
  104. 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("<", "&lt;"),
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