primocode 10.1.0 → 10.3.0-beta.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.
@@ -20,24 +20,84 @@ const fs = require('fs');
20
20
  const os = require('os');
21
21
  const path = require('path');
22
22
 
23
- const PASTA = path.join(os.homedir(), '.primocode', 'studio');
24
- const MEMORIA = { trilha: 16, sfx: 40, broll: 80, icones: 4, estilo: 8, '3d': 24 };
25
-
26
- function arquivo(tipo) { return path.join(PASTA, `${tipo}-usadas.json`); }
23
+ /* A pasta é lida a cada uso (não no require): o teste troca o HOME depois de
24
+ carregar o módulo e a memória de verdade fica intocada. */
25
+ const PASTA = () => path.join(os.homedir(), '.primocode', 'studio');
26
+ /* Os tipos do gerativo e do fluido (09/10): "vício em cores, músicas, fontes"
27
+ — a paleta, as fontes e o gênero saíam só da semente, sem lembrar do vídeo
28
+ anterior. Agora passam por aqui como o resto. */
29
+ const MEMORIA = { trilha: 16, sfx: 40, broll: 80, icones: 4, estilo: 8, '3d': 24, 'icone-nome': 30, 'paleta-fluido': 6,
30
+ 'fonte-fluido': 4, 'humor-gerativo': 4, 'paleta-gerativo': 10, 'fonte-gerativo': 18, 'genero-gerativo': 8,
31
+ 'arranjo-icone': 10, 'material-icone': 4, 'heroi-gerativo': 8, 'composicao-gerativo': 12,
32
+ 'moldura-gerativo': 4, 'ambiente-gerativo': 4, 'acento-gerativo': 3 };
33
+
34
+ /* A DIVERSIDADE POR PONTOS (07/10): "o sistema não deve simplesmente proibir
35
+ repetição; escolhas usadas recentemente recebem penalidade, as muito usadas
36
+ recebem penalidade maior, as antigas podem voltar, e o que é adequado ao
37
+ conteúdo continua tendo prioridade" (Joel). Antes era uma janela fixa: o
38
+ que saía da janela voltava como se nunca tivesse sido usado.
39
+ pontos = PESO_ADEQUACAO × adequação − recência − frequência
40
+ - recência: 1 para a última usada, cai pela metade a cada MEIA_VIDA_USOS
41
+ escolhas do mesmo tipo (exp), e some quando sai da lista;
42
+ - frequência: log2(1 + usos), com os usos perdendo metade a cada
43
+ MEIA_VIDA_DIAS — o muito usado pesa mais, e o tempo perdoa;
44
+ - adequação: 0 a 1, de quem chama (o clima, o assunto). Com peso 2, o que
45
+ serve ao roteiro ganha do que só é novo: a variedade não quebra a coerência. */
46
+ const PESO_ADEQUACAO = 2;
47
+ const PESO_FREQUENCIA = 0.25;
48
+ const MEIA_VIDA_DIAS = 21;
49
+ const EMPATE = 0.05; // dentro disso, é sorteio
50
+
51
+ function arquivo(tipo) { return path.join(PASTA(), `${tipo}-usadas.json`); }
52
+ function arquivoHistorico(tipo) { return path.join(PASTA(), `${tipo}-historico.json`); }
27
53
 
28
54
  function usadas(tipo) {
29
55
  try { const l = JSON.parse(fs.readFileSync(arquivo(tipo), 'utf8')); return Array.isArray(l) ? l : []; } catch { return []; }
30
56
  }
31
57
 
32
- function marcar(tipo, id) {
58
+ /** { id: { n: usos, t: ms da última } } — a frequência, que a lista de recentes não guarda. */
59
+ function historico(tipo) {
60
+ try { const h = JSON.parse(fs.readFileSync(arquivoHistorico(tipo), 'utf8')); return h && typeof h === 'object' && !Array.isArray(h) ? h : {}; } catch { return {}; }
61
+ }
62
+
63
+ const usosHoje = (reg, agora) => (reg ? reg.n * Math.pow(0.5, Math.max(0, agora - reg.t) / (MEIA_VIDA_DIAS * 864e5)) : 0);
64
+
65
+ function marcar(tipo, id, agora = Date.now()) {
33
66
  if (!id) return;
34
67
  try {
35
- fs.mkdirSync(PASTA, { recursive: true });
36
- const l = [id, ...usadas(tipo).filter((x) => x !== id)].slice(0, MEMORIA[tipo] || 20);
68
+ fs.mkdirSync(PASTA(), { recursive: true });
69
+ // A lista de recentes é maior que a janela de antes: a recência decai, não corta.
70
+ const l = [id, ...usadas(tipo).filter((x) => x !== id)].slice(0, (MEMORIA[tipo] || 20) * 2);
37
71
  fs.writeFileSync(arquivo(tipo), JSON.stringify(l));
72
+ const h = historico(tipo);
73
+ h[id] = { n: usosHoje(h[id], agora) + 1, t: agora };
74
+ // Guarda os 400 mais recentes, para o arquivo não crescer sem fim.
75
+ const ids = Object.keys(h).sort((x, y) => h[y].t - h[x].t).slice(0, 400);
76
+ fs.writeFileSync(arquivoHistorico(tipo), JSON.stringify(Object.fromEntries(ids.map((k) => [k, h[k]]))));
38
77
  } catch { /* sem memória: só perde a garantia de não repetir */ }
39
78
  }
40
79
 
80
+ /** Quanto pesa ter usado `id`: recência + frequência. 0 = nunca usado. */
81
+ function penalidade(id, tipo, { lista = usadas(tipo), hist = historico(tipo), agora = Date.now() } = {}) {
82
+ const pos = lista.indexOf(id);
83
+ const meiaVida = Math.max(1, (MEMORIA[tipo] || 20) / 4);
84
+ const recencia = pos < 0 ? 0 : Math.pow(0.5, pos / meiaVida);
85
+ return recencia + PESO_FREQUENCIA * Math.log2(1 + usosHoje(hist[id], agora));
86
+ }
87
+
88
+ /**
89
+ * Os pontos de cada opção: adequação menos penalidade. `adequacao` é uma
90
+ * função (opção → 0..1) ou um objeto {id: 0..1}; sem ela, tudo serve igual (1).
91
+ */
92
+ function pontuar(opcoes, tipo, { prefixo = '', adequacao = null, chave = (o) => o, agora = Date.now() } = {}) {
93
+ const lista = usadas(tipo), hist = historico(tipo);
94
+ const fit = (o) => {
95
+ const v = typeof adequacao === 'function' ? adequacao(o) : adequacao && typeof adequacao === 'object' ? adequacao[chave(o)] : 1;
96
+ return Number.isFinite(v) ? Math.max(0, Math.min(1, v)) : 0;
97
+ };
98
+ return opcoes.map((o) => ({ opcao: o, pontos: PESO_ADEQUACAO * fit(o) - penalidade(prefixo + chave(o), tipo, { lista, hist, agora }) }));
99
+ }
100
+
41
101
  function embaralhar(lista, aleatorio = Math.random) {
42
102
  const a = [...lista];
43
103
  for (let i = a.length - 1; i > 0; i--) {
@@ -47,25 +107,27 @@ function embaralhar(lista, aleatorio = Math.random) {
47
107
  return a;
48
108
  }
49
109
 
50
- /** Os itens da busca na ordem de tentar: os nunca usados primeiro (sorteados), os usados no fim. */
51
- function ordenar(itens, tipo, aleatorio = Math.random) {
52
- const ja = new Set(usadas(tipo));
110
+ /** Os itens da busca na ordem de tentar: mais pontos primeiro; quase empatados, sorteados. */
111
+ function ordenar(itens, tipo, aleatorio = Math.random, adequacao = null) {
53
112
  const validos = (itens || []).filter((x) => x && x.url);
54
- return [...embaralhar(validos.filter((x) => !ja.has(x.url)), aleatorio), ...validos.filter((x) => ja.has(x.url))];
113
+ return pontuar(validos, tipo, { adequacao, chave: (x) => x.url })
114
+ .map((p) => ({ ...p, pontos: p.pontos + aleatorio() * EMPATE }))
115
+ .sort((a, b) => b.pontos - a.pontos)
116
+ .map((p) => p.opcao);
55
117
  }
56
118
 
57
119
  /**
58
- * Uma das opções, sem repetir as últimas: as nunca usadas primeiro (sorteadas),
59
- * depois a usada há mais tempo. Marca a escolhida na memória do tipo. Com
60
- * `prefixo`, várias listas dividem o mesmo arquivo ('acabamento:fosco').
120
+ * Uma das opções, pelos pontos: as nunca usadas primeiro, a recente e a muito
121
+ * usada por último, e a adequada (opcional) acima de todas. Entre as quase
122
+ * empatadas, sorteia. Marca a escolhida na memória do tipo. Com `prefixo`,
123
+ * várias listas dividem o mesmo arquivo ('acabamento:fosco').
61
124
  */
62
- function sortear(opcoes, tipo, { prefixo = '', aleatorio = Math.random } = {}) {
125
+ function sortear(opcoes, tipo, { prefixo = '', aleatorio = Math.random, adequacao = null } = {}) {
63
126
  if (!opcoes || !opcoes.length) return null;
64
- const ja = usadas(tipo);
65
- const idade = (o) => ja.indexOf(prefixo + o); // 0 = a última usada
66
- const novas = embaralhar(opcoes.filter((o) => idade(o) < 0), aleatorio);
67
- const velhas = opcoes.filter((o) => idade(o) >= 0).sort((a, b) => idade(b) - idade(a));
68
- const escolha = [...novas, ...velhas][0];
127
+ const pts = pontuar(opcoes, tipo, { prefixo, adequacao });
128
+ const melhor = Math.max(...pts.map((p) => p.pontos));
129
+ const quase = pts.filter((p) => p.pontos >= melhor - EMPATE).map((p) => p.opcao);
130
+ const escolha = quase[Math.floor(aleatorio() * quase.length) % quase.length];
69
131
  marcar(tipo, prefixo + escolha);
70
132
  return escolha;
71
133
  }
@@ -179,5 +241,5 @@ function trocarIcones(plano, troca = {}) {
179
241
  return n;
180
242
  }
181
243
 
182
- module.exports = { usadas, marcar, embaralhar, ordenar, sortear, climaDe, termosTrilha, termosSfx, termosBroll, buscarSemRepetir,
244
+ module.exports = { usadas, historico, marcar, penalidade, pontuar, embaralhar, ordenar, sortear, climaDe, termosTrilha, termosSfx, termosBroll, buscarSemRepetir,
183
245
  colecaoIcones, iconesTrocaveis, trocarIcones, CLIMAS, SFX, JEITOS, FAMILIAS_ICONE };
@@ -224,7 +224,12 @@ function montarGrafo(tl, { from = 0, to, camadasDeTexto = {}, escala = 1, audio
224
224
  else f.push(`scale=${bw}:${bh}:force_original_aspect_ratio=increase`, `crop=${bw}:${bh}`);
225
225
  f.push('setsar=1');
226
226
  const zoom = expressao(c.keyframes.zoom, X, fps);
227
- if (zoom) f.push(`zoompan=z='max(1,${zoom})':x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)':d=1:s=${bw}x${bh}:fps=${fps}`);
227
+ // O foco do zoom (fx,fy) é o ponto do clipe que fica no meio; a vista nunca sai do quadro.
228
+ const fx = expressao(c.keyframes.fx, X, fps);
229
+ const fy = expressao(c.keyframes.fy, X, fps);
230
+ const zx = fx ? `max(0,min(iw-iw/zoom,(${fx})*iw-iw/zoom/2))` : 'iw/2-(iw/zoom/2)';
231
+ const zy = fy ? `max(0,min(ih-ih/zoom,(${fy})*ih-ih/zoom/2))` : 'ih/2-(ih/zoom/2)';
232
+ if (zoom) f.push(`zoompan=z='max(1,${zoom})':x='${zx}':y='${zy}':d=1:s=${bw}x${bh}:fps=${fps}`);
228
233
  for (const e of c.effects) f.push(...filtrosDoEfeito(e));
229
234
  f.push('format=yuva420p');
230
235
  const op = expressao(c.keyframes.opacity, Xt, fps);
package/lib/video/ops.js CHANGED
@@ -184,7 +184,7 @@ const OPS = {
184
184
  },
185
185
  },
186
186
  set_keyframes: {
187
- uso: 'set_keyframes {id,prop(zoom|cx|cy|rotation|opacity|volume),keys:[{frame(timeline),value,interp?:ease|linear|hold}]} — keys:[] limpa',
187
+ uso: 'set_keyframes {id,prop(zoom|fx|fy|cx|cy|rotation|opacity|volume),keys:[{frame(timeline),value,interp?:ease|linear|hold}]} — keys:[] limpa',
188
188
  async run(ctx, a) { precisaProjeto(ctx); return mutar(ctx, 'set_keyframes', (tl) => { E.keyframes(tl, a.id, a.prop, a.keys); }); },
189
189
  },
190
190
  apply_layout: {
@@ -273,7 +273,8 @@ const OPS = {
273
273
  const FXO = require('./ops-fx.js');
274
274
  const PED = require('./pedido.js');
275
275
  const AUTO = require('./auto.js');
276
- Object.assign(OPS, FXO.OPS, PED.OPS, AUTO.OPS);
276
+ const ZOOM = require('./zoom.js'); // o zoom que segue o cursor (gravação de tela): fica na ajuda principal
277
+ Object.assign(OPS, FXO.OPS, PED.OPS, AUTO.OPS, ZOOM.OPS);
277
278
 
278
279
  const ALIAS = { timeline: 'get_timeline', transcript: 'get_transcript', captions: 'add_captions', texts: 'add_texts', render: 'export', pedido: 'do', analyze: 'track' };
279
280
 
@@ -310,7 +311,7 @@ const EFEITOS = new Set([...Object.keys(FXO.OPS), ...Object.keys(PED.OPS), ...Ob
310
311
  /* A ajuda principal fica curta (o modelo a lê inteira a cada tarefa): os
311
312
  efeitos entram num resumo de três linhas, e o detalhe em `help efeitos`. */
312
313
  function ajuda(secao) {
313
- Object.assign(EX, FXO.EX, PED.EX, AUTO.EX);
314
+ Object.assign(EX, FXO.EX, PED.EX, AUTO.EX, ZOOM.EX);
314
315
  const linha = (nome, o, linhas) => {
315
316
  linhas.push(' ' + o.uso);
316
317
  if (EX[nome]) linhas.push(` ex: primocode video ${nome} --json '${EX[nome]}'`);
@@ -29,7 +29,7 @@ const CABE = { video: ['video', 'image'], audio: ['audio'], text: ['text'] };
29
29
 
30
30
  const TRANSFORM_PADRAO = { cx: 0.5, cy: 0.5, w: 1, h: 1, rotation: 0 };
31
31
  const CROP_PADRAO = { l: 0, t: 0, r: 0, b: 0 };
32
- const PROPS_ANIMAVEIS = ['zoom', 'cx', 'cy', 'rotation', 'opacity', 'volume'];
32
+ const PROPS_ANIMAVEIS = ['zoom', 'fx', 'fy', 'cx', 'cy', 'rotation', 'opacity', 'volume']; // fx,fy = foco do zoom (0–1 do clipe)
33
33
  const INTERP = ['linear', 'ease', 'hold'];
34
34
 
35
35
  function novo({ nome = 'video', width = 1080, height = 1920, fps = 30 } = {}) {
@@ -0,0 +1,275 @@
1
+ /**
2
+ * zoom.js — o zoom que segue o cursor numa gravação de tela.
3
+ *
4
+ * Três peças, como conta pura e testável sem vídeo nenhum:
5
+ *
6
+ * · os FOCOS: o clique, quando há registro dele; sem clique, o lugar onde
7
+ * o movimento PARA (o cursor anda, assenta e alguém vai clicar ali);
8
+ * · os TRECHOS: focos próximos no tempo viram um zoom só; uma pausa longa
9
+ * ou um salto para longe (com tempo de sobra) fecham o zoom e abrem outro;
10
+ * · a CÂMERA: dentro do trecho ela fica parada enquanto o cursor anda numa
11
+ * zona morta no meio da vista, e só anda quando ele sai dali — puxada por
12
+ * uma mola criticamente amortecida, sem tremer e sem passar do ponto.
13
+ *
14
+ * Reimplementação independente da ideia (curva, regra de clique e mola):
15
+ * nenhuma linha vem de outro projeto.
16
+ *
17
+ * Saída: keyframes de zoom e do FOCO do zoom (fx, fy: 0–1 do clipe), que o
18
+ * filters.js já sabe desenhar no zoompan.
19
+ */
20
+
21
+ 'use strict';
22
+
23
+ const { spawn } = require('child_process');
24
+
25
+ const lim = (v, a, b) => Math.max(a, Math.min(b, v));
26
+
27
+ // ── medir: onde a tela mexeu (quando ninguém gravou o cursor) ────────────
28
+
29
+ /**
30
+ * Quadros cinza (Uint8Array w*h, um por amostra) → [{t, x, y, move}].
31
+ * x,y = centro do que mudou desde o quadro anterior (o cursor, numa tela
32
+ * parada). Mudança grande demais (rolagem, troca de janela) não é cursor.
33
+ */
34
+ function movimento(quadros, w, h, fps, { limiar = 24, maxFracao = 0.2 } = {}) {
35
+ const out = [];
36
+ for (let i = 1; i < quadros.length; i++) {
37
+ const a = quadros[i - 1];
38
+ const b = quadros[i];
39
+ let n = 0;
40
+ let sx = 0;
41
+ let sy = 0;
42
+ for (let k = 0; k < w * h; k++) {
43
+ if (Math.abs(a[k] - b[k]) > limiar) { n++; sx += k % w; sy += Math.floor(k / w); }
44
+ }
45
+ const t = i / fps;
46
+ if (n < 2 || n > maxFracao * w * h) out.push({ t, x: null, y: null, move: false });
47
+ else out.push({ t, x: (sx / n + 0.5) / w, y: (sy / n + 0.5) / h, move: true });
48
+ }
49
+ return out;
50
+ }
51
+
52
+ /** Trilha de cursor [{t,x,y,click?}] → amostras com `move` (andou desde a anterior). */
53
+ function doCursor(cursor, { parado = 0.004 } = {}) {
54
+ const c = cursor.filter((p) => Number.isFinite(p.t) && Number.isFinite(p.x) && Number.isFinite(p.y)).sort((a, b) => a.t - b.t);
55
+ return c.map((p, i) => ({ ...p, move: i > 0 && Math.hypot(p.x - c[i - 1].x, p.y - c[i - 1].y) > parado }));
56
+ }
57
+
58
+ /**
59
+ * Os focos: os cliques, se houver; senão, onde o movimento para — andou por
60
+ * `minAndou` s e depois ficou parado por `minParado` s. [{t, x, y}].
61
+ */
62
+ function focos(amostras, { minAndou = 0.15, minParado = 0.3 } = {}) {
63
+ const cliques = amostras.filter((a) => a.click);
64
+ if (cliques.length) return cliques.map(({ t, x, y }) => ({ t, x, y }));
65
+ const out = [];
66
+ let ini = null;
67
+ let ult = null;
68
+ for (const a of amostras) {
69
+ if (a.move) { if (ini === null) ini = a.t; ult = a; continue; }
70
+ if (ult && ini !== null && ult.t - ini >= minAndou - 1e-9 && a.t - ult.t >= minParado - 1e-9) {
71
+ out.push({ t: ult.t, x: ult.x, y: ult.y });
72
+ ini = null;
73
+ ult = null;
74
+ }
75
+ }
76
+ return out;
77
+ }
78
+
79
+ // ── os trechos e a câmera ────────────────────────────────────────────────
80
+
81
+ /**
82
+ * Focos (t em QUADROS do clipe) → trechos [{de, ate, focos}]. Fecha o trecho
83
+ * na pausa maior que `juntar` ou no salto maior que `longe` quando há tempo
84
+ * de sair e voltar (2 rampas); salto rápido fica no mesmo trecho e vira pan.
85
+ */
86
+ function trechos(fs, { fps, juntar = 2.5, longe = 0.35, antes = 0.4, segura = 1.2, rampa = 0.5 } = {}) {
87
+ const out = [];
88
+ const R = rampa * fps;
89
+ for (const f of [...fs].sort((a, b) => a.t - b.t)) {
90
+ const u = out[out.length - 1];
91
+ const ult = u && u.focos[u.focos.length - 1];
92
+ const gap = ult ? f.t - ult.t : Infinity;
93
+ if (u && gap <= juntar * fps && !(Math.hypot(f.x - ult.x, f.y - ult.y) > longe && gap > 2 * R + segura * fps)) { u.focos.push(f); continue; }
94
+ out.push({ focos: [f] });
95
+ }
96
+ for (const s of out) {
97
+ s.de = Math.max(0, Math.round(s.focos[0].t - antes * fps - R));
98
+ s.ate = Math.round(s.focos[s.focos.length - 1].t + segura * fps);
99
+ }
100
+ // Um trecho não invade a saída do anterior.
101
+ for (let i = 1; i < out.length; i++) out[i - 1].ate = Math.max(out[i - 1].focos[out[i - 1].focos.length - 1].t, Math.min(out[i - 1].ate, out[i].de - R));
102
+ return out;
103
+ }
104
+
105
+ /**
106
+ * A câmera, quadro a quadro: [{z, x, y}] para `dur` quadros. `pos(f)` = onde
107
+ * o cursor está no quadro f (ou null). Zona morta = fração da meia-vista.
108
+ */
109
+ function camera(dur, ts, pos, { fps, zoom = 1.8, rampa = 0.5, zonaMorta = 0.45, hz = 1.4 } = {}) {
110
+ const R = Math.max(1, Math.round(rampa * fps));
111
+ const out = [];
112
+ const w = 2 * Math.PI * hz;
113
+ const dt = 1 / fps;
114
+ let cam = { x: 0.5, y: 0.5 };
115
+ let vel = { x: 0, y: 0 };
116
+ let alvo = { ...cam };
117
+ let s = null;
118
+ for (let f = 0; f < dur; f++) {
119
+ const novo = ts.find((t) => f >= t.de && f < t.ate + R);
120
+ if (novo !== s) {
121
+ s = novo;
122
+ if (s) { cam = { x: s.focos[0].x, y: s.focos[0].y }; alvo = { ...cam }; vel = { x: 0, y: 0 }; }
123
+ }
124
+ let z = 1;
125
+ if (s) {
126
+ const p = f < s.de + R ? (f - s.de) / R : f >= s.ate ? 1 - (f - s.ate) / R : 1;
127
+ z = 1 + (zoom - 1) * p * p * (3 - 2 * p);
128
+ // Segue o cursor só entre o primeiro e o último foco: antes, mira o foco; depois, segura.
129
+ const c = f >= s.focos[0].t && f <= s.focos[s.focos.length - 1].t ? pos(f) : null;
130
+ const meia = 0.5 / zoom;
131
+ if (c) {
132
+ for (const k of ['x', 'y']) {
133
+ const d = c[k] - alvo[k];
134
+ const zm = zonaMorta * meia;
135
+ if (Math.abs(d) > zm) alvo[k] = c[k] - Math.sign(d) * zm;
136
+ alvo[k] = lim(alvo[k], meia, 1 - meia);
137
+ }
138
+ }
139
+ for (const k of ['x', 'y']) {
140
+ vel[k] += (w * w * (alvo[k] - cam[k]) - 2 * w * vel[k]) * dt; // mola crítica: chega sem passar
141
+ cam[k] += vel[k] * dt;
142
+ }
143
+ }
144
+ const meiaZ = 0.5 / z;
145
+ out.push({ z, x: lim(cam.x, meiaZ, 1 - meiaZ), y: lim(cam.y, meiaZ, 1 - meiaZ) });
146
+ }
147
+ return out;
148
+ }
149
+
150
+ /** Ramer–Douglas–Peucker numa série: os quadros que bastam para a reta não errar mais que `tol`. */
151
+ function enxugar(v, tol) {
152
+ const manter = new Set([0, v.length - 1]);
153
+ const pilha = [[0, v.length - 1]];
154
+ while (pilha.length) {
155
+ const [a, b] = pilha.pop();
156
+ let pior = -1;
157
+ let max = tol;
158
+ for (let i = a + 1; i < b; i++) {
159
+ const d = Math.abs(v[i] - (v[a] + ((v[b] - v[a]) * (i - a)) / (b - a)));
160
+ if (d > max) { max = d; pior = i; }
161
+ }
162
+ if (pior >= 0) { manter.add(pior); pilha.push([a, pior], [pior, b]); }
163
+ }
164
+ return [...manter].sort((x, y) => x - y);
165
+ }
166
+
167
+ /** Câmera → keyframes {zoom, fx, fy} em quadros do clipe ({frame, value, interp}). */
168
+ function keyframes(cam, { tol = 0.002 } = {}) {
169
+ const out = {};
170
+ for (const [prop, k] of [['zoom', 'z'], ['fx', 'x'], ['fy', 'y']]) {
171
+ const v = cam.map((c) => c[k]);
172
+ out[prop] = enxugar(v, prop === 'zoom' ? 0.01 : tol).map((f) => ({ frame: f, value: Math.round(v[f] * 10000) / 10000, interp: 'linear' }));
173
+ }
174
+ return out;
175
+ }
176
+
177
+ /**
178
+ * Tudo junto. `amostras` com t em QUADROS do clipe e x,y em 0–1 do clipe.
179
+ * Devolve {focos, trechos, cam, keys}.
180
+ */
181
+ function planejar(amostras, dur, opts) {
182
+ const fs = focos(amostras.map((a) => ({ ...a, t: a.t / opts.fps })), opts).map((f) => ({ ...f, t: Math.round(f.t * opts.fps) }));
183
+ const ts = trechos(fs, opts);
184
+ const comPos = amostras.filter((a) => a.x !== null && a.x !== undefined);
185
+ let i = 0;
186
+ const pos = (f) => { // a última posição conhecida até f (f só cresce)
187
+ while (i + 1 < comPos.length && comPos[i + 1].t <= f) i++;
188
+ return comPos[i] && comPos[i].t <= f ? comPos[i] : null;
189
+ };
190
+ const cam = camera(dur, ts, pos, opts);
191
+ return { focos: fs, trechos: ts, cam, keys: keyframes(cam) };
192
+ }
193
+
194
+ // ── o ffmpeg mede ────────────────────────────────────────────────────────
195
+
196
+ /** Quadros cinza pequenos da origem [de, ate) segundos, a `fps` por segundo. */
197
+ function quadrosCinza(arquivo, de, ate, { fps = 15, w = 160, h = 90 } = {}) {
198
+ const video = require('../estudio/video.js');
199
+ return new Promise((resolve, reject) => {
200
+ const p = spawn(video.ffmpeg(), ['-hide_banner', '-loglevel', 'error', '-ss', String(de), '-t', String(ate - de), '-i', arquivo,
201
+ '-vf', `fps=${fps},scale=${w}:${h}:flags=area,format=gray`, '-f', 'rawvideo', '-']);
202
+ const partes = [];
203
+ p.stdout.on('data', (d) => partes.push(d));
204
+ p.on('error', reject);
205
+ p.on('close', (code) => {
206
+ if (code !== 0) return reject(new Error('não consegui ler os quadros'));
207
+ const b = Buffer.concat(partes);
208
+ const out = [];
209
+ for (let k = 0; k + w * h <= b.length; k += w * h) out.push(b.subarray(k, k + w * h));
210
+ resolve(out);
211
+ });
212
+ });
213
+ }
214
+
215
+ // ── a operação do editor ─────────────────────────────────────────────────
216
+
217
+ /** Ponto da ORIGEM (0–1) → ponto do clipe (0–1), depois do encaixe (cover/contain). */
218
+ function paraClipe(tl, clip, asset, x, y) {
219
+ if (clip.fit === 'stretch') return { x, y };
220
+ const bw = clip.transform.w * tl.width;
221
+ const bh = clip.transform.h * tl.height;
222
+ const aw = asset.width || bw;
223
+ const ah = asset.height || bh;
224
+ const esc = clip.fit === 'contain' ? Math.min(bw / aw, bh / ah) : Math.max(bw / aw, bh / ah);
225
+ return { x: lim(0.5 + ((x - 0.5) * aw * esc) / bw, 0, 1), y: lim(0.5 + ((y - 0.5) * ah * esc) / bh, 0, 1) };
226
+ }
227
+
228
+ const OPS = {
229
+ auto_zoom: {
230
+ uso: 'auto_zoom {id?,zoom?:1.8,cursor?:[{t(s da origem),x,y(0–1),click?}]|cursorFile?,deadZone?:0.45,remove?} — gravação de tela: entra no clique (sem cursor: onde o movimento para), segue com zona morta e mola, sai na pausa',
231
+ async run(ctx, a) {
232
+ const T = require('./timeline.js');
233
+ const E = require('./edits.js');
234
+ const { mutar } = require('./ops.js');
235
+ if (!ctx.tl) throw T.erro('nenhum projeto aberto: comece com import {"path":"..."}');
236
+ const primeiro = T.todosOsClipes(ctx.tl).find((x) => x.clip.type === 'video');
237
+ if (!a.id && !primeiro) throw T.erro('não há clipe de vídeo');
238
+ const { clip } = T.achar(ctx.tl, a.id || primeiro.clip.id);
239
+ if (a.remove) return mutar(ctx, 'auto_zoom', (tl) => { for (const p of ['zoom', 'fx', 'fy']) E.keyframes(tl, clip.id, p, []); });
240
+ const asset = ctx.tl.assets[clip.asset];
241
+ const fps = ctx.tl.fps;
242
+ const { de, ate } = T.janelaOrigem(ctx.tl, clip);
243
+ let cru;
244
+ let fonte;
245
+ if (a.cursor || a.cursorFile) {
246
+ cru = doCursor(a.cursor || JSON.parse(require('fs').readFileSync(a.cursorFile, 'utf8')));
247
+ fonte = cru.some((x) => x.click) ? 'cliques' : 'cursor';
248
+ } else {
249
+ const mfps = 15;
250
+ const h = 2 * Math.round((80 * (asset.height || 9)) / (asset.width || 16));
251
+ cru = movimento(await quadrosCinza(asset.path, de, ate, { fps: mfps, w: 160, h }), 160, h, mfps).map((x) => ({ ...x, t: x.t + de }));
252
+ fonte = 'movimento';
253
+ }
254
+ const amostras = [];
255
+ for (const x of cru) {
256
+ const f = T.origemParaQuadro(ctx.tl, clip, x.t);
257
+ if (f === null) continue;
258
+ const p = x.x === null ? { x: null, y: null } : paraClipe(ctx.tl, clip, asset, x.x, x.y);
259
+ amostras.push({ ...x, ...p, t: f - clip.startFrame });
260
+ }
261
+ const pl = planejar(amostras, clip.durationFrames, { fps, zoom: lim(Number(a.zoom) || 1.8, 1.1, 3), zonaMorta: a.deadZone ?? 0.45 });
262
+ const zooms = pl.trechos.map((t) => [t.de + clip.startFrame, t.ate + clip.startFrame]);
263
+ if (!pl.focos.length) return { ok: true, nada: true, fonte, focos: 0, aviso: 'nenhum clique nem parada do cursor: nada para aproximar' };
264
+ return mutar(ctx, 'auto_zoom', (tl) => {
265
+ for (const [p, ks] of Object.entries(pl.keys)) E.keyframes(tl, clip.id, p, ks.map((k) => ({ ...k, frame: k.frame + clip.startFrame })));
266
+ return { fonte, focos: pl.focos.length, zooms };
267
+ });
268
+ },
269
+ },
270
+ };
271
+
272
+ // Sem linha de exemplo: a ajuda principal cabe em 60 linhas, e o `uso` já mostra a forma.
273
+ const EX = {};
274
+
275
+ module.exports = { movimento, doCursor, focos, trechos, camera, enxugar, keyframes, planejar, quadrosCinza, paraClipe, OPS, EX };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "primocode",
3
- "version": "10.1.0",
3
+ "version": "10.3.0-beta.0",
4
4
  "description": "PrimoCode — agente de engenharia com IA e cursor próprio. Requer conta Conecta Primo AI (Premium ou Super). Cria arquivos, roda comandos, controla navegador e desktop: abre apps, clica em botões e ícones pelo nome, digita e usa atalhos.",
5
5
  "main": "bin/primocode.js",
6
6
  "bin": {
@@ -8,7 +8,7 @@
8
8
  },
9
9
  "scripts": {
10
10
  "start": "node bin/primocode.js",
11
- "test": "node test/tools.test.js && node test/ui.test.js && node test/entrada.test.js && node test/transparencia.test.js && node test/permissao.test.js && node test/compatibilidade.test.js && node test/skills.test.js && node test/skills-de-fora.test.js && node test/skills-novas.test.js && node test/comandos.test.js && node test/estudio.test.js && node test/voz.test.js && node test/modo.test.js && node test/pasta.test.js && node test/projeto.test.js && node test/nome-projeto.test.js && node test/api-propria.test.js && node test/claude-engine.test.js && node test/codex.test.js && node test/conferir.test.js && node test/catalogo.test.js && node test/desktop.test.js && node test/seta.test.js && node test/effort.test.js && node test/regressao.test.js && node test/memoria-conversa.test.js && node test/repeticao.test.js && node test/casar.test.js && node test/recado.test.js && node test/releitura.test.js && node test/snapshot.test.js && node test/video.test.js && node test/limpeza.test.js && node test/autonomia.test.js && node test/desfazer.test.js && node test/desfazer-git.test.js && node test/referencia.test.js && node test/parar.test.js && node test/continuar.test.js && node test/pipeline.test.js && node test/conta.test.mjs && node test/nuvem.test.js && node test/prazo.test.js && node test/primeira-vez.test.js && node test/fala.test.js && node test/especialistas.test.js && node test/empurrao.test.js && node test/terminal-novo.test.js && node test/markdown.test.js && node test/coerencia.test.js && node test/contexto.test.js && node test/perigo.test.js && node test/rapidez.test.js && node test/mascotes.test.js && node test/comandos-extra.test.js && node test/offline.test.js && node test/edicao.test.js && node test/composicao.test.js && node test/acervo-site.test.js && node test/impacto.test.js && node test/cinema.test.js && node test/producao.test.js && node test/ende.test.js && node test/capa.test.js && node test/broll.test.js && node test/morfar.test.js && node test/motion.test.js && node test/motion-roteiro.test.js && node test/kit-motion.test.js && node test/gerativo.test.js && node test/hibrido.test.js && node test/biblioteca.test.js && node test/mcp.test.js && node test/personas.test.js && node test/efeitos.test.js && node test/auto.test.js && node test/colar.test.js && node test/orquestra.test.js && node test/shell-limpo.test.js && node test/editar-remotion.test.js && node test/variacao3d.test.js && node test/portao-skill.test.js && node test/clones.test.js && node test/kokoro.test.js && node test/fluido.test.js"
11
+ "test": "node test/tools.test.js && node test/ui.test.js && node test/entrada.test.js && node test/transparencia.test.js && node test/permissao.test.js && node test/compatibilidade.test.js && node test/skills.test.js && node test/skills-de-fora.test.js && node test/skills-novas.test.js && node test/comandos.test.js && node test/estudio.test.js && node test/voz.test.js && node test/modo.test.js && node test/pasta.test.js && node test/projeto.test.js && node test/nome-projeto.test.js && node test/api-propria.test.js && node test/claude-engine.test.js && node test/codex.test.js && node test/conferir.test.js && node test/catalogo.test.js && node test/desktop.test.js && node test/seta.test.js && node test/effort.test.js && node test/regressao.test.js && node test/memoria-conversa.test.js && node test/repeticao.test.js && node test/casar.test.js && node test/recado.test.js && node test/releitura.test.js && node test/snapshot.test.js && node test/video.test.js && node test/limpeza.test.js && node test/autonomia.test.js && node test/desfazer.test.js && node test/desfazer-git.test.js && node test/referencia.test.js && node test/parar.test.js && node test/continuar.test.js && node test/pipeline.test.js && node test/conta.test.mjs && node test/nuvem.test.js && node test/prazo.test.js && node test/primeira-vez.test.js && node test/fala.test.js && node test/especialistas.test.js && node test/empurrao.test.js && node test/terminal-novo.test.js && node test/markdown.test.js && node test/coerencia.test.js && node test/contexto.test.js && node test/perigo.test.js && node test/rapidez.test.js && node test/mascotes.test.js && node test/comandos-extra.test.js && node test/offline.test.js && node test/edicao.test.js && node test/composicao.test.js && node test/acervo-site.test.js && node test/impacto.test.js && node test/cinema.test.js && node test/producao.test.js && node test/ende.test.js && node test/capa.test.js && node test/broll.test.js && node test/morfar.test.js && node test/motion.test.js && node test/motion-roteiro.test.js && node test/kit-motion.test.js && node test/gerativo.test.js && node test/gerativo-identidade.test.js && node test/gerativo-moldura.test.js && node test/gerativo-heroi.test.js && node test/gerativo-ambiente.test.js && node test/hibrido.test.js && node test/batida.test.js && node test/estudio-acervo.test.js && node test/estudio-diretor.test.js && node test/estudio-marca.test.js && node test/estudio-novo-projeto.test.js && node test/estudio-plano.test.js && node test/estudio-slides.test.js && node test/estudio-video.test.js && node test/revisor.test.js && node test/assinatura.test.js && node test/biblioteca.test.js && node test/mcp.test.js && node test/personas.test.js && node test/efeitos.test.js && node test/auto.test.js && node test/colar.test.js && node test/orquestra.test.js && node test/shell-limpo.test.js && node test/editar-remotion.test.js && node test/variacao3d.test.js && node test/portao-skill.test.js && node test/clones.test.js && node test/kokoro.test.js && node test/fluido.test.js && node test/zoom.test.js"
12
12
  },
13
13
  "engines": {
14
14
  "node": ">=18.17.0"
@@ -57,11 +57,9 @@ Em vídeo de produto, anúncio e vinheta, escreva **uma cena de 8 a 15 s** no
57
57
  - **A troca é no mesmo lugar e instante**: o foguete some onde o ícone nasce; o
58
58
  app nasce do tamanho e da COR do ícone.
59
59
 
60
- Por roteiro, o padrão é `hibrido` (dê `busca` em inglês por parte); `gerativo` (o motion), `vox` (explicar; dê
61
- `destaque` e `fonte`), `marca`, `lancamento` pelo nome; `visual` troca a peça de uma parte.
62
-
63
- Pedido de motion (ou sem referências): `estilo: "gerativo"`, só o roteiro
64
- (`fala`, `destaque`); `roteiro.semente` sorteia outro, `modelo3d` traz o 3D.
60
+ Sem imagem é o padrão (foto só se pedirem). Decida `clima`, o impacto (`destaque`, ≤1 em 3 partes), o
61
+ respiro e o `estilo` deste roteiro: `gerativo`, `fluido`, `impacto`, `vox`, `marca`, `lancamento`; vazio, gira.
62
+ `hibrido` só com imagem pedida. Leia os avisos da revisão.
65
63
 
66
64
  ## Cor e tipo
67
65