primocode 10.0.0 → 10.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 +4 -4
- package/bin/primocode.js +88 -3
- package/lib/act.js +122 -20
- package/lib/browser.js +89 -5
- package/lib/catalogo.js +6 -3
- package/lib/claude-engine.js +38 -17
- package/lib/codex-engine.js +27 -9
- package/lib/config.js +10 -2
- package/lib/entrada.js +73 -9
- package/lib/estudio/assets.js +12 -6
- package/lib/estudio/biblioteca.js +174 -0
- package/lib/estudio/cinema.js +63 -25
- package/lib/estudio/composicao.js +2 -1
- package/lib/estudio/diretor.js +13 -1
- package/lib/estudio/edicao.js +2 -1
- package/lib/estudio/ende.js +16 -5
- package/lib/estudio/estilo.js +41 -10
- package/lib/estudio/fluido.js +283 -0
- package/lib/estudio/gerativo.js +1471 -0
- package/lib/estudio/hibrido.js +169 -0
- package/lib/estudio/impacto.js +66 -31
- package/lib/estudio/index.js +366 -47
- package/lib/estudio/kit/fluido.jsx +493 -0
- package/lib/estudio/kit/gerativo.jsx +990 -0
- package/lib/estudio/kit/index.jsx +4 -1
- package/lib/estudio/kit/tres.jsx +375 -0
- package/lib/estudio/kit.js +9 -1
- package/lib/estudio/kokoro.js +111 -0
- package/lib/estudio/lancamento.js +27 -0
- package/lib/estudio/legibilidade.js +20 -0
- package/lib/estudio/referencias.js +105 -0
- package/lib/estudio/remotion.js +103 -14
- package/lib/estudio/ritmo.js +2 -2
- package/lib/estudio/sons-leves.js +38 -1
- package/lib/estudio/variacao3d.js +46 -0
- package/lib/estudio/variedade.js +183 -0
- package/lib/estudio/vox.js +37 -8
- package/lib/mcp.js +156 -0
- package/lib/personas.js +67 -0
- package/lib/portao-skill.js +150 -0
- package/lib/skills.js +51 -1
- package/lib/tools.js +53 -8
- package/lib/ui.js +41 -6
- package/lib/video/auto.js +329 -0
- package/lib/video/camadas.js +207 -0
- package/lib/video/cli.js +1 -1
- package/lib/video/filters.js +39 -6
- package/lib/video/fx3d/Camadas.jsx +64 -0
- package/lib/video/fx3d/Objetos.jsx +128 -0
- package/lib/video/fx3d/Root.jsx +18 -0
- package/lib/video/fx3d/modelos.jsx +304 -0
- package/lib/video/fx3d.js +108 -0
- package/lib/video/gerados.js +119 -0
- package/lib/video/objetos.js +111 -0
- package/lib/video/ops-fx.js +453 -0
- package/lib/video/ops.js +30 -5
- package/lib/video/paralelo.js +50 -0
- package/lib/video/pedido.js +191 -0
- package/lib/video/rastrear.js +95 -0
- package/lib/video/rastro.js +276 -0
- package/lib/video/render.js +67 -14
- package/lib/video/texto.js +6 -4
- package/lib/video/timeline.js +9 -1
- package/package.json +2 -2
- package/skills/escrever-em-portugues/SKILL.md +3 -0
- package/skills/interface-revisada/SKILL.md +7 -1
- package/skills/motion-de-primeira/SKILL.md +5 -4
- package/skills/projeto-no-github/SKILL.md +6 -0
- package/skills/publicar-na-internet/SKILL.md +3 -0
- package/skills/site-com-motion/SKILL.md +2 -0
- package/studio/narrar.py +10 -7
- package/studio/rastrear.py +399 -0
- package/studio/voz_kokoro.py +98 -0
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""rastrear.py — onde está a pessoa, a cabeça e as mãos em CADA quadro.
|
|
3
|
+
|
|
4
|
+
"Tira o fundo e separa em camadas… põe o logo na minha mão… quando eu
|
|
5
|
+
juntar as mãos…" (30/09/2026)
|
|
6
|
+
|
|
7
|
+
Tudo o que o editor faz "seguindo" alguém nasce aqui, numa passada só pelo
|
|
8
|
+
vídeo, e fica guardado na pasta do projeto. Daí em diante as operações são
|
|
9
|
+
conta sobre números (lib/video/rastro.js) — rápidas e testáveis.
|
|
10
|
+
|
|
11
|
+
── O QUE SAI ────────────────────────────────────────────────────────────────
|
|
12
|
+
· matte-<asset>.mkv a máscara da pessoa, cinza 0–255, um quadro por quadro
|
|
13
|
+
da origem (FFV1: sem perda, e todo quadro é chave, então
|
|
14
|
+
o -ss do ffmpeg cai no quadro exato — a máscara não
|
|
15
|
+
escorrega um quadro em relação ao vídeo);
|
|
16
|
+
· fundo-<asset>.png a "placa limpa": o fundo SEM a pessoa, montado com os
|
|
17
|
+
pixels que a pessoa não cobriu ao longo do vídeo (e o
|
|
18
|
+
buraco que sobra, preenchido por inpaint). É a camada
|
|
19
|
+
de trás do "separa em camadas";
|
|
20
|
+
· rastro-<asset>.json por quadro: o rosto (caixa), o topo da cabeça (da
|
|
21
|
+
máscara, não do rosto — o cabelo conta) e até duas
|
|
22
|
+
mãos (centro da palma, tamanho, abertura, lado).
|
|
23
|
+
|
|
24
|
+
── POR QUE A MÁSCARA PASSA POR UM FILTRO GUIADO ─────────────────────────────
|
|
25
|
+
O segmentador do MediaPipe pensa em 256×256: ampliada para 1080×1920, a borda
|
|
26
|
+
vira uma rampa larga e borrada — o "adesivo" que denuncia o recorte. O filtro
|
|
27
|
+
guiado (He et al., 2010) usa o PRÓPRIO quadro como guia: a borda da máscara
|
|
28
|
+
gruda na borda de verdade (ombro, cabelo) sem modelo nenhum a mais. A média
|
|
29
|
+
com o quadro anterior tira o tremor de um quadro para o outro.
|
|
30
|
+
|
|
31
|
+
── EM PARTES, EM PARALELO ───────────────────────────────────────────────────
|
|
32
|
+
Cada processo analisa um trecho fixo de quadros (lib/video/rastrear.js
|
|
33
|
+
reparte e roda vários ao mesmo tempo); o `juntar` costura as partes na
|
|
34
|
+
ordem: as máscaras por concat sem recodificar, os quadros em fila, as somas
|
|
35
|
+
da placa somadas.
|
|
36
|
+
|
|
37
|
+
Uso (pelo uv, como o pessoa.py e o rostos.py — o MediaPipe só existe até o
|
|
38
|
+
Python 3.12):
|
|
39
|
+
python rastrear.py analisar --entrada v.mp4 --pasta <midia> --id a1 [--de 96 --ate 192 --aquecer 12]
|
|
40
|
+
python rastrear.py juntar --pasta <midia> --id a1
|
|
41
|
+
python rastrear.py conferir
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
import argparse
|
|
45
|
+
import json
|
|
46
|
+
import os
|
|
47
|
+
import shutil
|
|
48
|
+
import subprocess
|
|
49
|
+
import sys
|
|
50
|
+
import urllib.request
|
|
51
|
+
|
|
52
|
+
MODELO_URL = ("https://storage.googleapis.com/mediapipe-models/image_segmenter/"
|
|
53
|
+
"selfie_multiclass_256x256/float32/latest/selfie_multiclass_256x256.tflite")
|
|
54
|
+
PONTAS = (4, 8, 12, 16, 20)
|
|
55
|
+
PALMA = (0, 5, 9, 13, 17)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def achar_ffmpeg():
|
|
59
|
+
achado = shutil.which("ffmpeg")
|
|
60
|
+
if achado:
|
|
61
|
+
return achado
|
|
62
|
+
for c in ("/opt/homebrew/bin/ffmpeg", "/usr/local/bin/ffmpeg", "/usr/bin/ffmpeg"):
|
|
63
|
+
if os.path.exists(c):
|
|
64
|
+
return c
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def modelo_multiclasse():
|
|
69
|
+
"""O segmentador de 6 classes (fundo, cabelo, pele, roupa…) recorta o
|
|
70
|
+
cabelo bem melhor que o selfie_segmentation. Baixado uma vez; sem rede,
|
|
71
|
+
fica o antigo, que vem dentro do pacote."""
|
|
72
|
+
pasta = os.path.join(os.path.expanduser("~"), ".primocode", "studio", "modelos")
|
|
73
|
+
arq = os.path.join(pasta, "selfie_multiclass_256x256.tflite")
|
|
74
|
+
if os.path.exists(arq) and os.path.getsize(arq) > 100000:
|
|
75
|
+
return arq
|
|
76
|
+
try:
|
|
77
|
+
os.makedirs(pasta, exist_ok=True)
|
|
78
|
+
tmp = arq + ".tmp"
|
|
79
|
+
urllib.request.urlretrieve(MODELO_URL, tmp)
|
|
80
|
+
os.replace(tmp, arq)
|
|
81
|
+
return arq
|
|
82
|
+
except Exception:
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class Segmentador:
|
|
87
|
+
def __init__(self, mp):
|
|
88
|
+
self.mp = mp
|
|
89
|
+
self.tipo = "selfie"
|
|
90
|
+
arq = modelo_multiclasse()
|
|
91
|
+
if arq:
|
|
92
|
+
try:
|
|
93
|
+
v = mp.tasks.vision
|
|
94
|
+
op = v.ImageSegmenterOptions(
|
|
95
|
+
base_options=mp.tasks.BaseOptions(model_asset_path=arq),
|
|
96
|
+
running_mode=v.RunningMode.VIDEO, output_confidence_masks=True,
|
|
97
|
+
output_category_mask=False)
|
|
98
|
+
self.seg = v.ImageSegmenter.create_from_options(op)
|
|
99
|
+
self.tipo = "multiclasse"
|
|
100
|
+
return
|
|
101
|
+
except Exception:
|
|
102
|
+
pass
|
|
103
|
+
self.seg = mp.solutions.selfie_segmentation.SelfieSegmentation(model_selection=1)
|
|
104
|
+
|
|
105
|
+
def mascara(self, rgb, ms):
|
|
106
|
+
import numpy as np
|
|
107
|
+
if self.tipo == "multiclasse":
|
|
108
|
+
img = self.mp.Image(image_format=self.mp.ImageFormat.SRGB, data=rgb)
|
|
109
|
+
r = self.seg.segment_for_video(img, int(ms))
|
|
110
|
+
return 1.0 - np.asarray(r.confidence_masks[0].numpy_view(), dtype=np.float32)
|
|
111
|
+
return self.seg.process(rgb).segmentation_mask.astype(np.float32)
|
|
112
|
+
|
|
113
|
+
def fechar(self):
|
|
114
|
+
try:
|
|
115
|
+
self.seg.close()
|
|
116
|
+
except Exception:
|
|
117
|
+
pass
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def guiado(guia, p, r=8, eps=1e-2, fator=2):
|
|
121
|
+
"""Filtro guiado rápido: a/b calculados em meia resolução, aplicados na cheia."""
|
|
122
|
+
import cv2
|
|
123
|
+
h, w = guia.shape
|
|
124
|
+
gi = cv2.resize(guia, (w // fator, h // fator), interpolation=cv2.INTER_AREA)
|
|
125
|
+
pi = cv2.resize(p, (w // fator, h // fator), interpolation=cv2.INTER_AREA)
|
|
126
|
+
rr = max(1, r // fator)
|
|
127
|
+
box = lambda x: cv2.boxFilter(x, -1, (2 * rr + 1, 2 * rr + 1))
|
|
128
|
+
mi, mp_ = box(gi), box(pi)
|
|
129
|
+
cov = box(gi * pi) - mi * mp_
|
|
130
|
+
var = box(gi * gi) - mi * mi
|
|
131
|
+
a = cov / (var + eps)
|
|
132
|
+
b = mp_ - a * mi
|
|
133
|
+
a = cv2.resize(box(a), (w, h), interpolation=cv2.INTER_LINEAR)
|
|
134
|
+
b = cv2.resize(box(b), (w, h), interpolation=cv2.INTER_LINEAR)
|
|
135
|
+
return a * guia + b
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def maos_do_quadro(res, w, h, pl=0, pt=0, W=None, H=None):
|
|
139
|
+
"""As mãos em coordenadas do quadro ORIGINAL (0–1), descontada a borda."""
|
|
140
|
+
import math
|
|
141
|
+
W = W or w
|
|
142
|
+
H = H or h
|
|
143
|
+
out = []
|
|
144
|
+
if not res.multi_hand_landmarks:
|
|
145
|
+
return out
|
|
146
|
+
pares = list(zip(res.multi_hand_landmarks, res.multi_handedness or [None] * 9))
|
|
147
|
+
pares.sort(key=lambda x: -(x[1].classification[0].score if x[1] else 0))
|
|
148
|
+
for lm, mao in pares[:2]:
|
|
149
|
+
pts = [((p.x * W - pl) / w, (p.y * H - pt) / h) for p in lm.landmark]
|
|
150
|
+
cx = sum(pts[i][0] for i in PALMA) / len(PALMA)
|
|
151
|
+
cy = sum(pts[i][1] for i in PALMA) / len(PALMA)
|
|
152
|
+
# Tamanho da palma em fração da ALTURA (o mesmo para x e y em pixel).
|
|
153
|
+
palma = max(1e-6, math.hypot((pts[9][0] - pts[0][0]) * w, (pts[9][1] - pts[0][1]) * h))
|
|
154
|
+
abre = sum(math.hypot((pts[i][0] - pts[0][0]) * w, (pts[i][1] - pts[0][1]) * h) for i in PONTAS[1:]) / 4 / palma
|
|
155
|
+
rot = mao.classification[0] if mao else None
|
|
156
|
+
out.append([round(cx, 4), round(cy, 4), round(palma / h, 4), round(abre, 3),
|
|
157
|
+
(rot.label[0] if rot else "?"), round(rot.score if rot else 0, 2)])
|
|
158
|
+
return out
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def medidas(entrada):
|
|
162
|
+
import cv2
|
|
163
|
+
cap = cv2.VideoCapture(entrada)
|
|
164
|
+
if not cap.isOpened():
|
|
165
|
+
return None
|
|
166
|
+
r = (cap.get(cv2.CAP_PROP_FPS) or 30, int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)), int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)))
|
|
167
|
+
cap.release()
|
|
168
|
+
return r
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def quadros_de(ff, entrada, w, h, fps, inicio, quantos):
|
|
172
|
+
"""Os quadros [inicio, inicio+quantos) pelo ffmpeg, um a um (BGR).
|
|
173
|
+
|
|
174
|
+
O -ss meio quadro ANTES do primeiro: o ffmpeg decodifica desde o quadro
|
|
175
|
+
chave e descarta o que vem antes do tempo pedido, então o primeiro que
|
|
176
|
+
sai é exatamente o quadro `inicio` — a parte 3 de um rastreio em
|
|
177
|
+
paralelo começa no mesmo quadro em qualquer máquina."""
|
|
178
|
+
import numpy as np
|
|
179
|
+
args = [ff, "-hide_banner", "-loglevel", "error"]
|
|
180
|
+
if inicio > 0:
|
|
181
|
+
args += ["-ss", f"{(inicio - 0.5) / fps:.6f}"]
|
|
182
|
+
args += ["-i", entrada]
|
|
183
|
+
if quantos is not None:
|
|
184
|
+
args += ["-frames:v", str(quantos)]
|
|
185
|
+
args += ["-fps_mode", "passthrough", "-f", "rawvideo", "-pix_fmt", "bgr24", "-"]
|
|
186
|
+
p = subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
|
|
187
|
+
tam = w * h * 3
|
|
188
|
+
try:
|
|
189
|
+
while True:
|
|
190
|
+
b = p.stdout.read(tam)
|
|
191
|
+
if len(b) < tam:
|
|
192
|
+
break
|
|
193
|
+
yield np.frombuffer(b, np.uint8).reshape(h, w, 3)
|
|
194
|
+
finally:
|
|
195
|
+
p.stdout.close()
|
|
196
|
+
p.wait()
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def analisar(entrada, pasta, ident, de=0, ate=None, aquecer=0):
|
|
200
|
+
"""Analisa os quadros [de, ate) (ate=None: até o fim) e grava a PARTE.
|
|
201
|
+
|
|
202
|
+
`aquecer` quadros antes de `de` passam pelos modelos e não são gravados:
|
|
203
|
+
o rastreador de mão e a média da máscara chegam à emenda já "quentes",
|
|
204
|
+
então a parte começa como se o vídeo tivesse sido lido desde o início.
|
|
205
|
+
As emendas ficam em quadros FIXOS (o Node corta de 96 em 96), não no
|
|
206
|
+
número de processos: o resultado é o mesmo com 1 ou com 12 deles."""
|
|
207
|
+
import cv2
|
|
208
|
+
import numpy as np
|
|
209
|
+
import mediapipe as mp
|
|
210
|
+
|
|
211
|
+
med = medidas(entrada)
|
|
212
|
+
if not med:
|
|
213
|
+
return {"ok": False, "error": f"não consegui abrir {entrada}"}
|
|
214
|
+
fps, w, h = med
|
|
215
|
+
ff = achar_ffmpeg()
|
|
216
|
+
if not ff:
|
|
217
|
+
return {"ok": False, "error": "falta o ffmpeg"}
|
|
218
|
+
dir_partes = os.path.join(pasta, f"partes-{ident}")
|
|
219
|
+
os.makedirs(dir_partes, exist_ok=True)
|
|
220
|
+
base = os.path.join(dir_partes, f"{de:06d}")
|
|
221
|
+
tmp_matte = base + ".tmp.mkv"
|
|
222
|
+
esc = subprocess.Popen([ff, "-hide_banner", "-loglevel", "error", "-y", "-f", "rawvideo", "-pix_fmt", "gray",
|
|
223
|
+
"-s", f"{w}x{h}", "-r", str(fps), "-i", "-", "-c:v", "ffv1", "-level", "3",
|
|
224
|
+
"-g", "1", "-pix_fmt", "gray", tmp_matte], stdin=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
225
|
+
|
|
226
|
+
seg = Segmentador(mp)
|
|
227
|
+
maos = mp.solutions.hands.Hands(static_image_mode=False, max_num_hands=2, model_complexity=1,
|
|
228
|
+
min_detection_confidence=0.35, min_tracking_confidence=0.35)
|
|
229
|
+
rosto = mp.solutions.face_detection.FaceDetection(model_selection=1, min_detection_confidence=0.5)
|
|
230
|
+
|
|
231
|
+
# A placa limpa em meia resolução: soma dos pixels de fundo e quantas vezes cada um foi visto.
|
|
232
|
+
pw, ph = w // 2, h // 2
|
|
233
|
+
soma = np.zeros((ph, pw, 3), np.float64)
|
|
234
|
+
conta = np.zeros((ph, pw), np.float64)
|
|
235
|
+
anterior = None
|
|
236
|
+
quadros = []
|
|
237
|
+
inicio = max(0, de - aquecer)
|
|
238
|
+
quantos = None if ate is None else ate - inicio
|
|
239
|
+
k = inicio
|
|
240
|
+
try:
|
|
241
|
+
for bgr in quadros_de(ff, entrada, w, h, fps, inicio, quantos):
|
|
242
|
+
grava = k >= de
|
|
243
|
+
rgb = cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB)
|
|
244
|
+
m = seg.mascara(rgb, k * 1000.0 / fps)
|
|
245
|
+
if m.shape != (h, w):
|
|
246
|
+
m = cv2.resize(m, (w, h), interpolation=cv2.INTER_LINEAR)
|
|
247
|
+
guia = cv2.cvtColor(bgr, cv2.COLOR_BGR2GRAY).astype(np.float32) / 255.0
|
|
248
|
+
m = guiado(guia, m, eps=1e-2)
|
|
249
|
+
# A curva: o segmentador deixa ~0,07 no fundo e uma rampa larga no
|
|
250
|
+
# ombro; sem ela o fundo velho aparece como fantasma atrás da pessoa.
|
|
251
|
+
m = np.clip((m - 0.3) / 0.4, 0, 1)
|
|
252
|
+
if anterior is not None:
|
|
253
|
+
m = 0.7 * m + 0.3 * anterior
|
|
254
|
+
anterior = m
|
|
255
|
+
# A mão que sai pela borda de baixo (o normal em quem fala para a
|
|
256
|
+
# câmera) quase nunca é achada: o detector de palma quer a mão
|
|
257
|
+
# inteira. Esticar a borda (replicada) dá a ele onde "terminar" a
|
|
258
|
+
# mão — medido no vídeo de teste: de 119 para 247 quadros com mão.
|
|
259
|
+
pl, pb = int(w * 0.12), int(h * 0.25)
|
|
260
|
+
borda = cv2.copyMakeBorder(rgb, 0, pb, pl, pl, cv2.BORDER_REPLICATE)
|
|
261
|
+
hs = maos_do_quadro(maos.process(borda), w, h, pl, 0, w + 2 * pl, h + pb)
|
|
262
|
+
k += 1
|
|
263
|
+
if not grava:
|
|
264
|
+
continue
|
|
265
|
+
esc.stdin.write((m * 255).astype(np.uint8).tobytes())
|
|
266
|
+
|
|
267
|
+
# Fundo: só o que está LONGE da pessoa (a máscara dilatada), para a borda não sujar a placa.
|
|
268
|
+
peq = cv2.resize(m, (pw, ph), interpolation=cv2.INTER_AREA)
|
|
269
|
+
livre = (cv2.dilate((peq > 0.15).astype(np.uint8), np.ones((15, 15), np.uint8)) == 0)
|
|
270
|
+
soma[livre] += cv2.resize(bgr, (pw, ph), interpolation=cv2.INTER_AREA)[livre]
|
|
271
|
+
conta[livre] += 1
|
|
272
|
+
|
|
273
|
+
q = {}
|
|
274
|
+
rf = rosto.process(rgb)
|
|
275
|
+
if rf.detections:
|
|
276
|
+
d = max(rf.detections, key=lambda x: x.score[0])
|
|
277
|
+
b = d.location_data.relative_bounding_box
|
|
278
|
+
q["f"] = [round(b.xmin, 4), round(b.ymin, 4), round(b.xmin + b.width, 4), round(b.ymin + b.height, 4), round(d.score[0], 2)]
|
|
279
|
+
# O topo da cabeça: a primeira linha com pessoa na faixa do rosto (o cabelo entra).
|
|
280
|
+
x0 = int(max(0, b.xmin + b.width * 0.3) * w)
|
|
281
|
+
x1 = int(min(1, b.xmin + b.width * 0.7) * w)
|
|
282
|
+
y1 = int(min(1, b.ymin + b.height * 0.5) * h)
|
|
283
|
+
if x1 > x0 and y1 > 0:
|
|
284
|
+
faixa = m[:y1, x0:x1].mean(axis=1)
|
|
285
|
+
linhas = np.where(faixa > 0.5)[0]
|
|
286
|
+
if len(linhas):
|
|
287
|
+
q["t"] = round(float(linhas[0]) / h, 4)
|
|
288
|
+
if hs:
|
|
289
|
+
q["h"] = hs
|
|
290
|
+
quadros.append(q)
|
|
291
|
+
finally:
|
|
292
|
+
try:
|
|
293
|
+
esc.stdin.close()
|
|
294
|
+
except Exception:
|
|
295
|
+
pass
|
|
296
|
+
seg.fechar()
|
|
297
|
+
maos.close()
|
|
298
|
+
rosto.close()
|
|
299
|
+
esc.wait()
|
|
300
|
+
if esc.returncode != 0:
|
|
301
|
+
return {"ok": False, "error": "o ffmpeg recusou a máscara: " + (esc.stderr.read() or b"").decode("utf-8", "ignore")[-300:]}
|
|
302
|
+
os.replace(tmp_matte, base + ".mkv")
|
|
303
|
+
np.savez(base + ".npz", soma=soma, conta=conta)
|
|
304
|
+
with open(base + ".json", "w") as f:
|
|
305
|
+
json.dump({"de": de, "fps": fps, "w": w, "h": h, "segmentador": seg.tipo, "quadros": quadros}, f, separators=(",", ":"))
|
|
306
|
+
return {"ok": True, "parte": de, "quadros": len(quadros)}
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def juntar(pasta, ident):
|
|
310
|
+
"""As partes, em ordem de quadro, viram o rastro, a máscara e a placa."""
|
|
311
|
+
import cv2
|
|
312
|
+
import numpy as np
|
|
313
|
+
dir_partes = os.path.join(pasta, f"partes-{ident}")
|
|
314
|
+
nomes = sorted(n[:-5] for n in os.listdir(dir_partes) if n.endswith(".json"))
|
|
315
|
+
if not nomes:
|
|
316
|
+
return {"ok": False, "error": "nenhuma parte para juntar"}
|
|
317
|
+
quadros, soma, conta, meta = [], None, None, None
|
|
318
|
+
usadas = []
|
|
319
|
+
for nome in nomes:
|
|
320
|
+
with open(os.path.join(dir_partes, nome + ".json")) as f:
|
|
321
|
+
parte = json.load(f)
|
|
322
|
+
# O contêiner (.mov do celular) promete mais quadros do que o vídeo
|
|
323
|
+
# tem: a duração é a do áudio. A parte do fim sai vazia, e a anterior
|
|
324
|
+
# curta — é o fim do vídeo, não um buraco.
|
|
325
|
+
if not parte["quadros"]:
|
|
326
|
+
continue
|
|
327
|
+
if len(quadros) != parte["de"]:
|
|
328
|
+
return {"ok": False, "error": f"parte {nome} começa em {parte['de']}, esperava {len(quadros)}"}
|
|
329
|
+
quadros += parte["quadros"]
|
|
330
|
+
usadas.append(nome)
|
|
331
|
+
meta = meta or parte
|
|
332
|
+
z = np.load(os.path.join(dir_partes, nome + ".npz"))
|
|
333
|
+
soma = z["soma"] if soma is None else soma + z["soma"]
|
|
334
|
+
conta = z["conta"] if conta is None else conta + z["conta"]
|
|
335
|
+
w, h, fps = meta["w"], meta["h"], meta["fps"]
|
|
336
|
+
ff = achar_ffmpeg()
|
|
337
|
+
lista = os.path.join(dir_partes, "lista.txt")
|
|
338
|
+
with open(lista, "w") as f:
|
|
339
|
+
for nome in usadas:
|
|
340
|
+
f.write(f"file '{os.path.join(dir_partes, nome + '.mkv')}'\n")
|
|
341
|
+
matte = os.path.join(pasta, f"matte-{ident}.mkv")
|
|
342
|
+
r = subprocess.run([ff, "-hide_banner", "-loglevel", "error", "-y", "-f", "concat", "-safe", "0", "-i", lista,
|
|
343
|
+
"-c", "copy", matte + ".tmp.mkv"], capture_output=True)
|
|
344
|
+
if r.returncode != 0:
|
|
345
|
+
return {"ok": False, "error": "não juntei as máscaras: " + r.stderr.decode("utf-8", "ignore")[-300:]}
|
|
346
|
+
os.replace(matte + ".tmp.mkv", matte)
|
|
347
|
+
|
|
348
|
+
ph, pw = conta.shape
|
|
349
|
+
placa = np.zeros((ph, pw, 3), np.uint8)
|
|
350
|
+
visto = conta > 0
|
|
351
|
+
placa[visto] = (soma[visto] / conta[visto][:, None]).astype(np.uint8)
|
|
352
|
+
buraco = (~visto).astype(np.uint8) * 255
|
|
353
|
+
if buraco.any():
|
|
354
|
+
placa = cv2.inpaint(placa, buraco, 9, cv2.INPAINT_TELEA)
|
|
355
|
+
fundo = os.path.join(pasta, f"fundo-{ident}.png")
|
|
356
|
+
cv2.imwrite(fundo, cv2.resize(placa, (w, h), interpolation=cv2.INTER_CUBIC))
|
|
357
|
+
|
|
358
|
+
dados = {"versao": 2, "fps": round(fps, 4), "w": w, "h": h, "n": len(quadros), "segmentador": meta["segmentador"],
|
|
359
|
+
"partes": len(nomes), "buraco": round(float((~visto).mean()), 3), "quadros": quadros}
|
|
360
|
+
arq = os.path.join(pasta, f"rastro-{ident}.json")
|
|
361
|
+
with open(arq, "w") as f:
|
|
362
|
+
json.dump(dados, f, separators=(",", ":"))
|
|
363
|
+
shutil.rmtree(dir_partes, ignore_errors=True)
|
|
364
|
+
return {"ok": True, "rastro": arq, "matte": matte, "fundo": fundo, "quadros": len(quadros), "segmentador": meta["segmentador"],
|
|
365
|
+
"comRosto": sum(1 for q in quadros if "f" in q), "comMao": sum(1 for q in quadros if "h" in q)}
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def main():
|
|
369
|
+
p = argparse.ArgumentParser(description="Rastreio de pessoa, rosto e mãos (PrimoCode)")
|
|
370
|
+
sub = p.add_subparsers(dest="comando", required=True)
|
|
371
|
+
sub.add_parser("conferir")
|
|
372
|
+
a = sub.add_parser("analisar")
|
|
373
|
+
a.add_argument("--entrada", required=True)
|
|
374
|
+
a.add_argument("--pasta", required=True)
|
|
375
|
+
a.add_argument("--id", required=True)
|
|
376
|
+
a.add_argument("--de", type=int, default=0)
|
|
377
|
+
a.add_argument("--ate", type=int, default=None)
|
|
378
|
+
a.add_argument("--aquecer", type=int, default=0)
|
|
379
|
+
j = sub.add_parser("juntar")
|
|
380
|
+
j.add_argument("--pasta", required=True)
|
|
381
|
+
j.add_argument("--id", required=True)
|
|
382
|
+
args = p.parse_args()
|
|
383
|
+
if args.comando == "conferir":
|
|
384
|
+
try:
|
|
385
|
+
import cv2, mediapipe, numpy # noqa: F401
|
|
386
|
+
print(json.dumps({"ok": True}))
|
|
387
|
+
except Exception as e:
|
|
388
|
+
print(json.dumps({"ok": False, "error": str(e)}))
|
|
389
|
+
return
|
|
390
|
+
if args.comando == "juntar":
|
|
391
|
+
r = juntar(args.pasta, args.id)
|
|
392
|
+
else:
|
|
393
|
+
r = analisar(args.entrada, args.pasta, args.id, args.de, args.ate, args.aquecer)
|
|
394
|
+
print(json.dumps(r, ensure_ascii=False))
|
|
395
|
+
sys.exit(0 if r.get("ok") else 1)
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
if __name__ == "__main__":
|
|
399
|
+
main()
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""voz_kokoro.py — a voz local, sem cota e sem servidor (Kokoro-82M, na CPU).
|
|
3
|
+
|
|
4
|
+
O Gemini é a voz de gente, mas a cota grátis é pequena e às vezes o áudio
|
|
5
|
+
volta cortado (medido em 25/09). O Kokoro roda nesta máquina: 11 s de áudio
|
|
6
|
+
em 3,5 s, mais 4,3 s para carregar o modelo, sem corte no fim (05/10).
|
|
7
|
+
|
|
8
|
+
Roda pelo `uv` (lib/estudio/kokoro.js monta o comando), com as versões
|
|
9
|
+
fixadas: sem `transformers>=4.45` o uv escolhe a 4.12 e o tokenizers não
|
|
10
|
+
compila; sem ESPEAK_DATA_PATH o espeak embutido aponta para uma pasta que
|
|
11
|
+
não existe.
|
|
12
|
+
|
|
13
|
+
Não se chama kokoro.py: a pasta do script entra no sys.path e o nome
|
|
14
|
+
esconderia o pacote `kokoro` (medido em 05/10).
|
|
15
|
+
|
|
16
|
+
Uso (a mesma entrada e a mesma saída do `narrar.py roteiro`):
|
|
17
|
+
voz_kokoro.py roteiro --arquivo partes.json --saida narracao.mp3 [--voz pf_dora]
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import argparse
|
|
21
|
+
import json
|
|
22
|
+
import re
|
|
23
|
+
import subprocess
|
|
24
|
+
import sys
|
|
25
|
+
import tempfile
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
TAXA = 24000
|
|
29
|
+
RESPIRO = 0.12 # o mesmo respiro curto entre as partes do narrar.py
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _palavras(texto, de, ate):
|
|
33
|
+
"""O Kokoro em português não devolve o tempo de cada palavra: ele é
|
|
34
|
+
repartido pelo tamanho da palavra dentro da parte (melhor que nada para
|
|
35
|
+
a tipografia seguir a voz)."""
|
|
36
|
+
ws = texto.split()
|
|
37
|
+
total = sum(len(w) for w in ws) or 1
|
|
38
|
+
out, t = [], de
|
|
39
|
+
for w in ws:
|
|
40
|
+
d = (ate - de) * len(w) / total
|
|
41
|
+
out.append({"de": round(t, 3), "ate": round(t + d, 3), "texto": w})
|
|
42
|
+
t += d
|
|
43
|
+
return out
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def comando_roteiro(args):
|
|
47
|
+
import numpy as np
|
|
48
|
+
import soundfile as sf
|
|
49
|
+
from kokoro import KPipeline
|
|
50
|
+
|
|
51
|
+
partes = json.loads(Path(args.arquivo).read_text(encoding="utf-8"))
|
|
52
|
+
if not partes:
|
|
53
|
+
return {"ok": False, "error": "o roteiro veio vazio"}
|
|
54
|
+
pipe = KPipeline(lang_code="p") # português do Brasil
|
|
55
|
+
pausa = np.zeros(int(TAXA * RESPIRO), dtype=np.float32)
|
|
56
|
+
blocos, trechos, palavras, t = [], [], [], 0.0
|
|
57
|
+
for parte in partes:
|
|
58
|
+
texto = re.sub(r"\s+", " ", str(parte.get("texto") or "")).strip()
|
|
59
|
+
if not texto:
|
|
60
|
+
trechos.append({"de": round(t, 3), "ate": round(t, 3), "palavras": []})
|
|
61
|
+
continue
|
|
62
|
+
audio = np.concatenate([np.asarray(a, dtype=np.float32) for _, _, a in pipe(texto, voice=args.voz)])
|
|
63
|
+
dur = len(audio) / TAXA
|
|
64
|
+
ws = _palavras(texto, t, t + dur)
|
|
65
|
+
palavras += ws
|
|
66
|
+
trechos.append({"de": round(t, 3), "ate": round(t + dur, 3), "palavras": ws})
|
|
67
|
+
blocos += [audio, pausa]
|
|
68
|
+
t += dur + RESPIRO
|
|
69
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
70
|
+
wav = Path(tmp) / "voz.wav"
|
|
71
|
+
sf.write(str(wav), np.concatenate(blocos), TAXA)
|
|
72
|
+
r = subprocess.run(["ffmpeg", "-y", "-loglevel", "error", "-i", str(wav),
|
|
73
|
+
"-af", "loudnorm=I=-14:TP=-1.5:LRA=9", "-c:a", "libmp3lame", "-q:a", "3",
|
|
74
|
+
str(args.saida)], capture_output=True, timeout=180)
|
|
75
|
+
if r.returncode != 0:
|
|
76
|
+
return {"ok": False, "error": (r.stderr or b"").decode("utf-8", "ignore")[-300:]}
|
|
77
|
+
return {"ok": True, "arquivo": str(args.saida), "motor": "kokoro", "voz": f"kokoro {args.voz}",
|
|
78
|
+
"segundos": round(t, 3), "partes": trechos, "palavras": palavras}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def main():
|
|
82
|
+
p = argparse.ArgumentParser(description="Narração local do PrimoCode Studio (Kokoro)")
|
|
83
|
+
sub = p.add_subparsers(dest="comando", required=True)
|
|
84
|
+
r = sub.add_parser("roteiro")
|
|
85
|
+
r.add_argument("--arquivo", required=True)
|
|
86
|
+
r.add_argument("--saida", required=True)
|
|
87
|
+
r.add_argument("--voz", default="pm_alex")
|
|
88
|
+
args = p.parse_args()
|
|
89
|
+
try:
|
|
90
|
+
saida = comando_roteiro(args)
|
|
91
|
+
except Exception as e: # nunca derruba a corrente: o JS passa para a próxima voz
|
|
92
|
+
saida = {"ok": False, "error": f"o kokoro falhou: {str(e)[:300]}"}
|
|
93
|
+
print(json.dumps(saida, ensure_ascii=False))
|
|
94
|
+
sys.exit(0 if saida.get("ok") else 1)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
if __name__ == "__main__":
|
|
98
|
+
main()
|