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.
Files changed (73) hide show
  1. package/README.md +4 -4
  2. package/bin/primocode.js +88 -3
  3. package/lib/act.js +122 -20
  4. package/lib/browser.js +89 -5
  5. package/lib/catalogo.js +6 -3
  6. package/lib/claude-engine.js +38 -17
  7. package/lib/codex-engine.js +27 -9
  8. package/lib/config.js +10 -2
  9. package/lib/entrada.js +73 -9
  10. package/lib/estudio/assets.js +12 -6
  11. package/lib/estudio/biblioteca.js +174 -0
  12. package/lib/estudio/cinema.js +63 -25
  13. package/lib/estudio/composicao.js +2 -1
  14. package/lib/estudio/diretor.js +13 -1
  15. package/lib/estudio/edicao.js +2 -1
  16. package/lib/estudio/ende.js +16 -5
  17. package/lib/estudio/estilo.js +41 -10
  18. package/lib/estudio/fluido.js +283 -0
  19. package/lib/estudio/gerativo.js +1471 -0
  20. package/lib/estudio/hibrido.js +169 -0
  21. package/lib/estudio/impacto.js +66 -31
  22. package/lib/estudio/index.js +366 -47
  23. package/lib/estudio/kit/fluido.jsx +493 -0
  24. package/lib/estudio/kit/gerativo.jsx +990 -0
  25. package/lib/estudio/kit/index.jsx +4 -1
  26. package/lib/estudio/kit/tres.jsx +375 -0
  27. package/lib/estudio/kit.js +9 -1
  28. package/lib/estudio/kokoro.js +111 -0
  29. package/lib/estudio/lancamento.js +27 -0
  30. package/lib/estudio/legibilidade.js +20 -0
  31. package/lib/estudio/referencias.js +105 -0
  32. package/lib/estudio/remotion.js +103 -14
  33. package/lib/estudio/ritmo.js +2 -2
  34. package/lib/estudio/sons-leves.js +38 -1
  35. package/lib/estudio/variacao3d.js +46 -0
  36. package/lib/estudio/variedade.js +183 -0
  37. package/lib/estudio/vox.js +37 -8
  38. package/lib/mcp.js +156 -0
  39. package/lib/personas.js +67 -0
  40. package/lib/portao-skill.js +150 -0
  41. package/lib/skills.js +51 -1
  42. package/lib/tools.js +53 -8
  43. package/lib/ui.js +41 -6
  44. package/lib/video/auto.js +329 -0
  45. package/lib/video/camadas.js +207 -0
  46. package/lib/video/cli.js +1 -1
  47. package/lib/video/filters.js +39 -6
  48. package/lib/video/fx3d/Camadas.jsx +64 -0
  49. package/lib/video/fx3d/Objetos.jsx +128 -0
  50. package/lib/video/fx3d/Root.jsx +18 -0
  51. package/lib/video/fx3d/modelos.jsx +304 -0
  52. package/lib/video/fx3d.js +108 -0
  53. package/lib/video/gerados.js +119 -0
  54. package/lib/video/objetos.js +111 -0
  55. package/lib/video/ops-fx.js +453 -0
  56. package/lib/video/ops.js +30 -5
  57. package/lib/video/paralelo.js +50 -0
  58. package/lib/video/pedido.js +191 -0
  59. package/lib/video/rastrear.js +95 -0
  60. package/lib/video/rastro.js +276 -0
  61. package/lib/video/render.js +67 -14
  62. package/lib/video/texto.js +6 -4
  63. package/lib/video/timeline.js +9 -1
  64. package/package.json +2 -2
  65. package/skills/escrever-em-portugues/SKILL.md +3 -0
  66. package/skills/interface-revisada/SKILL.md +7 -1
  67. package/skills/motion-de-primeira/SKILL.md +5 -4
  68. package/skills/projeto-no-github/SKILL.md +6 -0
  69. package/skills/publicar-na-internet/SKILL.md +3 -0
  70. package/skills/site-com-motion/SKILL.md +2 -0
  71. package/studio/narrar.py +10 -7
  72. package/studio/rastrear.py +399 -0
  73. 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()