primocode 8.11.1 → 8.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/studio/server.py CHANGED
@@ -25,6 +25,37 @@ from urllib.parse import urlparse, unquote, urlencode
25
25
  import urllib.request
26
26
 
27
27
  RAIZ = os.path.dirname(os.path.abspath(__file__))
28
+ sys.path.insert(0, RAIZ)
29
+ try:
30
+ import voz as voz_lib # noqa: E402 (precisa da RAIZ no path antes)
31
+ except Exception as _e: # instalação parcial, ou voz.py de uma versão velha
32
+ # Narração é acabamento; o Studio inteiro não pode morrer por causa dela.
33
+ # Sem o módulo, a corrente começa direto no Google e tudo mais funciona.
34
+ sys.stderr.write("[voz] módulo indisponível (%s); narração vai só pelo Google\n" % _e)
35
+
36
+ class _SemVoz:
37
+ @staticmethod
38
+ def pronta(*a, **k):
39
+ return False
40
+
41
+ @staticmethod
42
+ def sintetizar(*a, **k):
43
+ return None
44
+
45
+ @staticmethod
46
+ def instalar(*a, **k):
47
+ return {"pronta": False, "erro": "módulo de voz indisponível"}
48
+
49
+ @staticmethod
50
+ def estado():
51
+ return {"pronta": False, "vozes": [], "erro": "módulo de voz indisponível"}
52
+
53
+ @staticmethod
54
+ def apagar():
55
+ return {"ok": True}
56
+
57
+ voz_lib = _SemVoz()
58
+
28
59
  WEB = os.path.join(RAIZ, "web")
29
60
  ARQUIVOS = os.path.join(RAIZ, "arquivos")
30
61
  FERRAMENTAS = os.path.join(RAIZ, "ferramentas")
@@ -32,13 +63,21 @@ MIDIA = os.path.join(RAIZ, "midia")
32
63
  PORTA = int(sys.argv[1]) if len(sys.argv) > 1 else 7777
33
64
 
34
65
  def _assinatura():
35
- """SHA-1 do próprio arquivo, lido NA SUBIDA. Se o npm trocar o código no
66
+ """SHA-1 do código do Studio, lido NA SUBIDA. Se o npm trocar o código no
36
67
  disco depois, o processo no ar continua respondendo a assinatura antiga —
37
68
  e é exatamente essa diferença que o CLI usa para saber que precisa
38
- reiniciar o Studio em vez de servir a versão velha para sempre."""
69
+ reiniciar o Studio em vez de servir a versão velha para sempre.
70
+
71
+ Entra todo módulo do Studio, não só este arquivo: uma correção que mexa
72
+ apenas na voz também precisa derrubar o processo velho."""
73
+ h = hashlib.sha1()
39
74
  try:
40
- with open(os.path.abspath(__file__), "rb") as fh:
41
- return hashlib.sha1(fh.read()).hexdigest()[:12]
75
+ for nome in ("server.py", "voz.py"):
76
+ caminho = os.path.join(RAIZ, nome)
77
+ if os.path.exists(caminho):
78
+ with open(caminho, "rb") as fh:
79
+ h.update(fh.read())
80
+ return h.hexdigest()[:12]
42
81
  except Exception:
43
82
  return "?"
44
83
 
@@ -149,6 +188,132 @@ def gravar(arq):
149
188
  return arq
150
189
 
151
190
 
191
+ # ------------------------------------------------------------------ versões --
192
+ #
193
+ # Um pedido de ajuste apagou o trabalho de um usuário: ele tinha um vídeo de
194
+ # oito cenas, com narração e trilha, pediu "aplique a identidade visual" e o
195
+ # agente mandou um PUT com doc={"tema":"..."}. O handler trocava o documento
196
+ # INTEIRO pelo que chegou — as oito cenas, a narração e a trilha viraram um
197
+ # documento de uma chave só. Não havia como voltar.
198
+ #
199
+ # Daí saíram duas defesas. `mesclar` faz o pedido parcial ser parcial de
200
+ # verdade, que é o conserto. Este histórico é o que sobra quando o conserto
201
+ # não basta: guarda o estado anterior a cada gravação, para que sobrescrever
202
+ # nunca mais seja definitivo. Nada aqui apaga o arquivo vivo; o pior caso é
203
+ # ocupar disco, e vinte versões de um documento de texto não dão um megabyte.
204
+
205
+ VERSOES = os.path.join(ARQUIVOS, ".versoes")
206
+ MAX_VERSOES = 20
207
+
208
+
209
+ _seq_versao = 0
210
+
211
+
212
+ def guardar_versao(arq):
213
+ """Copia o estado atual para .versoes/<id>/<carimbo>.json antes de trocar.
214
+
215
+ O carimbo é ordenável como texto, e por isso precisa de milissegundo: o
216
+ editor do navegador salva a cada 600 ms, e duas versões do mesmo segundo
217
+ desempatadas por sorteio fariam "desfaz a última mudança" desfazer a
218
+ penúltima. O contador no fim resolve o empate que sobra."""
219
+ global _seq_versao
220
+ try:
221
+ pasta = os.path.join(VERSOES, arq["id"])
222
+ os.makedirs(pasta, exist_ok=True)
223
+ _seq_versao = (_seq_versao + 1) % 10000
224
+ agora = datetime.now(timezone.utc)
225
+ carimbo = "%s%03d-%04d" % (agora.strftime("%Y%m%dT%H%M%S"),
226
+ agora.microsecond // 1000, _seq_versao)
227
+ with open(os.path.join(pasta, carimbo + ".json"), "w", encoding="utf-8") as fh:
228
+ json.dump(arq, fh, ensure_ascii=False)
229
+ antigas = sorted(n for n in os.listdir(pasta) if n.endswith(".json"))
230
+ for velha in antigas[:-MAX_VERSOES]:
231
+ os.remove(os.path.join(pasta, velha))
232
+ return carimbo
233
+ except Exception:
234
+ # Histórico é rede de proteção, não requisito: se o disco recusar, a
235
+ # gravação principal segue. Falhar aqui seria trocar perder o passado
236
+ # por perder o presente.
237
+ return None
238
+
239
+
240
+ def versoes(arq_id):
241
+ """As versões guardadas, da mais nova para a mais velha."""
242
+ if not caminho(arq_id):
243
+ return []
244
+ pasta = os.path.join(VERSOES, arq_id)
245
+ if not os.path.isdir(pasta):
246
+ return []
247
+ saida = []
248
+ for nome in sorted(os.listdir(pasta), reverse=True):
249
+ if not nome.endswith(".json"):
250
+ continue
251
+ try:
252
+ with open(os.path.join(pasta, nome), encoding="utf-8") as fh:
253
+ a = json.load(fh)
254
+ except Exception:
255
+ continue
256
+ doc = a.get("doc") or {}
257
+ saida.append({
258
+ "versao": nome[:-5],
259
+ "titulo": a.get("titulo", ""),
260
+ "pecas": len(doc.get("cenas") or doc.get("blocos") or doc.get("abas") or []),
261
+ "atualizado": a.get("atualizado", ""),
262
+ })
263
+ return saida
264
+
265
+
266
+ def ler_versao(arq_id, versao):
267
+ if not caminho(arq_id) or not re.fullmatch(r"[0-9A-Za-z\-]{1,40}", versao or ""):
268
+ return None
269
+ c = os.path.join(VERSOES, arq_id, versao + ".json")
270
+ if not os.path.exists(c):
271
+ return None
272
+ with open(c, encoding="utf-8") as fh:
273
+ return json.load(fh)
274
+
275
+
276
+ def _tem_narracao(doc):
277
+ if not isinstance(doc, dict):
278
+ return False
279
+ if isinstance(doc.get("narracao"), str) and doc["narracao"].strip():
280
+ return True
281
+ for c in doc.get("cenas") or []:
282
+ if isinstance(c, dict) and isinstance(c.get("narracao"), str) and c["narracao"].strip():
283
+ return True
284
+ return False
285
+
286
+
287
+ def preparar_voz(doc):
288
+ """Documento com narração chegou: começa a baixar a voz agora.
289
+
290
+ O download é de ~90 MB e leva algum tempo. Fazê-lo quando o usuário
291
+ aperta play seria tarde — ele ouviria a voz robótica do Google. Fazê-lo
292
+ aqui, no instante em que o roteiro é gravado, dá ao download a mesma
293
+ janela que o agente gasta escrevendo o resto: quando alguém toca o vídeo,
294
+ a voz boa já está na máquina. Se não der tempo, a corrente cobre."""
295
+ if _tem_narracao(doc) and not voz_lib.pronta():
296
+ try:
297
+ voz_lib.instalar()
298
+ except Exception:
299
+ pass
300
+
301
+
302
+ def mesclar(antigo, novo):
303
+ """O documento novo por cima do antigo, chave a chave.
304
+
305
+ Quem só quer trocar o tema manda {"tema":"papel"} — e é a coisa natural a
306
+ mandar, porque o pedido era esse. Trocar o documento inteiro por isso
307
+ apagava cenas, narração e trilha. Mesclando, o que não foi citado fica
308
+ onde estava e o que foi citado vale: mandar cenas novas ainda troca as
309
+ cenas, que é o ajuste de verdade."""
310
+ if not isinstance(antigo, dict) or not isinstance(novo, dict):
311
+ return novo
312
+ saida = dict(antigo)
313
+ saida.update(novo)
314
+ return saida
315
+
316
+
152
317
  def listar():
153
318
  saida = []
154
319
  for nome in os.listdir(ARQUIVOS):
@@ -269,6 +434,7 @@ def criar(dados):
269
434
  if problema:
270
435
  raise ValueError(problema)
271
436
 
437
+ preparar_voz(doc)
272
438
  arq_id = ferramenta + "-" + slugify(titulo) + "-" + uuid.uuid4().hex[:5]
273
439
  return gravar({
274
440
  "id": arq_id,
@@ -311,6 +477,22 @@ def _texto_do_doc(doc):
311
477
  return " ".join(pedacos).lower()
312
478
 
313
479
 
480
+ # Os temas que o render.css de fato define. Fora desta lista não existe cor
481
+ # nenhuma — foi o que aconteceu com "noite", que passou como se fosse tema.
482
+ TEMAS_VALIDOS = {"claro", "meia-noite", "papel", "neon", "brasa", "floresta",
483
+ "retro", "oceano", "doce", "mono"}
484
+
485
+
486
+ def _tema_invalido(tema, onde=""):
487
+ if tema is None:
488
+ return None
489
+ if isinstance(tema, str) and tema in TEMAS_VALIDOS:
490
+ return None
491
+ return ('tema %r%s não existe — a peça sairia sem cor nenhuma. Os temas são: %s. '
492
+ 'Para escuro use "meia-noite".'
493
+ % (tema, onde, ", ".join(sorted(TEMAS_VALIDOS))))
494
+
495
+
314
496
  def validar_doc(modelo, doc):
315
497
  """None quando o doc serve; senão, a frase que ENSINA o formato certo.
316
498
 
@@ -332,6 +514,13 @@ def validar_doc(modelo, doc):
332
514
  TIPOS_VALIDOS = {"texto", "lista", "cartoes", "numeros", "tabela", "codigo",
333
515
  "citacao", "forma", "imagem", "icone", "grafico", "no", "conector"}
334
516
 
517
+ # Tema inventado tinha o mesmo destino silencioso do tipo inventado: o CSS
518
+ # não casa com [data-tema="noite"], nenhuma cor é definida e a peça sai
519
+ # sem identidade nenhuma. Recusar aqui devolve a lista certa ao agente.
520
+ problema = _tema_invalido(doc.get("tema"))
521
+ if problema:
522
+ return problema
523
+
335
524
  if modelo == "cena":
336
525
  cenas = doc.get("cenas")
337
526
  if not isinstance(cenas, list) or not cenas:
@@ -345,6 +534,9 @@ def validar_doc(modelo, doc):
345
534
  if not isinstance(els, list) or not els:
346
535
  return ('cena %d sem "elementos". Cada cena precisa de elementos:[...] — '
347
536
  'ex.: {"tipo":"texto","texto":"..."}. Leia studio_spec e refaça.' % (i + 1))
537
+ problema = _tema_invalido(c.get("tema"), " (cena %d)" % (i + 1))
538
+ if problema:
539
+ return problema
348
540
  dur = c.get("duracao")
349
541
  if dur is not None and not isinstance(dur, (int, float)):
350
542
  return ('cena %d com "duracao" que não é número (veio %r). Use número de segundos: '
@@ -600,6 +792,12 @@ class Handler(SimpleHTTPRequestHandler):
600
792
  a = ler(m.group(1))
601
793
  return self.js(a) if a else self.js({"erro": "não encontrado"}, 404)
602
794
 
795
+ m = re.fullmatch(r"/api/arquivos/([^/]+)/versoes", p)
796
+ if m:
797
+ if not ler(m.group(1)):
798
+ return self.js({"erro": "não encontrado"}, 404)
799
+ return self.js({"versoes": versoes(m.group(1))})
800
+
603
801
  m = re.fullmatch(r"/api/arquivos/([^/]+)/export", p)
604
802
  if m:
605
803
  a = ler(m.group(1))
@@ -610,17 +808,25 @@ class Handler(SimpleHTTPRequestHandler):
610
808
  return super().do_GET()
611
809
 
612
810
  # ── Voz ────────────────────────────────────────────────────────────
613
- # Ponte para o TTS do Google. Existe porque o Google não manda cabeçalho
614
- # de CORS: do navegador direto o pedido morre, daqui funciona.
811
+ # A narração inteira é decidida AQUI, no Python, e não no navegador.
615
812
  #
616
- # É a rede de segurança da narração. O caminho bom é a voz neural do Edge,
617
- # que fala direto do navegador por WebSocket (sem CORS) e tem voz grave de
618
- # verdade para vídeo; quando ela não sobe, cai aqui — uma voz só, sem
619
- # escolha, mas que responde sempre.
813
+ # Antes o navegador tentava a voz neural do Edge por WebSocket e, quando
814
+ # ela não subia, caía no speechSynthesis — o TTS da própria máquina, que
815
+ # soa a computador. A voz do Edge parou de subir (a Microsoft responde
816
+ # 403 na síntese), então na prática TODO vídeo saía com voz de robô. Era
817
+ # o que o usuário estava ouvindo.
620
818
  #
621
- # O endpoint recusa texto comprido, então a frase é cortada em pedaços de
622
- # até 190 caracteres, sempre numa fronteira de palavra, e os MP3 são
623
- # emendados. MP3 aceita concatenação de quadros: é feio, mas toca.
819
+ # A corrente agora é esta, e o navegador só consome o resultado:
820
+ # 1. PIPER neural, roda na máquina, offline, ilimitado, com escolha
821
+ # de voz — inclusive graves de verdade (voz.py mede e diz
822
+ # a frequência de cada uma). É o bom.
823
+ # 2. VOICEBOX se o usuário tiver o app: voz clonável.
824
+ # 3. GOOGLE uma voz só, robótica, mas responde na hora sem instalar
825
+ # nada — é o que cobre enquanto o Piper baixa.
826
+ #
827
+ # O endpoint do Google recusa texto comprido, então a frase é cortada em
828
+ # pedaços de até 190 caracteres, sempre numa fronteira de palavra, e os
829
+ # MP3 são emendados. MP3 aceita concatenação de quadros: é feio, mas toca.
624
830
  def voz(self, query):
625
831
  from urllib.parse import parse_qs
626
832
  q = parse_qs(query or "")
@@ -631,22 +837,45 @@ class Handler(SimpleHTTPRequestHandler):
631
837
  if len(texto) > 4000:
632
838
  texto = texto[:4000]
633
839
 
634
- voz = (q.get("voz", [""])[0] or "").strip()
840
+ voz_pedida = (q.get("voz", [""])[0] or "").strip()
635
841
  instrucao = (q.get("instrucao", [""])[0] or "").strip()
636
842
  motor = (q.get("motor", [""])[0] or "").strip()
843
+ grave = (q.get("grave", [""])[0] or "").lower() in ("1", "true", "sim")
844
+ try:
845
+ velocidade = float(q.get("velocidade", ["1"])[0])
846
+ except ValueError:
847
+ velocidade = 1.0
637
848
 
638
849
  tipo = "audio/mpeg"
639
850
  audio = b""
640
- # 1) Voicebox, se o usuário tiver instalado: voz clonável, ilimitada e
851
+
852
+ # 1) Piper: o motor bom. Se ainda não baixou, começa a baixar agora e
853
+ # segue para o próximo — quem pediu narração hoje ouve o Google, e
854
+ # a partir da próxima já é voz de verdade.
855
+ if motor not in ("google", "voicebox"):
856
+ try:
857
+ if voz_lib.pronta(voz_pedida or None):
858
+ audio = voz_lib.sintetizar(texto, voz_pedida or None,
859
+ grave=grave, velocidade=velocidade) or b""
860
+ if audio:
861
+ tipo = "audio/wav"
862
+ else:
863
+ voz_lib.instalar(voz_pedida or None)
864
+ except Exception as e:
865
+ sys.stderr.write("[voz] Piper falhou (%s); seguindo a corrente\n" % e)
866
+ audio = b""
867
+
868
+ # 2) Voicebox, se o usuário tiver instalado: voz clonável, ilimitada e
641
869
  # sem depender de nada na internet.
642
- if motor != "google" and self._voicebox_vivo():
870
+ if not audio and motor != "google" and self._voicebox_vivo():
643
871
  try:
644
- audio, tipo = self._voz_voicebox(texto, voz, instrucao)
872
+ audio, tipo = self._voz_voicebox(texto, voz_pedida, instrucao)
645
873
  except Exception as e:
646
874
  sys.stderr.write("[voz] Voicebox falhou (%s); indo de Google\n" % e)
647
875
  audio = b""
648
- # 2) Google: uma voz só, mas responde sempre.
876
+ # 3) Google: uma voz só, mas responde sempre.
649
877
  if not audio:
878
+ tipo = "audio/mpeg"
650
879
  try:
651
880
  audio = b"".join(self._trecho_de_voz(t, idioma) for t in _picar(texto, 190))
652
881
  except Exception as e: # rede, bloqueio, mudança do serviço
@@ -675,7 +904,8 @@ class Handler(SimpleHTTPRequestHandler):
675
904
 
676
905
  def voz_status(self):
677
906
  """O que está disponível para narrar, para a interface não oferecer o
678
- que não existe."""
907
+ que não existe — e para ela poder mostrar o download em vez de fingir
908
+ que já está tudo pronto."""
679
909
  vivo = self._voicebox_vivo()
680
910
  vozes = []
681
911
  if vivo:
@@ -686,6 +916,7 @@ class Handler(SimpleHTTPRequestHandler):
686
916
  except Exception:
687
917
  vozes = []
688
918
  return self.js({
919
+ "piper": voz_lib.estado(),
689
920
  "voicebox": {"ativo": vivo, "vozes": vozes, "site": "https://voicebox.sh"},
690
921
  "google": {"ativo": True},
691
922
  })
@@ -828,6 +1059,13 @@ class Handler(SimpleHTTPRequestHandler):
828
1059
  corpo, status = guardar_midia(self.corpo())
829
1060
  return self.js(corpo, status)
830
1061
 
1062
+ if rota == "/api/voz/instalar":
1063
+ pedido = self.corpo()
1064
+ if pedido.get("apagar"):
1065
+ return self.js(voz_lib.apagar())
1066
+ return self.js(voz_lib.instalar(pedido.get("voz"),
1067
+ esperar=min(60, int(pedido.get("esperar") or 0))))
1068
+
831
1069
  if rota == "/api/sair":
832
1070
  # Só o CLI chama, e só quando o código no disco mudou (update do
833
1071
  # npm). Sem isto o processo antigo ficava no ar servindo o bug que
@@ -838,6 +1076,32 @@ class Handler(SimpleHTTPRequestHandler):
838
1076
  threading.Thread(target=lambda: (time.sleep(0.3), os._exit(0)), daemon=True).start()
839
1077
  return
840
1078
 
1079
+ m = re.fullmatch(r"/api/arquivos/([^/]+)/restaurar", rota)
1080
+ if m:
1081
+ a = ler(m.group(1))
1082
+ if not a:
1083
+ return self.js({"erro": "não encontrado"}, 404)
1084
+ pedido = self.corpo()
1085
+ disponiveis = versoes(m.group(1))
1086
+ # Sem versão dita, a anterior — que é o que "desfaz" quer dizer
1087
+ # quando alguém acabou de estragar o arquivo.
1088
+ alvo = pedido.get("versao") or (disponiveis[0]["versao"] if disponiveis else None)
1089
+ velha = ler_versao(m.group(1), alvo) if alvo else None
1090
+ if not velha:
1091
+ return self.js({"erro": "versão não encontrada",
1092
+ "versoes": disponiveis}, 404)
1093
+ # Restaurar também guarda versão: desfazer o desfazer tem de valer.
1094
+ guardar_versao(a)
1095
+ a["titulo"] = velha.get("titulo", a.get("titulo"))
1096
+ a["doc"] = velha.get("doc") or a.get("doc")
1097
+ gravar(a)
1098
+ doc = a.get("doc") or {}
1099
+ return self.js({
1100
+ "ok": True, "id": a["id"], "restaurado": alvo,
1101
+ "titulo": a.get("titulo"),
1102
+ "pecas": len(doc.get("cenas") or doc.get("blocos") or doc.get("abas") or []),
1103
+ })
1104
+
841
1105
  if rota == "/api/arquivos":
842
1106
  try:
843
1107
  a = criar(self.corpo())
@@ -873,10 +1137,37 @@ class Handler(SimpleHTTPRequestHandler):
873
1137
  return self.js({"erro": "titulo precisa ser texto"}, 400)
874
1138
  if "doc" in dados and dados["doc"] is not None and not isinstance(dados["doc"], dict):
875
1139
  return self.js({"erro": "doc precisa ser um objeto"}, 400)
876
- for chave in ("titulo", "doc"):
877
- if chave in dados:
878
- a[chave] = dados[chave]
879
- return self.js(gravar(a))
1140
+
1141
+ antes = a.get("doc") or {}
1142
+ novo = a.get("doc")
1143
+ if "doc" in dados and dados["doc"] is not None:
1144
+ # Mesclar em vez de trocar. Era aqui que o trabalho sumia: um doc
1145
+ # parcial substituía o inteiro. Quem quiser mesmo jogar fora o que
1146
+ # existe manda "substituir": true e assume.
1147
+ novo = dados["doc"] if dados.get("substituir") else mesclar(antes, dados["doc"])
1148
+
1149
+ # O agente precisa da validação: é a única forma de ele saber que
1150
+ # errou o formato. O editor do navegador, não — ali há uma pessoa
1151
+ # digitando, e uma cena momentaneamente vazia no meio da edição não
1152
+ # pode fazer o salvamento falhar calado.
1153
+ if self.headers.get("X-Primo-Agente"):
1154
+ problema = validar_doc(
1155
+ POR_SLUG.get(a.get("ferramenta"), {}).get("modelo"), novo)
1156
+ if problema:
1157
+ return self.js({"erro": problema}, 400)
1158
+
1159
+ preparar_voz(novo)
1160
+ versao = guardar_versao(a)
1161
+ if "titulo" in dados:
1162
+ a["titulo"] = dados["titulo"]
1163
+ if "doc" in dados:
1164
+ a["doc"] = novo
1165
+ salvo = gravar(a)
1166
+ if versao:
1167
+ # O agente vê isto no resultado da tool: se ele estragou algo, o
1168
+ # caminho de volta está na resposta, não numa doc que ele não leu.
1169
+ salvo = dict(salvo, versao_anterior=versao)
1170
+ return self.js(salvo)
880
1171
 
881
1172
  @seguro
882
1173
  def do_DELETE(self):