primocode 9.10.0 → 10.0.9
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 +30 -6
- package/bin/primocode.js +169 -342
- package/lib/act.js +360 -39
- package/lib/browser.js +105 -4
- package/lib/casar.js +108 -0
- package/lib/catalogo.js +6 -2
- package/lib/claude-engine.js +38 -17
- package/lib/codex-engine.js +27 -9
- package/lib/config.js +10 -3
- package/lib/cursor.js +10 -1
- package/lib/entrada.js +77 -11
- package/lib/estudio/cinema.js +27 -9
- package/lib/estudio/diretor.js +21 -1
- package/lib/estudio/ende.js +16 -5
- package/lib/estudio/estilo.js +66 -0
- package/lib/estudio/gerativo.js +1453 -0
- package/lib/estudio/glifos.js +215 -0
- package/lib/estudio/hibrido.js +165 -0
- package/lib/estudio/index.js +284 -53
- package/lib/estudio/kit/base.jsx +285 -0
- package/lib/estudio/kit/gerativo.jsx +980 -0
- package/lib/estudio/kit/index.jsx +13 -0
- package/lib/estudio/kit/marca.jsx +741 -0
- package/lib/estudio/kit/tres.jsx +308 -0
- package/lib/estudio/kit/vox.jsx +357 -0
- package/lib/estudio/kit.js +78 -0
- package/lib/estudio/lancamento.js +441 -0
- package/lib/estudio/motion.js +2 -5
- package/lib/estudio/plano.js +28 -3
- package/lib/estudio/referencias.js +105 -0
- package/lib/estudio/remotion.js +139 -51
- package/lib/estudio/ritmo.js +77 -0
- package/lib/estudio/sons-leves.js +38 -1
- package/lib/estudio/variedade.js +105 -0
- package/lib/estudio/vox.js +414 -0
- package/lib/fala.js +1 -94
- package/lib/mcp.js +156 -0
- package/lib/personas.js +67 -0
- package/lib/pipeline.js +55 -56
- package/lib/repeticao.js +3 -1
- package/lib/skills.js +44 -2
- package/lib/snapshot.js +111 -0
- package/lib/tools.js +100 -22
- package/lib/ui.js +50 -8
- package/lib/video/analise.js +89 -0
- package/lib/video/auto.js +329 -0
- package/lib/video/camadas.js +207 -0
- package/lib/video/cli.js +74 -0
- package/lib/video/delta.js +90 -0
- package/lib/video/edits.js +379 -0
- package/lib/video/filters.js +280 -0
- 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 +347 -0
- package/lib/video/paralelo.js +50 -0
- package/lib/video/pedido.js +191 -0
- package/lib/video/projeto.js +133 -0
- package/lib/video/rastrear.js +95 -0
- package/lib/video/rastro.js +276 -0
- package/lib/video/render.js +274 -0
- package/lib/video/texto.js +144 -0
- package/lib/video/timeline.js +318 -0
- package/lib/video/transcript.js +124 -0
- package/lib/voz.js +75 -223
- package/package.json +2 -3
- package/skills/motion-de-primeira/SKILL.md +5 -5
- package/studio/narrar.py +10 -7
- package/studio/rastrear.py +399 -0
- package/studio/texto.py +208 -0
- package/voz/primo_voz/__main__.py +96 -236
- package/voz/primo_voz/transcrever.py +31 -1
- package/app/ansi.js +0 -55
- package/app/app.css +0 -500
- package/app/app.js +0 -1017
- package/app/icone.svg +0 -6
- package/app/index.html +0 -220
- package/app/primo-marca.js +0 -192
- package/lib/app.js +0 -615
- package/lib/icone.js +0 -263
- package/lib/instalar-app.js +0 -231
- package/lib/painel.js +0 -291
- package/voz/primo_voz/desperta.py +0 -195
- package/voz/primo_voz/orbe.py +0 -406
|
@@ -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()
|
package/studio/texto.py
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""texto.py — desenha os textos do editor de vídeo em PNG transparente.
|
|
2
|
+
|
|
3
|
+
python3 texto.py estados job.json → um png por estado (lib/video/texto.js)
|
|
4
|
+
python3 texto.py grade job.json → a folha do inspect: quadros com grade
|
|
5
|
+
python3 texto.py conferir → {"ok": true} se o Pillow está aqui
|
|
6
|
+
|
|
7
|
+
── POR QUE PNG E NÃO drawtext ─────────────────────────────────────────────
|
|
8
|
+
O ffmpeg do Homebrew saiu sem drawtext e sem libass (ffmpeg 9, set/2026).
|
|
9
|
+
Desenhar aqui funciona igual em qualquer ffmpeg, e a animação vem pronta do
|
|
10
|
+
JavaScript: este arquivo só desenha o que recebe, não decide nada.
|
|
11
|
+
|
|
12
|
+
Responde sempre UMA linha de JSON no stdout.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import sys
|
|
19
|
+
|
|
20
|
+
try:
|
|
21
|
+
from PIL import Image, ImageDraw, ImageFont
|
|
22
|
+
except Exception: # sem Pillow: o node avisa com o comando de instalar
|
|
23
|
+
Image = None
|
|
24
|
+
|
|
25
|
+
FONTES = {
|
|
26
|
+
"bold": [
|
|
27
|
+
"/System/Library/Fonts/Supplemental/Arial Bold.ttf", "/Library/Fonts/Arial Bold.ttf",
|
|
28
|
+
"C:/Windows/Fonts/arialbd.ttf", "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
|
|
29
|
+
"/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf", "/usr/share/fonts/TTF/DejaVuSans-Bold.ttf",
|
|
30
|
+
],
|
|
31
|
+
"black": [
|
|
32
|
+
"/System/Library/Fonts/Supplemental/Arial Black.ttf", "C:/Windows/Fonts/ariblk.ttf",
|
|
33
|
+
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
|
|
34
|
+
],
|
|
35
|
+
"regular": [
|
|
36
|
+
"/System/Library/Fonts/Supplemental/Arial.ttf", "/Library/Fonts/Arial.ttf", "C:/Windows/Fonts/arial.ttf",
|
|
37
|
+
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", "/usr/share/fonts/TTF/DejaVuSans.ttf",
|
|
38
|
+
],
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
_cache = {}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def fonte(nome, tamanho):
|
|
45
|
+
tamanho = max(4, int(round(tamanho)))
|
|
46
|
+
chave = (nome, tamanho)
|
|
47
|
+
if chave in _cache:
|
|
48
|
+
return _cache[chave]
|
|
49
|
+
caminhos = [nome] if nome and os.path.isfile(str(nome)) else FONTES.get(nome or "bold", FONTES["bold"]) + FONTES["bold"]
|
|
50
|
+
f = None
|
|
51
|
+
for c in caminhos:
|
|
52
|
+
if os.path.isfile(c):
|
|
53
|
+
try:
|
|
54
|
+
f = ImageFont.truetype(c, tamanho)
|
|
55
|
+
break
|
|
56
|
+
except Exception:
|
|
57
|
+
pass
|
|
58
|
+
if f is None:
|
|
59
|
+
try:
|
|
60
|
+
f = ImageFont.load_default(size=tamanho)
|
|
61
|
+
except TypeError:
|
|
62
|
+
f = ImageFont.load_default()
|
|
63
|
+
_cache[chave] = f
|
|
64
|
+
return f
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def cor(hexa, alpha=1.0):
|
|
68
|
+
h = str(hexa or "#FFFFFF").lstrip("#")
|
|
69
|
+
if len(h) == 3:
|
|
70
|
+
h = "".join(ch * 2 for ch in h)
|
|
71
|
+
a = int(h[6:8], 16) / 255 if len(h) == 8 else 1.0
|
|
72
|
+
return (int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16), int(255 * max(0.0, min(1.0, a * alpha))))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def largura(f, t):
|
|
76
|
+
return f.getlength(t) if hasattr(f, "getlength") else f.getsize(t)[0]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def desenhar_item(img, it):
|
|
80
|
+
escala = float(it.get("scale", 1))
|
|
81
|
+
alpha = float(it.get("alpha", 1))
|
|
82
|
+
tam = it["size"] * escala
|
|
83
|
+
f = fonte(it.get("font"), tam)
|
|
84
|
+
espaco = largura(f, " ")
|
|
85
|
+
maxw = max(10, it["w"] * escala)
|
|
86
|
+
# Quebra de linha pela largura, com a palavra no tamanho BASE: a palavra
|
|
87
|
+
# em destaque cresce sem empurrar as outras (senão a legenda treme).
|
|
88
|
+
linhas, atual, w_atual = [], [], 0.0
|
|
89
|
+
for w in it["words"]:
|
|
90
|
+
lw = largura(f, w["t"])
|
|
91
|
+
if atual and w_atual + espaco + lw > maxw * 0.92:
|
|
92
|
+
linhas.append((atual, w_atual))
|
|
93
|
+
atual, w_atual = [], 0.0
|
|
94
|
+
w_atual = lw if not atual else w_atual + espaco + lw
|
|
95
|
+
atual.append((w, lw))
|
|
96
|
+
if atual:
|
|
97
|
+
linhas.append((atual, w_atual))
|
|
98
|
+
altura_linha = tam * 1.18
|
|
99
|
+
total_h = altura_linha * len(linhas)
|
|
100
|
+
cx, cy = it["cx"], it["cy"]
|
|
101
|
+
camada = Image.new("RGBA", img.size, (0, 0, 0, 0))
|
|
102
|
+
d = ImageDraw.Draw(camada)
|
|
103
|
+
if it.get("bg"):
|
|
104
|
+
pad = tam * 0.35
|
|
105
|
+
larg = max(l for _, l in linhas)
|
|
106
|
+
d.rounded_rectangle([cx - larg / 2 - pad, cy - total_h / 2 - pad * 0.6, cx + larg / 2 + pad, cy + total_h / 2 + pad * 0.6],
|
|
107
|
+
radius=tam * 0.3, fill=cor(it["bg"], alpha))
|
|
108
|
+
sw = int(round(it.get("sw", 0) * escala))
|
|
109
|
+
for n, (palavras, _) in enumerate(linhas):
|
|
110
|
+
y = cy - total_h / 2 + altura_linha * (n + 0.5)
|
|
111
|
+
# A linha é quebrada no tamanho base, mas posicionada com a largura
|
|
112
|
+
# REAL de cada palavra: a palavra em destaque abre espaço para si.
|
|
113
|
+
medidas = []
|
|
114
|
+
for w, lw in palavras:
|
|
115
|
+
s = float(w.get("s", 1))
|
|
116
|
+
fw = fonte(it.get("font"), tam * s) if s != 1 else f
|
|
117
|
+
medidas.append((w, largura(fw, w["t"]) if s != 1 else lw, fw, s))
|
|
118
|
+
larg = sum(m[1] for m in medidas) + espaco * (len(medidas) - 1)
|
|
119
|
+
alinhar = it.get("align", "center")
|
|
120
|
+
x = cx - larg / 2 if alinhar == "center" else (cx - maxw / 2 if alinhar == "left" else cx + maxw / 2 - larg)
|
|
121
|
+
for w, lw, fw, s in medidas:
|
|
122
|
+
a = alpha * float(w.get("a", 1))
|
|
123
|
+
texto = w.get("show", w["t"])
|
|
124
|
+
if a > 0.01 and texto:
|
|
125
|
+
meio = x + lw / 2
|
|
126
|
+
# "show" (typewriter) escreve a partir da esquerda da palavra.
|
|
127
|
+
if "show" in w:
|
|
128
|
+
d.text((x, y), texto, font=fw, anchor="lm", fill=cor(w.get("c", it["color"]), a),
|
|
129
|
+
stroke_width=int(sw * s), stroke_fill=cor(it.get("stroke") or "#000000", a))
|
|
130
|
+
else:
|
|
131
|
+
d.text((meio, y), texto, font=fw, anchor="mm", fill=cor(w.get("c", it["color"]), a),
|
|
132
|
+
stroke_width=int(sw * s) if it.get("stroke") else 0, stroke_fill=cor(it.get("stroke") or "#000000", a))
|
|
133
|
+
x += lw + espaco
|
|
134
|
+
if it.get("rot"):
|
|
135
|
+
camada = camada.rotate(-float(it["rot"]), center=(cx, cy), resample=Image.BICUBIC)
|
|
136
|
+
img.alpha_composite(camada)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def estados(job):
|
|
140
|
+
W, H = int(job["width"]), int(job["height"])
|
|
141
|
+
feitos = 0
|
|
142
|
+
for e in job["estados"]:
|
|
143
|
+
img = Image.new("RGBA", (W, H), (0, 0, 0, 0))
|
|
144
|
+
for it in e["items"]:
|
|
145
|
+
desenhar_item(img, it)
|
|
146
|
+
img.save(e["file"], optimize=False, compress_level=3)
|
|
147
|
+
feitos += 1
|
|
148
|
+
vazio = job.get("vazio")
|
|
149
|
+
if vazio and not os.path.exists(vazio):
|
|
150
|
+
Image.new("RGBA", (W, H), (0, 0, 0, 0)).save(vazio)
|
|
151
|
+
return {"ok": True, "feitos": feitos}
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def grade(job):
|
|
155
|
+
"""Cada quadro com a grade 0–1 e o número do quadro; todos numa folha."""
|
|
156
|
+
quadros = []
|
|
157
|
+
for q in job["frames"]:
|
|
158
|
+
im = Image.open(q["png"]).convert("RGBA")
|
|
159
|
+
W, H = im.size
|
|
160
|
+
cam = Image.new("RGBA", im.size, (0, 0, 0, 0))
|
|
161
|
+
d = ImageDraw.Draw(cam)
|
|
162
|
+
f = fonte("bold", max(12, H // 60))
|
|
163
|
+
for k in range(1, 10):
|
|
164
|
+
forte = k == 5
|
|
165
|
+
c = (255, 255, 255, 150 if forte else 80)
|
|
166
|
+
d.line([(W * k / 10, 0), (W * k / 10, H)], fill=c, width=3 if forte else 1)
|
|
167
|
+
d.line([(0, H * k / 10), (W, H * k / 10)], fill=c, width=3 if forte else 1)
|
|
168
|
+
rot = "%.1f" % (k / 10)
|
|
169
|
+
d.text((W * k / 10 + 4, 4), rot, font=f, fill=(255, 255, 0, 230), stroke_width=2, stroke_fill=(0, 0, 0, 230))
|
|
170
|
+
d.text((4, H * k / 10 + 2), rot, font=f, fill=(0, 255, 255, 230), stroke_width=2, stroke_fill=(0, 0, 0, 230))
|
|
171
|
+
rotulo = "frame %d %.2fs" % (q["frame"], q["tempo"])
|
|
172
|
+
fg = fonte("bold", max(16, H // 34))
|
|
173
|
+
d.rectangle([0, H - H // 20, largura(fg, rotulo) + 24, H], fill=(0, 0, 0, 190))
|
|
174
|
+
d.text((12, H - H // 40), rotulo, font=fg, anchor="lm", fill=(255, 255, 255, 255))
|
|
175
|
+
im.alpha_composite(cam)
|
|
176
|
+
quadros.append(im.convert("RGB"))
|
|
177
|
+
alvo = int(job.get("altura", 640))
|
|
178
|
+
reduzidos = [q.resize((max(1, int(q.width * alvo / q.height)), alvo)) for q in quadros]
|
|
179
|
+
cols = min(len(reduzidos), int(job.get("cols", 4)))
|
|
180
|
+
linhas = (len(reduzidos) + cols - 1) // cols
|
|
181
|
+
cw = max(r.width for r in reduzidos)
|
|
182
|
+
folha = Image.new("RGB", (cw * cols + 8 * (cols - 1), alvo * linhas + 8 * (linhas - 1)), (40, 40, 40))
|
|
183
|
+
for i, r in enumerate(reduzidos):
|
|
184
|
+
folha.paste(r, ((i % cols) * (cw + 8), (i // cols) * (alvo + 8)))
|
|
185
|
+
folha.save(job["out"])
|
|
186
|
+
return {"ok": True, "out": job["out"], "size": [folha.width, folha.height]}
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def main():
|
|
190
|
+
acao = sys.argv[1] if len(sys.argv) > 1 else "conferir"
|
|
191
|
+
if Image is None:
|
|
192
|
+
print(json.dumps({"ok": False, "error": "o Pillow não está instalado", "comoResolver": "python3 -m pip install pillow"}))
|
|
193
|
+
return 1
|
|
194
|
+
if acao == "conferir":
|
|
195
|
+
print(json.dumps({"ok": True}))
|
|
196
|
+
return 0
|
|
197
|
+
with open(sys.argv[2], encoding="utf-8") as fh:
|
|
198
|
+
job = json.load(fh)
|
|
199
|
+
try:
|
|
200
|
+
r = estados(job) if acao == "estados" else grade(job) if acao == "grade" else {"ok": False, "error": "ação " + acao}
|
|
201
|
+
except Exception as e:
|
|
202
|
+
r = {"ok": False, "error": "%s: %s" % (type(e).__name__, e)}
|
|
203
|
+
print(json.dumps(r))
|
|
204
|
+
return 0 if r.get("ok") else 1
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
if __name__ == "__main__":
|
|
208
|
+
sys.exit(main())
|