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,31 @@
1
+ # Edit 2.0: terceiros (licença e link)
2
+
3
+ Cada dependência nova do Edit 2.0 (`editor/pyproject.toml`, bloco "Edit 2.0") tem uma linha aqui. Licença conferida
4
+ em 13/11/2026 na pesquisa da equipe `[LI]` (arquivo LICENSE ou API do GitHub). Regra da casa: GPL/AGPL nunca
5
+ é importada; só poderia ser chamada por linha de comando, e preferimos não usar. Remotion fica de fora (licença paga).
6
+
7
+ | Peça | Licença | Como entra | Link |
8
+ |---|---|---|---|
9
+ | whisperx | BSD-2-Clause | importado (alinhamento palavra a palavra; diarização não usada) | https://github.com/m-bain/whisperX/blob/main/LICENSE |
10
+ | faster-whisper | MIT | via whisperx | https://github.com/SYSTRAN/faster-whisper/blob/master/LICENSE |
11
+ | wav2vec2 PT (modelo de alinhamento) | Apache-2.0 | baixado pelo whisperx | https://huggingface.co/jonatasgrosman/wav2vec2-large-xlsr-53-portuguese |
12
+ | whisper.cpp | MIT | só por linha de comando (`whisper-cli`), reserva do whisperx | https://github.com/ggml-org/whisper.cpp/blob/master/LICENSE |
13
+ | silero-vad | MIT | importado (fala vs silêncio) | https://github.com/snakers4/silero-vad/blob/master/LICENSE |
14
+ | auto-editor | Unlicense (domínio público) | linha de comando (1º passe do corte) | https://github.com/WyattBlue/auto-editor/blob/master/LICENSE |
15
+ | mediapipe | Apache-2.0 | importado (detector de rosto) | https://github.com/google-ai-edge/mediapipe/blob/master/LICENSE |
16
+ | BlazeFace short-range (`blaze_face_short_range.tflite`) | Apache-2.0 segundo o model card do Google `[VERIFICAR: cartão não relido nesta sessão]` | baixado na 1ª vez para `editor/assets/models/` (sha256 conferido, `assets/baixar.json`) | https://storage.googleapis.com/mediapipe-assets/MediaPipe%20BlazeFace%20Model%20Card%20(Short%20Range).pdf |
17
+ | pysubs2 | MIT | importado (legenda ASS) | https://github.com/tkarabela/pysubs2/blob/master/LICENSE.txt |
18
+ | libass (dentro do ffmpeg) | ISC | queima a legenda ASS | https://github.com/libass/libass/blob/master/COPYING |
19
+ | pyloudnorm | MIT | importado (mede LUFS) | https://github.com/csteinmetz1/pyloudnorm/blob/master/LICENSE |
20
+ | ffmpeg-normalize | MIT | importado/linha de comando (-14 LUFS) | https://github.com/slhck/ffmpeg-normalize/blob/master/LICENSE.md |
21
+ | DeepFilterNet | MIT ou Apache-2.0, à escolha | ambiente à parte (`~/.primocode/editor/df-venv`), chamado por processo | https://github.com/Rikorose/DeepFilterNet/blob/main/LICENSE |
22
+ | FFmpeg (`sidechaincompress`, `lut3d`, `noise`, `deshake`) | LGPL-2.1 (GPL se a build tiver `--enable-gpl`/libx264) | binário do sistema, por linha de comando | https://ffmpeg.org/legal.html |
23
+ | browser-use/video-use (só as regras do corte) | MIT | ideia, sem código novo; crédito em `editor/THIRD_PARTY_NOTICES.md` | https://github.com/browser-use/video-use/blob/main/LICENSE |
24
+ | pyautoflip (só a ideia da câmera suave) | MIT | nenhuma linha copiada | https://github.com/AhmedHisham1/pyautoflip |
25
+
26
+ ## Fora de propósito
27
+
28
+ - Remotion: licença paga para a Paris Group. Motion e legenda especial seguem no HyperFrames.
29
+ - pyannote (diarização): modelos "gated" no Hugging Face. Fora por enquanto (vem como dependência do whisperx, não é chamado).
30
+ - vid.stab: exige build GPL do ffmpeg. Usamos `deshake`.
31
+ - pedalboard (GPL-3.0), ultralytics (AGPL-3.0), whisper-timestamped (AGPL-3.0), insightface (modelos não comerciais): nunca.
@@ -0,0 +1,25 @@
1
+ Catálogo de modelos lido do models.dev (https://github.com/anomalyco/models.dev), licença MIT.
2
+ Usado em: lib/provider/catalogo.js e lib/provider/local/catalogo-embutido.json.gz (recorte enxuto do api.json).
3
+ Ideia de camadas (rede, cache, snapshot embutido) vista no OpenCode (anomalyco/opencode, MIT). Código reescrito.
4
+
5
+ MIT License
6
+
7
+ Copyright (c) 2025 models.dev
8
+
9
+ Permission is hereby granted, free of charge, to any person obtaining a copy
10
+ of this software and associated documentation files (the "Software"), to deal
11
+ in the Software without restriction, including without limitation the rights
12
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
13
+ copies of the Software, and to permit persons to whom the Software is
14
+ furnished to do so, subject to the following conditions:
15
+
16
+ The above copyright notice and this permission notice shall be included in all
17
+ copies or substantial portions of the Software.
18
+
19
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
20
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
21
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
22
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
23
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
24
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
25
+ SOFTWARE.
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # PrimoCode v11.4.0
1
+ # PrimoCode v11.5.0
2
2
 
3
3
  Agente de engenharia brasileiro para o terminal, no estilo Claude Code.
4
4
  Cria arquivos de verdade, roda comandos, **controla o navegador e o desktop** —
@@ -9,7 +9,7 @@ OpenRouter, e cada apelido (`top`/`main`/`fast`) é uma **corrente**: se um mode
9
9
  está no limite de pedidos, o próximo atende. Você não vê o erro, e não paga nada.
10
10
 
11
11
  ```
12
- ▐▛███▜▌ Primo Code v11.4.0
12
+ ▐▛███▜▌ Primo Code v11.5.0
13
13
  ▝▜█████▛▘ Bem-vindo de volta, Joel
14
14
  ▘▘ ▝▝ ~/primocode · /dir <pasta> muda
15
15
  ```
package/editor/SKILL.md CHANGED
@@ -67,6 +67,30 @@ aparece em cada momento e por quê. O jeito de trabalhar:
67
67
  - Um render por vez na mesma pasta.
68
68
  - Dinheiro só com o ok da pessoa: imagem, vídeo, trilha ou efeito gerado por IA, serviço pago.
69
69
 
70
+ ## Edit 2.0: o que você decide e o que o helper mede
71
+
72
+ Para um vídeo falado de uma tomada só (ou poucas), o caminho curto é o `helpers/edit2.py` (`references/edit2.md`).
73
+ Ele mede; **você decide o que sai e conta em uma linha**:
74
+
75
+ - **Você decide a tomada e o erro.** Leia `fala_do_corte.md`/`takes_packed.md`, ache falso começo, palavra repetida
76
+ e frase dita duas vezes, e passe cada um como `--tirar a-b` (segundos da fonte). Fique com a última tomada inteira.
77
+ O que o `primeiro_passe.py` já resolve sozinho (VAD + onda): respiro, estalo, ruído. Nunca tire uma ressalva, número
78
+ ou condição que muda o sentido.
79
+ - **Regras que o helper garante** (do browser-use/video-use, MIT): nunca cortar dentro de palavra; folga de 30 a 200 ms
80
+ em cada borda; fade de 30 ms em cada emenda; pausa menor que 0,25 s não vira corte. Leia o `primeiro_passe.json`:
81
+ `cortes_dentro_de_palavra` tem que ser 0. Se não for, não entregue.
82
+ - **Palavra a palavra vem do WhisperX.** Se o relatório diz `motor: whisper.cpp` ou outro, o tempo é menos justo: diga.
83
+ Nome de marca ou sigla que o Whisper erra ("IA" virou "Iá"): rode de novo com `--prompt`.
84
+ - **Legenda simples** é ASS queimada (`legenda_ass.py`); sem libass no ffmpeg, o relatório diz que usou PNG. Legenda
85
+ especial e motion continuam no HyperFrames. Nada de Remotion (licença paga).
86
+ - **Som:** voz limpa (DeepFilterNet, se instalado), música abaixo da voz com `sidechaincompress`, tudo em -14 LUFS.
87
+ O número que vale é o medido (pyloudnorm) no arquivo final, não o pedido.
88
+ - **Rosto e cor:** o corte segue o rosto (MediaPipe, câmera suave); cor só com os filtros do ffmpeg e LUT própria
89
+ (`look_ffmpeg.py`). Estabilizar (`deshake`) só se a tomada tremer; nunca vid.stab.
90
+ - **Entrega honesta:** diga o que não rodou (DeepFilterNet ausente, libass ausente, motor de reserva) e o antes/depois
91
+ medido (duração, cortes, LUFS, % do rosto enquadrado). Quem aprova a qualidade é a pessoa, não você.
92
+ - Licença de tudo o que entra: `LICENSES/edit2-terceiros.md`. GPL/AGPL nunca é importada.
93
+
70
94
  ## Onde as coisas ficam
71
95
 
72
96
  - `<SKILL>`: a pasta deste SKILL.md. Todo helper roda assim:
@@ -18,6 +18,7 @@ Arquivos deste projeto que têm código adaptado (todos começam com a linha
18
18
  - `helpers/render_cut.py`
19
19
  - `helpers/grade.py`
20
20
  - `helpers/timeline_view.py`
21
+ - `helpers/primeiro_passe.py` (só as regras do corte: nunca cortar dentro de palavra, folga de 30 a 200 ms, fade de 30 ms)
21
22
 
22
23
  Texto da licença, como publicado pelo projeto original:
23
24
 
@@ -594,6 +594,14 @@
594
594
  "bytes": 16454537,
595
595
  "grupo": "espelho",
596
596
  "origem": "MediaPipe selfie_multiclass (Apache-2.0), convertido para ONNX pelo EDIT IA PRO"
597
+ },
598
+ {
599
+ "caminho": "assets/models/blaze_face_short_range.tflite",
600
+ "sha256": "b4578f35940bf5a1a655214a1cce5cab13eba73c1297cd78e1a04c2380b0152f",
601
+ "bytes": 229746,
602
+ "grupo": "essencial",
603
+ "url": "https://storage.googleapis.com/mediapipe-models/face_detector/blaze_face_short_range/float16/1/blaze_face_short_range.tflite",
604
+ "origem": "Google MediaPipe BlazeFace short-range (Apache-2.0, conferir model card)"
597
605
  }
598
606
  ]
599
- }
607
+ }
@@ -0,0 +1,29 @@
1
+ """Roda DENTRO do ambiente do DeepFilterNet (~/.primocode/editor/df-venv): limpa a voz de um WAV.
2
+
3
+ Uso: <python do df-venv> df_limpar.py <entrada.wav> <saida.wav>
4
+ O DeepFilterNet (MIT ou Apache-2.0) trabalha a 48 kHz; entra e sai WAV mono/estéreo de 48 kHz.
5
+ Fica separado do ambiente do editor porque pede numpy<2 e torch antigo.
6
+ """
7
+ import sys
8
+
9
+ import numpy as np
10
+ import soundfile as sf
11
+ import torch
12
+ from df.enhance import enhance, init_df
13
+
14
+
15
+ def main(entrada, saida):
16
+ modelo, estado, _ = init_df()
17
+ audio, sr = sf.read(entrada, dtype="float32", always_2d=True)
18
+ if sr != estado.sr():
19
+ raise SystemExit(f"esperava {estado.sr()} Hz, veio {sr}")
20
+ saidas = []
21
+ for c in range(audio.shape[1]):
22
+ x = torch.from_numpy(np.ascontiguousarray(audio[:, c])).unsqueeze(0)
23
+ saidas.append(enhance(modelo, estado, x).squeeze(0).numpy())
24
+ n = min(len(s) for s in saidas)
25
+ sf.write(saida, np.stack([s[:n] for s in saidas], axis=1), sr, subtype="PCM_16")
26
+
27
+
28
+ if __name__ == "__main__":
29
+ main(sys.argv[1], sys.argv[2])
@@ -0,0 +1,155 @@
1
+ """Edit 2.0: o pipeline inteiro de um vídeo falado, numa chamada só (a IA decide o que tirar; o resto é medido).
2
+
3
+ 1. WhisperX: palavra a palavra alinhada (whisper.cpp de reserva). motor_palavras.py
4
+ 2. 1º passe: silero-vad + auto-editor, bordas presas nas palavras. primeiro_passe.py
5
+ 3. A IA tira o que errou (`--tirar a-b`, em segundos da fonte) e fica com a última tomada.
6
+ 4. Corte pela onda + render do corte (fade de 30 ms, folga), voz em -14 LUFS. corte_por_onda.py, render_cut.py
7
+ 5. Voz limpa (DeepFilterNet, se instalado). som_edit2.py
8
+ 6. Rosto (MediaPipe) -> câmera suavizada, cor (lut3d + grão), legenda (ASS ou PNG) em UMA passada de vídeo.
9
+ 7. -14 LUFS (ffmpeg-normalize), LUFS medido (pyloudnorm), % do tempo com rosto enquadrado.
10
+
11
+ Uso:
12
+ uv run python helpers/edit2.py <video> <pasta_de_trabalho> -o <saida.mp4> [--tirar 8.09-8.59] [--look agencia]
13
+ [--grao 5] [--zoom 1.12] [--tamanho 1080x1920]
14
+ Gera <pasta>/edit/relatorio_edit2.json com o antes/depois (duração, cortes, LUFS, % enquadrado).
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import argparse
20
+ import json
21
+ import subprocess
22
+ import sys
23
+ import time
24
+ from pathlib import Path
25
+
26
+ HELPERS = Path(__file__).resolve().parent
27
+ sys.path.insert(0, str(HELPERS))
28
+
29
+
30
+ def _run(cmd: list[str]) -> None:
31
+ r = subprocess.run(cmd, capture_output=True, text=True)
32
+ if r.returncode != 0:
33
+ raise RuntimeError(f"{cmd[1] if len(cmd) > 1 else cmd[0]} falhou:\n{(r.stderr or r.stdout)[-600:]}")
34
+
35
+
36
+ def medir_video(video: Path) -> dict:
37
+ import face_mediapipe # noqa: PLC0415
38
+ import som_edit2 # noqa: PLC0415
39
+ from common import probe_video # noqa: PLC0415
40
+
41
+ info = probe_video(video)
42
+ return {"duracao_s": round(float(info["duration"]), 2), "largura": info["width"], "altura": info["height"],
43
+ **som_edit2.medir_lufs(video), **face_mediapipe.percentual_enquadrado(video)}
44
+
45
+
46
+ def cues_das_palavras(palavras: list[dict]) -> dict:
47
+ import legenda_ass # noqa: PLC0415
48
+
49
+ return {"cues": [{"start": g[0]["start"], "end": g[-1]["end"], "words": g} for g in legenda_ass.blocos(palavras)]}
50
+
51
+
52
+ def render_final(cut: Path, palavras: list[dict], trilha: dict, saida: Path, edit: Path, look: str, grao: int, zoom: float,
53
+ largura: int, altura: int) -> dict:
54
+ import enquadrar_camera as ec # noqa: PLC0415
55
+ import legenda_ass # noqa: PLC0415
56
+ import look_ffmpeg # noqa: PLC0415
57
+
58
+ cam = ec.caminho(trilha, zoom)
59
+ send = ec.escrever_sendcmd(cam, edit / "camera.sendcmd")
60
+ base = ec.cadeia(cam, send, largura, altura)
61
+ cor = look_ffmpeg.cadeia(look, grao, False, edit)
62
+ if cor:
63
+ base += "," + cor
64
+ entradas = ["-i", str(cut)]
65
+ leg = {"metodo": ""}
66
+ if legenda_ass.tem_libass():
67
+ ass = edit / "legenda.ass"
68
+ legenda_ass.montar_ass(palavras, largura, altura).save(str(ass))
69
+ fc = f"[0:v]{base},{legenda_ass.filtro_ass(ass)}[v]"
70
+ leg = {"metodo": "ass (libass)", "arquivo": str(ass)}
71
+ else:
72
+ import text_render # noqa: PLC0415
73
+
74
+ pngs = edit / "legenda_png"
75
+ estados = text_render.render_caption_track(cues_das_palavras(palavras), "legenda_limpa", "#FFD700", str(pngs),
76
+ canvas=(largura, altura), fps=30)
77
+ lista = text_render.write_ffconcat(estados, str(edit / "legenda.ffconcat"), float(cut_duracao(cut)), str(pngs / "vazio.png"), fps=30)
78
+ entradas += ["-f", "concat", "-safe", "0", "-i", str(lista)]
79
+ fc = f"[0:v]{base}[b];[1:v]format=rgba[cap];[b][cap]overlay=0:0:eof_action=pass:format=auto[v]"
80
+ leg = {"metodo": "PNG por estado (text_render + overlay): este ffmpeg não tem libass", "estados": len(estados)}
81
+ _run(["ffmpeg", "-hide_banner", "-nostdin", "-v", "error", "-y", *entradas, "-filter_complex", fc, "-map", "[v]", "-map", "0:a?",
82
+ "-c:v", "libx264", "-preset", "medium", "-crf", "18", "-pix_fmt", "yuv420p", "-c:a", "copy", "-movflags", "+faststart", str(saida)])
83
+ return {"camera": ec.relatorio(cam), "cor": cor, "legenda": leg}
84
+
85
+
86
+ def cut_duracao(p: Path) -> float:
87
+ from common import probe_video # noqa: PLC0415
88
+
89
+ return float(probe_video(p)["duration"])
90
+
91
+
92
+ def rodar(video: Path, trabalho: Path, saida: Path, tirar: list[list[float]], look: str = "agencia", grao: int = 5,
93
+ zoom: float = 1.12, tamanho: str = "1080x1920") -> dict:
94
+ import primeiro_passe # noqa: PLC0415
95
+ import rastrear_rosto # noqa: PLC0415
96
+ import som_edit2 # noqa: PLC0415
97
+ import transcribe # noqa: PLC0415
98
+ from common import load_json # noqa: PLC0415
99
+
100
+ t0 = time.time()
101
+ largura, altura = (int(x) for x in tamanho.split("x"))
102
+ edit = trabalho / "edit"
103
+ edit.mkdir(parents=True, exist_ok=True)
104
+ rel: dict = {"fonte": str(video), "passos": {}}
105
+ rel["antes"] = medir_video(video)
106
+ rel["antes"]["cortes"] = 0
107
+
108
+ tp = transcribe.transcribe_one(video, edit_dir=edit, language="pt", model=transcribe.DEFAULT_MODEL, prompt=transcribe.build_prompt(None, True), verbose=False)
109
+ tr = load_json(tp)
110
+ rel["passos"]["transcricao"] = {"motor": tr.get("motor_usado"), "palavras": len(tr["words"]), "aviso": tr.get("aviso")}
111
+
112
+ rel["passos"]["primeiro_passe"] = primeiro_passe.montar(video, edit, tirar=tirar or None)
113
+ _run([sys.executable, str(HELPERS / "corte_por_onda.py"), str(edit / "edl_manual.json"), "-o", str(edit / "edl.json")])
114
+ _run([sys.executable, str(HELPERS / "render_cut.py"), str(edit / "edl.json")]) # render 1: o corte
115
+ cut = edit / "cut.mp4"
116
+
117
+ limpo = edit / "cut_limpo.mp4"
118
+ voz = som_edit2.limpar_voz(cut, limpo)
119
+ rel["passos"]["voz"] = voz
120
+ fonte_video = limpo if voz["limpou"] else cut
121
+
122
+ tpc = transcribe.transcribe_one(cut, edit_dir=edit, language="pt", model=transcribe.DEFAULT_MODEL, prompt=transcribe.build_prompt(None, True), verbose=False)
123
+ palavras = [{"text": w["text"], "start": w["start"], "end": w["end"]} for w in load_json(tpc)["words"]]
124
+ trilha = rastrear_rosto.construir(edit, forcar=True)
125
+ bruto = edit / "final_sem_normalizar.mp4"
126
+ rel["passos"]["acabamento"] = render_final(fonte_video, palavras, trilha, bruto, edit, look, grao, zoom, largura, altura) # render 2
127
+ saida.parent.mkdir(parents=True, exist_ok=True)
128
+ rel["passos"]["som_final"] = som_edit2.normalizar(bruto, saida)
129
+
130
+ rel["depois"] = medir_video(saida)
131
+ rel["depois"]["cortes"] = len(load_json(edit / "edl.json")["ranges"]) - 1
132
+ rel["segundos"] = round(time.time() - t0, 1)
133
+ (edit / "relatorio_edit2.json").write_text(json.dumps(rel, ensure_ascii=False, indent=1), encoding="utf-8")
134
+ return rel
135
+
136
+
137
+ def main() -> int:
138
+ ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
139
+ ap.add_argument("video", type=Path)
140
+ ap.add_argument("trabalho", type=Path)
141
+ ap.add_argument("-o", "--saida", type=Path, required=True)
142
+ ap.add_argument("--tirar", action="append", default=[], help="a-b em segundos da fonte (repetível): o que a IA decidiu tirar")
143
+ ap.add_argument("--look", default="agencia")
144
+ ap.add_argument("--grao", type=int, default=5)
145
+ ap.add_argument("--zoom", type=float, default=1.12)
146
+ ap.add_argument("--tamanho", default="1080x1920")
147
+ a = ap.parse_args()
148
+ tirar = [[float(x) for x in t.split("-")] for t in a.tirar]
149
+ rel = rodar(a.video.absolute(), a.trabalho.absolute(), a.saida.absolute(), tirar, a.look, a.grao, a.zoom, a.tamanho)
150
+ print(json.dumps({"antes": rel["antes"], "depois": rel["depois"]}, ensure_ascii=False, indent=1))
151
+ return 0
152
+
153
+
154
+ if __name__ == "__main__":
155
+ sys.exit(main())
@@ -0,0 +1,77 @@
1
+ """Edit 2.0, peça 7: o enquadramento que segue o rosto (câmera suavizada) direto no ffmpeg, sem HyperFrames.
2
+
3
+ Entrada: o `rosto_trilha.json` do rastrear_rosto.py (rosto medido 10 vezes por segundo pelo MediaPipe, mediana de 5,
4
+ zona morta de 3%, transição de 0,5 s: a suavização que já existia; a ideia vem do pyautoflip, MIT, sem código copiado).
5
+ Saída: um `sendcmd` que move o `crop` a cada 0,1 s e a cadeia `-vf` (crop com zoom fixo + scale para o quadro final).
6
+
7
+ Regras: o corte fica sempre dentro do quadro; o olho fica a ~40% da altura do corte (regra dos terços); tomada sem
8
+ rosto fica no enquadramento de antes (centro) e o relatório conta.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ from pathlib import Path
15
+
16
+ ZOOM_PADRAO = 1.12
17
+ OLHO_NA_ALTURA = 0.42 # onde o centro do rosto cai dentro do corte (fração da altura do corte)
18
+
19
+
20
+ def _par(n: float) -> int:
21
+ return int(n) // 2 * 2
22
+
23
+
24
+ def caminho(trilha: dict, zoom: float = ZOOM_PADRAO) -> dict:
25
+ """-> {"cw","ch","pontos":[(t,x,y)], "sem_rosto_s": segundos de tomada sem rosto}"""
26
+ W, H = int(trilha["largura"]), int(trilha["altura"])
27
+ cw, ch = _par(W / zoom), _par(H / zoom)
28
+ pts: list[tuple[float, int, int]] = []
29
+ sem = 0.0
30
+ for tom in trilha["tomadas"]:
31
+ pontos = tom.get("pontos") or []
32
+ if not pontos:
33
+ sem += float(tom["ate"]) - float(tom["de"])
34
+ for k in range(int((tom["ate"] - tom["de"]) / 0.1) + 1):
35
+ pts.append((round(tom["de"] + k * 0.1, 3), (W - cw) // 2, (H - ch) // 2))
36
+ continue
37
+ for t, cx, cy, w, h, medido in pontos:
38
+ x = min(max(cx * W - cw / 2, 0), W - cw)
39
+ y = min(max(cy * H - ch * OLHO_NA_ALTURA, 0), H - ch)
40
+ pts.append((float(t), int(round(x)), int(round(y))))
41
+ pts.sort()
42
+ return {"cw": cw, "ch": ch, "pontos": pts, "sem_rosto_s": round(sem, 2)}
43
+
44
+
45
+ def escrever_sendcmd(cam: dict, destino: Path, alvo: str = "cam") -> Path:
46
+ linhas = []
47
+ for t, x, y in cam["pontos"]:
48
+ linhas.append(f"{t:.3f} crop@{alvo} x {x}, crop@{alvo} y {y};")
49
+ destino.write_text("\n".join(linhas) + "\n", encoding="utf-8")
50
+ return destino
51
+
52
+
53
+ def _esc(p: Path) -> str:
54
+ return str(p).replace("\\", "/").replace(":", "\\:").replace("'", "\\'")
55
+
56
+
57
+ def cadeia(cam: dict, sendcmd: Path, saida_w: int, saida_h: int) -> str:
58
+ x0, y0 = (cam["pontos"][0][1], cam["pontos"][0][2]) if cam["pontos"] else (0, 0)
59
+ return (f"sendcmd=f='{_esc(sendcmd)}',crop@cam={cam['cw']}:{cam['ch']}:{x0}:{y0}:exact=1,"
60
+ f"scale={saida_w}:{saida_h}:flags=lanczos,setsar=1")
61
+
62
+
63
+ def relatorio(cam: dict) -> dict:
64
+ xs = [p[1] for p in cam["pontos"]]
65
+ ys = [p[2] for p in cam["pontos"]]
66
+ salto = max([abs(xs[i + 1] - xs[i]) for i in range(len(xs) - 1)] or [0])
67
+ return {"zoom": round(1080 / cam["cw"], 3), "pontos": len(cam["pontos"]), "x_min": min(xs, default=0), "x_max": max(xs, default=0),
68
+ "y_min": min(ys, default=0), "y_max": max(ys, default=0), "maior_salto_px_por_0_1s": salto,
69
+ "sem_rosto_s": cam["sem_rosto_s"]}
70
+
71
+
72
+ if __name__ == "__main__":
73
+ import sys
74
+
75
+ tr = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
76
+ c = caminho(tr)
77
+ print(json.dumps(relatorio(c), ensure_ascii=False))
@@ -98,6 +98,26 @@ def _detector(w: int, h: int):
98
98
 
99
99
 
100
100
  def detect_array(bgr) -> dict | None:
101
+ """MediaPipe (Edit 2.0) when installed, YuNet otherwise or when MediaPipe sees nothing."""
102
+ usou_mp = False
103
+ try:
104
+ import face_mediapipe # noqa: PLC0415
105
+ if face_mediapipe.disponivel():
106
+ usou_mp = True
107
+ r = face_mediapipe.detect_array(bgr)
108
+ if r is not None:
109
+ return r
110
+ except Exception:
111
+ pass
112
+ try:
113
+ return detect_array_yunet(bgr)
114
+ except FaceLayoutError:
115
+ if usou_mp: # o MediaPipe respondeu "sem rosto" e o YuNet não está instalado: sem rosto mesmo
116
+ return None
117
+ raise
118
+
119
+
120
+ def detect_array_yunet(bgr) -> dict | None:
101
121
  """The speaker's face in one BGR frame (numpy HxWx3): the largest confident box.
102
122
 
103
123
  -> {"x0", "y0", "x1", "y1", "eyes_y", "mouth_y", "score"} as fractions of the frame, or None.
@@ -0,0 +1,147 @@
1
+ """Edit 2.0, peça 3: detector de rosto MediaPipe (Apache-2.0, BlazeFace short-range) para o enquadramento.
2
+
3
+ Mesmo retorno do `face_layout.detect_array` ({x0,y0,x1,y1,eyes_y,mouth_y,score}, frações do quadro), então a
4
+ câmera suavizada que já existe (rastrear_rosto.py: mediana, zona morta, transição de 0,5 s; ideia de câmera suave
5
+ do pyautoflip, MIT, sem código copiado) segue igual e só troca quem enxerga o rosto. Sem o MediaPipe, o
6
+ face_layout continua no YuNet.
7
+
8
+ Também mede "% do tempo com rosto enquadrado" de um vídeo (`enquadrado`): rosto inteiro dentro da zona segura.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ import subprocess
15
+ from pathlib import Path
16
+
17
+ MODELO = Path(os.environ.get("PRIMOCODE_ROSTO_MODELO") or Path(__file__).resolve().parent.parent / "assets" / "models" / "blaze_face_short_range.tflite")
18
+ SCORE_MIN = 0.6
19
+ MIN_FACE_H = 0.06
20
+ # zona onde um rosto "bem enquadrado" fica (frações do quadro): centro nos 2/3 do meio, rosto inteiro no quadro
21
+ ZONA_X = (0.25, 0.75)
22
+ ZONA_Y = (0.12, 0.60)
23
+ _DET = {}
24
+
25
+
26
+ MODELO_URL = "https://storage.googleapis.com/mediapipe-models/face_detector/blaze_face_short_range/float16/1/blaze_face_short_range.tflite"
27
+ MODELO_SHA256 = "b4578f35940bf5a1a655214a1cce5cab13eba73c1297cd78e1a04c2380b0152f" # o mesmo de assets/baixar.json
28
+
29
+
30
+ def garantir_modelo() -> bool:
31
+ """O modelo (230 KB) não vai no pacote: baixa na primeira vez (como o assets/baixar.json) e confere o sha256."""
32
+ if MODELO.exists():
33
+ return True
34
+ try:
35
+ import hashlib # noqa: PLC0415
36
+ import urllib.request # noqa: PLC0415
37
+
38
+ dados = urllib.request.urlopen(MODELO_URL, timeout=30).read()
39
+ if hashlib.sha256(dados).hexdigest() != MODELO_SHA256:
40
+ return False
41
+ MODELO.parent.mkdir(parents=True, exist_ok=True)
42
+ MODELO.write_bytes(dados)
43
+ return True
44
+ except Exception:
45
+ return False
46
+
47
+
48
+ def disponivel() -> bool:
49
+ if os.environ.get("PRIMOCODE_ROSTO") == "yunet" or not garantir_modelo():
50
+ return False
51
+ try:
52
+ import mediapipe # noqa: F401, PLC0415
53
+ return True
54
+ except Exception:
55
+ return False
56
+
57
+
58
+ def _detector():
59
+ if "d" not in _DET:
60
+ import mediapipe as mp # noqa: PLC0415
61
+ from mediapipe.tasks import python as mpt # noqa: PLC0415
62
+ from mediapipe.tasks.python import vision # noqa: PLC0415
63
+
64
+ opts = vision.FaceDetectorOptions(base_options=mpt.BaseOptions(model_asset_path=str(MODELO)),
65
+ min_detection_confidence=SCORE_MIN)
66
+ _DET["d"] = (vision.FaceDetector.create_from_options(opts), mp)
67
+ return _DET["d"]
68
+
69
+
70
+ def escolher(deteccoes: list[dict], w: int, h: int) -> dict | None:
71
+ """A maior caixa confiável. `deteccoes`: [{"x","y","w","h","score","eyes_y","mouth_y"}] em pixels."""
72
+ melhor = None
73
+ for d in deteccoes:
74
+ if d["score"] < SCORE_MIN or d["h"] < MIN_FACE_H * h:
75
+ continue
76
+ if melhor is None or d["w"] * d["h"] > melhor["w"] * melhor["h"]:
77
+ melhor = d
78
+ if melhor is None:
79
+ return None
80
+ return {"x0": max(0.0, melhor["x"] / w), "y0": max(0.0, melhor["y"] / h),
81
+ "x1": min(1.0, (melhor["x"] + melhor["w"]) / w), "y1": min(1.0, (melhor["y"] + melhor["h"]) / h),
82
+ "eyes_y": melhor["eyes_y"] / h, "mouth_y": melhor["mouth_y"] / h, "score": melhor["score"]}
83
+
84
+
85
+ def detect_array(bgr) -> dict | None:
86
+ import numpy as np # noqa: PLC0415
87
+
88
+ det, mp = _detector()
89
+ h, w = bgr.shape[:2]
90
+ rgb = np.ascontiguousarray(bgr[:, :, ::-1])
91
+ res = det.detect(mp.Image(image_format=mp.ImageFormat.SRGB, data=rgb))
92
+ out = []
93
+ for d in res.detections:
94
+ bb = d.bounding_box
95
+ kp = d.keypoints # olho dir., olho esq., nariz, centro da boca, orelhas
96
+ score = d.categories[0].score if d.categories else 0.0
97
+ if len(kp) >= 4:
98
+ eyes, mouth = (kp[0].y + kp[1].y) / 2 * h, kp[3].y * h
99
+ else:
100
+ eyes, mouth = bb.origin_y + bb.height * 0.4, bb.origin_y + bb.height * 0.8
101
+ out.append({"x": bb.origin_x, "y": bb.origin_y, "w": bb.width, "h": bb.height, "score": score,
102
+ "eyes_y": eyes, "mouth_y": mouth})
103
+ return escolher(out, w, h)
104
+
105
+
106
+ def enquadrado(rosto: dict | None) -> bool:
107
+ if not rosto:
108
+ return False
109
+ cx, cy = (rosto["x0"] + rosto["x1"]) / 2, (rosto["y0"] + rosto["y1"]) / 2
110
+ return ZONA_X[0] <= cx <= ZONA_X[1] and ZONA_Y[0] <= cy <= ZONA_Y[1] and rosto["x0"] > 0.0 and rosto["x1"] < 1.0 and rosto["y0"] > 0.0
111
+
112
+
113
+ def percentual_enquadrado(video: Path, por_segundo: float = 4.0, janelas: list[tuple[float, float]] | None = None) -> dict:
114
+ """% dos quadros amostrados (4 por segundo) em que há rosto dentro da zona. `janelas`: só esses trechos."""
115
+ import numpy as np # noqa: PLC0415
116
+
117
+ from common import probe_video # noqa: PLC0415
118
+
119
+ info = probe_video(video)
120
+ w0, h0 = int(info["width"]), int(info["height"])
121
+ esc = min(1.0, 540 / min(w0, h0))
122
+ w, h = max(2, int(round(w0 * esc / 2)) * 2), max(2, int(round(h0 * esc / 2)) * 2)
123
+ cmd = ["ffmpeg", "-hide_banner", "-nostdin", "-v", "error", "-i", str(video), "-vf", f"fps={por_segundo},scale={w}:{h}",
124
+ "-an", "-f", "rawvideo", "-pix_fmt", "bgr24", "-"]
125
+ p = subprocess.Popen(cmd, stdout=subprocess.PIPE)
126
+ tam = w * h * 3
127
+ n = com = bem = 0
128
+ dx, dy = [], []
129
+ while True:
130
+ buf = p.stdout.read(tam)
131
+ if len(buf) < tam:
132
+ break
133
+ t = n / por_segundo
134
+ n += 1
135
+ if janelas and not any(a <= t <= b for a, b in janelas):
136
+ continue
137
+ r = detect_array(np.frombuffer(buf, np.uint8).reshape(h, w, 3))
138
+ com += r is not None
139
+ bem += enquadrado(r)
140
+ if r:
141
+ dx.append(abs((r["x0"] + r["x1"]) / 2 - 0.5) * 100)
142
+ dy.append(abs((r["y0"] + r["y1"]) / 2 - 0.40) * 100)
143
+ p.wait()
144
+ total = max(1, n if not janelas else sum(1 for k in range(n) if any(a <= k / por_segundo <= b for a, b in janelas)))
145
+ return {"quadros": total, "com_rosto_pct": round(100 * com / total, 1), "enquadrado_pct": round(100 * bem / total, 1),
146
+ "desvio_centro_x_pct": round(sum(dx) / len(dx), 1) if dx else None,
147
+ "desvio_altura_pct": round(sum(dy) / len(dy), 1) if dy else None}
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env bash
2
+ # Cria o ambiente do DeepFilterNet (MIT ou Apache-2.0) fora do ambiente do editor: ele pede numpy<2 e torch 2.5.
3
+ set -euo pipefail
4
+ DEST="${PRIMOCODE_DF_VENV:-$HOME/.primocode/editor/df-venv}"
5
+ uv venv "$DEST" --python 3.12
6
+ uv pip install --python "$DEST/bin/python" deepfilternet "torch<2.6" "torchaudio<2.6" "numpy<2" soundfile
7
+ echo "DeepFilterNet pronto em $DEST"