primocode 8.22.1 → 8.26.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/app/app.css +230 -0
- package/app/app.js +273 -0
- package/app/icone.svg +6 -0
- package/app/index.html +68 -0
- package/bin/primocode.js +52 -0
- package/lib/app.js +334 -0
- package/lib/catalogo.js +2 -0
- package/lib/config.js +53 -2
- package/lib/studio.js +125 -11
- package/lib/tools.js +35 -1
- package/package.json +3 -2
- package/studio/__pycache__/voz.cpython-311.pyc +0 -0
- package/studio/arquivos/corte-estatico-corte-a5bab.json +150 -0
- package/studio/arquivos/corte-founders-ai-teste-do-player-2de1d.json +65 -0
- package/studio/arquivos/corte-legado-teste.json +1 -0
- package/studio/arquivos/corte-legado2.json +1 -0
- package/studio/arquivos/corte-minimo-2a829.json +24 -0
- package/studio/arquivos/corte-ok-00a3b.json +18 -0
- package/studio/arquivos/corte-ok-1270e.json +22 -0
- package/studio/arquivos/corte-sweep-corte-cfb29.json +147 -0
- package/studio/arquivos/corte-visual-completo-78f50.json +35 -0
- package/studio/arquivos/grade-sweep-grade-0c2f1.json +119 -0
- package/studio/arquivos/prisma-estatico-prisma-d8740.json +182 -0
- package/studio/arquivos/prisma-sweep-prisma-4c58c.json +166 -0
- package/studio/arquivos/prisma-y-b0785.json +52 -0
- package/studio/arquivos/prosa-sweep-prosa-f29cf.json +80 -0
- package/studio/arquivos/tela-estatico-tela-c7daf.json +90 -0
- package/studio/arquivos/tela-sweep-tela-0d2bf.json +84 -0
- package/studio/arquivos/traco-estatico-traco-25872.json +113 -0
- package/studio/arquivos/traco-sweep-traco-0bcf4.json +102 -0
- package/studio/ferramentas/corte.md +159 -12
- package/studio/server.py +264 -8
- package/studio/web/analise.html +52 -0
- package/studio/web/js/analise.js +202 -0
- package/studio/web/js/exportar.js +88 -1
- package/studio/web/js/player.js +172 -46
- package/studio/web/js/render.js +99 -1
- package/studio/web/js/video.js +434 -0
- package/studio/web/render.css +27 -0
- package/studio/web/tool.html +1 -0
- 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
|
|
651
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
+
})();
|