primocode 10.0.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 (55) hide show
  1. package/README.md +2 -2
  2. package/bin/primocode.js +69 -1
  3. package/lib/act.js +118 -19
  4. package/lib/browser.js +89 -5
  5. package/lib/catalogo.js +1 -1
  6. package/lib/claude-engine.js +38 -17
  7. package/lib/codex-engine.js +27 -9
  8. package/lib/entrada.js +73 -9
  9. package/lib/estudio/cinema.js +27 -9
  10. package/lib/estudio/diretor.js +13 -1
  11. package/lib/estudio/ende.js +16 -5
  12. package/lib/estudio/estilo.js +23 -8
  13. package/lib/estudio/gerativo.js +1453 -0
  14. package/lib/estudio/hibrido.js +165 -0
  15. package/lib/estudio/index.js +244 -36
  16. package/lib/estudio/kit/gerativo.jsx +980 -0
  17. package/lib/estudio/kit/index.jsx +3 -1
  18. package/lib/estudio/kit/tres.jsx +308 -0
  19. package/lib/estudio/kit.js +7 -1
  20. package/lib/estudio/lancamento.js +28 -0
  21. package/lib/estudio/referencias.js +105 -0
  22. package/lib/estudio/remotion.js +53 -9
  23. package/lib/estudio/ritmo.js +2 -2
  24. package/lib/estudio/sons-leves.js +38 -1
  25. package/lib/estudio/variedade.js +105 -0
  26. package/lib/estudio/vox.js +37 -8
  27. package/lib/mcp.js +156 -0
  28. package/lib/personas.js +67 -0
  29. package/lib/skills.js +36 -0
  30. package/lib/tools.js +22 -3
  31. package/lib/ui.js +41 -6
  32. package/lib/video/auto.js +329 -0
  33. package/lib/video/camadas.js +207 -0
  34. package/lib/video/cli.js +1 -1
  35. package/lib/video/filters.js +39 -6
  36. package/lib/video/fx3d/Camadas.jsx +64 -0
  37. package/lib/video/fx3d/Objetos.jsx +128 -0
  38. package/lib/video/fx3d/Root.jsx +18 -0
  39. package/lib/video/fx3d/modelos.jsx +304 -0
  40. package/lib/video/fx3d.js +108 -0
  41. package/lib/video/gerados.js +119 -0
  42. package/lib/video/objetos.js +111 -0
  43. package/lib/video/ops-fx.js +453 -0
  44. package/lib/video/ops.js +30 -5
  45. package/lib/video/paralelo.js +50 -0
  46. package/lib/video/pedido.js +191 -0
  47. package/lib/video/rastrear.js +95 -0
  48. package/lib/video/rastro.js +276 -0
  49. package/lib/video/render.js +67 -14
  50. package/lib/video/texto.js +6 -4
  51. package/lib/video/timeline.js +9 -1
  52. package/package.json +2 -2
  53. package/skills/motion-de-primeira/SKILL.md +5 -4
  54. package/studio/narrar.py +10 -7
  55. package/studio/rastrear.py +399 -0
@@ -0,0 +1,329 @@
1
+ /**
2
+ * auto.js — `primocode video auto`: o vídeo é o pedido.
3
+ *
4
+ * "Quero que ele leia o que eu falo no vídeo e aplique cada pedido no
5
+ * momento em que eu digo." (01/10)
6
+ *
7
+ * A pessoa grava falando o que quer ("separe em camadas", "coloca o logo do
8
+ * Claude aqui", "com o estalar de dedos me coloca numa praia") e o editor
9
+ * acha cada pedido na transcrição e o ancora no que o rastro viu: a palavra,
10
+ * a mão que aparece logo depois, o estalo que vem em seguida, o juntar das
11
+ * mãos. Nada de quadro escrito à mão.
12
+ *
13
+ * planejar(dados) → [{op, args(segundos da origem), motivo}] pura (o teste roda)
14
+ * OPS.auto.run → junta os dados, executa o plano, devolve o que fez
15
+ *
16
+ * Tudo em SEGUNDOS DA ORIGEM até a hora de executar: os cortes (silêncio,
17
+ * fim) vão por último e a legenda depois deles, já nos cortes.
18
+ */
19
+
20
+ 'use strict';
21
+
22
+ const PED = require('./pedido.js');
23
+
24
+ const tira = (s) => String(s || '').normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase();
25
+ const r2 = (n) => Math.round(n * 100) / 100;
26
+
27
+ /* O que o Whisper ouve no lugar do que a pessoa disse — visto nos dois
28
+ primeiros vídeos de verdade: "cloud", "Claudio", "xgbt", "estravar",
29
+ "instruar de dedos". Normaliza antes de qualquer regra. */
30
+ const OUVIDO = [
31
+ [/\b(claudio|claudia|cloud|clode|clod|claud|cloude)\b/g, 'claude'],
32
+ [/\b(x ?gbt|x ?gpt|chat ?gbt|chat ?gpt|chat ?jpt|tchat ?gpt|chatgbt|gbt)\b/g, 'chatgpt'],
33
+ [/\bgoogle\s+(chrome|drive)\b/g, '$1'],
34
+ [/\b(estr[ae]l\w*|estrav\w*|estal\w*|instru\w*|estral\w*)\s+(?:os|nos|dos|de|o|do)?\s*dedos?\b/g, 'estalar o dedo'],
35
+ [/\b(estal\w*|estral\w*)\b/g, 'estalar'],
36
+ ];
37
+ function normal(t) {
38
+ let s = tira(t).replace(/[^a-z0-9 ]/g, ' ').replace(/\s+/g, ' ').trim();
39
+ for (const [re, por] of OUVIDO) s = s.replace(re, por);
40
+ return s;
41
+ }
42
+
43
+ /* "colocar um foguete aqui na palma da minha mão": qualquer coisa na mão, não
44
+ só as logos. Vira o modelo, se houver, ou a placa com o nome. */
45
+ const NA_MAO = /\b(?:coloc\w*|po[er]\w*|bot\w*|aparec\w*)\s+(?:um|uma|o|a)\s+([a-z]{3,20})(?:\s+[a-z]{3,20})?\s+(?:aqui\s+)?(?:na|em|sobre|nas)\s+(?:a\s+)?(?:palma|mao|maos)\b/g;
46
+ const NAO_OBJETO = /^(logo|imagem|foto|fundo|legenda|praia|cerebro|video|mao|coisa)$/;
47
+ function objetoNaMao(t) {
48
+ const out = [];
49
+ let m;
50
+ NA_MAO.lastIndex = 0;
51
+ while ((m = NA_MAO.exec(t))) if (!NAO_OBJETO.test(m[1])) out.push({ object: m[1], pos: m.index, mao: null });
52
+ return out;
53
+ }
54
+
55
+ /** As palavras viram frases: ponto final, ou pausa de mais de 0,7 s. */
56
+ function frases(palavras) {
57
+ const out = [];
58
+ let atual = [];
59
+ const fecha = () => { if (atual.length) out.push({ ws: atual, de: atual[0].de, ate: atual[atual.length - 1].ate }); atual = []; };
60
+ palavras.forEach((p, i) => {
61
+ const ant = palavras[i - 1];
62
+ if (ant && p.de - ant.ate > 0.7) fecha();
63
+ atual.push(p);
64
+ if (/[.!?]$/.test(String(p.texto).trim())) fecha();
65
+ });
66
+ fecha();
67
+ for (const f of out) f.t = normal(f.ws.map((w) => w.texto).join(' '));
68
+ return out;
69
+ }
70
+
71
+ /** O segundo em que a frase diz `re` (a primeira palavra que casa), ou o começo dela. */
72
+ function quando(f, re) {
73
+ for (let i = 0; i < f.ws.length; i++) {
74
+ const um = normal(f.ws[i].texto);
75
+ const dois = normal(f.ws.slice(i, i + 3).map((w) => w.texto).join(' '));
76
+ if (re.test(um) || (re.test(dois) && !re.test(normal(f.ws.slice(i + 1, i + 3).map((w) => w.texto).join(' '))))) return f.ws[i].de;
77
+ }
78
+ return f.de;
79
+ }
80
+
81
+ const RE = {
82
+ camadas: /\bcamadas?\b|\btres dimens|\b(tir\w*|remov\w*|recort\w*|separ\w*)\s+(?:me\s+)?(?:o\s+|do\s+|meu\s+)?fundo\b|\bme\s+(tir\w*|recort\w*)\s+do\s+fundo\b/,
83
+ volta: /\bvolt\w*\b|\bnormal\b/,
84
+ estalo: /\bestalar\b/,
85
+ juntar: /\bjunt\w*\b|\buni\w*\b/,
86
+ cerebro: /\bcerebro\b/,
87
+ silencio: /\bsilencios?\b/,
88
+ fim: /\b(encerr|acab|termin|finaliz)\w*\b/,
89
+ legenda: /\blegendas?\b/,
90
+ acima: /\b(em cima|acima|encima|sobre|por cima)\s+da\s+(?:minha\s+)?cabeca\b|\baqui em cima\b|\bla em cima\b/,
91
+ imagem: /\b(?:imagem|foto|fundo)\s+(?:de|do|da)\s+(?:(?:um|uma)\s+)?([a-z]{3,20})\b/,
92
+ };
93
+
94
+ /** O fundo pedido na frase: um lugar conhecido, ou "imagem de um tubarão". */
95
+ function fundoPedido(t) {
96
+ const l = PED.lugar(t);
97
+ if (l && !/^(tras|atras|aqui)$/.test(l)) return l;
98
+ const m = t.match(RE.imagem);
99
+ return m && !/^(tras|atras|aqui|novo|nova)$/.test(m[1]) ? m[1] : null;
100
+ }
101
+
102
+ /**
103
+ * dados: {palavras:[{texto,de,ate}], estalos:[{t,score,mao}], juntaAbre:[{junta,abre}],
104
+ * maos:{left:[[de,ate]],right:[[de,ate]]}, silencios:[{de,ate}], dur} — tudo em segundos.
105
+ * → {passos:[{op,args,motivo}], legenda:{color?,position?,desde?}}
106
+ */
107
+ function planejar(d) {
108
+ const fs = frases(d.palavras || []);
109
+ const passos = [];
110
+ const bons = (d.estalos || []).filter((e) => e.mao || e.score >= 20);
111
+ const usados = new Set();
112
+ /* O estalo da janela é o MAIS FORTE dela, não o primeiro: o dedo que se
113
+ prepara faz um "tec" fraco antes do estalo de verdade (visto: 10,3 s
114
+ com 17 antes do 11,8 s com 21). */
115
+ const estaloDepois = (t, janela) => {
116
+ const c = bons.filter((x) => x.t >= t - 0.2 && x.t <= t + janela && !usados.has(x.t)).sort((a, b) => b.score - a.score)[0];
117
+ if (!c) return undefined;
118
+ usados.add(c.t);
119
+ return c.t;
120
+ };
121
+ const ehPedido = (f) => RE.camadas.test(f.t) || !!fundoPedido(f.t) || PED.objetosCitados(f.t).length || RE.juntar.test(f.t) || RE.silencio.test(f.t) || RE.fim.test(f.t);
122
+
123
+ // 1. As camadas em 3D: da palavra até o "volta" (ou o próximo pedido), 2,5–5 s.
124
+ const iCam = fs.findIndex((f) => RE.camadas.test(f.t) && !fundoPedido(f.t));
125
+ if (iCam >= 0) {
126
+ const de = Math.max(0, quando(fs[iCam], /camada|fundo|tir|recort|separ/) - 0.2);
127
+ const prox = fs.slice(iCam + 1).find((f) => f.de > de + 1 && (RE.volta.test(f.t) || ehPedido(f)));
128
+ const ate = Math.min(d.dur, de + 5, Math.max(de + 2.5, prox ? prox.de : de + 4));
129
+ passos.push({ op: 'separate_layers', args: { show3d: true, from: r2(de), to: r2(ate) }, motivo: `"${fs[iCam].t}"` });
130
+ }
131
+
132
+ // 2. Os objetos: na palavra, na mão que aparece logo ali (a outra, se a primeira já tem um).
133
+ const objetos = [];
134
+ const maoEm = (t, ocupadas) => {
135
+ const cand = [];
136
+ for (const lado of ['left', 'right']) {
137
+ for (const [a, b] of (d.maos && d.maos[lado]) || []) {
138
+ if (b < t - 0.2 || a > t + 3) continue;
139
+ cand.push({ lado, a: Math.max(a, t), b });
140
+ }
141
+ }
142
+ cand.sort((x, y) => (ocupadas.has(x.lado) - ocupadas.has(y.lado)) || x.a - y.a);
143
+ return cand[0] || null;
144
+ };
145
+ for (const f of fs) {
146
+ for (const o of [...PED.objetosCitados(f.t), ...objetoNaMao(f.t)]) {
147
+ const nome = o.object === 'chatgpt' ? /chatgpt|gpt/ : new RegExp(o.object.slice(0, 5));
148
+ const t = quando(f, nome);
149
+ if (objetos.some((x) => x.object === o.object && Math.abs(x.t - t) < 4)) continue;
150
+ const ocupadas = new Set(objetos.filter((x) => x.ate === null || x.ate > t).map((x) => x.mao));
151
+ const m = maoEm(t, ocupadas);
152
+ const mao = o.mao === 'left' || o.mao === 'right' ? o.mao : m ? m.lado : (ocupadas.has('left') ? 'right' : 'auto');
153
+ objetos.push({ object: o.object, t, de: m && m.lado === mao ? m.a : t, mao, ate: null, motivo: `"${f.t}"` });
154
+ }
155
+ }
156
+
157
+ // 3. Juntar as mãos (com objeto na mão): a junção que vem depois da frase; o cérebro se for dito.
158
+ let fusao = null;
159
+ if (objetos.length) {
160
+ const iJ = fs.findIndex((f) => (RE.juntar.test(f.t) || RE.cerebro.test(f.t)) && f.ate > objetos[0].t);
161
+ if (iJ >= 0) {
162
+ const f = fs[iJ];
163
+ const ev = (d.juntaAbre || []).find((e) => e.junta >= f.de - 1.5 && e.junta > objetos[0].de + 0.5);
164
+ if (ev) {
165
+ const cerebro = [f, fs[iJ + 1]].some((x) => x && RE.cerebro.test(x.t));
166
+ fusao = { junta: ev.junta, abre: ev.abre, then: cerebro ? 'brain' : 'none', motivo: `"${f.t}"` };
167
+ }
168
+ }
169
+ }
170
+ for (const o of objetos) {
171
+ o.ate = fusao && fusao.junta > o.de ? fusao.junta : Math.min(d.dur, o.de + 4);
172
+ if (o.ate > o.de + 0.3) passos.push({ op: 'attach_3d', args: { object: o.object, hand: o.mao, from: r2(o.de), to: r2(o.ate) }, motivo: o.motivo });
173
+ }
174
+ if (fusao) passos.push({ op: 'merge_on_join', args: { junta: r2(fusao.junta), ...(fusao.abre !== null ? { abre: r2(fusao.abre) } : {}), then: fusao.then }, motivo: fusao.motivo });
175
+
176
+ // 4. Fundos novos: no estalo, se a frase (ou a anterior) fala dele, ou se um estalo vem logo depois; senão na palavra.
177
+ const fundos = [];
178
+ fs.forEach((f, i) => {
179
+ const onde = fundoPedido(f.t);
180
+ if (!onde || !/\b(coloc\w*|poe|pon\w*|bot\w*|lev\w*|troc\w*|mud\w*|fundo|imagem|foto|me\s+em)\b/.test(f.t)) return;
181
+ const t = quando(f, new RegExp(onde.split(' ')[0].slice(0, 5)));
182
+ /* Um estalo logo depois do lugar é o gatilho, dito ou não. Se o estalo
183
+ foi dito ANTES do lugar ("se estalar os dedos ela coloca um
184
+ tubarão"), ele pode vir uns segundos depois da frase. */
185
+ const antes = f.t.slice(0, f.t.indexOf(onde.split(' ')[0]));
186
+ const falaEstalo = RE.estalo.test(antes) || (fs[i - 1] && RE.estalo.test(fs[i - 1].t) && !fundoPedido(fs[i - 1].t));
187
+ let e = estaloDepois(t, 3);
188
+ if (e === undefined && falaEstalo) e = estaloDepois(f.de, 10);
189
+ // "só aqui", "somente aqui": o fundo vale para a frase, depois volta o original.
190
+ const so = /\b(somente|so|apenas)\s+(aqui|agora|nessa parte|nesse momento)\b/.test(f.t) ? Math.min(d.dur, f.ate + 0.6) : null;
191
+ fundos.push({ with: onde, at: e !== undefined ? e : t, ...(so ? { ate: so } : {}), motivo: `"${f.t}"` + (e !== undefined ? ' (no estalo)' : '') });
192
+ });
193
+ fundos.forEach((b, i) => {
194
+ const until = b.ate !== undefined ? b.ate : fundos[i + 1] ? fundos[i + 1].at : null;
195
+ passos.push({ op: 'replace_background', args: { with: b.with, at: r2(b.at), ...(until !== null ? { until: r2(until) } : {}) }, motivo: b.motivo });
196
+ });
197
+
198
+ // 5. Legenda: sempre (é reels); a cor e a posição se forem pedidas, a partir do estalo se ele for citado.
199
+ const legenda = {};
200
+ const iL = fs.findIndex((f) => RE.legenda.test(f.t));
201
+ if (iL >= 0) {
202
+ const janela = fs.slice(iL, iL + 3);
203
+ const cor = janela.map((f) => PED.corPedida(f.t)).find(Boolean);
204
+ if (cor) legenda.color = cor;
205
+ const fp = janela.find((f) => RE.acima.test(f.t));
206
+ if (fp) {
207
+ legenda.position = 'above_head';
208
+ if (RE.estalo.test(fp.t)) { const e = estaloDepois(fp.de, 8); if (e !== undefined) legenda.desde = e; }
209
+ // "a legenda que tá aqui… aqui em cima": sobe quando ela diz onde.
210
+ else if (/\b(aqui|la) em cima\b/.test(fp.t)) legenda.desde = quando(fp, /cima/);
211
+ } else {
212
+ /* "a legenda daqui ficar aqui", apontando: o pedido é mudar de lugar, e
213
+ o lugar que se aponta em quem fala para a câmera é perto do rosto —
214
+ a legenda sobe para a cabeça no segundo "aqui". (01/10) */
215
+ const fd = janela.find((f) => /\bdaqui\b[\s\S]*\b(ficar|ir|pra|para)\b[\s\S]*\baqui\b|\b(mud|muda|mover|move|pass)\w*\b[\s\S]*\baqui\b/.test(f.t));
216
+ if (fd) {
217
+ legenda.position = 'above_head';
218
+ const aquis = fd.ws.filter((w) => /^aqui/.test(normal(w.texto)));
219
+ legenda.desde = (aquis[aquis.length - 1] || fd.ws[0]).de;
220
+ }
221
+ }
222
+ }
223
+
224
+ // 6. Cortes, por último: o silêncio que a pessoa cita, e o fim que ela anuncia.
225
+ const cortes = [];
226
+ const iS = fs.findIndex((f) => RE.silencio.test(f.t) && /\b(cort\w*|tir\w*|remov\w*|apag\w*|retir\w*)\b/.test(f.t));
227
+ if (iS >= 0) {
228
+ const t0 = fs[iS].de;
229
+ const sil = juntarSilencios(d.silencios || []).filter((s) => s.de >= t0 && s.de <= t0 + 15 && s.ate - s.de >= 1.5)
230
+ .sort((a, b) => (b.ate - b.de) - (a.ate - a.de))[0];
231
+ if (sil) cortes.push({ de: r2(sil.de + 0.1), ate: r2(sil.ate - 0.1), motivo: `"${fs[iS].t}" (${r2(sil.ate - sil.de)} s parado)` });
232
+ }
233
+ const iF = fs.findIndex((f) => RE.fim.test(f.t) && /\bvideo\b/.test(f.t));
234
+ if (iF >= 0) {
235
+ const f = fs[iF];
236
+ const ultima = fs[fs.length - 1].ate;
237
+ const e = RE.estalo.test(f.t) ? estaloDepois(f.de, 6) : undefined;
238
+ const fim = Math.max(ultima, e !== undefined ? e : 0) + 0.4;
239
+ if (d.dur - fim > 0.8) cortes.push({ de: r2(fim), ate: r2(d.dur), motivo: `"${f.t}"` });
240
+ }
241
+ if (cortes.length) passos.push({ op: 'cortes', args: { trechos: cortes.map(({ de, ate }) => [de, ate]) }, motivo: cortes.map((c) => c.motivo).join(' · ') });
242
+
243
+ return { passos, legenda };
244
+ }
245
+
246
+ /** Silêncios separados por um respiro (< 0,4 s) são um só. */
247
+ function juntarSilencios(sil) {
248
+ const out = [];
249
+ for (const s of [...sil].sort((a, b) => a.de - b.de)) {
250
+ const u = out[out.length - 1];
251
+ if (u && s.de - u.ate < 0.4) u.ate = Math.max(u.ate, s.ate);
252
+ else out.push({ de: s.de, ate: s.ate });
253
+ }
254
+ return out;
255
+ }
256
+
257
+ const OPS = {
258
+ auto: {
259
+ uso: 'auto {asset?,dryRun?} — O VÍDEO É O PEDIDO: lê o que a pessoa FALA na gravação ("separe em camadas", "coloca o logo do Claude aqui", "com o estalar de dedos me coloca numa praia", "junta e vira um cérebro", "cortou o silêncio", "o vídeo acaba") e aplica cada efeito no momento em que ela diz, ancorado na palavra, na mão, no estalo e no juntar das mãos; legenda sempre. Use logo depois do import quando pedirem "edite esse vídeo" sem dizer o quê',
260
+ async run(ctx, a) {
261
+ const T = require('./timeline.js');
262
+ const C = require('./camadas.js');
263
+ const R = require('./rastro.js');
264
+ const RT = require('./rastrear.js');
265
+ const TR = require('./transcript.js');
266
+ const FXO = require('./ops-fx.js');
267
+ const tl = ctx.tl;
268
+ if (!tl) throw T.erro('nenhum projeto aberto: import {"path":"..."} primeiro');
269
+ const asset = C.base(tl, a.asset);
270
+ const inicio = Date.now();
271
+ // O lento e independente junto: rastreio (MediaPipe em paralelo) e transcrição.
272
+ const [rs, tr] = await Promise.all([RT.garantir(ctx.p.dir, asset), TR.obter(ctx.p.dir, asset)]);
273
+ if (!rs.ok) return rs;
274
+ if (!tr.ok) return { ok: false, error: 'sem a fala não sei o que fazer: ' + tr.error };
275
+ const fps = rs.rastro.fps;
276
+ const trilhas = R.trilhasDasMaos(rs.rastro);
277
+ const trechos = (t) => R.trechosVisiveis(t, Math.round(fps * 0.3)).map(([x, y]) => [x / fps, y / fps]);
278
+ const dados = {
279
+ palavras: tr.palavras,
280
+ estalos: (await FXO.estalosDe(ctx, asset)).map((e) => ({ t: e.t, score: e.score, mao: e.mao })),
281
+ juntaAbre: R.juntaEAbre(trilhas, { rastro: rs.rastro }).map((e) => ({ junta: e.junta / fps, abre: e.abre === null ? null : e.abre / fps })),
282
+ maos: { left: trechos(trilhas.left), right: trechos(trilhas.right) },
283
+ silencios: await require('./render.js').silenciosDe(ctx.p.dir, asset, { threshold: -35, minSilence: 0.8 }),
284
+ dur: asset.seconds,
285
+ };
286
+ const plano = planejar(dados);
287
+ const q = (s) => C.origemParaQuadro(ctx.tl, asset.id, s);
288
+ const ver = plano.passos.map((p) => ({ op: p.op, motivo: p.motivo, args: p.args }));
289
+ if (a.dryRun) return { ok: true, plano: ver, legenda: plano.legenda, segundos: 'da gravação' };
290
+
291
+ const { OPS: TODAS } = require('./ops.js');
292
+ const feitos = [];
293
+ const roda = async (op, args, motivo) => {
294
+ const res = await TODAS[op].run(ctx, args);
295
+ feitos.push({ op, motivo, ok: !!res && res.ok !== false, ...(res && res.ok === false ? { error: res.error } : {}) });
296
+ return res;
297
+ };
298
+ for (const p of plano.passos) {
299
+ const g = p.args;
300
+ if (p.op === 'separate_layers') await roda(p.op, { show3d: true, from: q(g.from), to: q(g.to) }, p.motivo);
301
+ else if (p.op === 'attach_3d') await roda(p.op, { object: g.object, hand: g.hand, from: q(g.from), to: q(g.to) }, p.motivo);
302
+ else if (p.op === 'merge_on_join') await roda(p.op, { junta: q(g.junta), ...(g.abre !== undefined ? { abre: q(g.abre) } : {}), then: g.then }, p.motivo);
303
+ else if (p.op === 'replace_background') await roda(p.op, { with: g.with, at: q(g.at), ...(g.until !== undefined ? { until: q(g.until) } : {}) }, p.motivo);
304
+ else if (p.op === 'cortes') {
305
+ const ranges = g.trechos.map(([de, ate]) => [q(de), Math.min(T.duracao(ctx.tl), q(Math.min(ate, asset.seconds - 0.001)) + 1)]).filter(([x, y]) => x !== null && y > x);
306
+ if (ranges.length) await roda('ripple_delete_ranges', { ranges }, p.motivo);
307
+ }
308
+ }
309
+ // A legenda, já nos cortes; a posição a partir do estalo, se ele foi citado.
310
+ await roda('add_captions', { maxWords: 3, animation: 'highlightPop' }, 'legenda (reels)');
311
+ const L = plano.legenda;
312
+ if (L.color || L.position) {
313
+ const leg = T.todosOsClipes(ctx.tl).filter((x) => x.clip.caption).map((x) => x.clip);
314
+ const desde = L.desde !== undefined ? q(L.desde) : null;
315
+ if (L.color) await roda('style_captions', { color: L.color }, 'cor pedida');
316
+ const ids = leg.filter((c) => desde === null || c.startFrame >= desde).map((c) => c.id);
317
+ if (L.position && ids.length) await roda('style_captions', { ids, position: L.position }, desde !== null ? 'em cima da cabeça, desde o momento dito' : 'em cima da cabeça');
318
+ }
319
+ const falhou = feitos.filter((f) => !f.ok);
320
+ return { ok: !falhou.length, feitos, tookS: Math.round((Date.now() - inicio) / 100) / 10,
321
+ ...(falhou.length ? { error: falhou.map((f) => `${f.op}: ${f.error}`).join('; ') } : {}),
322
+ depois: 'inspect para OLHAR os momentos; export renderiza (o 3D sai no export)' };
323
+ },
324
+ },
325
+ };
326
+
327
+ const EX = { auto: '{}' };
328
+
329
+ module.exports = { planejar, frases, normal, fundoPedido, juntarSilencios, OPS, EX };
@@ -0,0 +1,207 @@
1
+ /**
2
+ * camadas.js — as peças comuns das operações "de efeito" (separar, trocar
3
+ * fundo, objeto na mão, legenda na cabeça): achar a fala, espelhar clipes
4
+ * sobre ela, ordenar as faixas por papel e converter "o estalo", "quando eu
5
+ * falar camadas" ou um quadro em SEGUNDO DA ORIGEM.
6
+ *
7
+ * ── POR QUE TUDO MORA EM SEGUNDO DA ORIGEM ───────────────────────────────
8
+ * O que se rastreia (a mão, a cabeça, o estalo) aconteceu na GRAVAÇÃO. Se a
9
+ * receita de um efeito guardasse quadro da timeline, o primeiro corte de
10
+ * silêncio a deixaria apontando para o lugar errado. Guardando o segundo da
11
+ * origem, o efeito é redesenhado em cima da timeline que existir — e a
12
+ * camada gerada entra "espelhando" os clipes da fala: o corte que pega a
13
+ * fala pega a camada no mesmo quadro.
14
+ *
15
+ * ── AS FAIXAS TÊM PAPEL ──────────────────────────────────────────────────
16
+ * De cima para baixo: textos, objetos 3D, o momento das camadas, a pessoa
17
+ * recortada, o fundo novo e, por baixo de tudo, a gravação (que segue
18
+ * levando o áudio). `arrumar` impõe essa ordem depois de cada operação, em
19
+ * qualquer ordem que o pedido venha.
20
+ */
21
+
22
+ 'use strict';
23
+
24
+ const T = require('./timeline.js');
25
+
26
+ const ORDEM = { objetos: 1, camadas3d: 2, pessoa: 3, fundo: 4 };
27
+
28
+ /** A gravação: a mídia de vídeo pedida, ou a primeira com áudio que está na timeline. */
29
+ function base(tl, ref) {
30
+ if (ref) {
31
+ const a = T.acharAsset(tl, ref);
32
+ if (a.kind !== 'video' || a.gerado) throw T.erro(`a mídia ${a.id} não é uma gravação de vídeo`);
33
+ return a;
34
+ }
35
+ const usados = new Set(clipesBase(tl).map((c) => c.asset));
36
+ const lista = Object.values(tl.assets).filter((a) => a.kind === 'video' && !a.gerado && usados.has(a.id));
37
+ const achada = lista.find((a) => a.hasAudio) || lista[0];
38
+ if (!achada) throw T.erro('não há gravação de vídeo na timeline: comece com import {"path":"..."}');
39
+ return achada;
40
+ }
41
+
42
+ /** Os clipes da gravação "de verdade": faixa de vídeo sem papel. */
43
+ function clipesBase(tl, assetId) {
44
+ const out = [];
45
+ for (const t of tl.tracks) {
46
+ if (t.type !== 'video' || t.role) continue;
47
+ for (const c of t.clips) if (c.type === 'video' && (!assetId || c.asset === assetId) && !(tl.assets[c.asset] || {}).gerado) out.push(c);
48
+ }
49
+ return out.sort((a, b) => a.startFrame - b.startFrame);
50
+ }
51
+
52
+ /** Quadro da timeline → segundo da origem da gravação (ou null fora dela). */
53
+ function quadroParaOrigem(tl, assetId, f) {
54
+ for (const c of clipesBase(tl, assetId)) {
55
+ if (f >= c.startFrame && f < T.fim(c)) return T.segundos(tl, c.trimStartFrame + (f - c.startFrame) * (c.speed || 1));
56
+ }
57
+ return null;
58
+ }
59
+
60
+ /** Segundo da origem → quadro da timeline (null se o trecho foi cortado). */
61
+ function origemParaQuadro(tl, assetId, seg) {
62
+ for (const c of clipesBase(tl, assetId)) {
63
+ const f = T.origemParaQuadro(tl, c, seg);
64
+ if (f !== null && f < T.fim(c)) return f;
65
+ }
66
+ // O fim exato da gravação (o "até o fim") cai no fim do último clipe.
67
+ const ult = clipesBase(tl, assetId).slice(-1)[0];
68
+ if (ult && Math.abs(T.janelaOrigem(tl, ult).ate - seg) < 0.5 / tl.fps) return T.fim(ult);
69
+ return null;
70
+ }
71
+
72
+ /** O clipe da gravação que está no quadro f (para saber o encaixe na tela). */
73
+ function clipeNoQuadro(tl, assetId, f) {
74
+ return clipesBase(tl, assetId).find((c) => f >= c.startFrame && f < T.fim(c)) || null;
75
+ }
76
+
77
+ function arrumar(tl) {
78
+ const rank = (t) => (t.type === 'text' ? 0 : t.type === 'audio' ? 9 : ORDEM[t.role] || 5);
79
+ tl.tracks = tl.tracks.map((t, i) => ({ t, i })).sort((a, b) => rank(a.t) - rank(b.t) || a.i - b.i).map((x) => x.t);
80
+ return tl;
81
+ }
82
+
83
+ /** A faixa com aquele papel (cria se não houver). Devolve o índice já na ordem certa. */
84
+ function faixa(tl, papel) {
85
+ let t = tl.tracks.find((x) => x.role === papel);
86
+ if (!t) {
87
+ const i = T.faixaPara(tl, 'video', 'new');
88
+ t = tl.tracks[i];
89
+ t.role = papel;
90
+ }
91
+ arrumar(tl);
92
+ return tl.tracks.indexOf(t);
93
+ }
94
+
95
+ /** Tira os clipes de um papel (de uma gravação), e a faixa se esvaziar. */
96
+ function limparPapel(tl, papel, assetId) {
97
+ for (const t of tl.tracks) {
98
+ if (t.role !== papel) continue;
99
+ t.clips = t.clips.filter((c) => assetId && c.base !== assetId);
100
+ }
101
+ tl.tracks = tl.tracks.filter((t) => t.role !== papel || t.clips.length);
102
+ }
103
+
104
+ /** Apaga os assets gerados que nenhum clipe usa mais. */
105
+ function limparGerados(tl) {
106
+ const usados = new Set(T.todosOsClipes(tl).map((x) => x.clip.asset));
107
+ for (const [id, a] of Object.entries(tl.assets)) if (a.gerado && !usados.has(id)) delete tl.assets[id];
108
+ }
109
+
110
+ /**
111
+ * Cria, na faixa `ti`, um clipe por clipe da gravação que cai em [de, ate)
112
+ * (segundos da origem), no MESMO lugar da timeline. `asset`: a mídia do
113
+ * clipe novo; `origemDe`: o segundo da gravação que corresponde ao 0 dela
114
+ * (a própria gravação: 0; uma camada gerada a partir de `de`: `de`).
115
+ */
116
+ function espelhar(tl, assetId, ti, { de = 0, ate = Infinity, asset = assetId, origemDe = 0, props = {}, papel } = {}) {
117
+ const criados = [];
118
+ for (const c of clipesBase(tl, assetId)) {
119
+ const j = T.janelaOrigem(tl, c);
120
+ const i0 = Math.max(j.de, de);
121
+ const i1 = Math.min(j.ate, ate);
122
+ if (i1 - i0 < 0.5 / tl.fps) continue;
123
+ const s = T.origemParaQuadro(tl, c, i0);
124
+ const e = Math.min(T.fim(c), T.origemParaQuadro(tl, c, i1));
125
+ if (s === null || e === null || e <= s) continue;
126
+ const o = s - c.startFrame;
127
+ const kf = {};
128
+ for (const [k, keys] of Object.entries(c.keyframes || {})) if (k !== 'volume') kf[k] = keys.map((x) => ({ ...x, frame: x.frame - o }));
129
+ const novo = T.clipe(tl, {
130
+ type: 'video', asset, startFrame: s, durationFrames: e - s,
131
+ trimStartFrame: Math.max(0, Math.round((i0 - origemDe) * tl.fps)), speed: c.speed,
132
+ transform: { ...c.transform }, crop: { ...c.crop }, fit: c.fit, volume: 0, ...props,
133
+ });
134
+ novo.keyframes = kf;
135
+ novo.base = assetId;
136
+ if (papel) novo.papel = papel;
137
+ tl.tracks[ti].clips.push(novo);
138
+ criados.push(novo);
139
+ }
140
+ T.ordenar(tl);
141
+ return criados;
142
+ }
143
+
144
+ // ── os momentos ──────────────────────────────────────────────────────────
145
+
146
+ const sem = (s) => String(s || '').normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase().replace(/[^a-z0-9]+/g, '');
147
+
148
+ /** A primeira palavra da transcrição que casa (sem acento, sem espaço: "chat gpt" casa "chatgpt"). */
149
+ function acharPalavra(palavras, alvo, { desde = 0 } = {}) {
150
+ const a = sem(alvo);
151
+ if (!a) return null;
152
+ for (let i = 0; i < palavras.length; i++) {
153
+ const p = palavras[i];
154
+ if (p.de < desde) continue;
155
+ const um = sem(p.texto);
156
+ const dois = um + sem((palavras[i + 1] || {}).texto);
157
+ if (um.startsWith(a) || (a.length > 4 && um.length >= 4 && a.startsWith(um) && dois.startsWith(a))) return p;
158
+ }
159
+ return null;
160
+ }
161
+
162
+ /** Cor por nome (português ou inglês) → hex. Hex passa direto. */
163
+ const CORES = {
164
+ azul: '#2F80FF', blue: '#2F80FF', 'azul claro': '#5AC8FA', ciano: '#22D3EE', cyan: '#22D3EE',
165
+ vermelho: '#EF4444', vermelha: '#EF4444', red: '#EF4444', verde: '#22C55E', green: '#22C55E',
166
+ amarelo: '#FFD400', amarela: '#FFD400', yellow: '#FFD400', laranja: '#FF8A00', orange: '#FF8A00',
167
+ rosa: '#FF4FA3', pink: '#FF4FA3', roxo: '#8B5CF6', roxa: '#8B5CF6', purple: '#8B5CF6', lilas: '#C084FC',
168
+ branco: '#FFFFFF', branca: '#FFFFFF', white: '#FFFFFF', preto: '#000000', preta: '#000000', black: '#000000',
169
+ dourado: '#F5C542', dourada: '#F5C542', gold: '#F5C542', cinza: '#9CA3AF', gray: '#9CA3AF',
170
+ };
171
+ function cor(nome) {
172
+ if (nome === undefined || nome === null) return undefined;
173
+ const s = String(nome).trim();
174
+ if (/^#?[0-9a-f]{6}$/i.test(s)) return s.startsWith('#') ? s.toUpperCase() : '#' + s.toUpperCase();
175
+ const k = s.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase();
176
+ if (CORES[k]) return CORES[k];
177
+ throw T.erro(`cor "${nome}" (use #RRGGBB ou azul/vermelho/verde/amarelo/laranja/rosa/roxo/ciano/branco/preto/dourado/cinza)`);
178
+ }
179
+
180
+ /** Fundo pedido por palavra → termo de busca que acha foto boa (o acervo é em inglês). */
181
+ const FUNDOS = {
182
+ praia: 'tropical beach turquoise sea', beach: 'tropical beach turquoise sea', mar: 'ocean waves', espaco: 'outer space nebula stars',
183
+ space: 'outer space nebula stars', cidade: 'city skyline night', floresta: 'green forest', montanha: 'mountain landscape',
184
+ escritorio: 'modern office interior', deserto: 'desert dunes', neve: 'snowy mountains', estudio: 'dark studio backdrop',
185
+ paris: 'paris eiffel tower', 'nova york': 'new york skyline', por_do_sol: 'sunset sky', 'por do sol': 'sunset sky',
186
+ };
187
+ function termoDeFundo(pedido) {
188
+ const k = String(pedido || '').normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase().trim();
189
+ return FUNDOS[k] || FUNDOS[k.replace(/^(uma?|o|a|na|no|numa|num)\s+/, '')] || k;
190
+ }
191
+
192
+ /** Objeto pedido → modelo do fx3d/modelos.jsx. */
193
+ function modeloDe(nome) {
194
+ const k = sem(nome);
195
+ if (/^(chatgpt|gpt|openai|chat)/.test(k)) return 'chatgpt';
196
+ if (/^(claude|claud|anthropic)/.test(k)) return 'claude';
197
+ if (/^(brain|cerebro)/.test(k)) return 'cerebro';
198
+ if (/^(chrome|google ?chrome|navegador)/.test(k)) return 'chrome';
199
+ if (/^(drive|google ?drive)/.test(k)) return 'drive';
200
+ if (/^(foguete|rocket|nave)/.test(k)) return 'foguete';
201
+ return null;
202
+ }
203
+
204
+ module.exports = {
205
+ ORDEM, base, clipesBase, quadroParaOrigem, origemParaQuadro, clipeNoQuadro, arrumar, faixa, limparPapel, limparGerados,
206
+ espelhar, acharPalavra, sem, cor, CORES, termoDeFundo, FUNDOS, modeloDe,
207
+ };
package/lib/video/cli.js CHANGED
@@ -52,7 +52,7 @@ async function main(argv, { out = process.stdout, err = process.stderr } = {}) {
52
52
  try {
53
53
  const { op, args } = lerArgs(argv);
54
54
  if (!op || op === 'help' || op === '--help' || op === '-h') {
55
- out.write(ajuda() + '\n');
55
+ out.write(ajuda(argv[1]) + '\n');
56
56
  return 0;
57
57
  }
58
58
  const nome = ALIAS[op] || op;
@@ -100,6 +100,27 @@ const EFEITOS = {
100
100
  params: 'color (hex, #00FF00), similarity (0.12), blend (0.08)',
101
101
  filtro: (p) => ['format=yuva420p', `chromakey=${String(p.color || '#00FF00').replace('#', '0x')}:${num(p.similarity || 0.12)}:${num(p.blend || 0.08)}`],
102
102
  },
103
+ /* A pessoa recortada: a máscara (studio/rastrear.py) entra como uma
104
+ segunda entrada, cortada no MESMO trecho do clipe, e vira o alfa
105
+ (alphamerge) antes de qualquer outro filtro — ver montarGrafo. Aqui
106
+ só ficam os números: `choke` encolhe a borda (px), `feather` a suaviza. */
107
+ 'key.matte': {
108
+ params: 'matte (arquivo, posto pelo separate_layers), choke (0..4, 1), feather (0..6, 1.2)',
109
+ filtro: () => [],
110
+ cadeiaDaMascara(p) {
111
+ const f = ['format=gray'];
112
+ for (let i = 0; i < Math.round(lim(p.choke ?? 1, 0, 4)); i++) f.push('erosion');
113
+ const fe = lim(p.feather ?? 1.2, 0, 6);
114
+ if (fe > 0) f.push(`gblur=sigma=${num(fe)}`);
115
+ return f;
116
+ },
117
+ },
118
+ /* A pessoa no fundo novo: um empurrão leve da cor dela para a luz do
119
+ fundo (ganho por canal perto de 1) — o "colado" some sem mudar a pele. */
120
+ 'color.match': {
121
+ params: 'r g b (ganho por canal, 0.85..1.15), medido pelo replace_background',
122
+ filtro: (p) => [`colorchannelmixer=rr=${num(lim(p.r ?? 1, 0.85, 1.15))}:gg=${num(lim(p.g ?? 1, 0.85, 1.15))}:bb=${num(lim(p.b ?? 1, 0.85, 1.15))}`],
123
+ },
103
124
  };
104
125
 
105
126
  function filtrosDoEfeito(e) {
@@ -145,7 +166,7 @@ function atempo(speed) {
145
166
  * Monta tudo. `camadasDeTexto`: {índiceDaFaixa: caminho do .ffconcat}.
146
167
  * `escala`: 0.5 para prévia. Devolve {inputs, grafo, saidaV, saidaA, segundos}.
147
168
  */
148
- function montarGrafo(tl, { from = 0, to, camadasDeTexto = {}, escala = 1, audio = true } = {}) {
169
+ function montarGrafo(tl, { from = 0, to, camadasDeTexto = {}, escala = 1, audio = true, video = true } = {}) {
149
170
  const ate = to === undefined ? T.duracao(tl) : Math.min(to, T.duracao(tl));
150
171
  if (!(ate > from)) throw T.erro('nada para renderizar nesse trecho');
151
172
  const fps = tl.fps;
@@ -161,7 +182,7 @@ function montarGrafo(tl, { from = 0, to, camadasDeTexto = {}, escala = 1, audio
161
182
  for (let ti = tl.tracks.length - 1; ti >= 0; ti--) {
162
183
  const faixa = tl.tracks[ti];
163
184
  if (faixa.type === 'text') {
164
- const arq = camadasDeTexto[ti];
185
+ const arq = video ? camadasDeTexto[ti] : null;
165
186
  if (!arq || faixa.hidden) continue;
166
187
  const k = inputs.length;
167
188
  inputs.push(['-f', 'concat', '-safe', '0', '-i', arq]);
@@ -177,13 +198,17 @@ function montarGrafo(tl, { from = 0, to, camadasDeTexto = {}, escala = 1, audio
177
198
  if (!a) continue;
178
199
  const speed = c.speed || 1;
179
200
  const temSom = a.kind === 'audio' || (a.kind === 'video' && a.hasAudio);
180
- const visivel = faixa.type === 'video' && !c.hidden && !faixa.hidden;
201
+ // video:false — só o áudio (o export em partes faz o som numa passada à parte).
202
+ const visivel = video && faixa.type === 'video' && !c.hidden && !faixa.hidden;
181
203
  if (!visivel && !(audio && temSom)) continue;
182
204
  const k = inputs.length;
183
205
  const startS = j.start / fps;
184
206
  const durS = j.dur / fps;
185
207
  if (a.kind === 'image') inputs.push(['-loop', '1', '-framerate', String(fps), '-t', num(durS), '-i', a.path]);
186
208
  else inputs.push(['-ss', num(j.trim / fps), '-t', num(durS * speed + 0.05), '-i', a.path]);
209
+ const matte = visivel && c.effects.find((e) => e.type === 'key.matte' && e.params && e.params.matte);
210
+ let km = null;
211
+ if (matte) { km = inputs.length; inputs.push(['-ss', num(j.trim / fps), '-t', num(durS * speed + 0.05), '-i', matte.params.matte]); }
187
212
  const X = `on/${fps}+${num(j.corte / fps)}`; // tempo do clipe, dentro do zoompan
188
213
  const Xt = `t+${num(j.corte / fps)}`; // tempo do clipe, nos filtros com t local
189
214
  const Xo = `(t-${num(startS)})+${num(j.corte / fps)}`; // tempo do clipe, no overlay (t global)
@@ -210,7 +235,13 @@ function montarGrafo(tl, { from = 0, to, camadasDeTexto = {}, escala = 1, audio
210
235
  const rot = expressao(c.keyframes.rotation, Xt, fps) || (c.transform.rotation ? num(c.transform.rotation) : null);
211
236
  if (rot) f.push(`rotate=a='(${rot})*PI/180':c=none:ow='hypot(iw,ih)':oh='ow'`);
212
237
  f.push(`setpts=PTS+${num(startS)}/TB`);
213
- linhas.push(`[${k}:v]${f.join(',')}[c${k}]`);
238
+ if (km !== null) {
239
+ // O alfa vem da máscara, no mesmo tempo e no mesmo tamanho da origem.
240
+ const pre = f.splice(0, 2);
241
+ linhas.push(`[${k}:v]${pre.join(',')}[pre${k}]`);
242
+ linhas.push(`[${km}:v]${[...EFEITOS['key.matte'].cadeiaDaMascara(matte.params), ...pre].join(',')}[mt${k}]`);
243
+ linhas.push(`[pre${k}][mt${k}]alphamerge,${f.join(',')}[c${k}]`);
244
+ } else linhas.push(`[${k}:v]${f.join(',')}[c${k}]`);
214
245
  const cx = expressao(c.keyframes.cx, Xo, fps) || num(c.transform.cx);
215
246
  const cy = expressao(c.keyframes.cy, Xo, fps) || num(c.transform.cy);
216
247
  const fimS = (j.start + j.dur) / fps;
@@ -234,14 +265,16 @@ function montarGrafo(tl, { from = 0, to, camadasDeTexto = {}, escala = 1, audio
234
265
  }
235
266
  }
236
267
 
237
- linhas.push(`[${atual}]format=yuv420p[vout]`);
268
+ // Só o áudio: o vídeo (uma cor preta) termina num ralo — saída solta derruba o ffmpeg.
269
+ if (!video) linhas.push(`[${atual}]nullsink`);
270
+ else linhas.push(`[${atual}]format=yuv420p[vout]`);
238
271
  if (!audio) return { inputs, grafo: linhas.join(';\n'), saidaV: '[vout]', saidaA: null, segundos: D, W, H };
239
272
  if (audios.length) {
240
273
  linhas.push(`${audios.join('')}amix=inputs=${audios.length}:duration=longest:dropout_transition=0:normalize=0,apad,atrim=0:${num(D)}[aout]`);
241
274
  } else {
242
275
  linhas.push(`anullsrc=r=48000:cl=stereo,atrim=0:${num(D)}[aout]`);
243
276
  }
244
- return { inputs, grafo: linhas.join(';\n'), saidaV: '[vout]', saidaA: '[aout]', segundos: D, W, H };
277
+ return { inputs, grafo: linhas.join(';\n'), saidaV: video ? '[vout]' : null, saidaA: '[aout]', segundos: D, W, H };
245
278
  }
246
279
 
247
280
  module.exports = { num, expressao, EFEITOS, PRESETS_COR, CURVAS, filtrosDoEfeito, efeitoDeCor, naJanela, atempo, montarGrafo };