primocode 8.22.1 → 8.25.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 (35) hide show
  1. package/lib/catalogo.js +2 -0
  2. package/lib/config.js +53 -2
  3. package/lib/studio.js +125 -11
  4. package/lib/tools.js +35 -1
  5. package/package.json +2 -2
  6. package/studio/__pycache__/voz.cpython-311.pyc +0 -0
  7. package/studio/arquivos/corte-estatico-corte-a5bab.json +150 -0
  8. package/studio/arquivos/corte-founders-ai-teste-do-player-2de1d.json +65 -0
  9. package/studio/arquivos/corte-legado-teste.json +1 -0
  10. package/studio/arquivos/corte-legado2.json +1 -0
  11. package/studio/arquivos/corte-minimo-2a829.json +24 -0
  12. package/studio/arquivos/corte-ok-00a3b.json +18 -0
  13. package/studio/arquivos/corte-ok-1270e.json +22 -0
  14. package/studio/arquivos/corte-sweep-corte-cfb29.json +147 -0
  15. package/studio/arquivos/corte-visual-completo-78f50.json +35 -0
  16. package/studio/arquivos/grade-sweep-grade-0c2f1.json +119 -0
  17. package/studio/arquivos/prisma-estatico-prisma-d8740.json +182 -0
  18. package/studio/arquivos/prisma-sweep-prisma-4c58c.json +166 -0
  19. package/studio/arquivos/prisma-y-b0785.json +52 -0
  20. package/studio/arquivos/prosa-sweep-prosa-f29cf.json +80 -0
  21. package/studio/arquivos/tela-estatico-tela-c7daf.json +90 -0
  22. package/studio/arquivos/tela-sweep-tela-0d2bf.json +84 -0
  23. package/studio/arquivos/traco-estatico-traco-25872.json +113 -0
  24. package/studio/arquivos/traco-sweep-traco-0bcf4.json +102 -0
  25. package/studio/ferramentas/corte.md +159 -12
  26. package/studio/server.py +264 -8
  27. package/studio/web/analise.html +52 -0
  28. package/studio/web/js/analise.js +202 -0
  29. package/studio/web/js/exportar.js +88 -1
  30. package/studio/web/js/player.js +172 -46
  31. package/studio/web/js/render.js +99 -1
  32. package/studio/web/js/video.js +434 -0
  33. package/studio/web/render.css +27 -0
  34. package/studio/web/tool.html +1 -0
  35. package/studio/web/view.html +2 -0
package/studio/server.py CHANGED
@@ -140,6 +140,10 @@ ASSINATURA = _assinatura()
140
140
 
141
141
  # 60 MB por arquivo: cobre faixa longa em mp3 e foto de câmera sem susto
142
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
143
147
 
144
148
  os.makedirs(ARQUIVOS, exist_ok=True)
145
149
  os.makedirs(MIDIA, exist_ok=True)
@@ -628,6 +632,60 @@ def listar_midia():
628
632
  return sorted(fora, key=lambda m: m["nome"])
629
633
 
630
634
 
635
+ def _nome_livre(base, ext):
636
+ nome = base + ext
637
+ if os.path.exists(os.path.join(MIDIA, nome)):
638
+ nome = base + "-" + uuid.uuid4().hex[:4] + ext
639
+ return nome
640
+
641
+
642
+ def guardar_midia_bruta(nome_pedido, fluxo, tamanho):
643
+ """Grava o arquivo lendo o corpo direto do socket, sem passar por base64.
644
+
645
+ Um vídeo gravado no celular tem centenas de MB. Em base64 ele cresce um
646
+ terço, vira uma linha só de JSON e precisa caber INTEIRO na memória três
647
+ vezes — no texto, no decodificado e no que o json guarda. O estúdio
648
+ engasgava antes de gravar um byte, e o dono só via "falhou".
649
+
650
+ Aqui o corpo desce em pedaços de 1 MB direto para o disco: o consumo não
651
+ depende do tamanho do arquivo.
652
+ """
653
+ base, ext = nome_seguro(nome_pedido)
654
+ if ext not in TIPOS_OK:
655
+ return {"erro": "formato não aceito", "aceitos": sorted(TIPOS_OK)}, 400
656
+ if tamanho <= 0:
657
+ return {"erro": "corpo vazio"}, 400
658
+ limite = LIMITE_VIDEO if TIPOS_OK[ext] == "video" else LIMITE_MIDIA
659
+ if tamanho > limite:
660
+ return {"erro": "arquivo acima de %d MB" % (limite // 1048576)}, 413
661
+
662
+ nome = _nome_livre(base, ext)
663
+ destino = os.path.join(MIDIA, nome)
664
+ escrito = 0
665
+ try:
666
+ with open(destino, "wb") as fh:
667
+ while escrito < tamanho:
668
+ pedaco = fluxo.read(min(1048576, tamanho - escrito))
669
+ if not pedaco:
670
+ break
671
+ fh.write(pedaco)
672
+ escrito += len(pedaco)
673
+ except Exception as erro:
674
+ try: os.remove(destino)
675
+ except OSError: pass
676
+ return {"erro": "não consegui gravar: %s" % erro}, 500
677
+
678
+ if escrito != tamanho:
679
+ # Meio arquivo é pior que nenhum: o <video> abre, mostra o começo e
680
+ # corta no meio da frase, e nada denuncia de onde veio.
681
+ try: os.remove(destino)
682
+ except OSError: pass
683
+ return {"erro": "a transferência parou em %d de %d bytes" % (escrito, tamanho)}, 400
684
+
685
+ return {"nome": nome, "url": "/midia/" + nome,
686
+ "tipo": TIPOS_OK[ext], "bytes": escrito}, 201
687
+
688
+
631
689
  def guardar_midia(dados):
632
690
  """Recebe {nome, dados:"data:...;base64,..."} e grava em midia/."""
633
691
  bruto = dados.get("dados") or ""
@@ -647,12 +705,11 @@ def guardar_midia(dados):
647
705
  except Exception:
648
706
  return {"erro": "base64 inválido"}, 400
649
707
 
650
- if len(conteudo) > LIMITE_MIDIA:
651
- return {"erro": "arquivo acima de %d MB" % (LIMITE_MIDIA // 1048576)}, 413
708
+ limite = LIMITE_VIDEO if TIPOS_OK[ext] == "video" else LIMITE_MIDIA
709
+ if len(conteudo) > limite:
710
+ return {"erro": "arquivo acima de %d MB" % (limite // 1048576)}, 413
652
711
 
653
- nome = base + ext
654
- if os.path.exists(os.path.join(MIDIA, nome)):
655
- nome = base + "-" + uuid.uuid4().hex[:4] + ext
712
+ nome = _nome_livre(base, ext)
656
713
  with open(os.path.join(MIDIA, nome), "wb") as fh:
657
714
  fh.write(conteudo)
658
715
 
@@ -660,6 +717,132 @@ def guardar_midia(dados):
660
717
  "tipo": TIPOS_OK[ext], "bytes": len(conteudo)}, 201
661
718
 
662
719
 
720
+ # ------------------------------------------------------------ análise de áudio --
721
+ #
722
+ # "Ele vai cortar as pausas demoradas no vídeo."
723
+ #
724
+ # Para cortar as pausas é preciso SABER onde elas estão, e ninguém aqui sabe
725
+ # ler o áudio de um .mp4: o PrimoCode não tem dependência nenhuma, e o Python
726
+ # da máquina do usuário lê WAV e mais nada. ffmpeg resolveria — e seria mais
727
+ # uma instalação de 80 MB para quem só quer editar um vídeo.
728
+ #
729
+ # Quem sabe decodificar MP4, MOV e webm já está aberto na mesa: o navegador.
730
+ # `decodeAudioData` faz isso desde sempre, em qualquer sistema, sem instalar
731
+ # coisa alguma. Então a conta roda lá, e o servidor só segura o pedido e a
732
+ # resposta enquanto isso — é um quadro de recados entre o agente e a aba.
733
+ #
734
+ # Fica na memória de propósito: é resultado de trabalho, não trabalho. Se o
735
+ # estúdio reiniciar, pedir de novo custa segundos.
736
+ ANALISES = {}
737
+ LIMITE_ANALISES = 40
738
+
739
+
740
+ def criar_analise(dados):
741
+ arquivo = (dados or {}).get("arquivo") or ""
742
+ if not arquivo.startswith("/midia/"):
743
+ return {"erro": 'passe "arquivo" como a URL que /api/midia devolveu (ex.: "/midia/x.mp4")'}, 400
744
+ nome = unquote(arquivo[len("/midia/"):])
745
+ if nome != os.path.basename(nome) or not os.path.exists(os.path.join(MIDIA, nome)):
746
+ return {"erro": "não achei %s em midia/" % arquivo}, 404
747
+
748
+ # Podar antes de inserir: sem isto uma sessão longa acumula análises que
749
+ # ninguém mais vai ler.
750
+ if len(ANALISES) >= LIMITE_ANALISES:
751
+ for velho in sorted(ANALISES, key=lambda k: ANALISES[k]["criado"])[:len(ANALISES) - LIMITE_ANALISES + 1]:
752
+ ANALISES.pop(velho, None)
753
+
754
+ ident = uuid.uuid4().hex[:10]
755
+ ANALISES[ident] = {
756
+ "id": ident, "arquivo": arquivo, "estado": "pedido",
757
+ "silencio": float((dados or {}).get("silencio") or 0.45),
758
+ "pausa": float((dados or {}).get("pausa") or 0.6),
759
+ "criado": time.time(), "resultado": None,
760
+ }
761
+ return {"id": ident, "pagina": "/analise#" + ident, "estado": "pedido"}, 201
762
+
763
+
764
+ def gravar_analise(ident, dados):
765
+ tarefa = ANALISES.get(ident)
766
+ if not tarefa:
767
+ return {"erro": "análise não encontrada"}, 404
768
+ if (dados or {}).get("erro"):
769
+ tarefa["estado"] = "erro"
770
+ tarefa["resultado"] = {"erro": str(dados["erro"])[:300]}
771
+ else:
772
+ tarefa["estado"] = "pronto"
773
+ tarefa["resultado"] = {
774
+ "duracao": dados.get("duracao"),
775
+ "trechos": dados.get("trechos") or [],
776
+ "pausas": dados.get("pausas") or [],
777
+ "cortado": dados.get("cortado"),
778
+ }
779
+ return {"ok": True, "estado": tarefa["estado"]}, 200
780
+
781
+
782
+ # ------------------------------------------------------- revisão de monotonia --
783
+ #
784
+ # "Ele não alterna entre os temas, usa um tema só pra todos os vídeos, e o
785
+ # vídeo sempre tem a mesma estrutura. Fica tudo genérico."
786
+ #
787
+ # O estúdio tem dez temas, onze fundos, oito transições e seis trilhas. O
788
+ # agente usa uma fatia estreita e repete — porque a spec tem UM exemplo
789
+ # completo e modelo ancora em exemplo. Conselho escrito na spec ajuda pouco
790
+ # quando ela nem sempre é relida.
791
+ #
792
+ # Então quem revisa é o próprio estúdio, na hora de gravar. Isto NÃO recusa
793
+ # nada: um vídeo monótono continua sendo um vídeo, e recusar conteúdo válido
794
+ # seria pior que o problema. O que ele faz é devolver o que dá para melhorar,
795
+ # em texto que o agente lê como resultado da tool — do mesmo jeito que
796
+ # validar_doc ensina o formato quando o tipo está errado.
797
+ def revisar_monotonia(doc, ferramenta):
798
+ if ferramenta not in ("corte", "prisma") or not isinstance(doc, dict):
799
+ return []
800
+ cenas = [c for c in (doc.get("cenas") or []) if isinstance(c, dict)]
801
+ if len(cenas) < 3:
802
+ return [] # peça curta demais para julgar
803
+
804
+ avisos = []
805
+
806
+ fundos = [c.get("fundo") for c in cenas]
807
+ distintos = {f if isinstance(f, str) else "?" for f in fundos if f}
808
+ if len(distintos) <= 1:
809
+ avisos.append(
810
+ "todas as %d cenas usam o mesmo fundo%s. São onze: malha, brilho, grade, "
811
+ "pontos, suave, vinheta, aurora, nevoa, ondas, raios, xadrez. Fundo igual "
812
+ "em tudo é o que faz o vídeo parecer template."
813
+ % (len(cenas), " (" + list(distintos)[0] + ")" if distintos else " — nenhum"))
814
+
815
+ trs = [c.get("transicao") for c in cenas[1:] if c.get("transicao")]
816
+ if len(trs) >= 2 and len(set(trs)) == 1:
817
+ avisos.append(
818
+ 'toda transição é "%s". São oito: corte, fade, deslizar, subir, zoom, '
819
+ "limpar, persiana, flash. Tudo igual parece slide, não vídeo." % trs[0])
820
+
821
+ if ferramenta == "corte" and not doc.get("trilha"):
822
+ avisos.append(
823
+ "o vídeo está sem trilha. Seis estilos gerados existem (pulso, ambiente, "
824
+ "lofi, epico, alegre, tenso) e nenhum custa arquivo nem licença — escolha "
825
+ "pelo assunto.")
826
+
827
+ duracoes = [c.get("duracao") for c in cenas if isinstance(c.get("duracao"), (int, float))]
828
+ if len(duracoes) >= 4 and len(set(duracoes)) == 1:
829
+ avisos.append(
830
+ "todas as cenas duram %s s. Ritmo igual do começo ao fim cansa: gancho "
831
+ "curto, argumento mais longo, fecho curto." % duracoes[0])
832
+
833
+ anims = []
834
+ for c in cenas:
835
+ for e in (c.get("elementos") or []):
836
+ if isinstance(e, dict) and e.get("anim"):
837
+ anims.append(e["anim"])
838
+ if len(anims) >= 4 and len(set(anims)) == 1:
839
+ avisos.append(
840
+ 'toda animação de entrada é "%s". Varie: subir, surgir, esquerda, direita, '
841
+ "zoom, desfoque, cair, girar, saltar, revelar, cortina, elastico." % anims[0])
842
+
843
+ return avisos
844
+
845
+
663
846
  def criar(dados):
664
847
  # O cliente é um agente de IA falando JSON cru — um tipo errado aqui
665
848
  # (titulo como lista, por exemplo) não pode virar AttributeError sem
@@ -752,6 +935,40 @@ def _tema_invalido(tema, onde=""):
752
935
  % (tema, onde, ", ".join(sorted(TEMAS_VALIDOS))))
753
936
 
754
937
 
938
+ def _clipe_invalido(e, i, j):
939
+ """Um `clipe` é um TRECHO da gravação do usuário: arquivo, começo e fim.
940
+
941
+ Os três erros abaixo saem calados. `src` faltando ou apontando para o
942
+ disco da pessoa dá <video> que nunca carrega — cena preta, sem aviso.
943
+ `ate` antes de `de` dá trecho de duração negativa, que vira cena de zero
944
+ segundo. E `de`/`ate` em texto ("00:12") faz a conta de duração virar
945
+ concatenação, que foi como já saiu um vídeo de 00:00 daqui.
946
+ """
947
+ onde = "elemento %d da cena %d" % (j + 1, i + 1)
948
+ src = e.get("src")
949
+ if not isinstance(src, str) or not src.strip():
950
+ return ('%s é "clipe" mas está sem "src". O caminho é a URL que /api/midia devolveu '
951
+ '(ex.: "/midia/gravacao.mp4"). Envie o arquivo primeiro; caminho do disco '
952
+ '("/Users/...", "C:\\\\...") o navegador não abre.' % onde)
953
+ if re.match(r"^([a-zA-Z]:[\\/]|/(Users|home|mnt|var|tmp)/|file:)", src.strip()):
954
+ return ('%s aponta para o disco (%r). O navegador não lê caminho de arquivo: envie o vídeo '
955
+ 'para /api/midia e use a URL "/midia/..." que ele devolve.' % (onde, src[:60]))
956
+ for campo in ("de", "ate"):
957
+ v = e.get(campo)
958
+ if v is None:
959
+ continue
960
+ if isinstance(v, bool) or not isinstance(v, (int, float)):
961
+ return ('%s tem "%s": %r, que não é número. São SEGUNDOS do arquivo original: '
962
+ '"de": 12.5, "ate": 19.' % (onde, campo, v))
963
+ if v < 0:
964
+ return '%s tem "%s": %r — segundo negativo não existe.' % (onde, campo, v)
965
+ de, ate = e.get("de"), e.get("ate")
966
+ if isinstance(de, (int, float)) and isinstance(ate, (int, float)) and ate <= de:
967
+ return ('%s tem "ate": %r antes ou igual a "de": %r. O trecho ficaria com duração zero ou '
968
+ 'negativa e a cena sairia vazia.' % (onde, ate, de))
969
+ return None
970
+
971
+
755
972
  def validar_doc(modelo, doc):
756
973
  """None quando o doc serve; senão, a frase que ENSINA o formato certo.
757
974
 
@@ -772,7 +989,7 @@ def validar_doc(modelo, doc):
772
989
  # preto": documento válido na forma, invisível na tela.
773
990
  TIPOS_VALIDOS = {"texto", "lista", "cartoes", "numeros", "tabela", "codigo",
774
991
  "citacao", "forma", "imagem", "icone", "grafico", "no", "conector",
775
- "objeto3d"}
992
+ "objeto3d", "clipe"}
776
993
 
777
994
  # Tema inventado tinha o mesmo destino silencioso do tipo inventado: o CSS
778
995
  # não casa com [data-tema="noite"], nenhuma cor é definida e a peça sai
@@ -818,6 +1035,12 @@ def validar_doc(modelo, doc):
818
1035
  if e.get("tipo") == "texto" and not (e.get("texto") or "").strip():
819
1036
  return ('elemento %d da cena %d é "texto" mas o campo "texto" está vazio — o conteúdo '
820
1037
  'vai SEMPRE no campo "texto".' % (j + 1, i + 1))
1038
+ # Clipe: o trecho da gravação do usuário. Erra-se em silêncio de
1039
+ # três formas, e as três dão vídeo preto ou corte que não corta.
1040
+ if e.get("tipo") == "clipe":
1041
+ problema = _clipe_invalido(e, i, j)
1042
+ if problema:
1043
+ return problema
821
1044
  elif modelo == "documento":
822
1045
  blocos = doc.get("blocos")
823
1046
  if not isinstance(blocos, list) or not blocos:
@@ -1020,6 +1243,8 @@ class Handler(SimpleHTTPRequestHandler):
1020
1243
  return self.pagina("tool.html")
1021
1244
  if re.fullmatch(r"/v/[^/]+", p):
1022
1245
  return self.pagina("view.html")
1246
+ if p == "/analise":
1247
+ return self.pagina("analise.html")
1023
1248
 
1024
1249
  if p == "/api/manual":
1025
1250
  return self.txt(manual())
@@ -1041,6 +1266,13 @@ class Handler(SimpleHTTPRequestHandler):
1041
1266
  if p == "/api/versao":
1042
1267
  return self.js({"assinatura": ASSINATURA})
1043
1268
 
1269
+ m = re.fullmatch(r"/api/analise/([a-z0-9]+)", p)
1270
+ if m:
1271
+ tarefa = ANALISES.get(m.group(1))
1272
+ if not tarefa:
1273
+ return self.js({"erro": "análise não encontrada"}, 404)
1274
+ return self.js(tarefa)
1275
+
1044
1276
  if p == "/api/voz":
1045
1277
  return self.voz(urlparse(self.path).query)
1046
1278
  if p == "/api/voz/status":
@@ -1351,6 +1583,10 @@ class Handler(SimpleHTTPRequestHandler):
1351
1583
  corpo, status = guardar_midia(self.corpo())
1352
1584
  return self.js(corpo, status)
1353
1585
 
1586
+ if rota == "/api/analise":
1587
+ corpo, status = criar_analise(self.corpo())
1588
+ return self.js(corpo, status)
1589
+
1354
1590
  if rota == "/api/voz/instalar":
1355
1591
  pedido = self.corpo()
1356
1592
  if pedido.get("apagar"):
@@ -1408,19 +1644,39 @@ class Handler(SimpleHTTPRequestHandler):
1408
1644
  return self.js({"erro": "ferramenta inválida",
1409
1645
  "validas": list(POR_SLUG)}, 400)
1410
1646
  base = "http://localhost:%d" % PORTA
1411
- return self.js({
1647
+ corpo = {
1412
1648
  "id": a["id"],
1413
1649
  "ferramenta": a["ferramenta"],
1414
1650
  "titulo": a["titulo"],
1415
1651
  "editar": "%s/t/%s/%s" % (base, a["ferramenta"], a["id"]),
1416
1652
  "ver": "%s/v/%s" % (base, a["id"]),
1417
- }, 201)
1653
+ }
1654
+ revisao = revisar_monotonia(a.get("doc") or {}, a.get("ferramenta"))
1655
+ if revisao:
1656
+ corpo["podeMelhorar"] = revisao
1657
+ return self.js(corpo, 201)
1418
1658
  return self.js({"erro": "não encontrado"}, 404)
1419
1659
 
1420
1660
  @seguro
1421
1661
  def do_PUT(self):
1422
1662
  if not self.origem_ok():
1423
1663
  return self.js({"erro": "origem não permitida"}, 403)
1664
+
1665
+ # Envio bruto: PUT /api/midia/<nome> com o arquivo no corpo, sem JSON
1666
+ # e sem base64. É por onde passa a gravação do usuário, que é grande
1667
+ # demais para virar texto.
1668
+ m = re.fullmatch(r"/api/midia/([^/]+)", urlparse(self.path).path)
1669
+ if m:
1670
+ corpo, status = guardar_midia_bruta(
1671
+ unquote(m.group(1)), self.rfile,
1672
+ int(self.headers.get("Content-Length") or 0))
1673
+ return self.js(corpo, status)
1674
+
1675
+ m = re.fullmatch(r"/api/analise/([a-z0-9]+)", urlparse(self.path).path)
1676
+ if m:
1677
+ corpo, status = gravar_analise(m.group(1), self.corpo())
1678
+ return self.js(corpo, status)
1679
+
1424
1680
  m = re.fullmatch(r"/api/arquivos/([^/]+)", urlparse(self.path).path)
1425
1681
  if not m:
1426
1682
  return self.js({"erro": "não encontrado"}, 404)
@@ -0,0 +1,52 @@
1
+ <!doctype html>
2
+ <html lang="pt-BR">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width,initial-scale=1">
6
+ <title>PrimoCode — ouvindo o vídeo</title>
7
+ <link rel="stylesheet" href="/core.css">
8
+ <link rel="stylesheet" href="/fontes.css">
9
+ <style>
10
+ /* Uma aba que abre sozinha, trabalha e some. Ela não pede nada da pessoa —
11
+ só conta o que está fazendo, para o segundo de espera não parecer travado. */
12
+ body {
13
+ margin: 0; min-height: 100vh;
14
+ display: flex; align-items: center; justify-content: center;
15
+ background: var(--c-fundo, #0b0e14); color: var(--c-texto, #e7ecf3);
16
+ font-family: var(--f-corpo, system-ui, sans-serif);
17
+ }
18
+ .caixa { width: min(560px, 92vw); padding: 34px 32px; }
19
+ h1 { font-size: 21px; font-weight: 640; margin: 0 0 4px; letter-spacing: -.01em; }
20
+ #qual {
21
+ display: block; font-size: 13px; opacity: .62;
22
+ font-family: var(--f-mono, ui-monospace, monospace);
23
+ margin-bottom: 22px; word-break: break-all;
24
+ }
25
+ .trilho { height: 4px; border-radius: 4px; background: rgba(255,255,255,.1); overflow: hidden; }
26
+ #barra {
27
+ height: 100%; width: 0;
28
+ background: linear-gradient(90deg, #2f6df6, #59a0ff);
29
+ transition: width .45s cubic-bezier(.22,1,.36,1);
30
+ }
31
+ [data-estado="erro"] #barra { background: #d9584f; }
32
+ [data-estado="pronto"] #barra { background: linear-gradient(90deg, #1f9d63, #35c584); }
33
+ #painel { margin-top: 20px; max-height: 40vh; overflow: auto; }
34
+ #painel p {
35
+ margin: 0 0 7px; font-size: 14px; line-height: 1.5; opacity: .88;
36
+ font-family: var(--f-mono, ui-monospace, monospace);
37
+ }
38
+ #painel p:last-child { opacity: 1; }
39
+ </style>
40
+ </head>
41
+ <body>
42
+
43
+ <div class="caixa">
44
+ <h1>Ouvindo o vídeo para achar as pausas</h1>
45
+ <code id="qual"></code>
46
+ <div class="trilho"><div id="barra"></div></div>
47
+ <div id="painel"></div>
48
+ </div>
49
+
50
+ <script src="/js/analise.js"></script>
51
+ </body>
52
+ </html>
@@ -0,0 +1,202 @@
1
+ /* Onde estão as pausas — a conta que permite cortá-las.
2
+ *
3
+ * "Ele vai cortar as pausas demoradas no vídeo."
4
+ *
5
+ * Cortar pausa é escolher trechos, e escolher trechos exige saber onde a fala
6
+ * para. Ninguém no PrimoCode sabe ler o áudio de um .mp4: o projeto não tem
7
+ * dependência nenhuma, e o Python da máquina lê WAV e mais nada. O navegador
8
+ * sabe, desde sempre, em qualquer sistema, sem instalar coisa alguma. Então a
9
+ * conta acontece aqui e o resultado volta para o agente pelo servidor.
10
+ *
11
+ * O método é o de sempre em edição: envelope de energia, limiar relativo ao
12
+ * que o próprio arquivo tem, e histerese para não picotar a fala.
13
+ */
14
+ (function () {
15
+ "use strict";
16
+
17
+ var painel = document.getElementById("painel");
18
+ var barra = document.getElementById("barra");
19
+
20
+ function conta(texto) {
21
+ var p = document.createElement("p");
22
+ p.textContent = texto;
23
+ painel.appendChild(p);
24
+ painel.scrollTop = painel.scrollHeight;
25
+ }
26
+
27
+ function andar(fracao, rotulo) {
28
+ barra.style.width = Math.round(Math.max(0, Math.min(1, fracao)) * 100) + "%";
29
+ barra.setAttribute("data-rotulo", rotulo || "");
30
+ }
31
+
32
+ function mmss(s) {
33
+ s = Math.max(0, s || 0);
34
+ var m = Math.floor(s / 60);
35
+ return m + ":" + String(Math.floor(s % 60)).padStart(2, "0");
36
+ }
37
+
38
+ /* ── o envelope ─────────────────────────────────────────────────────────
39
+ RMS em janelas de 20ms. Pico não serve: um estalo de teclado tem pico de
40
+ fala e energia de nada, e cortaria a pausa ao meio. */
41
+ var JANELA = 0.02;
42
+
43
+ function envelope(buffer) {
44
+ var canais = buffer.numberOfChannels;
45
+ var passo = Math.max(1, Math.round(buffer.sampleRate * JANELA));
46
+ var quantas = Math.floor(buffer.length / passo);
47
+ var fora = new Float32Array(quantas);
48
+ var dados = [];
49
+ for (var c = 0; c < canais; c++) dados.push(buffer.getChannelData(c));
50
+
51
+ for (var j = 0; j < quantas; j++) {
52
+ var soma = 0;
53
+ var base = j * passo;
54
+ for (var c2 = 0; c2 < canais; c2++) {
55
+ var d = dados[c2];
56
+ for (var i = 0; i < passo; i++) { var v = d[base + i]; soma += v * v; }
57
+ }
58
+ fora[j] = Math.sqrt(soma / (passo * canais));
59
+ }
60
+ return fora;
61
+ }
62
+
63
+ /* O limiar sai do PRÓPRIO arquivo. Um número fixo (-40 dB, digamos) trata
64
+ igual uma gravação de estúdio e um celular num café — e no café ele
65
+ acharia que não há pausa nenhuma, porque o chão de ruído já passa do
66
+ limiar. Aqui o chão é a mediana das janelas mais quietas, e a fala é o
67
+ percentil alto; o corte fica entre os dois, mais perto do chão. */
68
+ function limiarDe(env) {
69
+ var ordenado = Float32Array.from(env).sort();
70
+ var q = function (p) { return ordenado[Math.min(ordenado.length - 1, Math.floor(ordenado.length * p))]; };
71
+ var chao = q(0.10);
72
+ var fala = q(0.90);
73
+ if (!(fala > 0)) return { corte: 0, chao: chao, fala: fala };
74
+ // Um sexto do caminho entre o silêncio e a fala. Baixo de propósito: é
75
+ // melhor deixar uma pausa passar do que cortar a última sílaba de uma
76
+ // frase, que é o defeito que se ouve.
77
+ return { corte: Math.max(chao * 2.2, chao + (fala - chao) * 0.16), chao: chao, fala: fala };
78
+ }
79
+
80
+ /* Junta as janelas quietas em pausas, com histerese: uma janela alta no meio
81
+ do silêncio não reabre a fala, e vice-versa. `minimo` é o que conta como
82
+ "pausa demorada" — abaixo disso é respiração, e cortar respiração deixa a
83
+ fala robótica. */
84
+ function acharPausas(env, minimo, corte) {
85
+ var pausas = [];
86
+ var inicio = -1;
87
+ var seguidas = 0;
88
+ var TOLERA = 3; // ~60ms de estalo não encerram a pausa
89
+ for (var j = 0; j < env.length; j++) {
90
+ if (env[j] < corte) {
91
+ if (inicio < 0) inicio = j;
92
+ seguidas = 0;
93
+ } else if (inicio >= 0) {
94
+ seguidas++;
95
+ if (seguidas > TOLERA) {
96
+ var fim = j - seguidas;
97
+ if ((fim - inicio) * JANELA >= minimo) pausas.push([inicio * JANELA, fim * JANELA]);
98
+ inicio = -1; seguidas = 0;
99
+ }
100
+ }
101
+ }
102
+ if (inicio >= 0 && (env.length - inicio) * JANELA >= minimo) {
103
+ pausas.push([inicio * JANELA, env.length * JANELA]);
104
+ }
105
+ return pausas;
106
+ }
107
+
108
+ /* O que sobra depois de tirar as pausas — e é isto que o agente usa: a
109
+ lista de `de`/`ate` para escrever os clipes.
110
+
111
+ Cada trecho ganha uma folga nas pontas. Sem ela o corte come o ataque da
112
+ primeira palavra e a cauda da última, e a edição fica ofegante. */
113
+ var FOLGA = 0.12;
114
+
115
+ function trechosUteis(pausas, duracao) {
116
+ var trechos = [];
117
+ var cursor = 0;
118
+ pausas.forEach(function (p) {
119
+ var ate = Math.min(duracao, p[0] + FOLGA);
120
+ if (ate - cursor > 0.35) trechos.push([+cursor.toFixed(2), +ate.toFixed(2)]);
121
+ cursor = Math.max(0, p[1] - FOLGA);
122
+ });
123
+ if (duracao - cursor > 0.35) trechos.push([+cursor.toFixed(2), +duracao.toFixed(2)]);
124
+ return trechos;
125
+ }
126
+
127
+ /* ── a tarefa ──────────────────────────────────────────────────────────── */
128
+
129
+ function responder(id, corpo) {
130
+ return fetch("/api/analise/" + id, {
131
+ method: "PUT",
132
+ headers: { "Content-Type": "application/json" },
133
+ body: JSON.stringify(corpo),
134
+ });
135
+ }
136
+
137
+ function rodar(id) {
138
+ var tarefa;
139
+ andar(0.05, "buscando o pedido");
140
+
141
+ fetch("/api/analise/" + id).then(function (r) {
142
+ if (!r.ok) throw new Error("este pedido de análise não existe mais");
143
+ return r.json();
144
+ }).then(function (t) {
145
+ tarefa = t;
146
+ document.getElementById("qual").textContent = t.arquivo;
147
+ conta("Lendo " + t.arquivo + "…");
148
+ andar(0.15, "baixando o áudio");
149
+ return fetch(t.arquivo);
150
+ }).then(function (r) {
151
+ if (!r.ok) throw new Error("não consegui abrir o arquivo");
152
+ return r.arrayBuffer();
153
+ }).then(function (bruto) {
154
+ conta((bruto.byteLength / 1048576).toFixed(1) + " MB lidos. Decodificando o áudio…");
155
+ andar(0.35, "decodificando");
156
+ var Ctx = window.AudioContext || window.webkitAudioContext;
157
+ return new Ctx().decodeAudioData(bruto);
158
+ }).then(function (buffer) {
159
+ conta("Áudio de " + mmss(buffer.duration) + " a " + buffer.sampleRate + " Hz.");
160
+ andar(0.6, "medindo a energia");
161
+
162
+ var env = envelope(buffer);
163
+ var lim = limiarDe(env);
164
+ andar(0.85, "achando as pausas");
165
+
166
+ var pausas = acharPausas(env, tarefa.pausa || 0.6, lim.corte);
167
+ var trechos = trechosUteis(pausas, buffer.duration);
168
+ var somaTrechos = trechos.reduce(function (s, t) { return s + (t[1] - t[0]); }, 0);
169
+
170
+ conta(pausas.length + " pausa(s) de mais de " + (tarefa.pausa || 0.6) + "s.");
171
+ conta("Sobram " + trechos.length + " trecho(s), somando " + mmss(somaTrechos) +
172
+ " de " + mmss(buffer.duration) + ".");
173
+ andar(1, "pronto");
174
+
175
+ return responder(id, {
176
+ duracao: +buffer.duration.toFixed(2),
177
+ pausas: pausas.map(function (p) { return [+p[0].toFixed(2), +p[1].toFixed(2)]; }),
178
+ trechos: trechos,
179
+ cortado: +(buffer.duration - somaTrechos).toFixed(2),
180
+ }).then(function () {
181
+ conta("Resultado entregue ao PrimoCode. Pode fechar esta aba.");
182
+ document.body.setAttribute("data-estado", "pronto");
183
+ // Fecha sozinha quando foi o CLI que abriu: ninguém pediu uma aba,
184
+ // pediram um vídeo editado.
185
+ setTimeout(function () { try { window.close(); } catch (e) {} }, 2500);
186
+ });
187
+ }).catch(function (e) {
188
+ conta("Não deu: " + e.message);
189
+ document.body.setAttribute("data-estado", "erro");
190
+ andar(1, "erro");
191
+ responder(id, { erro: e.message }).catch(function () {});
192
+ });
193
+ }
194
+
195
+ var id = (location.hash || "").replace(/^#/, "").trim();
196
+ if (!/^[a-z0-9]+$/.test(id)) {
197
+ conta("Esta página é aberta pelo PrimoCode quando ele precisa ouvir um vídeo seu.");
198
+ document.body.setAttribute("data-estado", "erro");
199
+ } else {
200
+ rodar(id);
201
+ }
202
+ })();