primocode 8.42.2 → 9.1.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/README.md +41 -37
- package/bin/primocode.js +598 -360
- package/lib/acervo.js +8 -1
- package/lib/act.js +205 -114
- package/lib/api.js +35 -4
- package/lib/boas-vindas.js +42 -195
- package/lib/catalogo.js +100 -61
- package/lib/config.js +1 -1
- package/lib/conta.js +3 -24
- package/lib/diff.js +161 -0
- package/lib/diretor.js +37 -47
- package/lib/entrada.js +576 -0
- package/lib/especialistas.js +2 -2
- package/lib/estudio/assets.js +343 -0
- package/lib/estudio/identidade.js +560 -0
- package/lib/estudio/index.js +565 -0
- package/lib/estudio/plano.js +327 -0
- package/lib/estudio/projeto.js +246 -0
- package/lib/estudio/remotion.js +644 -0
- package/lib/estudio/slides.js +190 -0
- package/lib/estudio/video.js +296 -0
- package/lib/fala.js +49 -6
- package/lib/marca.js +4 -1
- package/lib/markdown.js +376 -0
- package/lib/marketplace.js +189 -0
- package/lib/pasta.js +15 -4
- package/lib/permissao.js +137 -0
- package/lib/registro.js +229 -0
- package/lib/skills.js +329 -0
- package/lib/tools.js +106 -187
- package/lib/ui.js +7 -3
- package/lib/voz.js +239 -0
- package/package.json +12 -3
- package/skills/acessibilidade/SKILL.md +28 -0
- package/skills/api-que-nao-quebra/SKILL.md +27 -0
- package/skills/depurar-de-verdade/SKILL.md +29 -0
- package/skills/editar-meu-video/SKILL.md +59 -0
- package/skills/escrever-em-portugues/SKILL.md +28 -0
- package/skills/git-limpo/SKILL.md +28 -0
- package/skills/motion-de-primeira/SKILL.md +62 -0
- package/skills/pagina-que-converte/SKILL.md +28 -0
- package/skills/performance-web/SKILL.md +28 -0
- package/skills/revisao-antes-de-entregar/SKILL.md +27 -0
- package/skills/slides-que-prendem/SKILL.md +40 -0
- package/studio/edge.py +16 -66
- package/studio/narrar.py +274 -0
- package/studio/pessoa.py +199 -0
- package/voz/primo_voz/__init__.py +0 -0
- package/voz/primo_voz/__main__.py +383 -0
- package/voz/primo_voz/escuta.py +235 -0
- package/voz/primo_voz/orbe.py +216 -0
- package/lib/audio-livre.js +0 -177
- package/lib/chaves.js +0 -149
- package/lib/claude-engine.js +0 -631
- package/lib/codex-engine.js +0 -208
- package/lib/remotion.js +0 -663
- package/lib/studio.js +0 -960
- package/studio/PRIMOCODE.md +0 -58
- package/studio/README.md +0 -154
- package/studio/__pycache__/edge.cpython-311.pyc +0 -0
- package/studio/__pycache__/elevenlabs.cpython-311.pyc +0 -0
- package/studio/__pycache__/voz.cpython-311.pyc +0 -0
- package/studio/arquivos/corte-teste-de-camera-fb823.json +0 -57
- package/studio/ferramentas/00-protocolo.md +0 -125
- package/studio/ferramentas/01-modelos.md +0 -270
- package/studio/ferramentas/README.md +0 -32
- package/studio/ferramentas/corte.md +0 -919
- package/studio/ferramentas/grade.md +0 -92
- package/studio/ferramentas/prisma.md +0 -107
- package/studio/ferramentas/prosa.md +0 -86
- package/studio/ferramentas/tela.md +0 -93
- package/studio/ferramentas/traco.md +0 -79
- package/studio/server.py +0 -2254
- package/studio/studio.log +0 -57
- package/studio/voz.py +0 -388
- package/studio/web/analise.html +0 -52
- package/studio/web/core.css +0 -1167
- package/studio/web/fontes/bebas.woff2 +0 -0
- package/studio/web/fontes/grotesk-700.woff2 +0 -0
- package/studio/web/fontes/inter-400.woff2 +0 -0
- package/studio/web/fontes/inter-700.woff2 +0 -0
- package/studio/web/fontes/jetbrains-400.woff2 +0 -0
- package/studio/web/fontes/playfair-700.woff2 +0 -0
- package/studio/web/fontes.css +0 -23
- package/studio/web/home.html +0 -217
- package/studio/web/js/analise.js +0 -510
- package/studio/web/js/audio.js +0 -607
- package/studio/web/js/editor.js +0 -1802
- package/studio/web/js/exportar.js +0 -222
- package/studio/web/js/ferramentas.js +0 -108
- package/studio/web/js/icones.js +0 -120
- package/studio/web/js/midia.js +0 -157
- package/studio/web/js/narracao.js +0 -325
- package/studio/web/js/player.js +0 -903
- package/studio/web/js/render-grade.js +0 -243
- package/studio/web/js/render-prosa.js +0 -104
- package/studio/web/js/render.js +0 -1852
- package/studio/web/js/sfx.js +0 -233
- package/studio/web/js/video.js +0 -464
- package/studio/web/js/voz-neural.js +0 -152
- package/studio/web/render.css +0 -1352
- package/studio/web/tf.html +0 -15
- package/studio/web/tool.html +0 -78
- package/studio/web/view.html +0 -27
package/studio/narrar.py
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""narrar.py — a narração, em Python, sem servidor.
|
|
3
|
+
|
|
4
|
+
"Narração: gerar em Python, com biblioteca de TTS instalada no projeto. A
|
|
5
|
+
IA escolhe a voz e o estilo da narração."
|
|
6
|
+
|
|
7
|
+
── POR QUE UM SCRIPT, E NÃO UM SERVIDOR ─────────────────────────────────────
|
|
8
|
+
O Studio antigo subia um servidor Python em localhost:7777 e falava com ele
|
|
9
|
+
por HTTP — inclusive para gerar narração. Aquilo existia porque havia uma
|
|
10
|
+
interface web para servir. A interface morreu; o servidor não tem mais razão
|
|
11
|
+
de ser, e com ele some uma classe inteira de defeito ("o estúdio não está
|
|
12
|
+
rodando", porta ocupada, processo zumbi entre sessões).
|
|
13
|
+
|
|
14
|
+
Aqui é um comando: entra texto, sai um mp3 e os tempos de cada fala.
|
|
15
|
+
|
|
16
|
+
── OS TEMPOS SÃO MEDIDOS, NÃO ESTIMADOS ─────────────────────────────────────
|
|
17
|
+
O edge-tts escreve as legendas junto com o áudio (`--write-media` +
|
|
18
|
+
`--write-subtitles`). Delas sai o instante exato de cada frase.
|
|
19
|
+
|
|
20
|
+
Isso é o que faz a cena caber na fala. Estimar duração por número de palavras
|
|
21
|
+
erra em segundos, e o erro aparece como imagem trocando antes de a frase
|
|
22
|
+
acabar — o defeito de vídeo automático mais fácil de reconhecer.
|
|
23
|
+
|
|
24
|
+
── A CORRENTE ───────────────────────────────────────────────────────────────
|
|
25
|
+
1. ELEVENLABS se houver chave com crédito (melhor entonação, custa).
|
|
26
|
+
2. EDGE TTS neural, grátis, sem chave, sem conta. ← o padrão
|
|
27
|
+
3. diz que não deu, com o comando de instalar.
|
|
28
|
+
|
|
29
|
+
Nunca uma voz de robô como "reserva": entregar um vídeo com voz dos anos 90
|
|
30
|
+
é pior que entregar sem narração e dizer o que falta.
|
|
31
|
+
|
|
32
|
+
Uso:
|
|
33
|
+
python3 narrar.py falar --texto "..." --saida narracao.mp3 [--voz antonio]
|
|
34
|
+
[--ritmo -8%] [--tom +2Hz]
|
|
35
|
+
python3 narrar.py vozes
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
import argparse
|
|
39
|
+
import json
|
|
40
|
+
import os
|
|
41
|
+
import re
|
|
42
|
+
import subprocess
|
|
43
|
+
import sys
|
|
44
|
+
import tempfile
|
|
45
|
+
from pathlib import Path
|
|
46
|
+
|
|
47
|
+
AQUI = Path(__file__).resolve().parent
|
|
48
|
+
sys.path.insert(0, str(AQUI))
|
|
49
|
+
|
|
50
|
+
# ── catálogo ────────────────────────────────────────────────────────────────
|
|
51
|
+
# As vozes neurais de português do Brasil que existem no serviço da Microsoft.
|
|
52
|
+
# Não há por que inventar mais: são estas três, e o `estilo` é o que a IA
|
|
53
|
+
# escolhe para dar registro diferente à mesma voz.
|
|
54
|
+
VOZES = [
|
|
55
|
+
{"id": "antonio", "voz": "pt-BR-AntonioNeural", "nome": "Antonio",
|
|
56
|
+
"genero": "masculina", "serve": "locução, abertura, institucional", "padrao": True},
|
|
57
|
+
{"id": "francisca", "voz": "pt-BR-FranciscaNeural", "nome": "Francisca",
|
|
58
|
+
"genero": "feminina", "serve": "conversa, explicação, tutorial"},
|
|
59
|
+
{"id": "thalita", "voz": "pt-BR-ThalitaMultilingualNeural", "nome": "Thalita",
|
|
60
|
+
"genero": "feminina", "serve": "jovem, anúncio, redes sociais"},
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
# O "estilo" não existe como parâmetro no edge-tts: ele é ritmo + tom. Estes
|
|
64
|
+
# quatro cobrem o que se pede num vídeo, e são o ponto de partida — a IA pode
|
|
65
|
+
# mandar ritmo e tom na mão quando quiser outra coisa.
|
|
66
|
+
ESTILOS = {
|
|
67
|
+
"normal": {"ritmo": "+0%", "tom": "+0Hz"},
|
|
68
|
+
"anuncio": {"ritmo": "+8%", "tom": "+2Hz"}, # mais rápido e mais alto: energia
|
|
69
|
+
"calmo": {"ritmo": "-8%", "tom": "-2Hz"}, # mais devagar e mais grave: explicação
|
|
70
|
+
"dramatico": {"ritmo": "-14%", "tom": "-4Hz"}, # pausa e peso
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def voz_de(apelido):
|
|
75
|
+
a = (apelido or "").strip().lower()
|
|
76
|
+
for v in VOZES:
|
|
77
|
+
if a in (v["id"], v["voz"].lower(), v["nome"].lower()):
|
|
78
|
+
return v
|
|
79
|
+
return VOZES[0]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def tempo_vtt(marca):
|
|
83
|
+
""""00:00:03.480" → 3.48"""
|
|
84
|
+
h, m, s = marca.split(":")
|
|
85
|
+
return int(h) * 3600 + int(m) * 60 + float(s.replace(",", "."))
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def ler_legendas(caminho):
|
|
89
|
+
"""As falas com início e fim, lidas do VTT que o edge-tts escreveu."""
|
|
90
|
+
falas = []
|
|
91
|
+
try:
|
|
92
|
+
bruto = Path(caminho).read_text(encoding="utf-8")
|
|
93
|
+
except OSError:
|
|
94
|
+
return falas
|
|
95
|
+
bloco = None
|
|
96
|
+
for linha in bruto.splitlines():
|
|
97
|
+
linha = linha.strip()
|
|
98
|
+
if "-->" in linha:
|
|
99
|
+
de, ate = [p.strip() for p in linha.split("-->")[:2]]
|
|
100
|
+
bloco = {"de": tempo_vtt(de), "ate": tempo_vtt(ate.split(" ")[0]), "texto": ""}
|
|
101
|
+
elif bloco is not None and linha:
|
|
102
|
+
bloco["texto"] = (bloco["texto"] + " " + linha).strip()
|
|
103
|
+
elif bloco is not None and not linha:
|
|
104
|
+
if bloco["texto"]:
|
|
105
|
+
falas.append(bloco)
|
|
106
|
+
bloco = None
|
|
107
|
+
if bloco and bloco["texto"]:
|
|
108
|
+
falas.append(bloco)
|
|
109
|
+
return falas
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def edge_disponivel():
|
|
113
|
+
try:
|
|
114
|
+
subprocess.run([sys.executable, "-m", "edge_tts", "--help"],
|
|
115
|
+
capture_output=True, timeout=25, check=True)
|
|
116
|
+
return True
|
|
117
|
+
except Exception:
|
|
118
|
+
return False
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# Por que uma única tentativa de pip não serve — e falha CALADA.
|
|
122
|
+
#
|
|
123
|
+
# Em Debian e Ubuntu, e no Python do Homebrew, o `pip install` no Python do
|
|
124
|
+
# sistema é recusado de saída: "externally-managed-environment" (PEP 668). Em
|
|
125
|
+
# máquina sem pip, o `-m pip` nem existe. Uma tentativa só, com a saída jogada
|
|
126
|
+
# fora, é exatamente a falha que ninguém vê: a instalação "não deu erro", o
|
|
127
|
+
# edge-tts continua ausente, e a narração cai calada na voz de reserva — o
|
|
128
|
+
# vídeo sai com outra voz e ninguém sabe por quê.
|
|
129
|
+
#
|
|
130
|
+
# Então são várias tentativas, em ordem da menos invasiva para a mais, e o
|
|
131
|
+
# erro da última fica guardado para a resposta poder contar o que houve.
|
|
132
|
+
ESTRATEGIAS = [
|
|
133
|
+
["-m", "pip", "install", "--user", "--quiet", "edge-tts"],
|
|
134
|
+
["-m", "pip", "install", "--user", "--quiet", "--break-system-packages", "edge-tts"],
|
|
135
|
+
["-m", "pip", "install", "--quiet", "edge-tts"],
|
|
136
|
+
["-m", "pip", "install", "--quiet", "--break-system-packages", "edge-tts"],
|
|
137
|
+
]
|
|
138
|
+
|
|
139
|
+
ultimo_erro_pip = ""
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def instalar_edge():
|
|
143
|
+
"""Traz o edge-tts com pip. Uma vez, e o usuário vê acontecer."""
|
|
144
|
+
global ultimo_erro_pip
|
|
145
|
+
|
|
146
|
+
# Sem pip, primeiro o ensurepip — é o que o python.org e a Apple trazem
|
|
147
|
+
# justamente para este caso.
|
|
148
|
+
try:
|
|
149
|
+
r = subprocess.run([sys.executable, "-m", "pip", "--version"],
|
|
150
|
+
capture_output=True, timeout=30)
|
|
151
|
+
if r.returncode != 0:
|
|
152
|
+
subprocess.run([sys.executable, "-m", "ensurepip", "--default-pip"],
|
|
153
|
+
capture_output=True, timeout=120)
|
|
154
|
+
except Exception as e:
|
|
155
|
+
ultimo_erro_pip = str(e)
|
|
156
|
+
|
|
157
|
+
for args in ESTRATEGIAS:
|
|
158
|
+
try:
|
|
159
|
+
r = subprocess.run([sys.executable] + args, capture_output=True, timeout=240)
|
|
160
|
+
if r.returncode == 0 and edge_disponivel():
|
|
161
|
+
return True
|
|
162
|
+
saida = (r.stderr or b"").decode("utf-8", "replace").strip()
|
|
163
|
+
if saida:
|
|
164
|
+
ultimo_erro_pip = saida.splitlines()[-1][:300]
|
|
165
|
+
except Exception as e:
|
|
166
|
+
ultimo_erro_pip = str(e)[:300]
|
|
167
|
+
return False
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def falar_edge(texto, saida, voz, ritmo, tom):
|
|
171
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
172
|
+
vtt = str(Path(tmp) / "legenda.vtt")
|
|
173
|
+
cmd = [
|
|
174
|
+
sys.executable, "-m", "edge_tts",
|
|
175
|
+
"--voice", voz,
|
|
176
|
+
"--text", texto,
|
|
177
|
+
"--write-media", str(saida),
|
|
178
|
+
"--write-subtitles", vtt,
|
|
179
|
+
]
|
|
180
|
+
if ritmo and ritmo != "+0%":
|
|
181
|
+
cmd += ["--rate", ritmo]
|
|
182
|
+
if tom and tom != "+0Hz":
|
|
183
|
+
cmd += ["--pitch", tom]
|
|
184
|
+
|
|
185
|
+
r = subprocess.run(cmd, capture_output=True, timeout=300)
|
|
186
|
+
if r.returncode != 0 or not Path(saida).exists():
|
|
187
|
+
return {"ok": False, "error": (r.stderr or b"").decode("utf-8", "ignore")[-400:]}
|
|
188
|
+
|
|
189
|
+
falas = ler_legendas(vtt)
|
|
190
|
+
return {
|
|
191
|
+
"ok": True,
|
|
192
|
+
"arquivo": str(saida),
|
|
193
|
+
"motor": "edge",
|
|
194
|
+
"voz": voz,
|
|
195
|
+
"segundos": round(falas[-1]["ate"], 3) if falas else None,
|
|
196
|
+
"falas": [{"de": round(f["de"], 3), "ate": round(f["ate"], 3), "texto": f["texto"]}
|
|
197
|
+
for f in falas],
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def falar_elevenlabs(texto, saida, voz):
|
|
202
|
+
"""Só quando há chave. Sem ela, nem se tenta — e não é erro."""
|
|
203
|
+
chave = os.environ.get("ELEVENLABS_API_KEY", "").strip()
|
|
204
|
+
if not chave:
|
|
205
|
+
return None
|
|
206
|
+
try:
|
|
207
|
+
sys.path.insert(0, str(AQUI))
|
|
208
|
+
import elevenlabs # o módulo que já existia no Studio
|
|
209
|
+
r = elevenlabs.falar(texto, str(saida), voz=voz, chave=chave)
|
|
210
|
+
if r and r.get("ok"):
|
|
211
|
+
r["motor"] = "elevenlabs"
|
|
212
|
+
return r
|
|
213
|
+
except Exception:
|
|
214
|
+
pass
|
|
215
|
+
return None
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def comando_falar(args):
|
|
219
|
+
texto = args.texto
|
|
220
|
+
if args.arquivo:
|
|
221
|
+
texto = Path(args.arquivo).read_text(encoding="utf-8")
|
|
222
|
+
texto = re.sub(r"\s+", " ", (texto or "")).strip()
|
|
223
|
+
if not texto:
|
|
224
|
+
return {"ok": False, "error": "não há texto para narrar"}
|
|
225
|
+
|
|
226
|
+
v = voz_de(args.voz)
|
|
227
|
+
estilo = ESTILOS.get((args.estilo or "normal").lower(), ESTILOS["normal"])
|
|
228
|
+
ritmo = args.ritmo or estilo["ritmo"]
|
|
229
|
+
tom = args.tom or estilo["tom"]
|
|
230
|
+
|
|
231
|
+
premium = falar_elevenlabs(texto, args.saida, v["id"])
|
|
232
|
+
if premium:
|
|
233
|
+
return premium
|
|
234
|
+
|
|
235
|
+
if not edge_disponivel() and not instalar_edge():
|
|
236
|
+
return {
|
|
237
|
+
"ok": False,
|
|
238
|
+
"error": "a narração precisa do pacote edge-tts, e não deu para instalá-lo aqui.",
|
|
239
|
+
"comoResolver": f"{sys.executable} -m pip install edge-tts"
|
|
240
|
+
+ (" (no Debian/Ubuntu ou no Homebrew, acrescente --break-system-packages)"
|
|
241
|
+
if "externally-managed" in ultimo_erro_pip else ""),
|
|
242
|
+
"erroDoPip": ultimo_erro_pip or None,
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return falar_edge(texto, args.saida, v["voz"], ritmo, tom)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def main():
|
|
249
|
+
p = argparse.ArgumentParser(description="Narração do PrimoCode Studio")
|
|
250
|
+
sub = p.add_subparsers(dest="comando", required=True)
|
|
251
|
+
|
|
252
|
+
f = sub.add_parser("falar")
|
|
253
|
+
f.add_argument("--texto", default="")
|
|
254
|
+
f.add_argument("--arquivo", default="")
|
|
255
|
+
f.add_argument("--saida", required=True)
|
|
256
|
+
f.add_argument("--voz", default="antonio")
|
|
257
|
+
f.add_argument("--estilo", default="normal")
|
|
258
|
+
f.add_argument("--ritmo", default="")
|
|
259
|
+
f.add_argument("--tom", default="")
|
|
260
|
+
|
|
261
|
+
sub.add_parser("vozes")
|
|
262
|
+
|
|
263
|
+
args = p.parse_args()
|
|
264
|
+
if args.comando == "vozes":
|
|
265
|
+
saida = {"ok": True, "vozes": VOZES, "estilos": list(ESTILOS.keys())}
|
|
266
|
+
else:
|
|
267
|
+
saida = comando_falar(args)
|
|
268
|
+
|
|
269
|
+
print(json.dumps(saida, ensure_ascii=False))
|
|
270
|
+
sys.exit(0 if saida.get("ok") else 1)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
if __name__ == "__main__":
|
|
274
|
+
main()
|
package/studio/pessoa.py
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""pessoa.py — tirar você do fundo do seu próprio vídeo.
|
|
3
|
+
|
|
4
|
+
"Recorte de pessoa: segmentar meu rosto/corpo e trocar o fundo por uma
|
|
5
|
+
imagem relacionada ao que estou falando (ex.: falando sobre o olho →
|
|
6
|
+
imagem do olho no fundo)."
|
|
7
|
+
|
|
8
|
+
── POR QUE ISTO NÃO É FEITO COM ffmpeg ──────────────────────────────────────
|
|
9
|
+
O ffmpeg sabe fazer chroma key: tirar um fundo VERDE. Ele não sabe olhar um
|
|
10
|
+
quadro e dizer onde termina a pessoa e começa a parede — isso é trabalho de
|
|
11
|
+
modelo de visão, quadro a quadro.
|
|
12
|
+
|
|
13
|
+
O MediaPipe faz exatamente isso, roda no computador da pessoa, é grátis e não
|
|
14
|
+
precisa de chave. O custo é o download do modelo na primeira vez.
|
|
15
|
+
|
|
16
|
+
── O QUE SAI DAQUI ──────────────────────────────────────────────────────────
|
|
17
|
+
Um .webm com CANAL ALFA: a pessoa recortada, e transparência no lugar do
|
|
18
|
+
fundo. Ele não é o resultado final — é a peça que o `video.sobreporAlfa`
|
|
19
|
+
costura sobre a imagem escolhida, ou que entra como elemento de vídeo numa
|
|
20
|
+
cena com qualquer coisa atrás.
|
|
21
|
+
|
|
22
|
+
Separar assim é de propósito: o recorte é caro (segundos por segundo de
|
|
23
|
+
vídeo) e o fundo muda de ideia. Recortar uma vez e testar cinco fundos custa
|
|
24
|
+
um recorte; recortar junto com o fundo custa cinco.
|
|
25
|
+
|
|
26
|
+
── A BORDA É O QUE DENUNCIA ─────────────────────────────────────────────────
|
|
27
|
+
Recorte automático erra na borda do cabelo, e o erro aparece como um contorno
|
|
28
|
+
duro, tipo adesivo. Duas defesas: suavizar a máscara (desfoque leve) e
|
|
29
|
+
encolhê-la um pouco, para o corte cair um fio DENTRO da pessoa em vez de
|
|
30
|
+
pegar um halo da parede.
|
|
31
|
+
|
|
32
|
+
Uso:
|
|
33
|
+
python3 pessoa.py recortar --entrada gravacao.mp4 --saida pessoa.webm
|
|
34
|
+
python3 pessoa.py conferir
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
import argparse
|
|
38
|
+
import json
|
|
39
|
+
import shutil
|
|
40
|
+
import os
|
|
41
|
+
import subprocess
|
|
42
|
+
import sys
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def achar_ffmpeg():
|
|
46
|
+
"""Onde está o ffmpeg — pelo PATH, e nos lugares onde ele costuma cair.
|
|
47
|
+
|
|
48
|
+
O PATH de um processo aberto pela interface gráfica não é o PATH do
|
|
49
|
+
terminal: no macOS um app aberto pelo ícone não enxerga /opt/homebrew/bin,
|
|
50
|
+
onde o Homebrew instala. Procurar nos lugares conhecidos evita um "não
|
|
51
|
+
achei" para quem tem o programa instalado.
|
|
52
|
+
"""
|
|
53
|
+
achado = shutil.which("ffmpeg")
|
|
54
|
+
if achado:
|
|
55
|
+
return achado
|
|
56
|
+
for c in ("/opt/homebrew/bin/ffmpeg", "/usr/local/bin/ffmpeg", "/usr/bin/ffmpeg",
|
|
57
|
+
r"C:\Program Files\ffmpeg\bin\ffmpeg.exe"):
|
|
58
|
+
if os.path.exists(c):
|
|
59
|
+
return c
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def faltando():
|
|
64
|
+
"""O que precisa estar instalado, e não está."""
|
|
65
|
+
falta = []
|
|
66
|
+
for modulo, pacote in (("cv2", "opencv-python"), ("mediapipe", "mediapipe"), ("numpy", "numpy")):
|
|
67
|
+
try:
|
|
68
|
+
__import__(modulo)
|
|
69
|
+
except ImportError:
|
|
70
|
+
falta.append(pacote)
|
|
71
|
+
return falta
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def instalar(pacotes):
|
|
75
|
+
try:
|
|
76
|
+
subprocess.run([sys.executable, "-m", "pip", "install", "--quiet", *pacotes],
|
|
77
|
+
capture_output=True, timeout=900, check=True)
|
|
78
|
+
return not faltando()
|
|
79
|
+
except Exception:
|
|
80
|
+
return False
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def recortar(entrada, saida, suavizar=7, encolher=2, limiar=0.6):
|
|
84
|
+
import cv2
|
|
85
|
+
import numpy as np
|
|
86
|
+
import mediapipe as mp
|
|
87
|
+
|
|
88
|
+
captura = cv2.VideoCapture(entrada)
|
|
89
|
+
if not captura.isOpened():
|
|
90
|
+
return {"ok": False, "error": f"não consegui abrir {entrada}"}
|
|
91
|
+
|
|
92
|
+
fps = captura.get(cv2.CAP_PROP_FPS) or 30
|
|
93
|
+
largura = int(captura.get(cv2.CAP_PROP_FRAME_WIDTH))
|
|
94
|
+
altura = int(captura.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
|
95
|
+
total = int(captura.get(cv2.CAP_PROP_FRAME_COUNT)) or 0
|
|
96
|
+
|
|
97
|
+
# VP9 num .webm é o formato de vídeo com transparência que o Chromium do
|
|
98
|
+
# Remotion abre — e é o Chromium dele que vai desenhar isto.
|
|
99
|
+
# O ffmpeg pelo nome cru estoura FileNotFoundError, e o que chega ao
|
|
100
|
+
# usuário é o traceback do Python — que não diz o que instalar. Pior: aqui
|
|
101
|
+
# o vídeo já foi ABERTO e a segmentação está prestes a começar, então a
|
|
102
|
+
# falha aparece depois da espera, não antes dela.
|
|
103
|
+
binario = achar_ffmpeg()
|
|
104
|
+
if not binario:
|
|
105
|
+
captura.release()
|
|
106
|
+
return {"ok": False,
|
|
107
|
+
"error": "o recorte de pessoa precisa do ffmpeg, e não achei nenhum nesta máquina.",
|
|
108
|
+
"comoResolver": "macOS: brew install ffmpeg · Linux: sudo apt install ffmpeg · "
|
|
109
|
+
"Windows: winget install ffmpeg"}
|
|
110
|
+
|
|
111
|
+
escritor = subprocess.Popen(
|
|
112
|
+
[binario, "-hide_banner", "-loglevel", "error", "-y",
|
|
113
|
+
"-f", "rawvideo", "-pix_fmt", "bgra",
|
|
114
|
+
"-s", f"{largura}x{altura}", "-r", str(fps), "-i", "-",
|
|
115
|
+
"-c:v", "libvpx-vp9", "-pix_fmt", "yuva420p", "-b:v", "3M",
|
|
116
|
+
"-auto-alt-ref", "0", saida],
|
|
117
|
+
stdin=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
118
|
+
|
|
119
|
+
segmentador = mp.solutions.selfie_segmentation.SelfieSegmentation(model_selection=1)
|
|
120
|
+
nucleo = np.ones((3, 3), np.uint8)
|
|
121
|
+
feitos = 0
|
|
122
|
+
|
|
123
|
+
try:
|
|
124
|
+
while True:
|
|
125
|
+
ok, quadro = captura.read()
|
|
126
|
+
if not ok:
|
|
127
|
+
break
|
|
128
|
+
resultado = segmentador.process(cv2.cvtColor(quadro, cv2.COLOR_BGR2RGB))
|
|
129
|
+
mascara = (resultado.segmentation_mask > limiar).astype(np.uint8) * 255
|
|
130
|
+
|
|
131
|
+
# Encolher: o corte cai um fio DENTRO da pessoa, em vez de levar
|
|
132
|
+
# um halo da parede junto — é o halo que faz parecer adesivo.
|
|
133
|
+
if encolher > 0:
|
|
134
|
+
mascara = cv2.erode(mascara, nucleo, iterations=int(encolher))
|
|
135
|
+
# Suavizar: borda dura no cabelo é o segundo sinal de recorte
|
|
136
|
+
# automático. Ímpar porque o desfoque gaussiano exige.
|
|
137
|
+
if suavizar > 0:
|
|
138
|
+
k = int(suavizar) | 1
|
|
139
|
+
mascara = cv2.GaussianBlur(mascara, (k, k), 0)
|
|
140
|
+
|
|
141
|
+
bgra = cv2.cvtColor(quadro, cv2.COLOR_BGR2BGRA)
|
|
142
|
+
bgra[:, :, 3] = mascara
|
|
143
|
+
escritor.stdin.write(bgra.tobytes())
|
|
144
|
+
feitos += 1
|
|
145
|
+
finally:
|
|
146
|
+
try:
|
|
147
|
+
escritor.stdin.close()
|
|
148
|
+
except Exception:
|
|
149
|
+
pass
|
|
150
|
+
captura.release()
|
|
151
|
+
segmentador.close()
|
|
152
|
+
|
|
153
|
+
escritor.wait()
|
|
154
|
+
if escritor.returncode != 0:
|
|
155
|
+
erro = (escritor.stderr.read() or b"").decode("utf-8", "ignore")[-400:]
|
|
156
|
+
return {"ok": False, "error": f"o ffmpeg recusou o recorte: {erro}"}
|
|
157
|
+
|
|
158
|
+
return {"ok": True, "arquivo": saida, "quadros": feitos,
|
|
159
|
+
"segundos": round(feitos / fps, 2) if fps else None,
|
|
160
|
+
"largura": largura, "altura": altura, "fps": round(fps, 2)}
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def main():
|
|
164
|
+
p = argparse.ArgumentParser(description="Recorte de pessoa do PrimoCode Studio")
|
|
165
|
+
sub = p.add_subparsers(dest="comando", required=True)
|
|
166
|
+
sub.add_parser("conferir")
|
|
167
|
+
r = sub.add_parser("recortar")
|
|
168
|
+
r.add_argument("--entrada", required=True)
|
|
169
|
+
r.add_argument("--saida", required=True)
|
|
170
|
+
r.add_argument("--suavizar", type=int, default=7)
|
|
171
|
+
r.add_argument("--encolher", type=int, default=2)
|
|
172
|
+
r.add_argument("--limiar", type=float, default=0.6)
|
|
173
|
+
args = p.parse_args()
|
|
174
|
+
|
|
175
|
+
falta = faltando()
|
|
176
|
+
|
|
177
|
+
if args.comando == "conferir":
|
|
178
|
+
saida = {"ok": not falta, "falta": falta,
|
|
179
|
+
"comoResolver": f"{sys.executable} -m pip install " + " ".join(falta) if falta else None}
|
|
180
|
+
else:
|
|
181
|
+
if falta:
|
|
182
|
+
# A instalação é pesada (o MediaPipe passa de 100 MB) e demora.
|
|
183
|
+
# Tentar em silêncio seria uma tarefa parada sem explicação; por
|
|
184
|
+
# isso o aviso sai ANTES, na saída de erro, que o agente mostra.
|
|
185
|
+
print(f"instalando {', '.join(falta)} (uma vez, alguns minutos)…", file=sys.stderr)
|
|
186
|
+
if not instalar(falta):
|
|
187
|
+
saida = {"ok": False,
|
|
188
|
+
"error": "o recorte de pessoa precisa do MediaPipe, e não deu para instalá-lo.",
|
|
189
|
+
"comoResolver": f"{sys.executable} -m pip install " + " ".join(falta)}
|
|
190
|
+
print(json.dumps(saida, ensure_ascii=False))
|
|
191
|
+
sys.exit(1)
|
|
192
|
+
saida = recortar(args.entrada, args.saida, args.suavizar, args.encolher, args.limiar)
|
|
193
|
+
|
|
194
|
+
print(json.dumps(saida, ensure_ascii=False))
|
|
195
|
+
sys.exit(0 if saida.get("ok") else 1)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
if __name__ == "__main__":
|
|
199
|
+
main()
|
|
File without changes
|