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.
Files changed (85) hide show
  1. package/LICENSES/edit2-terceiros.md +31 -0
  2. package/LICENSES/models-dev-MIT.txt +25 -0
  3. package/README.md +2 -2
  4. package/editor/SKILL.md +24 -0
  5. package/editor/THIRD_PARTY_NOTICES.md +1 -0
  6. package/editor/assets/baixar.json +9 -1
  7. package/editor/helpers/df_limpar.py +29 -0
  8. package/editor/helpers/edit2.py +155 -0
  9. package/editor/helpers/enquadrar_camera.py +77 -0
  10. package/editor/helpers/face_layout.py +20 -0
  11. package/editor/helpers/face_mediapipe.py +147 -0
  12. package/editor/helpers/instalar_df.sh +7 -0
  13. package/editor/helpers/legenda_ass.py +124 -0
  14. package/editor/helpers/look_ffmpeg.py +105 -0
  15. package/editor/helpers/motor_palavras.py +157 -0
  16. package/editor/helpers/primeiro_passe.py +214 -0
  17. package/editor/helpers/som_edit2.py +119 -0
  18. package/editor/helpers/transcribe.py +31 -2
  19. package/editor/pyproject.toml +9 -0
  20. package/editor/references/edit2.md +22 -0
  21. package/editor/uv.lock +3342 -214
  22. package/kits/3d/LICENSES/README.md +11 -0
  23. package/kits/3d/animacoes.js +87 -0
  24. package/kits/3d/base.js +44 -0
  25. package/kits/3d/catalogo.js +38 -0
  26. package/kits/3d/cenario.js +162 -0
  27. package/kits/3d/exemplos/asfalto.mjs +91 -0
  28. package/kits/3d/maquinas.js +97 -0
  29. package/kits/3d/montador.js +142 -0
  30. package/kits/3d/package.json +1 -0
  31. package/kits/3d/personagem.js +86 -0
  32. package/kits/3d/remotion/Maquete.jsx +42 -0
  33. package/kits/3d/remotion/Root.jsx +10 -0
  34. package/kits/3d/render.mjs +73 -0
  35. package/lib/cli/comando-api.js +30 -8
  36. package/lib/cli/modelos-lista.js +58 -0
  37. package/lib/cli/sessao.js +11 -0
  38. package/lib/cli/subcomandos.js +3 -2
  39. package/lib/outra-pasta.js +1 -1
  40. package/lib/permissao-sistema.js +60 -0
  41. package/lib/permission/perigo.js +1 -0
  42. package/lib/permission/permissao.js +1 -0
  43. package/lib/permission/sem-perguntar.js +29 -0
  44. package/lib/plugin/desktop.js +5 -4
  45. package/lib/plugin/estudio/cota.js +4 -2
  46. package/lib/plugin/estudio/dialogo3d.js +71 -0
  47. package/lib/plugin/estudio/effectcraft.js +1 -1
  48. package/lib/plugin/estudio/glifos.js +2 -0
  49. package/lib/plugin/estudio/gsap-local.js +43 -0
  50. package/lib/plugin/estudio/index.js +63 -10
  51. package/lib/plugin/estudio/kit-ae/base.jsx +1 -1
  52. package/lib/plugin/estudio/kit-ae/cenas.jsx +7 -4
  53. package/lib/plugin/estudio/kit-ae/destrava.jsx +211 -0
  54. package/lib/plugin/estudio/maquina-diretor.js +1 -1
  55. package/lib/plugin/estudio/maquina-elementos.js +2 -2
  56. package/lib/plugin/estudio/maquina.js +26 -8
  57. package/lib/plugin/estudio/motion-plano/ajustar.js +139 -0
  58. package/lib/plugin/estudio/motion-plano/critico.js +59 -0
  59. package/lib/plugin/estudio/motion-plano/estrutura.js +111 -0
  60. package/lib/plugin/estudio/motion-plano/fatos.js +137 -0
  61. package/lib/plugin/estudio/motion-plano/index.js +75 -0
  62. package/lib/plugin/estudio/motion-plano/llm.js +59 -0
  63. package/lib/plugin/estudio/motion-plano/motor.js +50 -0
  64. package/lib/plugin/estudio/motion-plano/passos.js +38 -0
  65. package/lib/plugin/estudio/motion-plano/pele.js +36 -0
  66. package/lib/plugin/estudio/motion-plano/plano.js +152 -0
  67. package/lib/plugin/estudio/motion-plano/regua.js +100 -0
  68. package/lib/plugin/estudio/personagem.js +3 -1
  69. package/lib/plugin/estudio/referencias.js +1 -1
  70. package/lib/provider/catalogo.js +161 -0
  71. package/lib/provider/local/catalogo-embutido.json.gz +0 -0
  72. package/lib/provider/local/motor.js +140 -27
  73. package/lib/provider/local/orcamento.js +193 -0
  74. package/lib/provider/local/provedor.js +127 -14
  75. package/lib/provider/local/recorte.js +170 -0
  76. package/lib/provider/local/revezamento.js +121 -0
  77. package/lib/session/runner/act.js +28 -3
  78. package/lib/skill/catalogo.js +5 -1
  79. package/lib/tool/tools.js +5 -1
  80. package/package.json +1 -1
  81. package/vendor/fontes/ttf/ORIGEM.txt +1 -0
  82. package/vendor/fontes/ttf/inter-800.ttf +0 -0
  83. package/vendor/gsap/ORIGEM.txt +6 -0
  84. package/vendor/gsap/gsap.min.js +11 -0
  85. 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())