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,191 @@
1
+ /**
2
+ * pedido.js — `primocode video do {"pedido":"..."}`: o pedido em português
3
+ * vira operações do editor, por REGRA (palavras-chave), sem modelo.
4
+ *
5
+ * "Tira o fundo e separa em camadas. Quando eu estalar o dedo me coloca
6
+ * numa praia. Põe o logo do ChatGPT na minha mão, e o do Claude nessa.
7
+ * Quando eu juntar as mãos vira um cérebro. Corta o silêncio depois do
8
+ * estalo. A legenda eu quero azul, em cima da minha cabeça."
9
+ *
10
+ * Por que regra e não o modelo: o modelo que dirige o editor pode ser um
11
+ * modelo grátis e pequeno. O pedido comum ("legenda azul") tem que dar
12
+ * certo sempre, igual, em milissegundos — e o que a regra não entende volta
13
+ * como "não entendi" com os exemplos, para o modelo usar as operações à mão.
14
+ *
15
+ * `interpretar` é pura (o teste roda cada frase); `do` executa o plano,
16
+ * adiantando EM PARALELO o que é lento e independente (rastreio e
17
+ * transcrição) antes do primeiro passo.
18
+ */
19
+
20
+ 'use strict';
21
+
22
+ const C = require('./camadas.js');
23
+
24
+ const tira = (s) => String(s || '').normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase();
25
+
26
+ const LUGARES = ['praia', 'espaco', 'cidade', 'floresta', 'montanha', 'escritorio', 'deserto', 'neve', 'por do sol', 'paris', 'nova york', 'mar'];
27
+ const NOMES_COR = Object.keys(C.CORES).filter((k) => !/[^a-z ]/.test(k)).sort((a, b) => b.length - a.length);
28
+
29
+ /** A cor PEDIDA: ignora a que só descreve o estado atual ("ela está amarela"). */
30
+ function corPedida(t) {
31
+ const limpo = t.replace(/\b(esta|ta|e|era|estava|tava|ficou)\s+(\w+)/g, (m, v, w) => (NOMES_COR.includes(w) ? ' ' : m));
32
+ let melhor = null;
33
+ for (const nome of NOMES_COR) {
34
+ const m = new RegExp(`\\b${nome}\\b`).exec(limpo);
35
+ if (m && (melhor === null || m.index < melhor.i)) melhor = { i: m.index, nome };
36
+ }
37
+ return melhor && melhor.nome;
38
+ }
39
+
40
+ function lugar(t) {
41
+ for (const l of LUGARES) if (new RegExp(`\\b${l}\\b`).test(t)) return l;
42
+ const m = t.match(/\bfundo\s+(?:de|com|do|da|num|numa)\s+(?:um|uma\s+)?([a-z][a-z ]{2,30}?)(?=[,.;!?]|\s+e\s|\s+quando|$)/);
43
+ return m ? m[1].trim() : null;
44
+ }
45
+
46
+ /** Onde cada objeto é citado, em ordem, com a mão dita logo depois. */
47
+ function objetosCitados(t) {
48
+ const out = [];
49
+ const re = /\b(chat ?gpt|gpt|openai|claude|anthropic|chrome|drive)\b|\blogo\s+d[aoe]s?\s+([a-z0-9]+)/g;
50
+ let m;
51
+ const todas = [];
52
+ while ((m = re.exec(t))) todas.push(m);
53
+ for (let k = 0; k < todas.length; k++) {
54
+ m = todas[k];
55
+ const nome = m[1] || m[2];
56
+ const modelo = C.modeloDe(nome);
57
+ if (!modelo && m[2] && /^(chat|gpt|claude)/.test(m[2])) continue;
58
+ if (out.some((o) => o.pos === m.index)) continue;
59
+ // A mão dita vale até a próxima citação: "ChatGPT na mão, e o do Claude nessa" — o "nessa" é do Claude.
60
+ const prox = todas.slice(k + 1).find((x) => x.index > m.index + m[0].length);
61
+ const depois = t.slice(m.index, Math.min(m.index + 70, prox ? prox.index : Infinity));
62
+ const mao = /\besquerd/.test(depois) ? 'left' : /\bdireit/.test(depois) ? 'right'
63
+ : /\b(nessa|nesta|outra|dessa|desta|nessa aqui|na outra)\b/.test(depois) ? 'other' : null;
64
+ if (modelo === 'cerebro') continue;
65
+ const item = { object: modelo || nome, pos: m.index, mao };
66
+ // "o logo do ChatGPT": a mesma citação casou duas vezes.
67
+ if (out.length && out[out.length - 1].object === item.object && m.index - out[out.length - 1].pos < 14) continue;
68
+ out.push(item);
69
+ }
70
+ return out;
71
+ }
72
+
73
+ /**
74
+ * pedido → {passos:[{op,args}], avisos}. `ctx`: {temLegenda, objetos:[{modelo,mao}]}.
75
+ */
76
+ function interpretar(pedido, ctx = {}) {
77
+ const t = tira(pedido).replace(/\s+/g, ' ');
78
+ const passos = [];
79
+ const avisos = [];
80
+ const add = (op, args = {}) => passos.push({ op, args });
81
+
82
+ const camadas = /\bcamadas?\b|\b3d\b|tres dimens/.test(t);
83
+ const tiraFundo = /\b(tir\w*|remov\w*|apag\w*|recort\w*)\s+(?:o\s+|do\s+|meu\s+)?fundo\b|\brecort\w*\s+(?:eu|me|a pessoa)\b|\bme\s+recort/.test(t);
84
+ const estalo = /\bestal\w*|\bsnap\b|\bdedo\b/.test(t);
85
+ const onde = lugar(t);
86
+ const trocaFundo = !!onde && /\b(coloc\w*|poe|pon\w*|bot\w*|lev\w*|transport\w*|troc\w*|mud\w*|fundo|joga\w*)\b/.test(t);
87
+
88
+ if (camadas && /\bsepar\w*|camadas\b/.test(t)) add('separate_layers', { show3d: true });
89
+ else if (tiraFundo && !trocaFundo) add('separate_layers', {});
90
+
91
+ if (trocaFundo) add('replace_background', { with: onde, ...(estalo ? { at: 'snap' } : {}) });
92
+
93
+ const objs = objetosCitados(t);
94
+ let anteriores = (ctx.objetos || []).length;
95
+ for (const o of objs) {
96
+ const hand = o.mao || (anteriores > 0 ? 'other' : 'auto');
97
+ add('attach_3d', { object: o.object, hand });
98
+ anteriores++;
99
+ }
100
+
101
+ const cerebro = /\bcerebro\b|\bbrain\b/.test(t);
102
+ const juntar = /\bjunt\w*\s+(?:as\s+|minhas\s+)?maos\b|\bmaos\s+juntas\b|\bfund\w*\s+(?:os|as)\b|\bjunt\w*\s+(?:os|as)\s+(?:dois|duas|logos)\b/.test(t);
103
+ if (juntar || (cerebro && objs.length + (ctx.objetos || []).length)) add('merge_on_join', { then: cerebro ? 'brain' : 'none' });
104
+ else if (cerebro) add('attach_3d', { object: 'brain', hand: 'auto' });
105
+
106
+ if (/\b(cort\w*|tir\w*|remov\w*|apag\w*)\s+(?:o\s+|os\s+|esse\s+|aquele\s+)?silencios?\b/.test(t)) {
107
+ if (estalo) add('cut_silence_at', { event: 'snap' });
108
+ else add('remove_silence', {});
109
+ }
110
+
111
+ if (/\blegenda/.test(t)) {
112
+ const cor = corPedida(t);
113
+ let position = null;
114
+ if (/\b(em cima|acima|sobre|por cima|encima)\s+da\s+(?:minha\s+)?cabeca\b/.test(t)) position = 'above_head';
115
+ else if (/\b(embaixo|em baixo|rodape|parte de baixo|la embaixo)\b/.test(t)) position = 'bottom';
116
+ else if (/\b(no topo|la em cima|parte de cima)\b/.test(t)) position = 'top';
117
+ else if (/\b(no meio|no centro|centralizad)\w*/.test(t)) position = 'center';
118
+ const estilo = {};
119
+ if (cor) estilo.color = cor;
120
+ if (position) estilo.position = position;
121
+ if (!ctx.temLegenda) add('add_captions', {});
122
+ if (Object.keys(estilo).length) add('style_captions', estilo);
123
+ else if (ctx.temLegenda) avisos.push('a legenda já existe: diga a cor ou a posição (ex.: "legenda azul", "em cima da minha cabeça")');
124
+ }
125
+
126
+ return { passos, avisos };
127
+ }
128
+
129
+ const EXEMPLOS = [
130
+ 'tira o fundo e separa em camadas', 'quando eu estalar o dedo me coloca numa praia', 'põe o logo do ChatGPT na minha mão',
131
+ 'e o do Claude nessa', 'quando eu juntar as mãos vira um cérebro', 'corta o silêncio depois do estalo',
132
+ 'essa legenda eu quero azul', 'quero a legenda em cima da minha cabeça',
133
+ ];
134
+
135
+ const OPS = {
136
+ do: {
137
+ uso: 'do {pedido:"<português>",dryRun?} — ex.: "tira o fundo e separa em camadas", "quando eu estalar o dedo me coloca numa praia", "põe o logo do ChatGPT na minha mão", "o do Claude nessa", "quando eu juntar as mãos vira um cérebro", "corta o silêncio depois do estalo", "legenda azul em cima da minha cabeça"',
138
+ async run(ctx, a) {
139
+ const T = require('./timeline.js');
140
+ const pedido = a.pedido || a.request || a.texto;
141
+ if (!pedido) throw T.erro('faltou "pedido"');
142
+ const tl = ctx.tl;
143
+ const info = {
144
+ temLegenda: !!tl && T.todosOsClipes(tl).some((x) => x.clip.caption),
145
+ objetos: tl && tl.fx ? Object.values(tl.fx).flatMap((v) => v.objetos || []) : [],
146
+ };
147
+ const plano = interpretar(pedido, info);
148
+ if (!plano.passos.length) {
149
+ return { ok: false, error: 'não entendi o pedido (nenhuma regra casou)', exemplos: EXEMPLOS,
150
+ dica: 'use as operações direto: primocode video help' };
151
+ }
152
+ const resumo = plano.passos.map((p) => ({ op: p.op, args: p.args }));
153
+ if (a.dryRun) return { ok: true, plano: resumo, ...(plano.avisos.length ? { avisos: plano.avisos } : {}) };
154
+ if (!tl) throw T.erro('nenhum projeto aberto: import {"path":"..."} primeiro');
155
+ const { OPS: TODAS } = require('./ops.js');
156
+ // O que é lento e não depende de nada roda JUNTO antes do primeiro passo.
157
+ const C2 = require('./camadas.js');
158
+ const asset = C2.base(tl);
159
+ const precisaRastro = plano.passos.some((p) => p.op !== 'add_captions' && p.op !== 'remove_silence');
160
+ const precisaFala = plano.passos.some((p) => ['add_captions', 'attach_3d', 'separate_layers'].includes(p.op));
161
+ const inicio = Date.now();
162
+ const [rs] = await Promise.all([
163
+ precisaRastro ? require('./rastrear.js').garantir(ctx.p.dir, asset) : null,
164
+ precisaFala && asset.hasAudio ? require('./transcript.js').obter(ctx.p.dir, asset).catch(() => null) : null,
165
+ ]);
166
+ if (rs && !rs.ok) return rs;
167
+ const feitos = [];
168
+ for (const p of plano.passos) {
169
+ const res = await TODAS[p.op].run(ctx, p.args);
170
+ feitos.push({ op: p.op, ...compacto(res) });
171
+ if (!res || res.ok === false) {
172
+ return { ok: false, error: `o passo ${p.op} falhou: ${res && res.error}`, feitos, desfazer: feitos.length > 1 ? `undo {"steps":${feitos.length - 1}}` : null };
173
+ }
174
+ }
175
+ return { ok: true, plano: resumo, feitos, tookS: Math.round((Date.now() - inicio) / 100) / 10, ...(plano.avisos.length ? { avisos: plano.avisos } : {}),
176
+ depois: 'inspect para OLHAR; export renderiza o 3D (Remotion) na primeira vez' };
177
+ },
178
+ },
179
+ };
180
+
181
+ /** O que importa de cada passo, numa linha (o delta inteiro de cada um encheria o contexto). */
182
+ function compacto(res) {
183
+ if (!res) return { ok: false };
184
+ const o = { ok: res.ok !== false };
185
+ for (const k of ['layers', 'show3d', 'background', 'object', 'merge', 'silence', 'removedFrames', 'captions', 'color', 'follow', 'dur', 'error']) if (res[k] !== undefined) o[k] = res[k];
186
+ return o;
187
+ }
188
+
189
+ const EX = { do: '{"pedido":"quando eu estalar o dedo me coloca numa praia"}' };
190
+
191
+ module.exports = { interpretar, corPedida, objetosCitados, lugar, OPS, EX, EXEMPLOS };
@@ -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 };