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,133 @@
1
+ /**
2
+ * projeto.js — onde a edição mora: `<pasta atual>/video/<id>/`.
3
+ *
4
+ * timeline.json o projeto (lib/video/timeline.js)
5
+ * history/ uma foto da timeline ANTES de cada edição (o undo)
6
+ * log.jsonl o caderno: que operação, quando, com que resultado
7
+ * midia/ transcrição e silêncio medidos (cache por mídia)
8
+ * cache/ os pngs do texto e os quadros do inspect
9
+ * saida/ o mp4 exportado e a folha do inspect
10
+ *
11
+ * A pasta fica NA PASTA DO TRABALHO, e não em ~/.primocode como os projetos
12
+ * do Estúdio: a gravação é da pessoa, está ali, e o vídeo editado tem que
13
+ * aparecer ao lado dela. O id segue a convenção do Estúdio (data na frente,
14
+ * apelido depois), para "o de semana passada" ser achado pela ordem.
15
+ *
16
+ * ── POR QUE O UNDO É FOTO E NÃO OPERAÇÃO INVERSA ─────────────────────────
17
+ * Desfazer um ripple de quarenta clipes com a conta inversa é reescrever o
18
+ * ripple ao contrário, com os mesmos defeitos. A foto do json custa alguns
19
+ * kB e desfaz qualquer coisa, inclusive o que for criado amanhã. É o que
20
+ * deixa o agente editar sem pedir licença a cada passo.
21
+ */
22
+
23
+ 'use strict';
24
+
25
+ const fs = require('fs');
26
+ const path = require('path');
27
+ const T = require('./timeline.js');
28
+ const { apelidar } = require('../estudio/projeto.js');
29
+
30
+ const MAX_HISTORICO = 100;
31
+
32
+ function raiz(cwd = process.cwd()) {
33
+ return process.env.PRIMOCODE_VIDEO_DIR || path.join(cwd, 'video');
34
+ }
35
+
36
+ function escreverAtomico(arq, texto) {
37
+ const tmp = `${arq}.${process.pid}.tmp`;
38
+ fs.writeFileSync(tmp, texto);
39
+ fs.renameSync(tmp, arq);
40
+ }
41
+
42
+ function criar({ nome = 'video', width, height, fps, cwd } = {}) {
43
+ const base = raiz(cwd);
44
+ const dia = new Date().toISOString().slice(0, 10);
45
+ const semente = `${dia}-${apelidar(nome)}`;
46
+ let id = semente;
47
+ for (let n = 2; fs.existsSync(path.join(base, id)); n++) id = `${semente}-${n}`;
48
+ const dir = path.join(base, id);
49
+ for (const sub of ['history', 'midia', 'cache', 'saida']) fs.mkdirSync(path.join(dir, sub), { recursive: true });
50
+ const tl = T.novo({ nome: apelidar(nome), width, height, fps });
51
+ const p = { id, dir };
52
+ salvar(p, tl);
53
+ fs.writeFileSync(path.join(base, '.atual'), id);
54
+ return { p, tl };
55
+ }
56
+
57
+ function atual(cwd) {
58
+ try { return fs.readFileSync(path.join(raiz(cwd), '.atual'), 'utf8').trim() || null; } catch { return null; }
59
+ }
60
+
61
+ function abrir(id, cwd) {
62
+ const base = raiz(cwd);
63
+ const alvo = id || atual(cwd);
64
+ if (!alvo) return null;
65
+ let dir = path.join(base, alvo);
66
+ if (!fs.existsSync(path.join(dir, 'timeline.json'))) {
67
+ // Aceita o começo do id ("2026-09-29-aula") como no clipe.
68
+ const casa = listar(cwd).filter((x) => x.id.startsWith(alvo) || x.id.endsWith(alvo));
69
+ if (casa.length !== 1) return null;
70
+ dir = path.join(base, casa[0].id);
71
+ }
72
+ const tl = JSON.parse(fs.readFileSync(path.join(dir, 'timeline.json'), 'utf8'));
73
+ return { p: { id: path.basename(dir), dir }, tl };
74
+ }
75
+
76
+ function usar(id, cwd) { fs.writeFileSync(path.join(raiz(cwd), '.atual'), id); }
77
+
78
+ function listar(cwd) {
79
+ const base = raiz(cwd);
80
+ if (!fs.existsSync(base)) return [];
81
+ return fs.readdirSync(base).filter((n) => fs.existsSync(path.join(base, n, 'timeline.json'))).sort().map((id) => {
82
+ let tl = null;
83
+ try { tl = JSON.parse(fs.readFileSync(path.join(base, id, 'timeline.json'), 'utf8')); } catch {}
84
+ return { id, s: tl ? T.r2(T.duracao(tl) / tl.fps) : null, clips: tl ? T.todosOsClipes(tl).length : null };
85
+ });
86
+ }
87
+
88
+ function salvar(p, tl) {
89
+ escreverAtomico(path.join(p.dir, 'timeline.json'), JSON.stringify(tl));
90
+ }
91
+
92
+ function historico(p) {
93
+ const dir = path.join(p.dir, 'history');
94
+ if (!fs.existsSync(dir)) return [];
95
+ return fs.readdirSync(dir).filter((n) => /^\d+\.json$/.test(n)).sort();
96
+ }
97
+
98
+ /** Guarda a foto de ANTES da edição. */
99
+ function fotografar(p, antes, op) {
100
+ const lista = historico(p);
101
+ const n = lista.length ? Number(lista[lista.length - 1].slice(0, -5)) + 1 : 1;
102
+ fs.mkdirSync(path.join(p.dir, 'history'), { recursive: true });
103
+ fs.writeFileSync(path.join(p.dir, 'history', `${String(n).padStart(6, '0')}.json`), JSON.stringify({ op, tl: antes }));
104
+ for (const velho of lista.slice(0, Math.max(0, lista.length + 1 - MAX_HISTORICO))) {
105
+ try { fs.unlinkSync(path.join(p.dir, 'history', velho)); } catch {}
106
+ }
107
+ }
108
+
109
+ /** Volta `passos` edições. Devolve {tl, desfeitas:[ops]} ou erro. */
110
+ function desfazer(p, passos = 1) {
111
+ const lista = historico(p);
112
+ if (!lista.length) throw T.erro('não há nada para desfazer');
113
+ // "abc" daria NaN, e slice(-NaN) é o histórico INTEIRO.
114
+ const n = Math.max(1, Math.min(lista.length, Math.round(Number(passos)) || 1));
115
+ const alvo = lista.slice(-n);
116
+ const desfeitas = [];
117
+ let tl = null;
118
+ for (const arq of [...alvo].reverse()) {
119
+ const foto = JSON.parse(fs.readFileSync(path.join(p.dir, 'history', arq), 'utf8'));
120
+ desfeitas.push(foto.op);
121
+ tl = foto.tl;
122
+ fs.unlinkSync(path.join(p.dir, 'history', arq));
123
+ }
124
+ return { tl, desfeitas, restam: lista.length - n };
125
+ }
126
+
127
+ function anotar(p, op, resumo) {
128
+ try {
129
+ fs.appendFileSync(path.join(p.dir, 'log.jsonl'), JSON.stringify({ q: new Date().toISOString(), op, ...resumo }) + '\n');
130
+ } catch { /* o caderno não pode derrubar a edição */ }
131
+ }
132
+
133
+ module.exports = { raiz, criar, abrir, atual, usar, listar, salvar, fotografar, desfazer, historico, anotar, MAX_HISTORICO };
@@ -0,0 +1,95 @@
1
+ /**
2
+ * rastrear.js — roda o studio/rastrear.py, em partes e em paralelo, e
3
+ * guarda o resultado na pasta do projeto (midia/rastro-<asset>.json,
4
+ * matte-<asset>.mkv, fundo-<asset>.png). Da segunda vez, é leitura de disco.
5
+ *
6
+ * O MediaPipe só existe até o Python 3.12: o script roda pelo `uv` num
7
+ * Python 3.12 isolado (o mesmo caminho do recorte de pessoa do Estúdio);
8
+ * sem `uv`, tenta o Python da máquina.
9
+ */
10
+
11
+ 'use strict';
12
+
13
+ const fs = require('fs');
14
+ const os = require('os');
15
+ const path = require('path');
16
+ const { spawn, execFileSync } = require('child_process');
17
+ const T = require('./timeline.js');
18
+ const PAR = require('./paralelo.js');
19
+
20
+ const PY = path.join(__dirname, '..', '..', 'studio', 'rastrear.py');
21
+ const TAMANHO_PARTE = 96; // medido: 48 não ganha nada (o MediaPipe já usa vários núcleos por processo)
22
+ const AQUECER = 12;
23
+
24
+ let _uv;
25
+ function acharUv() {
26
+ if (_uv !== undefined) return _uv;
27
+ for (const c of ['uv', path.join(os.homedir(), '.local', 'bin', 'uv'), '/opt/homebrew/bin/uv', '/usr/local/bin/uv']) {
28
+ try { execFileSync(c, ['--version'], { stdio: 'ignore', timeout: 5000 }); return (_uv = c); } catch { /* próximo */ }
29
+ }
30
+ return (_uv = null);
31
+ }
32
+
33
+ function rodarPy(args, { timeout = 60 * 60000 } = {}) {
34
+ const uv = acharUv();
35
+ const [cmd, todos] = uv
36
+ ? [uv, ['run', '--quiet', '--python', '3.12', '--with', 'mediapipe<0.10.22', '--with', 'opencv-python', '--with', 'numpy', 'python', PY, ...args]]
37
+ : [require('../voz.js').python() || 'python3', [PY, ...args]];
38
+ return new Promise((resolve) => {
39
+ const p = spawn(cmd, todos, { env: { ...process.env, PYTHONWARNINGS: 'ignore', GLOG_minloglevel: '2', TF_CPP_MIN_LOG_LEVEL: '2' } });
40
+ let out = '';
41
+ let err = '';
42
+ p.stdout.on('data', (d) => { out += d; });
43
+ p.stderr.on('data', (d) => { err = (err + d).slice(-2000); });
44
+ const rel = setTimeout(() => { try { p.kill(); } catch {} }, timeout);
45
+ p.on('error', (e) => { clearTimeout(rel); resolve({ ok: false, error: e.message }); });
46
+ p.on('close', () => {
47
+ clearTimeout(rel);
48
+ try { resolve(JSON.parse(out.trim().split('\n').pop())); } catch { resolve({ ok: false, error: (err || out).slice(-400) || 'o rastreio não respondeu' }); }
49
+ });
50
+ });
51
+ }
52
+
53
+ function arquivos(pasta, asset) {
54
+ const m = path.join(pasta, 'midia');
55
+ return { rastro: path.join(m, `rastro-${asset.id}.json`), matte: path.join(m, `matte-${asset.id}.mkv`), fundo: path.join(m, `fundo-${asset.id}.png`) };
56
+ }
57
+
58
+ const _memo = new Map();
59
+ function lerRastro(arq) {
60
+ const st = fs.statSync(arq);
61
+ const k = `${arq}:${st.mtimeMs}`;
62
+ if (!_memo.has(k)) _memo.set(k, JSON.parse(fs.readFileSync(arq, 'utf8')));
63
+ return _memo.get(k);
64
+ }
65
+
66
+ /**
67
+ * O rastro da mídia (do cache, ou medido agora em paralelo).
68
+ * Devolve {ok, rastro (objeto), arquivos, tookS?, partes?, trabalhadores?}.
69
+ */
70
+ async function garantir(pasta, asset, { force = false, trabalhadores } = {}) {
71
+ if (asset.kind !== 'video') throw T.erro(`a mídia ${asset.id} não é vídeo`);
72
+ const arq = arquivos(pasta, asset);
73
+ if (!force && fs.existsSync(arq.rastro) && fs.existsSync(arq.matte) && fs.existsSync(arq.fundo)) {
74
+ return { ok: true, cache: true, rastro: lerRastro(arq.rastro), arquivos: arq };
75
+ }
76
+ const inicio = Date.now();
77
+ const pastaMidia = path.join(pasta, 'midia');
78
+ fs.rmSync(path.join(pastaMidia, `partes-${asset.id}`), { recursive: true, force: true });
79
+ const total = Math.max(1, Math.round((asset.seconds || 0) * (asset.fps || 30)));
80
+ const lista = PAR.partes(total, TAMANHO_PARTE);
81
+ const n = trabalhadores || PAR.quantos(lista.length);
82
+ const res = await PAR.emParalelo(lista.map((p) => () => rodarPy(['analisar', '--entrada', asset.path, '--pasta', pastaMidia, '--id', asset.id,
83
+ '--de', String(p.de), ...(p.ate === null ? [] : ['--ate', String(p.ate)]), '--aquecer', String(AQUECER)])), n);
84
+ const ruim = res.find((r) => !r || !r.ok);
85
+ if (ruim) {
86
+ return { ok: false, error: `o rastreio falhou: ${(ruim && ruim.error) || '?'}`,
87
+ comoResolver: acharUv() ? 'confira a rede (o uv baixa o MediaPipe na primeira vez)' : 'instale o uv (https://docs.astral.sh/uv) — o MediaPipe precisa do Python 3.12' };
88
+ }
89
+ const j = await rodarPy(['juntar', '--pasta', pastaMidia, '--id', asset.id]);
90
+ if (!j.ok) return { ok: false, error: `não juntei o rastreio: ${j.error}` };
91
+ return { ok: true, rastro: lerRastro(arq.rastro), arquivos: arq, tookS: Math.round((Date.now() - inicio) / 100) / 10,
92
+ partes: lista.length, trabalhadores: n, comMao: j.comMao, comRosto: j.comRosto, segmentador: j.segmentador };
93
+ }
94
+
95
+ module.exports = { garantir, arquivos, rodarPy, acharUv, lerRastro, TAMANHO_PARTE };
@@ -0,0 +1,276 @@
1
+ /**
2
+ * rastro.js — o que o studio/rastrear.py mediu vira trilha, evento e posição.
3
+ *
4
+ * O Python só MEDE (por quadro: rosto, topo da cabeça, até duas mãos). Tudo
5
+ * o que DECIDE mora aqui, como conta pura e testável sem vídeo nenhum:
6
+ *
7
+ * · as duas TRILHAS de mão ("left"/"right" = o lado da TELA, que é o que
8
+ * quem assiste vê), com identidade por continuidade — o rótulo
9
+ * Left/Right do MediaPipe troca sozinho de um quadro para outro;
10
+ * · o buraco de poucos quadros (a mão que some por um instante) é
11
+ * preenchido, e o tremor sai no filtro One Euro: parado, segura firme;
12
+ * rápido, acompanha sem atraso — o que um filtro de média não faz;
13
+ * · os EVENTOS: mãos juntas, mãos abertas, e o ESTALO (um transiente
14
+ * curto e agudo no áudio, perto de uma mão);
15
+ * · a CABEÇA (topo e centro) para a legenda "em cima da minha cabeça".
16
+ */
17
+
18
+ 'use strict';
19
+
20
+ // ── suavizar ─────────────────────────────────────────────────────────────
21
+
22
+ /**
23
+ * Filtro One Euro (Casiez et al., 2012) sobre uma série com buracos (null).
24
+ * `minCutoff` baixo = parado fica parado; `beta` alto = movimento rápido passa.
25
+ */
26
+ function umEuro(serie, fps, { minCutoff = 1.2, beta = 0.6, dCutoff = 1 } = {}) {
27
+ const alfa = (corte) => { const tau = 1 / (2 * Math.PI * corte); const te = 1 / fps; return 1 / (1 + tau / te); };
28
+ const out = new Array(serie.length).fill(null);
29
+ let x = null;
30
+ let dx = 0;
31
+ for (let i = 0; i < serie.length; i++) {
32
+ const v = serie[i];
33
+ if (v === null || v === undefined || !Number.isFinite(v)) { x = null; dx = 0; continue; }
34
+ if (x === null) { x = v; out[i] = v; continue; }
35
+ const d = (v - x) * fps;
36
+ const ad = alfa(dCutoff);
37
+ dx = ad * d + (1 - ad) * dx;
38
+ const a = alfa(minCutoff + beta * Math.abs(dx));
39
+ x = a * v + (1 - a) * x;
40
+ out[i] = x;
41
+ }
42
+ return out;
43
+ }
44
+
45
+ /** Preenche buracos de até `max` quadros por interpolação linear. */
46
+ function preencher(serie, max = 6) {
47
+ const out = serie.slice();
48
+ let i = 0;
49
+ while (i < out.length) {
50
+ if (out[i] !== null) { i++; continue; }
51
+ let j = i;
52
+ while (j < out.length && out[j] === null) j++;
53
+ const antes = i - 1;
54
+ if (antes >= 0 && j < out.length && j - i <= max) {
55
+ for (let k = i; k < j; k++) out[k] = out[antes] + ((out[j] - out[antes]) * (k - antes)) / (j - antes);
56
+ }
57
+ i = j;
58
+ }
59
+ return out;
60
+ }
61
+
62
+ // ── as mãos ──────────────────────────────────────────────────────────────
63
+
64
+ /**
65
+ * As duas trilhas de mão. `rastro.quadros[i].h` = [[x,y,palma,abertura,rotulo,score]].
66
+ * Devolve {left:[{x,y,s,open}|null], right:[...]}, já preenchido e suavizado.
67
+ */
68
+ function trilhasDasMaos(rastro, { buraco = 6, suavizar = true } = {}) {
69
+ const n = rastro.quadros.length;
70
+ const asp = (rastro.h || 1920) / (rastro.w || 1080); // y em fração da altura, x da largura
71
+ const dist = (a, b) => Math.hypot(a.x - b.x, (a.y - b.y) * asp);
72
+ const cru = { A: new Array(n).fill(null), B: new Array(n).fill(null) };
73
+ let ult = { A: null, B: null };
74
+ let sem = { A: 99, B: 99 };
75
+ for (let i = 0; i < n; i++) {
76
+ let hs = ((rastro.quadros[i] || {}).h || []).map((h) => ({ x: h[0], y: h[1], s: h[2], open: h[3] }));
77
+ // A mesma mão achada duas vezes (acontece na borda): fica uma.
78
+ if (hs.length === 2 && dist(hs[0], hs[1]) < 0.6 * Math.max(hs[0].s, hs[1].s) * asp) hs = [hs[0]];
79
+ const vivo = (k) => ult[k] && sem[k] <= 15;
80
+ if (hs.length === 2) {
81
+ let [p, q] = hs;
82
+ if (vivo('A') || vivo('B')) {
83
+ const ref = (k) => (vivo(k) ? ult[k] : { x: ult[k === 'A' ? 'B' : 'A'] ? 1 - ult[k === 'A' ? 'B' : 'A'].x : (k === 'A' ? 0.25 : 0.75), y: 0.8 });
84
+ const direto = dist(p, ref('A')) + dist(q, ref('B'));
85
+ const trocado = dist(q, ref('A')) + dist(p, ref('B'));
86
+ if (trocado < direto) [p, q] = [q, p];
87
+ } else if (p.x > q.x) [p, q] = [q, p]; // primeira vez: A é a da esquerda da tela
88
+ cru.A[i] = p; cru.B[i] = q;
89
+ } else if (hs.length === 1) {
90
+ const h = hs[0];
91
+ let k;
92
+ if (vivo('A') && vivo('B')) k = dist(h, ult.A) <= dist(h, ult.B) ? 'A' : 'B';
93
+ else if (vivo('A')) k = dist(h, ult.A) < 0.25 ? 'A' : 'B';
94
+ else if (vivo('B')) k = dist(h, ult.B) < 0.25 ? 'B' : 'A';
95
+ else k = h.x < 0.5 ? 'A' : 'B';
96
+ cru[k][i] = h;
97
+ }
98
+ for (const k of ['A', 'B']) { if (cru[k][i]) { ult[k] = cru[k][i]; sem[k] = 0; } else sem[k]++; }
99
+ }
100
+ // Quem é a "esquerda": a trilha que passa mais tempo do lado esquerdo da tela.
101
+ const mediaX = (t) => { const v = t.filter(Boolean); return v.length ? v.reduce((s, h) => s + h.x, 0) / v.length : 0.5; };
102
+ const [L, R] = mediaX(cru.A) <= mediaX(cru.B) ? [cru.A, cru.B] : [cru.B, cru.A];
103
+ const fps = rastro.fps || 30;
104
+ const acabar = (t) => {
105
+ const campos = {};
106
+ for (const c of ['x', 'y', 's', 'open']) {
107
+ let serie = preencher(t.map((h) => (h ? h[c] : null)), buraco);
108
+ if (suavizar) serie = umEuro(serie, fps, c === 's' ? { minCutoff: 0.6, beta: 0.2 } : { minCutoff: 1.0, beta: 0.8 });
109
+ campos[c] = serie;
110
+ }
111
+ return t.map((_, i) => (campos.x[i] === null ? null : { x: campos.x[i], y: campos.y[i], s: campos.s[i], open: campos.open[i] }));
112
+ };
113
+ return { left: acabar(L), right: acabar(R) };
114
+ }
115
+
116
+ /** Os trechos [de, ate) (quadros) em que a trilha existe por pelo menos `min` quadros. */
117
+ function trechosVisiveis(trilha, min = 8) {
118
+ const out = [];
119
+ let i = 0;
120
+ while (i < trilha.length) {
121
+ if (!trilha[i]) { i++; continue; }
122
+ let j = i;
123
+ while (j < trilha.length && trilha[j]) j++;
124
+ if (j - i >= min) out.push([i, j]);
125
+ i = j;
126
+ }
127
+ return out;
128
+ }
129
+
130
+ /**
131
+ * Mãos juntas e mãos abertas: a distância entre as palmas, em palmas.
132
+ * "Juntas" = abaixo de `junto` por `min` quadros (ou as duas somem logo
133
+ * depois de chegarem perto — mão sobre mão o detector perde). "Abertas" =
134
+ * depois de juntas, acima de `aberto`. Devolve [{junta, abre}] em quadros.
135
+ */
136
+ function juntaEAbre(trilhas, { rastro = null, junto = 2.8, aberto = 3.8, min = 3, desde = 0 } = {}) {
137
+ const asp = rastro ? (rastro.h || 1920) / (rastro.w || 1080) : 16 / 9;
138
+ const n = Math.max(trilhas.left.length, trilhas.right.length);
139
+ const d = new Array(n).fill(null);
140
+ for (let i = 0; i < n; i++) {
141
+ const a = trilhas.left[i];
142
+ const b = trilhas.right[i];
143
+ if (a && b) d[i] = Math.hypot(a.x - b.x, (a.y - b.y) * asp) / Math.max(1e-6, ((a.s + b.s) / 2) * asp);
144
+ }
145
+ const eventos = [];
146
+ let i = Math.max(0, desde);
147
+ while (i < n) {
148
+ let j = i;
149
+ while (j < n && d[j] !== null && d[j] < junto) j++;
150
+ const perto = j - i;
151
+ const sumiu = perto > 0 && j < n && d[j] === null && d.slice(j, j + 6).every((x) => x === null);
152
+ if (perto >= min || (perto > 0 && sumiu)) {
153
+ let k = j;
154
+ while (k < n && !(d[k] !== null && d[k] > aberto)) k++;
155
+ if (k < n) { eventos.push({ junta: i, abre: k }); i = k + 1; continue; }
156
+ eventos.push({ junta: i, abre: null });
157
+ break;
158
+ }
159
+ i = Math.max(i + 1, j);
160
+ }
161
+ return eventos;
162
+ }
163
+
164
+ // ── a cabeça ─────────────────────────────────────────────────────────────
165
+
166
+ /** Por quadro: {top, cx, fy0, fy1} (frações), suavizado; null onde não há rosto. */
167
+ function trilhaDaCabeca(rastro) {
168
+ const fps = rastro.fps || 30;
169
+ const q = rastro.quadros;
170
+ const cx = q.map((x) => (x && x.f ? (x.f[0] + x.f[2]) / 2 : null));
171
+ const fy0 = q.map((x) => (x && x.f ? x.f[1] : null));
172
+ // O topo vem da máscara (o cabelo conta); sem ele, o rosto com uma folga para o cabelo.
173
+ const top = q.map((x) => (x && x.t !== undefined ? x.t : x && x.f ? x.f[1] - 0.35 * (x.f[3] - x.f[1]) : null));
174
+ const lento = { minCutoff: 0.4, beta: 0.3 };
175
+ const s = (v) => umEuro(preencher(v, 30), fps, lento);
176
+ const [A, B, C] = [s(cx), s(fy0), s(top)];
177
+ return q.map((_, i) => (A[i] === null || C[i] === null ? null : { cx: A[i], fy0: B[i], top: C[i] }));
178
+ }
179
+
180
+ /**
181
+ * Onde a legenda fica "em cima da cabeça": o centro da legenda acima do topo,
182
+ * com margem, sem sair da tela. Tudo em frações do QUADRO DO PROJETO.
183
+ * `altura` = altura da caixa da legenda (fração); `largura` idem.
184
+ */
185
+ function acimaDaCabeca(cabeca, { altura = 0.06, largura = 0.86, margem = 0.025, minCy = 0.05 } = {}) {
186
+ if (!cabeca) return null;
187
+ const cy = Math.max(minCy + altura / 2, cabeca.top - margem - altura / 2);
188
+ const meia = Math.min(0.5, largura / 2);
189
+ const cx = Math.max(meia, Math.min(1 - meia, cabeca.cx));
190
+ return { cx, cy };
191
+ }
192
+
193
+ // ── o estalo ─────────────────────────────────────────────────────────────
194
+
195
+ /**
196
+ * Os estalos (ou palmas) no áudio: um transiente AGUDO e CURTO. Amostras
197
+ * mono Float32. Para cada janela de 5 ms mede a energia da derivada (um
198
+ * passa-alta de graça) e procura:
199
+ * · subida grande sobre os 100 ms anteriores (≥ `subida` vezes);
200
+ * · queda rápida: 60 ms depois já caiu a menos de 1/`queda` do pico;
201
+ * · e o pico forte comparado ao áudio inteiro (não é a sibilante da fala).
202
+ * Devolve [{t (segundos), score}] em ordem de tempo.
203
+ */
204
+ function estalos(amostras, taxa, { subida = 20, queda = 6, minimoEntre = 0.3 } = {}) {
205
+ const hop = Math.max(16, Math.round(taxa * 0.005));
206
+ const n = Math.floor((amostras.length - 1) / hop);
207
+ const hf = new Float64Array(n);
208
+ for (let k = 0; k < n; k++) {
209
+ let s = 0;
210
+ for (let j = k * hop + 1; j < (k + 1) * hop + 1 && j < amostras.length; j++) { const d = amostras[j] - amostras[j - 1]; s += d * d; }
211
+ hf[k] = s / hop;
212
+ }
213
+ const ordenado = Array.from(hf).sort((a, b) => a - b);
214
+ const p99 = ordenado[Math.floor(0.99 * (n - 1))] || 0;
215
+ const piso = Math.max(1e-7, p99 * 0.5);
216
+ const out = [];
217
+ let ultimo = -Infinity;
218
+ const antes = Math.round(0.1 / 0.005);
219
+ const depois = Math.round(0.06 / 0.005);
220
+ for (let k = antes; k < n - depois; k++) {
221
+ if (hf[k] < piso || hf[k] < hf[k - 1] || hf[k] < hf[k + 1]) continue;
222
+ let m = 0;
223
+ for (let j = k - antes; j < k - 1; j++) m += hf[j];
224
+ m = m / (antes - 1) + 1e-9;
225
+ const razao = hf[k] / m;
226
+ if (razao < subida) continue;
227
+ if (hf[k + depois] > hf[k] / queda) continue;
228
+ const t = (k * hop) / taxa;
229
+ if (t - ultimo < minimoEntre) continue;
230
+ out.push({ t: Math.round(t * 1000) / 1000, score: Math.round(10 * Math.log10(razao) * 10) / 10 });
231
+ ultimo = t;
232
+ }
233
+ return out;
234
+ }
235
+
236
+ /**
237
+ * O estalo que vale: o de maior score, com bônus se há uma mão na tela por
238
+ * perto (±0,5 s). `trilhas` opcional. Devolve [{t, score, mao}] ordenado por score.
239
+ */
240
+ function escolherEstalos(cands, trilhas, fps) {
241
+ return cands.map((c) => {
242
+ let mao = false;
243
+ if (trilhas) {
244
+ const f = Math.round(c.t * fps);
245
+ for (let i = Math.max(0, f - Math.round(fps / 2)); i <= f + Math.round(fps / 2); i++) {
246
+ if ((trilhas.left[i]) || (trilhas.right[i])) { mao = true; break; }
247
+ }
248
+ }
249
+ return { ...c, mao, total: c.score + (mao ? 6 : 0) };
250
+ }).sort((a, b) => b.total - a.total);
251
+ }
252
+
253
+ // ── da origem para a tela ────────────────────────────────────────────────
254
+
255
+ /**
256
+ * Um ponto da ORIGEM (frações do vídeo) → frações do quadro do PROJETO,
257
+ * para um clipe desenhado com o transform dele e fit "cover" (o padrão).
258
+ */
259
+ function origemParaTela(tl, clip, asset, x, y) {
260
+ const W = tl.width;
261
+ const H = tl.height;
262
+ const tf = clip.transform || { cx: 0.5, cy: 0.5, w: 1, h: 1 };
263
+ const bw = tf.w * W;
264
+ const bh = tf.h * H;
265
+ const aw = asset.width || W;
266
+ const ah = asset.height || H;
267
+ let esc;
268
+ if (clip.fit === 'contain') esc = Math.min(bw / aw, bh / ah);
269
+ else if (clip.fit === 'stretch') return { x: (tf.cx * W - bw / 2 + x * bw) / W, y: (tf.cy * H - bh / 2 + y * bh) / H };
270
+ else esc = Math.max(bw / aw, bh / ah);
271
+ const px = tf.cx * W + (x - 0.5) * aw * esc;
272
+ const py = tf.cy * H + (y - 0.5) * ah * esc;
273
+ return { x: px / W, y: py / H, escala: (ah * esc) / H };
274
+ }
275
+
276
+ module.exports = { umEuro, preencher, trilhasDasMaos, trechosVisiveis, juntaEAbre, trilhaDaCabeca, acimaDaCabeca, estalos, escolherEstalos, origemParaTela };