primocode 9.9.0 → 10.0.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 (63) hide show
  1. package/README.md +30 -6
  2. package/bin/primocode.js +100 -341
  3. package/lib/act.js +245 -23
  4. package/lib/browser.js +17 -0
  5. package/lib/casar.js +108 -0
  6. package/lib/catalogo.js +6 -2
  7. package/lib/config.js +10 -3
  8. package/lib/cursor.js +10 -1
  9. package/lib/entrada.js +4 -2
  10. package/lib/estudio/diretor.js +9 -1
  11. package/lib/estudio/ende.js +2 -0
  12. package/lib/estudio/estilo.js +51 -0
  13. package/lib/estudio/glifos.js +215 -0
  14. package/lib/estudio/index.js +63 -11
  15. package/lib/estudio/kit/base.jsx +285 -0
  16. package/lib/estudio/kit/index.jsx +11 -0
  17. package/lib/estudio/kit/marca.jsx +741 -0
  18. package/lib/estudio/kit/vox.jsx +357 -0
  19. package/lib/estudio/kit.js +72 -0
  20. package/lib/estudio/lancamento.js +413 -0
  21. package/lib/estudio/morfar.js +191 -0
  22. package/lib/estudio/motion.js +333 -0
  23. package/lib/estudio/plano.js +54 -7
  24. package/lib/estudio/remotion.js +167 -52
  25. package/lib/estudio/ritmo.js +77 -0
  26. package/lib/estudio/sons-leves.js +62 -0
  27. package/lib/estudio/vox.js +385 -0
  28. package/lib/fala.js +1 -94
  29. package/lib/pipeline.js +55 -56
  30. package/lib/repeticao.js +3 -1
  31. package/lib/skills.js +8 -2
  32. package/lib/snapshot.js +111 -0
  33. package/lib/tools.js +79 -20
  34. package/lib/ui.js +13 -6
  35. package/lib/video/analise.js +89 -0
  36. package/lib/video/cli.js +74 -0
  37. package/lib/video/delta.js +90 -0
  38. package/lib/video/edits.js +379 -0
  39. package/lib/video/filters.js +247 -0
  40. package/lib/video/ops.js +322 -0
  41. package/lib/video/projeto.js +133 -0
  42. package/lib/video/render.js +221 -0
  43. package/lib/video/texto.js +142 -0
  44. package/lib/video/timeline.js +310 -0
  45. package/lib/video/transcript.js +124 -0
  46. package/lib/voz.js +75 -223
  47. package/package.json +2 -3
  48. package/skills/motion-de-primeira/SKILL.md +21 -1
  49. package/studio/texto.py +208 -0
  50. package/voz/primo_voz/__main__.py +96 -236
  51. package/voz/primo_voz/transcrever.py +31 -1
  52. package/app/ansi.js +0 -55
  53. package/app/app.css +0 -500
  54. package/app/app.js +0 -1017
  55. package/app/icone.svg +0 -6
  56. package/app/index.html +0 -220
  57. package/app/primo-marca.js +0 -192
  58. package/lib/app.js +0 -615
  59. package/lib/icone.js +0 -263
  60. package/lib/instalar-app.js +0 -231
  61. package/lib/painel.js +0 -291
  62. package/voz/primo_voz/desperta.py +0 -195
  63. package/voz/primo_voz/orbe.py +0 -406
@@ -0,0 +1,247 @@
1
+ /**
2
+ * filters.js — a linha do tempo vira UM filter_complex do ffmpeg.
3
+ *
4
+ * É uma função pura: entra o json, sai a lista de entradas e o texto do
5
+ * grafo. Quem roda é o render.js. Separado assim, o grafo inteiro é testável
6
+ * como string, sem ffmpeg na máquina de teste.
7
+ *
8
+ * ── COMO O GRAFO É MONTADO ───────────────────────────────────────────────
9
+ * · um fundo preto do tamanho do projeto;
10
+ * · cada clipe visual, da faixa de BAIXO para a de cima, entra como uma
11
+ * entrada própria já cortada na origem (-ss/-t na entrada, que é o corte
12
+ * rápido e exato), passa pela cadeia dele (velocidade, recorte, encaixe,
13
+ * zoom, efeitos, opacidade, fade, giro) e é sobreposto na posição;
14
+ * · cada faixa de TEXTO entra como uma sequência de PNGs transparentes (o
15
+ * texto.js decide os estados, o studio/texto.py desenha) e cobre a tela
16
+ * inteira — uma entrada por faixa, não uma por legenda;
17
+ * · o áudio de cada clipe atrasa até o lugar dele e tudo vira um amix.
18
+ *
19
+ * ── POR QUE O ZOOM É zoompan E NÃO scale ─────────────────────────────────
20
+ * Um scale animado muda o TAMANHO do quadro no meio do fluxo, e o overlay
21
+ * não aceita isso. O zoompan devolve sempre o mesmo tamanho e aproxima por
22
+ * dentro — que é exatamente o "punch-in" de quem edita fala para a câmera.
23
+ */
24
+
25
+ 'use strict';
26
+
27
+ const T = require('./timeline.js');
28
+
29
+ // ── números e expressões ─────────────────────────────────────────────────
30
+
31
+ function num(n) {
32
+ const s = Number(n).toFixed(4).replace(/0+$/, '').replace(/\.$/, '');
33
+ return s === '-0' ? '0' : s;
34
+ }
35
+
36
+ /**
37
+ * Keyframes → expressão do ffmpeg, em função do tempo `X` (segundos desde
38
+ * o começo do clipe). Antes do primeiro vale o primeiro; depois do último,
39
+ * o último.
40
+ */
41
+ function expressao(keys, X, fps) {
42
+ if (!keys || !keys.length) return null;
43
+ const ks = keys.map((k) => ({ t: k.frame / fps, v: k.value, i: k.interp || 'ease' }));
44
+ let e = num(ks[ks.length - 1].v);
45
+ for (let j = ks.length - 2; j >= 0; j--) {
46
+ const a = ks[j];
47
+ const b = ks[j + 1];
48
+ const dt = Math.max(1e-6, b.t - a.t);
49
+ const p = `((${X})-${num(a.t)})/${num(dt)}`;
50
+ const curva = a.i === 'hold' ? '0' : a.i === 'linear' ? p : `(${p})*(${p})*(3-2*(${p}))`;
51
+ const seg = a.i === 'hold' ? num(a.v) : `${num(a.v)}+(${num(b.v - a.v)})*${curva}`;
52
+ e = `if(lt(${X},${num(b.t)}),${seg},${e})`;
53
+ }
54
+ return `if(lt(${X},${num(ks[0].t)}),${num(ks[0].v)},${e})`;
55
+ }
56
+
57
+ // ── efeitos ──────────────────────────────────────────────────────────────
58
+
59
+ const PRESETS_COR = {
60
+ warm: { temperature: 0.35, saturation: 0.1 },
61
+ cool: { temperature: -0.35, saturation: 0.05 },
62
+ cinematic: { contrast: 0.18, saturation: -0.2, temperature: 0.12, curves: 'medium_contrast' },
63
+ punchy: { contrast: 0.2, saturation: 0.35 },
64
+ bw: { saturation: -1, contrast: 0.15 },
65
+ vintage: { curves: 'vintage', saturation: -0.15 },
66
+ bright: { exposure: 0.25, gamma: 1.1 },
67
+ };
68
+
69
+ const CURVAS = ['color_negative', 'cross_process', 'darker', 'increase_contrast', 'lighter', 'linear_contrast', 'medium_contrast', 'negative', 'strong_contrast', 'vintage'];
70
+
71
+ const lim = (v, a, b) => Math.max(a, Math.min(b, Number(v)));
72
+
73
+ // Cada efeito: parâmetros com padrão → os filtros do ffmpeg.
74
+ const EFEITOS = {
75
+ 'color.adjust': {
76
+ params: 'exposure contrast saturation temperature tint (-1..1), gamma (0.1..3), curves',
77
+ filtro(p) {
78
+ const f = [];
79
+ const eq = [];
80
+ if (p.exposure) eq.push(`brightness=${num(lim(p.exposure, -1, 1) * 0.3)}`);
81
+ if (p.contrast) eq.push(`contrast=${num(1 + lim(p.contrast, -1, 1))}`);
82
+ if (p.saturation) eq.push(`saturation=${num(1 + lim(p.saturation, -1, 2))}`);
83
+ if (p.gamma && Number(p.gamma) !== 1) eq.push(`gamma=${num(lim(p.gamma, 0.1, 3))}`);
84
+ if (eq.length) f.push('eq=' + eq.join(':'));
85
+ const t = lim(p.temperature || 0, -1, 1) * 0.25;
86
+ const g = lim(p.tint || 0, -1, 1) * -0.2;
87
+ if (t || g) f.push(`colorbalance=rs=${num(t)}:gs=${num(g)}:bs=${num(-t)}:rm=${num(t)}:gm=${num(g)}:bm=${num(-t)}`);
88
+ if (p.curves) {
89
+ if (!CURVAS.includes(p.curves)) throw T.erro(`curves "${p.curves}" (use ${CURVAS.join('/')})`);
90
+ f.push(`curves=preset=${p.curves}`);
91
+ }
92
+ return f;
93
+ },
94
+ },
95
+ 'blur.gaussian': { params: 'sigma (px, 8)', filtro: (p) => [`gblur=sigma=${num(lim(p.sigma != null ? p.sigma : 8, 0.1, 200))}`] },
96
+ sharpen: { params: 'amount (0..3, 1)', filtro: (p) => [`unsharp=5:5:${num(lim(p.amount != null ? p.amount : 1, 0, 3))}`] },
97
+ 'stylize.vignette': { params: 'strength (0..1, 0.5)', filtro: (p) => [`vignette=angle=${num(0.25 + 0.9 * lim(p.strength !== undefined ? p.strength : 0.5, 0, 1))}`] },
98
+ 'stylize.grain': { params: 'strength (0..1, 0.3)', filtro: (p) => [`noise=alls=${Math.round(lim(p.strength != null ? p.strength : 0.3, 0, 1) * 40)}:allf=t+u`] },
99
+ 'key.chroma': {
100
+ params: 'color (hex, #00FF00), similarity (0.12), blend (0.08)',
101
+ filtro: (p) => ['format=yuva420p', `chromakey=${String(p.color || '#00FF00').replace('#', '0x')}:${num(p.similarity || 0.12)}:${num(p.blend || 0.08)}`],
102
+ },
103
+ };
104
+
105
+ function filtrosDoEfeito(e) {
106
+ const def = EFEITOS[e.type];
107
+ if (!def) throw T.erro(`efeito "${e.type}" não existe (use ${Object.keys(EFEITOS).join('/')})`);
108
+ return def.filtro(e.params || {});
109
+ }
110
+
111
+ /** apply_color → o efeito color.adjust (preset + ajustes por cima). */
112
+ function efeitoDeCor(args) {
113
+ const base = args.preset ? PRESETS_COR[args.preset] : {};
114
+ if (args.preset && !base) throw T.erro(`preset "${args.preset}" (use ${Object.keys(PRESETS_COR).join('/')})`);
115
+ const params = { ...base };
116
+ for (const k of ['exposure', 'contrast', 'saturation', 'temperature', 'tint', 'gamma', 'curves']) {
117
+ if (args[k] !== undefined) params[k] = args[k];
118
+ }
119
+ filtrosDoEfeito({ type: 'color.adjust', params }); // valida já
120
+ return { type: 'color.adjust', params };
121
+ }
122
+
123
+ // ── o grafo ──────────────────────────────────────────────────────────────
124
+
125
+ /** O pedaço do clipe que cai na janela [from, to) — ou null. */
126
+ function naJanela(c, from, to) {
127
+ const s = Math.max(c.startFrame, from);
128
+ const e = Math.min(T.fim(c), to);
129
+ if (e <= s) return null;
130
+ const corte = s - c.startFrame;
131
+ return { start: s - from, dur: e - s, corte, trim: c.trimStartFrame + Math.round(corte * (c.speed || 1)) };
132
+ }
133
+
134
+ function atempo(speed) {
135
+ // atempo aceita 0.5..2 por filtro: acima disso, em cadeia.
136
+ const f = [];
137
+ let s = speed;
138
+ while (s > 2) { f.push('atempo=2'); s /= 2; }
139
+ while (s < 0.5) { f.push('atempo=0.5'); s /= 0.5; }
140
+ if (Math.abs(s - 1) > 1e-6) f.push(`atempo=${num(s)}`);
141
+ return f;
142
+ }
143
+
144
+ /**
145
+ * Monta tudo. `camadasDeTexto`: {índiceDaFaixa: caminho do .ffconcat}.
146
+ * `escala`: 0.5 para prévia. Devolve {inputs, grafo, saidaV, saidaA, segundos}.
147
+ */
148
+ function montarGrafo(tl, { from = 0, to, camadasDeTexto = {}, escala = 1, audio = true } = {}) {
149
+ const ate = to === undefined ? T.duracao(tl) : Math.min(to, T.duracao(tl));
150
+ if (!(ate > from)) throw T.erro('nada para renderizar nesse trecho');
151
+ const fps = tl.fps;
152
+ const W = T.par(tl.width * escala);
153
+ const H = T.par(tl.height * escala);
154
+ const D = (ate - from) / fps;
155
+ const inputs = [];
156
+ const linhas = [`color=c=black:s=${W}x${H}:r=${fps}:d=${num(D)}[base]`];
157
+ const audios = [];
158
+ let atual = 'base';
159
+ let n = 0;
160
+
161
+ for (let ti = tl.tracks.length - 1; ti >= 0; ti--) {
162
+ const faixa = tl.tracks[ti];
163
+ if (faixa.type === 'text') {
164
+ const arq = camadasDeTexto[ti];
165
+ if (!arq || faixa.hidden) continue;
166
+ const k = inputs.length;
167
+ inputs.push(['-f', 'concat', '-safe', '0', '-i', arq]);
168
+ linhas.push(`[${k}:v]fps=${fps},format=rgba,setpts=PTS-STARTPTS[tx${k}]`);
169
+ linhas.push(`[${atual}][tx${k}]overlay=0:0:eof_action=pass[v${++n}]`);
170
+ atual = `v${n}`;
171
+ continue;
172
+ }
173
+ for (const c of faixa.clips) {
174
+ const j = naJanela(c, from, ate);
175
+ if (!j) continue;
176
+ const a = tl.assets[c.asset];
177
+ if (!a) continue;
178
+ const speed = c.speed || 1;
179
+ const temSom = a.kind === 'audio' || (a.kind === 'video' && a.hasAudio);
180
+ const visivel = faixa.type === 'video' && !c.hidden && !faixa.hidden;
181
+ if (!visivel && !(audio && temSom)) continue;
182
+ const k = inputs.length;
183
+ const startS = j.start / fps;
184
+ const durS = j.dur / fps;
185
+ if (a.kind === 'image') inputs.push(['-loop', '1', '-framerate', String(fps), '-t', num(durS), '-i', a.path]);
186
+ else inputs.push(['-ss', num(j.trim / fps), '-t', num(durS * speed + 0.05), '-i', a.path]);
187
+ const X = `on/${fps}+${num(j.corte / fps)}`; // tempo do clipe, dentro do zoompan
188
+ const Xt = `t+${num(j.corte / fps)}`; // tempo do clipe, nos filtros com t local
189
+ const Xo = `(t-${num(startS)})+${num(j.corte / fps)}`; // tempo do clipe, no overlay (t global)
190
+
191
+ if (visivel) {
192
+ const bw = T.par(c.transform.w * W);
193
+ const bh = T.par(c.transform.h * H);
194
+ const f = [speed !== 1 ? `setpts=(PTS-STARTPTS)/${num(speed)}` : 'setpts=PTS-STARTPTS', `fps=${fps}`];
195
+ const cr = c.crop;
196
+ if (cr.l || cr.t || cr.r || cr.b) f.push(`crop=iw*${num(1 - cr.l - cr.r)}:ih*${num(1 - cr.t - cr.b)}:iw*${num(cr.l)}:ih*${num(cr.t)}`);
197
+ if (c.fit === 'contain') f.push(`scale=${bw}:${bh}:force_original_aspect_ratio=decrease`, 'format=yuva420p', `pad=${bw}:${bh}:(ow-iw)/2:(oh-ih)/2:color=black@0`);
198
+ else if (c.fit === 'stretch') f.push(`scale=${bw}:${bh}`);
199
+ else f.push(`scale=${bw}:${bh}:force_original_aspect_ratio=increase`, `crop=${bw}:${bh}`);
200
+ f.push('setsar=1');
201
+ const zoom = expressao(c.keyframes.zoom, X, fps);
202
+ 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}`);
203
+ for (const e of c.effects) f.push(...filtrosDoEfeito(e));
204
+ f.push('format=yuva420p');
205
+ const op = expressao(c.keyframes.opacity, Xt, fps);
206
+ if (op) f.push('format=rgba', `geq=r='r(X,Y)':g='g(X,Y)':b='b(X,Y)':a='alpha(X,Y)*max(0,min(1,${op}))'`);
207
+ else if (c.opacity < 1) f.push(`colorchannelmixer=aa=${num(Math.max(0, c.opacity))}`);
208
+ if (c.fadeIn && j.corte === 0) f.push(`fade=t=in:st=0:d=${num(c.fadeIn / fps)}:alpha=1`);
209
+ if (c.fadeOut && j.start + j.dur + from === T.fim(c)) f.push(`fade=t=out:st=${num(Math.max(0, durS - c.fadeOut / fps))}:d=${num(c.fadeOut / fps)}:alpha=1`);
210
+ const rot = expressao(c.keyframes.rotation, Xt, fps) || (c.transform.rotation ? num(c.transform.rotation) : null);
211
+ if (rot) f.push(`rotate=a='(${rot})*PI/180':c=none:ow='hypot(iw,ih)':oh='ow'`);
212
+ f.push(`setpts=PTS+${num(startS)}/TB`);
213
+ linhas.push(`[${k}:v]${f.join(',')}[c${k}]`);
214
+ const cx = expressao(c.keyframes.cx, Xo, fps) || num(c.transform.cx);
215
+ const cy = expressao(c.keyframes.cy, Xo, fps) || num(c.transform.cy);
216
+ const fimS = (j.start + j.dur) / fps;
217
+ linhas.push(`[${atual}][c${k}]overlay=x='(${cx})*W-w/2':y='(${cy})*H-h/2':eof_action=pass:enable='between(t,${num(startS)},${num(fimS - 0.0001)})'[v${++n}]`);
218
+ atual = `v${n}`;
219
+ }
220
+
221
+ if (audio && temSom && !faixa.muted && !(c.volume === 0 && !c.keyframes.volume)) {
222
+ const f = ['asetpts=PTS-STARTPTS', ...atempo(speed), 'aformat=sample_rates=48000:channel_layouts=stereo'];
223
+ const vol = expressao(c.keyframes.volume, Xt, fps);
224
+ if (vol) f.push(`volume='max(0,${vol})':eval=frame`);
225
+ else if (c.volume !== 1) f.push(`volume=${num(c.volume)}`);
226
+ if (c.fadeIn && j.corte === 0) f.push(`afade=t=in:st=0:d=${num(c.fadeIn / fps)}`);
227
+ if (c.fadeOut && j.start + j.dur + from === T.fim(c)) f.push(`afade=t=out:st=${num(Math.max(0, durS - c.fadeOut / fps))}:d=${num(c.fadeOut / fps)}`);
228
+ f.push(`atrim=0:${num(durS)}`);
229
+ const ms = Math.round(startS * 1000);
230
+ if (ms > 0) f.push(`adelay=${ms}:all=1`);
231
+ linhas.push(`[${k}:a]${f.join(',')}[a${k}]`);
232
+ audios.push(`[a${k}]`);
233
+ }
234
+ }
235
+ }
236
+
237
+ linhas.push(`[${atual}]format=yuv420p[vout]`);
238
+ if (!audio) return { inputs, grafo: linhas.join(';\n'), saidaV: '[vout]', saidaA: null, segundos: D, W, H };
239
+ if (audios.length) {
240
+ linhas.push(`${audios.join('')}amix=inputs=${audios.length}:duration=longest:dropout_transition=0:normalize=0,apad,atrim=0:${num(D)}[aout]`);
241
+ } else {
242
+ linhas.push(`anullsrc=r=48000:cl=stereo,atrim=0:${num(D)}[aout]`);
243
+ }
244
+ return { inputs, grafo: linhas.join(';\n'), saidaV: '[vout]', saidaA: '[aout]', segundos: D, W, H };
245
+ }
246
+
247
+ module.exports = { num, expressao, EFEITOS, PRESETS_COR, CURVAS, filtrosDoEfeito, efeitoDeCor, naJanela, atempo, montarGrafo };
@@ -0,0 +1,322 @@
1
+ /**
2
+ * ops.js — as operações do editor, uma por nome, com o texto de ajuda junto.
3
+ *
4
+ * O texto de ajuda mora AO LADO da operação de propósito: é ele que o modelo
5
+ * lê (`primocode video help`) para saber o que existe, e ajuda escrita
6
+ * noutro arquivo envelhece diferente do código. Cada linha é curta, com a
7
+ * unidade de cada número — quadro na linha do tempo, segundo na origem.
8
+ *
9
+ * Toda operação que MUDA o projeto passa por `mutar`: foto de antes, edição,
10
+ * validação, gravação, delta. Se qualquer passo falha, nada é gravado — um
11
+ * add_clips de dez clipes com um errado não deixa nove pela metade.
12
+ */
13
+
14
+ 'use strict';
15
+
16
+ const T = require('./timeline.js');
17
+ const E = require('./edits.js');
18
+ const F = require('./filters.js');
19
+ const { diff } = require('./delta.js');
20
+ const P = require('./projeto.js');
21
+ const TR = require('./transcript.js');
22
+ const A = require('./analise.js');
23
+
24
+ const r = () => require('./render.js'); // só carrega o ffmpeg quando precisa
25
+
26
+ const lista = (v) => (v === undefined ? [] : Array.isArray(v) ? v : [v]);
27
+
28
+ /** Os clipes que a operação pediu: ids, id, ou "all" (todos os visuais). */
29
+ function alvos(tl, args, { tipos = ['video', 'image'] } = {}) {
30
+ const refs = lista(args.ids !== undefined ? args.ids : args.id);
31
+ if (!refs.length) throw T.erro('faltou "ids" (ou "all")');
32
+ if (refs.length === 1 && (refs[0] === 'all' || refs[0] === '*')) {
33
+ return T.todosOsClipes(tl).filter((x) => tipos.includes(x.clip.type)).map((x) => x.clip.id);
34
+ }
35
+ return refs.map((ref) => T.achar(tl, ref).clip.id);
36
+ }
37
+
38
+ async function mutar(ctx, op, fn) {
39
+ const antes = T.clone(ctx.tl);
40
+ const extra = (await fn(ctx.tl)) || {};
41
+ T.ordenar(ctx.tl);
42
+ const probs = T.problemas(ctx.tl);
43
+ if (probs.length) throw T.erro(`edição recusada (nada mudou): ${probs.slice(0, 4).join('; ')}`);
44
+ P.fotografar(ctx.p, antes, op);
45
+ P.salvar(ctx.p, ctx.tl);
46
+ const d = diff(antes, ctx.tl, extra);
47
+ P.anotar(ctx.p, op, { dur: d.dur, added: (d.added || []).length, removed: (d.removed || []).length });
48
+ return d;
49
+ }
50
+
51
+ function precisaProjeto(ctx) {
52
+ if (!ctx.tl) throw T.erro('nenhum projeto aberto: comece com import {"path":"..."} (cria o projeto) ou new');
53
+ }
54
+
55
+ // ── as operações ─────────────────────────────────────────────────────────
56
+
57
+ const OPS = {
58
+ new: {
59
+ uso: 'new {name?,width?:1080,height?:1920,fps?:30} — cria projeto em ./video/<id>/ e o torna atual',
60
+ async run(ctx, a) {
61
+ const { p, tl } = P.criar({ nome: a.name, width: a.width, height: a.height, fps: a.fps });
62
+ return { ok: true, project: p.id, dir: p.dir, fps: tl.fps, size: `${tl.width}x${tl.height}` };
63
+ },
64
+ },
65
+ open: {
66
+ uso: 'open {id} | list {} — troca de projeto / lista os projetos desta pasta',
67
+ async run(ctx, a) {
68
+ const x = P.abrir(a.id);
69
+ if (!x) throw T.erro(`não há projeto "${a.id}"`);
70
+ P.usar(x.p.id);
71
+ return { ok: true, project: x.p.id, ...T.vista(x.tl) };
72
+ },
73
+ },
74
+ list: { oculto: true, async run() { return { ok: true, current: P.atual(), projects: P.listar() }; } },
75
+ import: {
76
+ uso: 'import {path|paths[],track?,at?,from?,to?,noClip?} — mídia vira asset + clipe no fim da faixa (sem projeto, cria um do tamanho do vídeo)',
77
+ async run(ctx, a) {
78
+ const caminhos = lista(a.paths || a.path);
79
+ if (!caminhos.length) throw T.erro('faltou "path"');
80
+ if (!ctx.tl) {
81
+ const probe = await r().medirAsset(T.novo(), caminhos[0]);
82
+ const vert = probe.width && probe.height;
83
+ const x = P.criar({ nome: a.name || probe.nome.replace(/\.[^.]+$/, ''),
84
+ width: a.width || (vert ? probe.width : 1080), height: a.height || (vert ? probe.height : 1920),
85
+ fps: a.fps || (probe.fps ? Math.min(60, Math.round(probe.fps)) : 30) });
86
+ Object.assign(ctx, x);
87
+ }
88
+ const medidos = [];
89
+ for (const c of caminhos) medidos.push(await r().medirAsset(ctx.tl, c));
90
+ return mutar(ctx, 'import', (tl) => {
91
+ for (const m of medidos) {
92
+ m.id = T.novoAssetId(tl);
93
+ tl.assets[m.id] = m;
94
+ if (!a.noClip) E.adicionar(tl, { asset: m.id, track: a.track, at: a.at, from: a.from, to: a.to });
95
+ }
96
+ return { project: ctx.p.id };
97
+ });
98
+ },
99
+ },
100
+ get_timeline: {
101
+ uso: 'get_timeline {from?,to?} — o projeto compacto (só o que não é padrão). src=[s,s] segundos da origem',
102
+ async run(ctx, a) { precisaProjeto(ctx); return { ok: true, project: ctx.p.id, ...T.vista(ctx.tl, a) }; },
103
+ },
104
+ inspect: {
105
+ uso: 'inspect {frames:[f..]|at:f} — renderiza os quadros com grade 0–1 + nº do quadro numa folha PNG (abra e OLHE)',
106
+ async run(ctx, a) {
107
+ precisaProjeto(ctx);
108
+ const d = T.duracao(ctx.tl);
109
+ const frames = a.frames ? lista(a.frames) : a.at !== undefined ? [a.at] : [0, Math.floor(d / 2), d - 1];
110
+ return r().inspecionar(ctx.p.dir, ctx.tl, { frames, escala: a.scale || 0.5, out: a.out });
111
+ },
112
+ },
113
+ get_transcript: {
114
+ uso: 'get_transcript {asset?,from?,to?,force?} — palavras [i,texto,f,e] em QUADROS da timeline atual; i é estável entre edições',
115
+ async run(ctx, a) {
116
+ precisaProjeto(ctx);
117
+ const asset = T.acharAsset(ctx.tl, a.asset);
118
+ const t = await TR.obter(ctx.p.dir, asset, { force: a.force, idioma: a.lang || 'pt' });
119
+ if (!t.ok) return t;
120
+ const mapa = TR.mapear(ctx.tl, asset.id, t.palavras)
121
+ .filter((w) => w.e > (a.from || 0) && w.f < (a.to === undefined ? Infinity : a.to));
122
+ return { ok: true, asset: asset.id, fonte: t.fonte, total: t.palavras.length, onTimeline: mapa.length, words: mapa.map((w) => [w.i, w.texto, w.f, w.e]) };
123
+ },
124
+ },
125
+ add_clips: {
126
+ uso: 'add_clips {clips:[{asset,track?,at?,from?,to?|dur?,...props}]} — lote atômico (tudo ou nada)',
127
+ async run(ctx, a) {
128
+ precisaProjeto(ctx);
129
+ return mutar(ctx, 'add_clips', (tl) => { lista(a.clips).forEach((c) => E.adicionar(tl, c)); });
130
+ },
131
+ },
132
+ insert: {
133
+ uso: 'insert {asset|type:"text",at,from?,to?|dur?,track?,rippleTracks?} — insere empurrando tudo depois de at (ripple)',
134
+ async run(ctx, a) { precisaProjeto(ctx); return mutar(ctx, 'insert', (tl) => { E.inserir(tl, a); }); },
135
+ },
136
+ move: {
137
+ uso: 'move {id,to?(quadro),track?(índice|"new")} — move sem ripple (sobrepor na mesma faixa é recusado)',
138
+ async run(ctx, a) { precisaProjeto(ctx); return mutar(ctx, 'move', (tl) => { E.mover(tl, a.id, a); }); },
139
+ },
140
+ remove: {
141
+ uso: 'remove {ids,ripple?:false} — apaga clipes; ripple fecha o buraco se nenhuma faixa usa o trecho',
142
+ async run(ctx, a) { precisaProjeto(ctx); return mutar(ctx, 'remove', (tl) => { E.remover(tl, lista(a.ids || a.id), { ripple: a.ripple }); }); },
143
+ },
144
+ split: {
145
+ uso: 'split {id,at(quadro da timeline)} — divide em dois',
146
+ async run(ctx, a) { precisaProjeto(ctx); return mutar(ctx, 'split', (tl) => { E.dividir(tl, a.id, a.at); }); },
147
+ },
148
+ ripple_delete_ranges: {
149
+ uso: 'ripple_delete_ranges {ranges:[[de,ate],..](quadros, ate exclusivo),tracks?} — apaga trechos em todas as faixas e fecha',
150
+ async run(ctx, a) {
151
+ precisaProjeto(ctx);
152
+ return mutar(ctx, 'ripple_delete_ranges', (tl) => E.apagarTrechos(tl, lista(a.ranges), { tracks: a.tracks }));
153
+ },
154
+ },
155
+ remove_silence: {
156
+ uso: 'remove_silence {asset?,threshold?:-35(dB),minSilence?:0.5(s),padding?:0.15(s),dryRun?} — silencedetect → ripple',
157
+ async run(ctx, a) {
158
+ precisaProjeto(ctx);
159
+ const asset = T.acharAsset(ctx.tl, a.asset);
160
+ const sil = await r().silenciosDe(ctx.p.dir, asset, { threshold: a.threshold ?? -35, minSilence: a.minSilence ?? 0.5 });
161
+ const clipes = T.todosOsClipes(ctx.tl).map((x) => x.clip).filter((c) => c.asset === asset.id);
162
+ const ranges = A.trechosDeSilencio(ctx.tl, clipes, { [asset.id]: sil }, { padding: a.padding ?? 0.15 });
163
+ if (a.dryRun) return { ok: true, silences: sil.length, ranges: E.juntar(ranges) };
164
+ return mutar(ctx, 'remove_silence', (tl) => ({ silences: sil.length, ...E.apagarTrechos(tl, ranges) }));
165
+ },
166
+ },
167
+ remove_words: {
168
+ uso: 'remove_words {words:[i..],asset?} — apaga as palavras (índices do get_transcript) com ripple',
169
+ async run(ctx, a) {
170
+ precisaProjeto(ctx);
171
+ const asset = T.acharAsset(ctx.tl, a.asset);
172
+ const t = await TR.obter(ctx.p.dir, asset);
173
+ if (!t.ok) return t;
174
+ const { ranges, faltou } = E.trechosDasPalavras(TR.mapear(ctx.tl, asset.id, t.palavras), lista(a.words));
175
+ if (!ranges.length) throw T.erro('nenhuma dessas palavras está na timeline');
176
+ return mutar(ctx, 'remove_words', (tl) => ({ ...E.apagarTrechos(tl, ranges), ...(faltou.length ? { notOnTimeline: faltou } : {}) }));
177
+ },
178
+ },
179
+ set_clip_properties: {
180
+ uso: 'set_clip_properties {ids|"all",props:{volume,opacity,speed,fadeIn,fadeOut(quadros),dur,cx,cy,w,h,rotation,crop{l,t,r,b},fit(cover|contain|stretch),hidden,content,style,animation}}',
181
+ async run(ctx, a) {
182
+ precisaProjeto(ctx);
183
+ return mutar(ctx, 'set_clip_properties', (tl) => { E.propriedades(tl, alvos(tl, a, { tipos: ['video', 'image', 'audio', 'text'] }), a.props || {}); });
184
+ },
185
+ },
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',
188
+ async run(ctx, a) { precisaProjeto(ctx); return mutar(ctx, 'set_keyframes', (tl) => { E.keyframes(tl, a.id, a.prop, a.keys); }); },
189
+ },
190
+ apply_layout: {
191
+ uso: `apply_layout {layout(${E.LAYOUTS.join('|')}),ids:[principal,..],size?:0.34} — posiciona (transform 0–1)`,
192
+ async run(ctx, a) {
193
+ precisaProjeto(ctx);
194
+ return mutar(ctx, 'apply_layout', (tl) => { const av = E.layout(tl, a.layout, lista(a.ids), a); return av.length ? { avisos: av } : {}; });
195
+ },
196
+ },
197
+ add_texts: {
198
+ uso: 'add_texts {texts:[{content,at,dur(quadros),cx?,cy?,w?,animation?,style?}]} — títulos/letreiros',
199
+ async run(ctx, a) { precisaProjeto(ctx); return mutar(ctx, 'add_texts', (tl) => { E.textos(tl, lista(a.texts)); }); },
200
+ },
201
+ add_captions: {
202
+ uso: 'add_captions {asset?,maxWords?:3,animation?:highlightPop,cy?:0.72,style?} — legenda da transcrição, já nos cortes atuais (refazer substitui)',
203
+ async run(ctx, a) {
204
+ precisaProjeto(ctx);
205
+ const asset = T.acharAsset(ctx.tl, a.asset);
206
+ const t = await TR.obter(ctx.p.dir, asset);
207
+ if (!t.ok) return t;
208
+ return mutar(ctx, 'add_captions', (tl) => ({ captions: E.legendas(tl, TR.mapear(tl, asset.id, t.palavras), a).length }));
209
+ },
210
+ },
211
+ apply_color: {
212
+ uso: `apply_color {ids|"all",preset?(${Object.keys(F.PRESETS_COR).join('|')}),exposure,contrast,saturation,temperature,tint(-1..1),gamma}`,
213
+ async run(ctx, a) {
214
+ precisaProjeto(ctx);
215
+ const ef = F.efeitoDeCor(a);
216
+ return mutar(ctx, 'apply_color', (tl) => {
217
+ for (const id of alvos(tl, a)) { const { clip } = T.achar(tl, id); clip.effects = clip.effects.filter((e) => e.type !== 'color.adjust').concat(ef); }
218
+ });
219
+ },
220
+ },
221
+ apply_effect: {
222
+ uso: `apply_effect {ids|"all",type(${Object.keys(F.EFEITOS).filter((k) => k !== 'color.adjust').join('|')}),params?,remove?:true}`,
223
+ async run(ctx, a) {
224
+ precisaProjeto(ctx);
225
+ const ef = { type: a.type, params: a.params || {} };
226
+ if (!a.remove) F.filtrosDoEfeito(ef);
227
+ return mutar(ctx, 'apply_effect', (tl) => {
228
+ for (const id of alvos(tl, a)) {
229
+ const { clip } = T.achar(tl, id);
230
+ clip.effects = clip.effects.filter((e) => e.type !== a.type);
231
+ if (!a.remove) clip.effects.push(ef);
232
+ }
233
+ });
234
+ },
235
+ },
236
+ detect_beats: {
237
+ uso: 'detect_beats {asset,sensitivity?:1.4,markers?:false} — batidas em quadros da timeline (+bpm); markers:true grava marcadores',
238
+ async run(ctx, a) {
239
+ precisaProjeto(ctx);
240
+ const asset = T.acharAsset(ctx.tl, a.asset);
241
+ const taxa = 22050;
242
+ const b = A.batidas(await r().amostras(asset.path, taxa), taxa, { sensibilidade: a.sensitivity || 1.4 });
243
+ const clipes = T.todosOsClipes(ctx.tl).map((x) => x.clip).filter((c) => c.asset === asset.id);
244
+ const quadros = clipes.length
245
+ ? [...new Set(clipes.flatMap((c) => b.beats.map((s) => T.origemParaQuadro(ctx.tl, c, s)).filter((f) => f !== null)))].sort((x, y) => x - y)
246
+ : b.beats.map((s) => T.quadros(ctx.tl, s));
247
+ const res = { bpm: b.bpm, count: quadros.length, beats: quadros.slice(0, 300) };
248
+ if (!a.markers) return { ok: true, ...res };
249
+ return mutar(ctx, 'detect_beats', (tl) => {
250
+ tl.markers = tl.markers.filter((m) => m.label !== 'beat').concat(quadros.map((f) => ({ frame: f, label: 'beat' })));
251
+ return res;
252
+ });
253
+ },
254
+ },
255
+ undo: {
256
+ uso: 'undo {steps?:1} — volta edições (cada edição guarda foto; edite sem pedir licença)',
257
+ async run(ctx, a) {
258
+ precisaProjeto(ctx);
259
+ const antes = ctx.tl;
260
+ const u = P.desfazer(ctx.p, a.steps || 1);
261
+ P.salvar(ctx.p, u.tl);
262
+ ctx.tl = u.tl;
263
+ return diff(antes, u.tl, { undone: u.desfeitas, left: u.restam });
264
+ },
265
+ },
266
+ export: {
267
+ uso: 'export {out?,from?,to?(quadros),preview?:false(meia resolução),crf?:20} — renderiza o mp4',
268
+ async run(ctx, a) { precisaProjeto(ctx); return r().exportar(ctx.p.dir, ctx.tl, a); },
269
+ },
270
+ };
271
+
272
+ const ALIAS = { timeline: 'get_timeline', transcript: 'get_transcript', captions: 'add_captions', texts: 'add_texts', render: 'export' };
273
+
274
+ // Um exemplo por operação: o modelo copia a forma, e não inventa a chave.
275
+ const EX = {
276
+ new: '{"name":"aula","width":1920,"height":1080,"fps":30}',
277
+ open: '{"id":"2026-09-29-aula"}',
278
+ import: '{"path":"gravacao.mp4"}',
279
+ get_timeline: '{"from":0,"to":300}',
280
+ inspect: '{"frames":[0,150,299]}',
281
+ get_transcript: '{"from":0,"to":600}',
282
+ add_clips: '{"clips":[{"asset":"a2","track":"new","at":90,"from":2.5,"to":6}]}',
283
+ insert: '{"asset":"a2","at":120,"from":0,"to":3}',
284
+ move: '{"id":"3f2a","to":240,"track":"new"}',
285
+ remove: '{"ids":["3f2a"],"ripple":true}',
286
+ split: '{"id":"3f2a","at":180}',
287
+ ripple_delete_ranges: '{"ranges":[[120,150],[400,410]]}',
288
+ remove_silence: '{"padding":0.12,"minSilence":0.4}',
289
+ remove_words: '{"words":[12,13,14]}',
290
+ set_clip_properties: '{"ids":["3f2a"],"props":{"volume":0.3,"fadeIn":15,"opacity":0.9}}',
291
+ set_keyframes: '{"id":"3f2a","prop":"zoom","keys":[{"frame":60,"value":1},{"frame":64,"value":1.2}]}',
292
+ apply_layout: '{"layout":"pip_br","ids":["<fala>","<broll>"]}',
293
+ add_texts: '{"texts":[{"content":"3 DICAS","at":0,"dur":60,"cy":0.2,"animation":"popIn","style":{"bg":"#E11D48"}}]}',
294
+ add_captions: '{"maxWords":3,"animation":"highlightPop","style":{"upper":true}}',
295
+ apply_color: '{"ids":"all","preset":"warm","contrast":0.1}',
296
+ apply_effect: '{"ids":["3f2a"],"type":"stylize.vignette","params":{"strength":0.6}}',
297
+ detect_beats: '{"asset":"a3","markers":true}',
298
+ undo: '{"steps":2}',
299
+ export: '{"out":"final.mp4"}',
300
+ };
301
+
302
+ function ajuda() {
303
+ const linhas = [
304
+ "primocode video <op> [--json '{...}'] [--project <id>] — editor de vídeo por linha do tempo (ffmpeg)",
305
+ 'UNIDADES: posição/duração na timeline = QUADROS inteiros (fps do projeto); from/to da mídia = SEGUNDOS da origem.',
306
+ 'Faixa 0 = a de cima. cx,cy,w,h em 0–1 do quadro (cx,cy = centro). Clipe por id curto ou prefixo único. Mídia: a1, a2…',
307
+ 'Toda edição devolve um DELTA (added/removed/changed/shifted[{by,ids}]/dur) e é desfeita com undo — não peça licença.',
308
+ 'Saída: 1 linha JSON no stdout (ok:false + error se falhou). Depois de editar, use inspect e OLHE o png.',
309
+ ];
310
+ for (const [nome, o] of Object.entries(OPS)) {
311
+ if (o.oculto) continue;
312
+ linhas.push(' ' + o.uso);
313
+ if (EX[nome]) linhas.push(` ex: primocode video ${nome} --json '${EX[nome]}'`);
314
+ }
315
+ linhas.push(
316
+ 'Texto: animation none|fade|popIn|typewriter|wordReveal|highlightPop; style {size(0–1 da altura),color,stroke,',
317
+ ' strokeWidth,highlight,bg,upper,font(bold|black|regular|caminho .ttf),align}.',
318
+ );
319
+ return linhas.join('\n');
320
+ }
321
+
322
+ module.exports = { OPS, ALIAS, ajuda, mutar, alvos };