primocode 11.4.0 → 11.5.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/LICENSES/edit2-terceiros.md +31 -0
- package/LICENSES/models-dev-MIT.txt +25 -0
- package/README.md +2 -2
- package/editor/SKILL.md +24 -0
- package/editor/THIRD_PARTY_NOTICES.md +1 -0
- package/editor/assets/baixar.json +9 -1
- package/editor/helpers/df_limpar.py +29 -0
- package/editor/helpers/edit2.py +155 -0
- package/editor/helpers/enquadrar_camera.py +77 -0
- package/editor/helpers/face_layout.py +20 -0
- package/editor/helpers/face_mediapipe.py +147 -0
- package/editor/helpers/instalar_df.sh +7 -0
- package/editor/helpers/legenda_ass.py +124 -0
- package/editor/helpers/look_ffmpeg.py +105 -0
- package/editor/helpers/motor_palavras.py +157 -0
- package/editor/helpers/primeiro_passe.py +214 -0
- package/editor/helpers/som_edit2.py +119 -0
- package/editor/helpers/transcribe.py +31 -2
- package/editor/pyproject.toml +9 -0
- package/editor/references/edit2.md +22 -0
- package/editor/uv.lock +3342 -214
- package/kits/3d/LICENSES/README.md +11 -0
- package/kits/3d/animacoes.js +87 -0
- package/kits/3d/base.js +44 -0
- package/kits/3d/catalogo.js +38 -0
- package/kits/3d/cenario.js +162 -0
- package/kits/3d/exemplos/asfalto.mjs +91 -0
- package/kits/3d/maquinas.js +97 -0
- package/kits/3d/montador.js +142 -0
- package/kits/3d/package.json +1 -0
- package/kits/3d/personagem.js +86 -0
- package/kits/3d/remotion/Maquete.jsx +42 -0
- package/kits/3d/remotion/Root.jsx +10 -0
- package/kits/3d/render.mjs +73 -0
- package/lib/cli/comando-api.js +30 -8
- package/lib/cli/modelos-lista.js +58 -0
- package/lib/cli/sessao.js +11 -0
- package/lib/cli/subcomandos.js +3 -2
- package/lib/outra-pasta.js +1 -1
- package/lib/permissao-sistema.js +60 -0
- package/lib/permission/perigo.js +1 -0
- package/lib/permission/permissao.js +1 -0
- package/lib/permission/sem-perguntar.js +29 -0
- package/lib/plugin/desktop.js +5 -4
- package/lib/plugin/estudio/cota.js +4 -2
- package/lib/plugin/estudio/dialogo3d.js +71 -0
- package/lib/plugin/estudio/effectcraft.js +1 -1
- package/lib/plugin/estudio/glifos.js +2 -0
- package/lib/plugin/estudio/gsap-local.js +43 -0
- package/lib/plugin/estudio/index.js +63 -10
- package/lib/plugin/estudio/kit-ae/base.jsx +1 -1
- package/lib/plugin/estudio/kit-ae/cenas.jsx +7 -4
- package/lib/plugin/estudio/kit-ae/destrava.jsx +211 -0
- package/lib/plugin/estudio/maquina-diretor.js +1 -1
- package/lib/plugin/estudio/maquina-elementos.js +2 -2
- package/lib/plugin/estudio/maquina.js +26 -8
- package/lib/plugin/estudio/motion-plano/ajustar.js +139 -0
- package/lib/plugin/estudio/motion-plano/critico.js +59 -0
- package/lib/plugin/estudio/motion-plano/estrutura.js +111 -0
- package/lib/plugin/estudio/motion-plano/fatos.js +137 -0
- package/lib/plugin/estudio/motion-plano/index.js +75 -0
- package/lib/plugin/estudio/motion-plano/llm.js +59 -0
- package/lib/plugin/estudio/motion-plano/motor.js +50 -0
- package/lib/plugin/estudio/motion-plano/passos.js +38 -0
- package/lib/plugin/estudio/motion-plano/pele.js +36 -0
- package/lib/plugin/estudio/motion-plano/plano.js +152 -0
- package/lib/plugin/estudio/motion-plano/regua.js +100 -0
- package/lib/plugin/estudio/personagem.js +3 -1
- package/lib/plugin/estudio/referencias.js +1 -1
- package/lib/provider/catalogo.js +161 -0
- package/lib/provider/local/catalogo-embutido.json.gz +0 -0
- package/lib/provider/local/motor.js +140 -27
- package/lib/provider/local/orcamento.js +193 -0
- package/lib/provider/local/provedor.js +127 -14
- package/lib/provider/local/recorte.js +170 -0
- package/lib/provider/local/revezamento.js +121 -0
- package/lib/session/runner/act.js +28 -3
- package/lib/skill/catalogo.js +5 -1
- package/lib/tool/tools.js +5 -1
- package/package.json +1 -1
- package/vendor/fontes/ttf/ORIGEM.txt +1 -0
- package/vendor/fontes/ttf/inter-800.ttf +0 -0
- package/vendor/gsap/ORIGEM.txt +6 -0
- package/vendor/gsap/gsap.min.js +11 -0
- package/voz/primo_voz/chat.html +167 -5
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Edit 2.0, peça 6: legenda simples em ASS (pysubs2, MIT) queimada no ffmpeg (libass, ISC). Sem Remotion.
|
|
2
|
+
|
|
3
|
+
A legenda especial (tipografia cinética, palavra gigante) continua no HyperFrames. Aqui fica a legenda de dia a dia:
|
|
4
|
+
até 3 palavras por vez, a palavra que está sendo dita acende (karaoke `\\k`) e dá um pulinho (`\\t` de escala),
|
|
5
|
+
na zona segura do Reels (texto entre y 230 e 1530; x 140 a 940).
|
|
6
|
+
|
|
7
|
+
O ffmpeg do Homebrew desta máquina vem SEM libass (nem o filtro `ass` nem `subtitles`). Então `queimar` confere o
|
|
8
|
+
filtro antes: com libass, queima o .ass; sem libass, devolve `queimou: false` e o `pipeline` usa o caminho antigo
|
|
9
|
+
(PNG por estado, text_render + overlay). Nunca finge que queimou.
|
|
10
|
+
|
|
11
|
+
Uso:
|
|
12
|
+
uv run python helpers/legenda_ass.py <transcript.json> <saida.ass> [--tamanho 1080x1920]
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
import json
|
|
19
|
+
import re
|
|
20
|
+
import subprocess
|
|
21
|
+
import sys
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
25
|
+
|
|
26
|
+
MAX_PALAVRAS = 3
|
|
27
|
+
GAP_QUEBRA_S = 0.45
|
|
28
|
+
ZONA_Y = (230, 1530)
|
|
29
|
+
ZONA_X = (140, 940)
|
|
30
|
+
FONTE = "Montserrat ExtraBold"
|
|
31
|
+
BRANCO, DESTAQUE, CONTORNO = "&H00FFFFFF&", "&H0000D7FF&", "&H00101010&" # ASS é BGR: DESTAQUE = âmbar #FFD700
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def blocos(palavras: list[dict], max_palavras: int = MAX_PALAVRAS, gap: float = GAP_QUEBRA_S) -> list[list[dict]]:
|
|
35
|
+
"""Grupos de até `max_palavras`; quebra em pontuação final ou em pausa. Nenhuma palavra some."""
|
|
36
|
+
out: list[list[dict]] = []
|
|
37
|
+
atual: list[dict] = []
|
|
38
|
+
for w in palavras:
|
|
39
|
+
if atual and (len(atual) >= max_palavras or w["start"] - atual[-1]["end"] > gap):
|
|
40
|
+
out.append(atual)
|
|
41
|
+
atual = []
|
|
42
|
+
atual.append(w)
|
|
43
|
+
if re.search(r"[.!?…]$", w["text"].strip()):
|
|
44
|
+
out.append(atual)
|
|
45
|
+
atual = []
|
|
46
|
+
if atual:
|
|
47
|
+
out.append(atual)
|
|
48
|
+
return out
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _limpo(t: str) -> str:
|
|
52
|
+
return t.replace("{", "(").replace("}", ")").replace("\\", "").strip()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def montar_ass(palavras: list[dict], largura: int = 1080, altura: int = 1920, fonte: str = FONTE,
|
|
56
|
+
tamanho: int | None = None, margem_v: int | None = None):
|
|
57
|
+
import pysubs2 # noqa: PLC0415
|
|
58
|
+
|
|
59
|
+
tam = tamanho or int(altura * 0.047)
|
|
60
|
+
# \an2 = base central; a base do texto fica a 22% do fim (y ~ 1500 num 1920), dentro da zona segura
|
|
61
|
+
mv = margem_v if margem_v is not None else int(altura * 0.22)
|
|
62
|
+
subs = pysubs2.SSAFile()
|
|
63
|
+
subs.info["PlayResX"], subs.info["PlayResY"] = str(largura), str(altura)
|
|
64
|
+
subs.info["ScaledBorderAndShadow"] = "yes"
|
|
65
|
+
est = pysubs2.SSAStyle(fontname=fonte, fontsize=tam, bold=True, alignment=2, marginl=int(largura * 0.13),
|
|
66
|
+
marginr=int(largura * 0.13), marginv=mv, outline=max(3, tam // 11), shadow=0,
|
|
67
|
+
primarycolor=pysubs2.Color(255, 215, 0), secondarycolor=pysubs2.Color(255, 255, 255),
|
|
68
|
+
outlinecolor=pysubs2.Color(16, 16, 16))
|
|
69
|
+
subs.styles["Padrao"] = est
|
|
70
|
+
for grupo in blocos(palavras):
|
|
71
|
+
ini, fim = grupo[0]["start"], grupo[-1]["end"]
|
|
72
|
+
# a palavra fica na tela até a próxima começar (sem piscar entre palavras coladas)
|
|
73
|
+
partes = []
|
|
74
|
+
for i, w in enumerate(grupo):
|
|
75
|
+
dur_w = (grupo[i + 1]["start"] if i + 1 < len(grupo) else w["end"]) - w["start"]
|
|
76
|
+
cs = max(1, int(round(dur_w * 100)))
|
|
77
|
+
off = int(round((w["start"] - ini) * 1000)) # o pulinho é na hora da palavra, não do bloco
|
|
78
|
+
partes.append("{\\fscx100\\fscy100\\k%d\\t(%d,%d,\\fscx112\\fscy112)\\t(%d,%d,\\fscx100\\fscy100)}%s"
|
|
79
|
+
% (cs, off, off + 120, off + 120, off + 260, _limpo(w["text"])))
|
|
80
|
+
subs.append(pysubs2.SSAEvent(start=int(round(ini * 1000)), end=int(round(fim * 1000)) + 80, style="Padrao",
|
|
81
|
+
text=" ".join(partes)))
|
|
82
|
+
return subs
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def palavras_do_transcript(tr: dict) -> list[dict]:
|
|
86
|
+
return [{"text": w["text"], "start": float(w["start"]), "end": float(w["end"])} for w in tr["words"]]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def tem_libass() -> bool:
|
|
90
|
+
r = subprocess.run(["ffmpeg", "-hide_banner", "-filters"], capture_output=True, text=True)
|
|
91
|
+
return bool(re.search(r"\b(ass|subtitles)\b\s+V->V", r.stdout))
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def filtro_ass(ass: Path, fontsdir: Path | None = None) -> str:
|
|
95
|
+
esc = lambda p: str(p).replace("\\", "/").replace(":", "\\:").replace("'", "\\'") # noqa: E731
|
|
96
|
+
f = f"ass='{esc(ass)}'"
|
|
97
|
+
if fontsdir:
|
|
98
|
+
f += f":fontsdir='{esc(fontsdir)}'"
|
|
99
|
+
return f
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def queimar(video: Path, ass: Path, saida: Path, fontsdir: Path | None = None) -> dict:
|
|
103
|
+
if not tem_libass():
|
|
104
|
+
return {"queimou": False, "motivo": "este ffmpeg não tem libass (filtro ass); use o caminho de PNG do text_render"}
|
|
105
|
+
r = subprocess.run(["ffmpeg", "-hide_banner", "-nostdin", "-v", "error", "-y", "-i", str(video), "-vf", filtro_ass(ass, fontsdir),
|
|
106
|
+
"-c:a", "copy", str(saida)], capture_output=True, text=True)
|
|
107
|
+
return {"queimou": r.returncode == 0, "motivo": r.stderr[-200:] if r.returncode else "libass"}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def main() -> int:
|
|
111
|
+
ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
|
|
112
|
+
ap.add_argument("transcript", type=Path)
|
|
113
|
+
ap.add_argument("saida", type=Path)
|
|
114
|
+
ap.add_argument("--tamanho", default="1080x1920")
|
|
115
|
+
a = ap.parse_args()
|
|
116
|
+
w, h = (int(x) for x in a.tamanho.split("x"))
|
|
117
|
+
tr = json.loads(a.transcript.read_text(encoding="utf-8"))
|
|
118
|
+
montar_ass(palavras_do_transcript(tr), w, h).save(str(a.saida))
|
|
119
|
+
print(a.saida)
|
|
120
|
+
return 0
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
if __name__ == "__main__":
|
|
124
|
+
sys.exit(main())
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Edit 2.0, peça 5: cor e acabamento só com filtros nativos do ffmpeg (lut3d, noise, deshake).
|
|
2
|
+
|
|
3
|
+
- LUT própria, gerada aqui em código (.cube de 33 pontos): nada de LUT baixada da internet, que tem licença própria.
|
|
4
|
+
Looks: `agencia` (contraste de S suave, sombra levemente fria, pele quente, saturação +6%), `limpo` (só contraste),
|
|
5
|
+
`quente`. O ffmpeg lê o .cube com `lut3d` (interpolação tetraédrica).
|
|
6
|
+
- Grão: `noise=alls=N:allf=t` (N 4 a 8; some a "cara de vídeo digital chapado" sem sujar a pele).
|
|
7
|
+
- Estabilização: `deshake` do ffmpeg, só quando pedida e quando a tomada treme. NÃO usamos vid.stab (pede build GPL).
|
|
8
|
+
Licenças: LICENSES/edit2-terceiros.md.
|
|
9
|
+
|
|
10
|
+
Uso:
|
|
11
|
+
uv run python helpers/look_ffmpeg.py cadeia --look agencia [--grao 6] [--estabilizar] --pasta <edit>
|
|
12
|
+
uv run python helpers/look_ffmpeg.py cube agencia saida.cube
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
import sys
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
LADO = 33
|
|
22
|
+
LOOKS = {
|
|
23
|
+
# contraste (0 a 1), sombra (r,g,b somados no preto), luz (idem no branco), saturação
|
|
24
|
+
"agencia": {"contraste": 0.18, "sombra": (-0.010, 0.004, 0.016), "luz": (0.014, 0.006, -0.012), "sat": 1.06},
|
|
25
|
+
"limpo": {"contraste": 0.10, "sombra": (0.0, 0.0, 0.0), "luz": (0.0, 0.0, 0.0), "sat": 1.02},
|
|
26
|
+
"quente": {"contraste": 0.14, "sombra": (0.004, 0.0, -0.008), "luz": (0.022, 0.008, -0.020), "sat": 1.05},
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _curva_s(x, forca: float):
|
|
31
|
+
"""Mistura a identidade com uma curva smoothstep: forca 0 = nada; preto e branco ficam onde estão."""
|
|
32
|
+
import numpy as np # noqa: PLC0415
|
|
33
|
+
|
|
34
|
+
s = x * x * (3 - 2 * x)
|
|
35
|
+
return np.clip((1 - forca) * x + forca * s, 0.0, 1.0)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def grade_cor(rgb, look: str):
|
|
39
|
+
"""rgb: array (...,3) em 0..1 -> mesma forma com o look aplicado."""
|
|
40
|
+
import numpy as np # noqa: PLC0415
|
|
41
|
+
|
|
42
|
+
p = LOOKS[look]
|
|
43
|
+
out = _curva_s(rgb, p["contraste"])
|
|
44
|
+
lum = out @ np.array([0.2126, 0.7152, 0.0722])
|
|
45
|
+
t = lum[..., None]
|
|
46
|
+
out = out + np.array(p["sombra"]) * (1 - t) + np.array(p["luz"]) * t # split-tone
|
|
47
|
+
out = lum[..., None] + (out - lum[..., None]) * p["sat"] # saturação
|
|
48
|
+
return np.clip(out, 0.0, 1.0)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def escrever_cube(look: str, destino: Path) -> Path:
|
|
52
|
+
""".cube de 33^3: o ffmpeg varia o R mais rápido, depois G, depois B."""
|
|
53
|
+
import numpy as np # noqa: PLC0415
|
|
54
|
+
|
|
55
|
+
if look not in LOOKS:
|
|
56
|
+
raise KeyError(f"look desconhecido: {look!r}. Existem: {', '.join(LOOKS)}")
|
|
57
|
+
eixo = np.linspace(0, 1, LADO)
|
|
58
|
+
b, g, r = np.meshgrid(eixo, eixo, eixo, indexing="ij")
|
|
59
|
+
rgb = np.stack([r, g, b], axis=-1).reshape(-1, 3)
|
|
60
|
+
saida = grade_cor(rgb, look)
|
|
61
|
+
linhas = [f'TITLE "primocode-{look}"', f"LUT_3D_SIZE {LADO}", "DOMAIN_MIN 0 0 0", "DOMAIN_MAX 1 1 1"]
|
|
62
|
+
linhas += [f"{a:.6f} {c:.6f} {d:.6f}" for a, c, d in saida]
|
|
63
|
+
destino.parent.mkdir(parents=True, exist_ok=True)
|
|
64
|
+
destino.write_text("\n".join(linhas) + "\n", encoding="utf-8")
|
|
65
|
+
return destino
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _esc(p: Path) -> str:
|
|
69
|
+
return str(p).replace("\\", "/").replace(":", "\\:").replace("'", "\\'")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def cadeia(look: str | None = "agencia", grao: int = 6, estabilizar: bool = False, pasta: Path | None = None) -> str:
|
|
73
|
+
"""Pedaço de -vf: [deshake,] lut3d, noise. Tudo nativo. '' quando nada é pedido."""
|
|
74
|
+
partes: list[str] = []
|
|
75
|
+
if estabilizar:
|
|
76
|
+
partes.append("deshake=rx=16:ry=16:edge=mirror")
|
|
77
|
+
if look and look != "nenhum":
|
|
78
|
+
cube = escrever_cube(look, (pasta or Path.cwd()) / f"look_{look}.cube")
|
|
79
|
+
partes.append(f"lut3d=file='{_esc(cube)}':interp=tetrahedral")
|
|
80
|
+
if grao and grao > 0:
|
|
81
|
+
partes.append(f"noise=alls={min(max(int(grao), 1), 12)}:allf=t")
|
|
82
|
+
return ",".join(partes)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def main() -> int:
|
|
86
|
+
ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
|
|
87
|
+
sp = ap.add_subparsers(dest="cmd", required=True)
|
|
88
|
+
c = sp.add_parser("cadeia")
|
|
89
|
+
c.add_argument("--look", default="agencia")
|
|
90
|
+
c.add_argument("--grao", type=int, default=6)
|
|
91
|
+
c.add_argument("--estabilizar", action="store_true")
|
|
92
|
+
c.add_argument("--pasta", type=Path, default=Path.cwd())
|
|
93
|
+
q = sp.add_parser("cube")
|
|
94
|
+
q.add_argument("look")
|
|
95
|
+
q.add_argument("saida", type=Path)
|
|
96
|
+
a = ap.parse_args()
|
|
97
|
+
if a.cmd == "cadeia":
|
|
98
|
+
print(cadeia(a.look, a.grao, a.estabilizar, a.pasta))
|
|
99
|
+
else:
|
|
100
|
+
print(escrever_cube(a.look, a.saida))
|
|
101
|
+
return 0
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
if __name__ == "__main__":
|
|
105
|
+
sys.exit(main())
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Edit 2.0, peça 1: motores de transcrição com tempo de palavra alinhado.
|
|
2
|
+
|
|
3
|
+
WhisperX (BSD-2-Clause, faster-whisper + alinhamento wav2vec2) é o motor novo: o alinhamento forçado dá o
|
|
4
|
+
início e o fim de cada palavra bem mais justos que o do Whisper puro. Diarização (pyannote) fica FORA.
|
|
5
|
+
Se o WhisperX não instalar ou falhar, cai no whisper.cpp (`whisper-cli`, MIT) e, por fim, no motor antigo.
|
|
6
|
+
|
|
7
|
+
Os dois devolvem o mesmo formato do transcribe.py: segmentos com `words` [{word,start,end,probability}].
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import shutil
|
|
15
|
+
import subprocess
|
|
16
|
+
import tempfile
|
|
17
|
+
import wave
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
SAMPLE_RATE = 16000
|
|
21
|
+
_CACHE: dict = {}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class MotorIndisponivel(RuntimeError):
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def preencher_tempos(words: list[dict], ini: float, fim: float) -> list[dict]:
|
|
29
|
+
"""Palavra sem tempo (número, símbolo: o alinhador não acha letras) herda o espaço entre as vizinhas."""
|
|
30
|
+
n = len(words)
|
|
31
|
+
i = 0
|
|
32
|
+
while i < n:
|
|
33
|
+
if "start" in words[i] and "end" in words[i]:
|
|
34
|
+
i += 1
|
|
35
|
+
continue
|
|
36
|
+
j = i
|
|
37
|
+
while j < n and not ("start" in words[j] and "end" in words[j]):
|
|
38
|
+
j += 1
|
|
39
|
+
a = words[i - 1]["end"] if i > 0 else ini
|
|
40
|
+
b = words[j]["start"] if j < n else fim
|
|
41
|
+
b = max(b, a)
|
|
42
|
+
passo = (b - a) / (j - i)
|
|
43
|
+
for k in range(i, j):
|
|
44
|
+
words[k]["start"] = round(a + passo * (k - i), 3)
|
|
45
|
+
words[k]["end"] = round(a + passo * (k - i + 1), 3)
|
|
46
|
+
words[k]["score"] = 0.0
|
|
47
|
+
i = j
|
|
48
|
+
return words
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _segmentos(resultado: dict) -> list[dict]:
|
|
52
|
+
segs = []
|
|
53
|
+
for seg in resultado.get("segments", []):
|
|
54
|
+
ws = preencher_tempos([dict(w) for w in seg.get("words", [])], float(seg["start"]), float(seg["end"]))
|
|
55
|
+
# convenção do transcribe.py (vinda do Whisper): palavra nova começa com espaço; sem ele o collect_words cola tudo
|
|
56
|
+
words = [{"word": " " + w.get("word", "").lstrip(), "start": float(w["start"]), "end": float(w["end"]),
|
|
57
|
+
"probability": float(w.get("score", w.get("probability", 0.0)))} for w in ws]
|
|
58
|
+
segs.append({"start": float(seg["start"]), "end": float(seg["end"]), "text": seg.get("text", ""), "words": words})
|
|
59
|
+
return segs
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def run_whisperx(audio, model: str, language: str | None, prompt: str | None) -> tuple[list[dict], str | None]:
|
|
63
|
+
try:
|
|
64
|
+
import whisperx # noqa: PLC0415
|
|
65
|
+
except ImportError as exc:
|
|
66
|
+
raise MotorIndisponivel("whisperx não está instalado. Rode: uv sync") from exc
|
|
67
|
+
device = "cpu" # o ctranslate2 não usa a GPU do Mac; int8 na CPU é o caminho
|
|
68
|
+
if ("wx", model) not in _CACHE:
|
|
69
|
+
opts = {"condition_on_previous_text": False}
|
|
70
|
+
if prompt:
|
|
71
|
+
opts["initial_prompt"] = prompt
|
|
72
|
+
_CACHE[("wx", model)] = whisperx.load_model(model, device, compute_type="int8", language=language,
|
|
73
|
+
asr_options=opts)
|
|
74
|
+
asr = _CACHE[("wx", model)]
|
|
75
|
+
res = asr.transcribe(audio, batch_size=8, language=language)
|
|
76
|
+
lang = res.get("language") or language or "pt"
|
|
77
|
+
if ("al", lang) not in _CACHE:
|
|
78
|
+
_CACHE[("al", lang)] = whisperx.load_align_model(language_code=lang, device=device)
|
|
79
|
+
modelo_al, meta = _CACHE[("al", lang)]
|
|
80
|
+
alinhado = whisperx.align(res["segments"], modelo_al, meta, audio, device, return_char_alignments=False)
|
|
81
|
+
return _segmentos(alinhado), lang
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _wav(audio, caminho: Path) -> None:
|
|
85
|
+
import numpy as np # noqa: PLC0415
|
|
86
|
+
|
|
87
|
+
pcm = (np.clip(audio, -1, 1) * 32767).astype("<i2")
|
|
88
|
+
with wave.open(str(caminho), "wb") as w:
|
|
89
|
+
w.setnchannels(1)
|
|
90
|
+
w.setsampwidth(2)
|
|
91
|
+
w.setframerate(SAMPLE_RATE)
|
|
92
|
+
w.writeframes(pcm.tobytes())
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def achar_modelo_cpp(model: str) -> str | None:
|
|
96
|
+
"""Procura um ggml-*.bin: PRIMOCODE_WHISPER_CPP_MODEL, ~/.primocode/whisper-cpp, pastas comuns do brew."""
|
|
97
|
+
env = os.environ.get("PRIMOCODE_WHISPER_CPP_MODEL")
|
|
98
|
+
if env and Path(env).exists():
|
|
99
|
+
return env
|
|
100
|
+
nomes = [f"ggml-{model}.bin", "ggml-large-v3-turbo.bin", "ggml-large-v3.bin", "ggml-medium.bin", "ggml-small.bin", "ggml-base.bin"]
|
|
101
|
+
pastas = [Path.home() / ".primocode" / "whisper-cpp", Path.home() / ".cache" / "whisper", Path("/opt/homebrew/share/whisper-cpp")]
|
|
102
|
+
for p in pastas:
|
|
103
|
+
for n in nomes:
|
|
104
|
+
if (p / n).exists():
|
|
105
|
+
return str(p / n)
|
|
106
|
+
return None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def parse_json_cpp(dados: dict) -> list[dict]:
|
|
110
|
+
"""JSON completo do whisper-cli (-ojf): tokens com `offsets` em ms. Junta os pedaços em palavras
|
|
111
|
+
(token que começa com espaço abre uma palavra nova)."""
|
|
112
|
+
palavras: list[dict] = []
|
|
113
|
+
for item in dados.get("transcription", []):
|
|
114
|
+
for tk in item.get("tokens", []):
|
|
115
|
+
texto = tk.get("text", "")
|
|
116
|
+
if not texto or texto.startswith("[_") or texto.startswith("<|"):
|
|
117
|
+
continue
|
|
118
|
+
ini, fim = tk["offsets"]["from"] / 1000, tk["offsets"]["to"] / 1000
|
|
119
|
+
p = float(tk.get("p", 0.0))
|
|
120
|
+
if texto.startswith(" ") or not palavras:
|
|
121
|
+
palavras.append({"word": texto, "start": ini, "end": fim, "probability": p, "_n": 1})
|
|
122
|
+
else:
|
|
123
|
+
w = palavras[-1]
|
|
124
|
+
w["word"] += texto
|
|
125
|
+
w["end"] = fim
|
|
126
|
+
w["probability"] = (w["probability"] * w["_n"] + p) / (w["_n"] + 1)
|
|
127
|
+
w["_n"] += 1
|
|
128
|
+
for w in palavras:
|
|
129
|
+
w.pop("_n", None)
|
|
130
|
+
w["word"] = w["word"] if w["word"].startswith(" ") else " " + w["word"]
|
|
131
|
+
return palavras
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def run_whisper_cpp(audio, model: str, language: str | None, prompt: str | None) -> tuple[list[dict], str | None]:
|
|
135
|
+
exe = shutil.which("whisper-cli") or shutil.which("whisper-cpp")
|
|
136
|
+
if not exe:
|
|
137
|
+
raise MotorIndisponivel("whisper.cpp (whisper-cli) não está no PATH")
|
|
138
|
+
mp = achar_modelo_cpp(model)
|
|
139
|
+
if not mp:
|
|
140
|
+
raise MotorIndisponivel("nenhum modelo ggml encontrado (veja PRIMOCODE_WHISPER_CPP_MODEL)")
|
|
141
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
142
|
+
wav, saida = Path(tmp) / "a.wav", Path(tmp) / "saida"
|
|
143
|
+
_wav(audio, wav)
|
|
144
|
+
cmd = [exe, "-m", mp, "-f", str(wav), "-l", language or "auto", "-ojf", "-of", str(saida), "-ml", "0", "-np"]
|
|
145
|
+
if prompt:
|
|
146
|
+
cmd += ["--prompt", prompt]
|
|
147
|
+
r = subprocess.run(cmd, capture_output=True, text=True)
|
|
148
|
+
js = Path(str(saida) + ".json")
|
|
149
|
+
if r.returncode != 0 or not js.exists():
|
|
150
|
+
raise MotorIndisponivel(f"whisper.cpp falhou: {(r.stderr or '')[-200:]}")
|
|
151
|
+
dados = json.loads(js.read_text(encoding="utf-8"))
|
|
152
|
+
words = parse_json_cpp(dados)
|
|
153
|
+
if not words:
|
|
154
|
+
return [], language
|
|
155
|
+
texto = "".join(w["word"] for w in words).strip()
|
|
156
|
+
return [{"start": words[0]["start"], "end": words[-1]["end"], "text": texto, "words": words}], \
|
|
157
|
+
(dados.get("result", {}).get("language") or language)
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
# Regras do corte adaptadas de browser-use/video-use (MIT, (c) 2026 Browser Use). See THIRD_PARTY_NOTICES.md.
|
|
2
|
+
"""Edit 2.0, peça 2: o 1º passe do corte. silero-vad + auto-editor acham a fala; as palavras travam as bordas.
|
|
3
|
+
|
|
4
|
+
1. silero-vad (MIT) diz onde há voz; respiro e ruído de fundo não viram "fala".
|
|
5
|
+
2. auto-editor (Unlicense), chamado por linha de comando, mede a energia do áudio. Trecho que só ele achou (estalo,
|
|
6
|
+
tosse, batida) sem voz do VAD é ruído e sai; borda do VAD que o auto-editor estende (consoante fraca) vale.
|
|
7
|
+
3. Regras do video-use: NUNCA cortar dentro de uma palavra; folga de 30 a 200 ms em cada borda (sempre menor que
|
|
8
|
+
metade da pausa, para não entrar na palavra vizinha); fade de 30 ms em cada emenda (o render_cut já faz).
|
|
9
|
+
Pausa que sobra com menos de 0,25 s não é corte: o trecho é emendado de volta.
|
|
10
|
+
4. A saída é um `edl_manual.json` de rascunho (trecho por trecho, com a frase). A IA depois escolhe a tomada e tira o
|
|
11
|
+
erro; o `corte_por_onda.py` ainda mede a onda de cada junta.
|
|
12
|
+
|
|
13
|
+
Uso:
|
|
14
|
+
uv run python helpers/primeiro_passe.py <video> <edit_dir> [--folga 0.08] [--saida edl_manual.json]
|
|
15
|
+
Precisa de <edit_dir>/transcripts/<nome>.json (transcribe.py).
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import argparse
|
|
21
|
+
import json
|
|
22
|
+
import subprocess
|
|
23
|
+
import sys
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
27
|
+
|
|
28
|
+
FOLGA_MIN, FOLGA_MAX, FOLGA_PADRAO = 0.03, 0.20, 0.08
|
|
29
|
+
PAUSA_CORTA_S = 0.25 # silêncio menor que isso depois da folga: o trecho é emendado
|
|
30
|
+
FADE_S = 0.03
|
|
31
|
+
MIN_TRECHO_S = 0.2
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def uniao(a: list[list[float]], b: list[list[float]]) -> list[list[float]]:
|
|
35
|
+
xs = sorted([list(x) for x in a + b])
|
|
36
|
+
out: list[list[float]] = []
|
|
37
|
+
for s, e in xs:
|
|
38
|
+
if out and s <= out[-1][1]:
|
|
39
|
+
out[-1][1] = max(out[-1][1], e)
|
|
40
|
+
else:
|
|
41
|
+
out.append([s, e])
|
|
42
|
+
return out
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def vad_fala(video: Path, min_silencio_ms: int = 250) -> list[list[float]]:
|
|
46
|
+
from speech_regions import load_mono # noqa: PLC0415
|
|
47
|
+
|
|
48
|
+
import torch # noqa: PLC0415
|
|
49
|
+
from silero_vad import get_speech_timestamps, load_silero_vad # noqa: PLC0415
|
|
50
|
+
|
|
51
|
+
mono, _ = load_mono(video)
|
|
52
|
+
audio = torch.from_numpy(mono.astype("float32"))
|
|
53
|
+
ts = get_speech_timestamps(audio, load_silero_vad(), sampling_rate=16000, min_silence_duration_ms=min_silencio_ms,
|
|
54
|
+
speech_pad_ms=30, return_seconds=True)
|
|
55
|
+
return [[round(t["start"], 3), round(t["end"], 3)] for t in ts]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def auto_editor_fala(video: Path, saida_json: Path, fps: float, limiar: str = "audio:0.03", margem: str = "0.1sec") -> list[list[float]]:
|
|
59
|
+
"""O que o auto-editor deixa (chunks com velocidade 1). Os chunks vêm em quadros."""
|
|
60
|
+
exe = Path(sys.executable).parent / "auto-editor"
|
|
61
|
+
cmd = [str(exe) if exe.exists() else "auto-editor", str(video), "--edit", limiar, "--margin", margem,
|
|
62
|
+
"--export", "v1", "-o", str(saida_json)]
|
|
63
|
+
r = subprocess.run(cmd, capture_output=True, text=True)
|
|
64
|
+
if r.returncode != 0 or not saida_json.exists():
|
|
65
|
+
raise RuntimeError(f"auto-editor falhou: {(r.stderr or r.stdout)[-200:]}")
|
|
66
|
+
chunks = json.loads(saida_json.read_text(encoding="utf-8"))["chunks"]
|
|
67
|
+
return [[round(a / fps, 3), round(b / fps, 3)] for a, b, vel in chunks if vel < 1000]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def filtrar_ruido(vad: list[list[float]], energia: list[list[float]]) -> tuple[list[list[float]], int]:
|
|
71
|
+
"""VAD manda. A energia só estende a borda de um trecho que ela compartilha com o VAD (até 0,15 s)."""
|
|
72
|
+
saida = []
|
|
73
|
+
for s, e in vad:
|
|
74
|
+
ini, fim = s, e
|
|
75
|
+
for a, b in energia:
|
|
76
|
+
if b > s and a < e:
|
|
77
|
+
ini, fim = min(ini, max(a, s - 0.15)), max(fim, min(b, e + 0.15))
|
|
78
|
+
saida.append([ini, fim])
|
|
79
|
+
solitarios = sum(1 for a, b in energia if not any(b > s and a < e for s, e in vad))
|
|
80
|
+
return uniao(saida, []), solitarios
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _vizinhas(palavras: list[dict]) -> list[dict]:
|
|
84
|
+
return sorted(palavras, key=lambda w: w["start"])
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def ajustar_a_palavras(trechos: list[list[float]], palavras: list[dict], duracao: float,
|
|
88
|
+
folga: float = FOLGA_PADRAO) -> list[list[float]]:
|
|
89
|
+
"""Cada borda sai do meio de palavra e ganha a folga, sem entrar na vizinha."""
|
|
90
|
+
folga = min(max(folga, FOLGA_MIN), FOLGA_MAX)
|
|
91
|
+
ws = _vizinhas(palavras)
|
|
92
|
+
if not ws:
|
|
93
|
+
return [list(t) for t in trechos]
|
|
94
|
+
out = []
|
|
95
|
+
for s, e in trechos:
|
|
96
|
+
# borda inicial: se cai dentro de palavra, volta pro começo dela
|
|
97
|
+
for w in ws:
|
|
98
|
+
if w["start"] < s < w["end"]:
|
|
99
|
+
s = w["start"]
|
|
100
|
+
for w in ws:
|
|
101
|
+
if w["start"] < e < w["end"]:
|
|
102
|
+
e = w["end"]
|
|
103
|
+
prim = min([w["start"] for w in ws if w["end"] > s and w["start"] < e] or [s])
|
|
104
|
+
ult = max([w["end"] for w in ws if w["start"] < e and w["end"] > s] or [e])
|
|
105
|
+
antes = max([w["end"] for w in ws if w["start"] < prim - 1e-6] or [0.0])
|
|
106
|
+
depois = min([w["start"] for w in ws if w["start"] > ult - 1e-6 and w["start"] >= e - 1e-6] or [duracao])
|
|
107
|
+
ini = max(0.0, prim - min(folga, max(0.0, (prim - antes) / 2)))
|
|
108
|
+
fim = min(duracao, ult + min(folga, max(0.0, (depois - ult) / 2)))
|
|
109
|
+
if fim - ini >= MIN_TRECHO_S:
|
|
110
|
+
out.append([round(ini, 3), round(fim, 3)])
|
|
111
|
+
# pausa que sobrou menor que 0,25 s não é corte
|
|
112
|
+
emendado: list[list[float]] = []
|
|
113
|
+
for s, e in out:
|
|
114
|
+
if emendado and s - emendado[-1][1] < PAUSA_CORTA_S:
|
|
115
|
+
emendado[-1][1] = max(emendado[-1][1], e)
|
|
116
|
+
else:
|
|
117
|
+
emendado.append([s, e])
|
|
118
|
+
return emendado
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def conferir(trechos: list[list[float]], palavras: list[dict]) -> dict:
|
|
122
|
+
"""A conta das regras: borda em cima de palavra, folga mínima, pausa entre trechos."""
|
|
123
|
+
ws = _vizinhas(palavras)
|
|
124
|
+
dentro = 0
|
|
125
|
+
folgas = []
|
|
126
|
+
for s, e in trechos:
|
|
127
|
+
for w in ws:
|
|
128
|
+
if w["start"] + 0.001 < s < w["end"] - 0.001 or w["start"] + 0.001 < e < w["end"] - 0.001:
|
|
129
|
+
dentro += 1
|
|
130
|
+
a = [w["end"] for w in ws if w["end"] <= s + 0.001]
|
|
131
|
+
d = [w["start"] for w in ws if w["start"] >= e - 0.001]
|
|
132
|
+
if a:
|
|
133
|
+
folgas.append(s - max(a))
|
|
134
|
+
if d:
|
|
135
|
+
folgas.append(min(d) - e)
|
|
136
|
+
pausas = [trechos[i + 1][0] - trechos[i][1] for i in range(len(trechos) - 1)]
|
|
137
|
+
return {"cortes_dentro_de_palavra": dentro, "folga_min_s": round(min(folgas), 3) if folgas else None,
|
|
138
|
+
"menor_pausa_cortada_s": round(min(pausas), 3) if pausas else None, "trechos": len(trechos)}
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def tirar_trechos(trechos: list[list[float]], tirar: list[list[float]], palavras: list[dict] | None = None) -> list[list[float]]:
|
|
142
|
+
"""A decisão da IA (tomada repetida, erro, falso começo): tira [a, b] dos trechos. Quem tira ganha as palavras
|
|
143
|
+
inteiras que o intervalo toca (sobra de meia palavra não fica), e sobra menor que o mínimo some. As bordas novas
|
|
144
|
+
ainda passam por `ajustar_a_palavras`, que as põe de volta no vão entre palavras."""
|
|
145
|
+
ws = _vizinhas(palavras or [])
|
|
146
|
+
out = [list(t) for t in trechos]
|
|
147
|
+
for a, b in sorted(tirar):
|
|
148
|
+
for w in ws:
|
|
149
|
+
if w["start"] < b and w["end"] > a:
|
|
150
|
+
a, b = min(a, w["start"]), max(b, w["end"])
|
|
151
|
+
novo = []
|
|
152
|
+
for s, e in out:
|
|
153
|
+
if b <= s or a >= e:
|
|
154
|
+
novo.append([s, e])
|
|
155
|
+
continue
|
|
156
|
+
if a - s >= MIN_TRECHO_S:
|
|
157
|
+
novo.append([s, a])
|
|
158
|
+
if e - b >= MIN_TRECHO_S:
|
|
159
|
+
novo.append([b, e])
|
|
160
|
+
out = novo
|
|
161
|
+
return out
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def frase_do_trecho(s: float, e: float, palavras: list[dict]) -> str:
|
|
165
|
+
return " ".join(w["text"] for w in palavras if w["start"] >= s - 0.01 and w["end"] <= e + 0.01)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def montar(video: Path, edit: Path, folga: float = FOLGA_PADRAO, saida: Path | None = None,
|
|
169
|
+
tirar: list[list[float]] | None = None) -> dict:
|
|
170
|
+
from common import load_json, probe_video # noqa: PLC0415
|
|
171
|
+
|
|
172
|
+
tr = load_json(edit / "transcripts" / f"{video.stem}.json")
|
|
173
|
+
info = probe_video(video)
|
|
174
|
+
fps = float(info.get("fps") or 30)
|
|
175
|
+
dur = float(tr.get("duration") or info.get("duration"))
|
|
176
|
+
vad = vad_fala(video)
|
|
177
|
+
erro_ae = None
|
|
178
|
+
try:
|
|
179
|
+
energia = auto_editor_fala(video, edit / "auto_editor.json", fps)
|
|
180
|
+
except Exception as exc: # sem o auto-editor o VAD segue sozinho; o relatório diz
|
|
181
|
+
energia, erro_ae = [], str(exc)
|
|
182
|
+
base, solitarios = filtrar_ruido(vad, energia) if energia else (vad, 0)
|
|
183
|
+
if tirar:
|
|
184
|
+
base = tirar_trechos(base, tirar, tr["words"])
|
|
185
|
+
trechos = ajustar_a_palavras(base, tr["words"], dur, folga)
|
|
186
|
+
rel = conferir(trechos, tr["words"])
|
|
187
|
+
rel.update({"vad_trechos": len(vad), "ruido_tirado": solitarios, "auto_editor": "ok" if not erro_ae else erro_ae,
|
|
188
|
+
"tirado_pela_ia": tirar or [], "folga_s": folga, "fade_s": FADE_S, "mantido_s": round(sum(e - s for s, e in trechos), 2), "duracao_s": round(dur, 2)})
|
|
189
|
+
edl = {"version": 1, "sources": {video.stem: str(video)},
|
|
190
|
+
"output": {"format": "vertical", "width": 1080, "height": 1920}, "grade": "",
|
|
191
|
+
"jcut": {"lead_frames": 4, "tail_trim_frames": 2},
|
|
192
|
+
"ranges": [{"id": f"p{i + 1:02d}", "source": video.stem, "start": s, "end": e,
|
|
193
|
+
"quote": frase_do_trecho(s, e, tr["words"]), "reason": "1º passe (VAD + onda); a IA escolhe a tomada"}
|
|
194
|
+
for i, (s, e) in enumerate(trechos)]}
|
|
195
|
+
out = saida or edit / "edl_manual.json"
|
|
196
|
+
out.write_text(json.dumps(edl, ensure_ascii=False, indent=1), encoding="utf-8")
|
|
197
|
+
(edit / "primeiro_passe.json").write_text(json.dumps(rel, ensure_ascii=False, indent=1), encoding="utf-8")
|
|
198
|
+
return rel
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def main() -> int:
|
|
202
|
+
ap = argparse.ArgumentParser(description="1º passe do corte: silero-vad + auto-editor + palavras.")
|
|
203
|
+
ap.add_argument("video", type=Path)
|
|
204
|
+
ap.add_argument("edit", type=Path)
|
|
205
|
+
ap.add_argument("--folga", type=float, default=FOLGA_PADRAO)
|
|
206
|
+
ap.add_argument("--saida", type=Path, default=None)
|
|
207
|
+
a = ap.parse_args()
|
|
208
|
+
rel = montar(a.video.absolute(), a.edit.absolute(), a.folga, a.saida)
|
|
209
|
+
print(json.dumps(rel, ensure_ascii=False, indent=1))
|
|
210
|
+
return 0
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
if __name__ == "__main__":
|
|
214
|
+
sys.exit(main())
|