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.
Files changed (89) hide show
  1. package/README.md +30 -6
  2. package/bin/primocode.js +169 -342
  3. package/lib/act.js +360 -39
  4. package/lib/browser.js +105 -4
  5. package/lib/casar.js +108 -0
  6. package/lib/catalogo.js +6 -2
  7. package/lib/claude-engine.js +38 -17
  8. package/lib/codex-engine.js +27 -9
  9. package/lib/config.js +10 -3
  10. package/lib/cursor.js +10 -1
  11. package/lib/entrada.js +77 -11
  12. package/lib/estudio/cinema.js +27 -9
  13. package/lib/estudio/diretor.js +21 -1
  14. package/lib/estudio/ende.js +16 -5
  15. package/lib/estudio/estilo.js +66 -0
  16. package/lib/estudio/gerativo.js +1453 -0
  17. package/lib/estudio/glifos.js +215 -0
  18. package/lib/estudio/hibrido.js +165 -0
  19. package/lib/estudio/index.js +284 -53
  20. package/lib/estudio/kit/base.jsx +285 -0
  21. package/lib/estudio/kit/gerativo.jsx +980 -0
  22. package/lib/estudio/kit/index.jsx +13 -0
  23. package/lib/estudio/kit/marca.jsx +741 -0
  24. package/lib/estudio/kit/tres.jsx +308 -0
  25. package/lib/estudio/kit/vox.jsx +357 -0
  26. package/lib/estudio/kit.js +78 -0
  27. package/lib/estudio/lancamento.js +441 -0
  28. package/lib/estudio/motion.js +2 -5
  29. package/lib/estudio/plano.js +28 -3
  30. package/lib/estudio/referencias.js +105 -0
  31. package/lib/estudio/remotion.js +139 -51
  32. package/lib/estudio/ritmo.js +77 -0
  33. package/lib/estudio/sons-leves.js +38 -1
  34. package/lib/estudio/variedade.js +105 -0
  35. package/lib/estudio/vox.js +414 -0
  36. package/lib/fala.js +1 -94
  37. package/lib/mcp.js +156 -0
  38. package/lib/personas.js +67 -0
  39. package/lib/pipeline.js +55 -56
  40. package/lib/repeticao.js +3 -1
  41. package/lib/skills.js +44 -2
  42. package/lib/snapshot.js +111 -0
  43. package/lib/tools.js +100 -22
  44. package/lib/ui.js +50 -8
  45. package/lib/video/analise.js +89 -0
  46. package/lib/video/auto.js +329 -0
  47. package/lib/video/camadas.js +207 -0
  48. package/lib/video/cli.js +74 -0
  49. package/lib/video/delta.js +90 -0
  50. package/lib/video/edits.js +379 -0
  51. package/lib/video/filters.js +280 -0
  52. package/lib/video/fx3d/Camadas.jsx +64 -0
  53. package/lib/video/fx3d/Objetos.jsx +128 -0
  54. package/lib/video/fx3d/Root.jsx +18 -0
  55. package/lib/video/fx3d/modelos.jsx +304 -0
  56. package/lib/video/fx3d.js +108 -0
  57. package/lib/video/gerados.js +119 -0
  58. package/lib/video/objetos.js +111 -0
  59. package/lib/video/ops-fx.js +453 -0
  60. package/lib/video/ops.js +347 -0
  61. package/lib/video/paralelo.js +50 -0
  62. package/lib/video/pedido.js +191 -0
  63. package/lib/video/projeto.js +133 -0
  64. package/lib/video/rastrear.js +95 -0
  65. package/lib/video/rastro.js +276 -0
  66. package/lib/video/render.js +274 -0
  67. package/lib/video/texto.js +144 -0
  68. package/lib/video/timeline.js +318 -0
  69. package/lib/video/transcript.js +124 -0
  70. package/lib/voz.js +75 -223
  71. package/package.json +2 -3
  72. package/skills/motion-de-primeira/SKILL.md +5 -5
  73. package/studio/narrar.py +10 -7
  74. package/studio/rastrear.py +399 -0
  75. package/studio/texto.py +208 -0
  76. package/voz/primo_voz/__main__.py +96 -236
  77. package/voz/primo_voz/transcrever.py +31 -1
  78. package/app/ansi.js +0 -55
  79. package/app/app.css +0 -500
  80. package/app/app.js +0 -1017
  81. package/app/icone.svg +0 -6
  82. package/app/index.html +0 -220
  83. package/app/primo-marca.js +0 -192
  84. package/lib/app.js +0 -615
  85. package/lib/icone.js +0 -263
  86. package/lib/instalar-app.js +0 -231
  87. package/lib/painel.js +0 -291
  88. package/voz/primo_voz/desperta.py +0 -195
  89. 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()
@@ -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())