primocode 10.1.0 → 10.2.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.
@@ -23,6 +23,7 @@ import { CURVAS_KIT, alfa } from './base';
23
23
  const ed = CURVAS_KIT.editorial, cam = CURVAS_KIT.camera;
24
24
  const c01 = (x) => (x < 0 ? 0 : x > 1 ? 1 : x);
25
25
  const lerp = (a, b, k) => a + (b - a) * k;
26
+ const lum = (hex) => { const n = parseInt(String(hex).slice(1), 16); return (0.299 * (n >> 16 & 255) + 0.587 * (n >> 8 & 255) + 0.114 * (n & 255)) / 255; };
26
27
  const entra = (t) => t * t;
27
28
  const SANS = (f) => '"' + f + '", system-ui, sans-serif';
28
29
  const SERIF = (f) => '"' + f + '", Georgia, serif';
@@ -52,7 +53,7 @@ function medir(t, tam, peso, serifa, P, mono) {
52
53
 
53
54
  /* Um bloco de texto: quebra palavra a palavra (largura medida), cada linha
54
55
  centrada em cx (ou alinhada à esquerda/direita), o bloco centrado em cy. */
55
- function bloco(P, f, { tam, peso = 400, max = 1e9, cx, cy, alinha = 'centro', cor, mono = false }) {
56
+ function bloco(P, f, { tam, peso = 400, max = 1e9, cx, cy, alinha = 'centro', cor, corAcento = null, mono = false }) {
56
57
  const esp = medir(' ', tam, peso, false, P, mono);
57
58
  const ws = f.palavras.map((w) => ({ ...w, w: medir(w.t, w.acento ? tam * 1.14 : tam, peso, w.acento, P, mono) }));
58
59
  const linhas = [[]];
@@ -67,7 +68,7 @@ function bloco(P, f, { tam, peso = 400, max = 1e9, cx, cy, alinha = 'centro', co
67
68
  const top = cy - h / 2;
68
69
  const x0 = alinha === 'centro' ? cx - w / 2 : alinha === 'esq' ? cx : cx - w;
69
70
  return {
70
- f, tam, peso, cor, esp, lh, w, h, top, cx, cy, mono, left: x0,
71
+ f, tam, peso, cor, corAcento, esp, lh, w, h, top, cx, cy, mono, left: x0,
71
72
  linhas: linhas.map((l, i) => ({ palavras: l, y: top + i * lh, x: alinha === 'centro' ? cx - larg(l) / 2 : alinha === 'esq' ? cx : cx - larg(l) })),
72
73
  };
73
74
  }
@@ -108,6 +109,60 @@ function leiaute(P) {
108
109
  ent = [0.3, 0.8];
109
110
  }
110
111
  const poe = (bl) => { out.blocos.push(bl); return bl; };
112
+ /* A IMAGEM (fluido.js escolhe a batida e o jeito): o texto se arruma em
113
+ volta dela, e o portador vira um ponto discreto enquanto a foto manda. */
114
+ if (b.imagem && pr) {
115
+ const j = b.imagem.jeito;
116
+ const t0 = Math.max(b.de + 0.05, Math.min(...b.frases.map((f) => f.palavras[0].em)));
117
+ const [sb] = fr('sub');
118
+ const branco = '#ffffff';
119
+ let foto, tY;
120
+ if (j === 'cartao') {
121
+ const cw = deitado ? W * 0.36 : W * 0.74, ch = deitado ? H * 0.62 : H * 0.42;
122
+ foto = { x: deitado ? cx + W * 0.2 : cx, y: deitado ? cy : cy + H * 0.12, w: cw, h: ch };
123
+ tY = deitado ? cy : cy - H * 0.24;
124
+ } else if (j === 'faixa') {
125
+ foto = { x: cx, y: deitado ? cy - H * 0.08 : cy - H * 0.06, w: W, h: deitado ? H * 0.46 : H * 0.34 };
126
+ tY = foto.y + foto.h / 2 + 130 * u;
127
+ } else { foto = { x: cx, y: cy, w: W, h: H }; tY = j === 'vazado' ? cy : cy + H * (deitado ? 0.22 : 0.2); }
128
+ const tx2 = j === 'cartao' && deitado ? cx - W * 0.2 : cx;
129
+ const sobreFoto = j === 'janela' || j === 'circulo';
130
+ let tam = (j === 'vazado' ? 200 : sobreFoto ? 92 : 76) * u;
131
+ if (j === 'vazado') {
132
+ const l100 = pr.palavras.reduce((s2, w) => s2 + medir(w.t, 100, 700, false, P), 0) + medir(' ', 100, 700, false, P) * (pr.palavras.length - 1);
133
+ tam = Math.min(360 * u, (W * 0.84 / (1 + 0.022 * (bi + 1))) / Math.max(1, l100) * 100);
134
+ }
135
+ const larg2 = j === 'cartao' && deitado ? W * 0.34 : W * 0.84;
136
+ const tb = poe(bloco(P, pr, { tam, peso: j === 'vazado' ? 700 : 400, max: larg2, cx: tx2, cy: tY, cor: sobreFoto ? branco : T, corAcento: sobreFoto ? branco : null }));
137
+ if (sb) poe(bloco(P, sb, { tam: 34 * u, max: larg2, cx: tx2, cy: tY + tb.h / 2 + 56 * u, cor: sobreFoto ? alfa(branco, 0.82) : S }));
138
+ out.imagem = { jeito: j, src: b.imagem.src, foto, t0, texto: tb };
139
+ // O portador: no círculo ele É a foto (cresce); nos outros, vira um ponto e some.
140
+ if (j === 'circulo') vai(Math.max(0, t0 - 0.5), t0 + 0.2, forma(cx, cy, 0, 0, { solid: 1, ac: 1, op: 0 }));
141
+ else vai(ent[0], ent[1], forma(tx2, tY - tb.h / 2 - 60 * u, 10 * u, 10 * u, { solid: 1, ac: 1, op: j === 'vazado' ? 1 : 0 }));
142
+ return out;
143
+ }
144
+ /* A PALAVRA GIGANTE (fluido.js marca): a frase curta enche a largura,
145
+ sem pílula, peso fino. Na VIRADA o portador cresce até cobrir a tela
146
+ de acento, e o texto troca para a cor que lê sobre ele. */
147
+ if (b.gigante && pr) {
148
+ const base = 100;
149
+ const larguraA100 = pr.palavras.reduce((s2, w) => s2 + medir(w.t, w.acento ? base * 1.14 : base, 300, w.acento, P), 0) + medir(' ', base, 300, false, P) * (pr.palavras.length - 1);
150
+ // Cabe já descontando o zoom que a câmera terá nesta batida (1 + 0,022 por batida) e a deriva.
151
+ const zoomAqui = 1 + 0.022 * (bi + 1);
152
+ const tam = Math.min(330 * u, Math.max(96 * u, (W * 0.8 / zoomAqui) / Math.max(1, larguraA100) * base));
153
+ const sobre = b.virada ? (lum(pal.acentos[0]) > 0.55 ? pal.fundo[0] : '#ffffff') : T;
154
+ const acentoSobre = b.virada ? sobre : pal.acentos[0];
155
+ const g = poe(bloco(P, pr, { tam, peso: 300, max: W * 0.84 / zoomAqui, cx, cy, cor: sobre, corAcento: acentoSobre }));
156
+ const [sb] = fr('sub');
157
+ if (sb) poe(bloco(P, sb, { tam: 34 * u, max: W * 0.7, cx, cy: cy + g.h / 2 + 70 * u, cor: b.virada ? sobre : S }));
158
+ // A virada começa com a PRIMEIRA frase que aparece (a voz pode dizer o sub antes do título).
159
+ const t0 = Math.max(b.de + 0.1, Math.min(...b.frases.map((f) => f.palavras[0].em)));
160
+ if (b.virada) {
161
+ const D = Math.hypot(W, H) * 1.08;
162
+ vai(Math.max(0, t0 - 0.55), t0 - 0.05, forma(cx, cy, D, D, { solid: 1, ac: 1 }));
163
+ } else vai(ent[0], ent[1], forma(cx, cy - g.h / 2 - 70 * u, 14 * u, 14 * u, { solid: 1, ac: 1 }));
164
+ return out;
165
+ }
111
166
  switch (b.tipo) {
112
167
  case 'abertura': {
113
168
  const t = pr && poe(bloco(P, pr, { tam: (lado === 'centro' ? 96 : 80) * u, max: larg, cx: tx, cy: ty, cor: T }));
@@ -146,7 +201,9 @@ function leiaute(P) {
146
201
  out.extra = { pilulas, tt };
147
202
  // O portador vira a PRIMEIRA pílula; as outras nascem na ponta da curva.
148
203
  const p0 = pilulas[0];
149
- if (p0) vai(ent[0], ent[1], forma(p0.x, p0.y, p0.w, p0.h, { fill: 1, stroke: 0.35 }));
204
+ // A primeira pílula nasce com o primeiro item (antes, ficava vazia e enorme esperando a voz).
205
+ const i0 = p0 ? p0.f.palavras[0].em : b.de;
206
+ if (p0) vai(Math.max(ent[0], i0 - 0.4), Math.max(ent[1], i0 + 0.1), forma(p0.x, p0.y, p0.w, p0.h, { fill: 1, stroke: 0.35 }));
150
207
  else vai(ent[0], ent[1], forma(cx, cy, 12 * u, 12 * u, { solid: 1, ac: 1 }));
151
208
  break;
152
209
  }
@@ -220,8 +277,12 @@ function leiaute(P) {
220
277
  const h = umaLinha ? (t ? t.tam * 2 : 60 * u) : t.h;
221
278
  if (sb) poe(umaLinha ? bloco(P, sb, { tam: 34 * u, max: larg, cx: tx, cy: ty + h / 2 + 62 * u, cor: S })
222
279
  : bloco(P, sb, { tam: 34 * u, max: larg, cx: t.left, cy: ty + h / 2 + 50 * u, alinha: 'esq', cor: S }));
223
- if (umaLinha) vai(ent[0], ent[1], forma(tx, ty, (t ? t.w : 200 * u) + 110 * u, h, { fill: 1, stroke: 0.35 }));
224
- else vai(ent[0], ent[1], forma(t.left - 40 * u, ty, 6 * u, t.h * 0.92, { solid: 1, ac: 1, r: 3 }));
280
+ /* A pílula nasce com a primeira palavra, não antes: entrando em b.de,
281
+ ela ficava VAZIA até a voz chegar na frase (teste de 08/10). */
282
+ const p0 = pr ? pr.palavras[0].em : b.de;
283
+ const entT = [Math.max(ent[0], p0 - 0.4), Math.max(ent[1], p0 + 0.1)];
284
+ if (umaLinha) vai(entT[0], entT[1], forma(tx, ty, (t ? t.w : 200 * u) + 110 * u, h, { fill: 1, stroke: 0.35 }));
285
+ else vai(entT[0], entT[1], forma(t.left - 40 * u, ty, 6 * u, t.h * 0.92, { solid: 1, ac: 1, r: 3 }));
225
286
  }
226
287
  }
227
288
  return out;
@@ -261,7 +322,7 @@ function Palavras({ bl, sai, t, P, op = 1, de = null }) {
261
322
  filter: (1 - k) + o > 0.02 ? 'blur(' + ((1 - k) * 8 + o * 6).toFixed(2) + 'px)' : undefined,
262
323
  fontFamily: w.acento ? SERIF(P.fontes.serifa) : bl.mono ? MONO(P.fontes.mono) : SANS(P.fontes.texto), fontStyle: w.acento ? 'italic' : 'normal',
263
324
  fontWeight: w.acento ? 400 : bl.peso, fontSize: w.acento ? bl.tam * 1.14 : bl.tam, letterSpacing: w.acento || bl.mono ? 0 : '-0.02em',
264
- color: w.acento ? pal.acentos[0] : bl.cor,
325
+ color: w.acento ? (bl.corAcento || pal.acentos[0]) : bl.cor,
265
326
  }}>{w.t}</span>
266
327
  );
267
328
  })}
@@ -340,6 +401,55 @@ function Cartao({ c, ui, de: de0, sai, t, P, u: u0 }) {
340
401
  );
341
402
  }
342
403
 
404
+ // ── a foto: cinco jeitos de entrar, nenhum repetido no vídeo ────────────
405
+ function Foto({ im, b, sai, t, P, u, W, H }) {
406
+ const k = ed(c01((t - (im.t0 - 0.45)) / 0.75)); // a entrada
407
+ const o = entra(c01((t - sai) / 0.4)); // a saída
408
+ if (k <= 0.001 || o >= 0.999) return null;
409
+ const vida = c01((t - im.t0) / Math.max(1, b.ate - im.t0)); // o empurrão lento enquanto ela fica
410
+ const src = staticFile(im.src);
411
+ const f = im.foto;
412
+ const img = (extra = {}) => <Img src={src} style={{ position: 'absolute', left: 0, top: 0, width: '100%', height: '100%', objectFit: 'cover', ...extra }} />;
413
+ const escurece = <div style={{ position: 'absolute', inset: 0, background: 'linear-gradient(180deg, rgba(0,0,0,0.05) 30%, rgba(0,0,0,0.62) 100%)' }} />;
414
+ if (im.jeito === 'janela') {
415
+ return <div style={{ position: 'absolute', left: 0, top: 0, width: W, height: H, overflow: 'hidden', opacity: 1 - o,
416
+ clipPath: 'inset(0 ' + ((1 - k) * 100).toFixed(2) + '% 0 0)' }}>
417
+ {img({ transform: 'scale(' + (1.14 - 0.08 * vida) + ') translateX(' + ((1 - k) * -6) + '%)' })}{escurece}</div>;
418
+ }
419
+ if (im.jeito === 'circulo') {
420
+ const r = Math.hypot(W, H) * 0.55 * k * (1 - o);
421
+ return <div style={{ position: 'absolute', left: 0, top: 0, width: W, height: H, overflow: 'hidden',
422
+ clipPath: 'circle(' + r.toFixed(1) + 'px at ' + W / 2 + 'px ' + H / 2 + 'px)' }}>
423
+ {img({ transform: 'scale(' + (1.2 - 0.12 * k - 0.05 * vida) + ')' })}{escurece}</div>;
424
+ }
425
+ if (im.jeito === 'cartao') {
426
+ const gira = (1 - k) * -38 + Math.sin(t * 0.6) * 3;
427
+ return <div style={{ position: 'absolute', left: f.x - f.w / 2, top: f.y - f.h / 2, width: f.w, height: f.h, borderRadius: 28 * u, overflow: 'hidden',
428
+ opacity: k * (1 - o), boxShadow: '0 ' + 50 * u + 'px ' + 90 * u + 'px -' + 30 * u + 'px rgba(0,0,0,0.55)',
429
+ transform: 'perspective(' + 1500 * u + 'px) translateY(' + ((1 - k) * 140 * u - o * 60 * u) + 'px) rotateY(' + gira + 'deg) rotateX(' + (6 * (1 - k)) + 'deg) scale(' + (0.86 + 0.14 * k) + ')' }}>
430
+ {img({ transform: 'scale(' + (1.08 - 0.06 * vida) + ')' })}</div>;
431
+ }
432
+ if (im.jeito === 'faixa') {
433
+ const meio = (1 - k + o) * 50;
434
+ return <div style={{ position: 'absolute', left: 0, top: f.y - f.h / 2, width: W, height: f.h, overflow: 'hidden',
435
+ clipPath: 'inset(' + meio.toFixed(2) + '% 0 ' + meio.toFixed(2) + '% 0)' }}>
436
+ {img({ transform: 'scale(1.12) translateX(' + (-4 + 8 * vida) + '%)' })}</div>;
437
+ }
438
+ // vazado: as letras do título, com a foto por dentro (SVG: o texto é o recorte).
439
+ const tb = im.texto;
440
+ const id = 'vz' + b.i;
441
+ const fam = '"' + P.fontes.texto + '", system-ui, sans-serif';
442
+ return <svg width={W} height={H} style={{ position: 'absolute', left: 0, top: 0, opacity: (1 - o) }}>
443
+ <defs><clipPath id={id}>{tb.linhas.map((l, li) => (
444
+ <text key={li} x={l.x} y={l.y + tb.lh * 0.8} fontFamily={fam} fontWeight={700} fontSize={tb.tam} letterSpacing={-0.02 * tb.tam}>
445
+ {l.palavras.map((w) => w.t).join(' ')}</text>))}</clipPath></defs>
446
+ <g clipPath={'url(#' + id + ')'} style={{ opacity: k }}>
447
+ <image href={src} x={-W * 0.06 * (1 - vida)} y={tb.top - tb.h * 0.6} width={W * 1.12} height={tb.h * 2.2} preserveAspectRatio="xMidYMid slice"
448
+ transform={'translate(0 ' + ((1 - k) * 40 * u) + ')'} />
449
+ </g>
450
+ </svg>;
451
+ }
452
+
343
453
  // ── a peça ───────────────────────────────────────────────────────────────
344
454
  export function Fluido({ el, t }) {
345
455
  const P = el.partitura;
@@ -371,12 +481,13 @@ export function Fluido({ el, t }) {
371
481
  const so = P.logo && ult.tipo === 'cta' ? 1 - ed(c01((t - ult.de - 0.65) / 0.5)) : 1;
372
482
 
373
483
  const camadas = [];
374
- L.batidas.forEach(({ b, blocos, extra, risca, cartao, comando }, i) => {
484
+ L.batidas.forEach(({ b, blocos, extra, risca, cartao, comando, imagem }, i) => {
375
485
  const corte = b.transicao === 'corte' || (L.batidas[i + 1] && L.batidas[i + 1].b.transicao === 'corte');
376
486
  const ultima = i === L.batidas.length - 1;
377
487
  const sai = ultima ? 1e9 : b.ate - (corte ? 0 : 0.15), fimVis = b.ate + (corte ? 0 : 0.4);
378
488
  if (t < b.de - 0.3 || t > fimVis || (corte && t >= b.ate && !ultima)) return;
379
489
  if (cartao) camadas.push(<Cartao key={'k' + i} c={cartao} ui={b.ui} de={b.de} sai={sai} t={t} P={P} u={u} />);
490
+ if (imagem) camadas.push(<Foto key={'f' + i} im={imagem} b={b} sai={sai} t={t} P={P} u={u} W={W} H={H} />);
380
491
  // A lista: as curvas que se desenham de uma pílula à próxima, e as pílulas que nascem na ponta.
381
492
  if (b.tipo === 'lista' && extra) {
382
493
  const ps = extra.pilulas;
@@ -398,17 +509,27 @@ export function Fluido({ el, t }) {
398
509
  background: sup, border: (1.2 * u) + 'px solid ' + alfa(pal.tinta, 0.2), opacity: kp * o, transform: 'scale(' + (0.7 + 0.3 * kp) + ')',
399
510
  boxShadow: pal.escuro ? undefined : '0 14px 34px -14px ' + alfa(pal.tinta, 0.3) }} />);
400
511
  }
401
- // O ícone de cada pílula: um brilho de quatro pontas que gira ao nascer.
512
+ /* O marcador de cada pílula muda de vídeo para vídeo (P.marcador): o
513
+ brilho de quatro pontas em toda lista era vício (08/10). */
402
514
  const kd = ed(c01((t - p.f.de) / 0.5));
403
- camadas.push(<svg key={'d' + i + j} width={p.ic} height={p.ic} viewBox="-1 -1 2 2" style={{ position: 'absolute', left: p.x - p.w / 2 + 34 * u, top: p.y - p.ic / 2,
515
+ const mk = P.marcador || 'brilho';
516
+ const mx0 = p.x - p.w / 2 + 34 * u, my0 = p.y - p.ic / 2;
517
+ if (mk === 'brilho') camadas.push(<svg key={'d' + i + j} width={p.ic} height={p.ic} viewBox="-1 -1 2 2" style={{ position: 'absolute', left: mx0, top: my0,
404
518
  opacity: kd * o, transform: 'rotate(' + ((1 - kd) * -90 + Math.sin(t * 1.3 + j) * 6) + 'deg) scale(' + kd + ')' }}>
405
519
  <path d={BRILHO4} fill={pal.acentos[0]} />
406
520
  </svg>);
521
+ else if (mk === 'numero') camadas.push(<div key={'d' + i + j} style={{ position: 'absolute', left: mx0 - 4 * u, top: p.y - p.ic * 0.62, height: p.ic * 1.24, lineHeight: p.ic * 1.24 + 'px',
522
+ fontFamily: MONO(P.fontes.mono), fontSize: p.ic * 0.78, color: pal.acentos[0], opacity: kd * o, transform: 'translateY(' + (1 - kd) * 10 * u + 'px)' }}>{String(j + 1).padStart(2, '0')}</div>);
523
+ else if (mk === 'traco') camadas.push(<div key={'d' + i + j} style={{ position: 'absolute', left: mx0, top: p.y - 1.5 * u, width: p.ic * kd, height: 3 * u, borderRadius: 3,
524
+ background: pal.acentos[0], opacity: o }} />);
525
+ else if (mk === 'ponto') camadas.push(<div key={'d' + i + j} style={{ position: 'absolute', left: mx0 + p.ic * 0.3, top: p.y - p.ic * 0.2, width: p.ic * 0.4, height: p.ic * 0.4, borderRadius: 99,
526
+ background: pal.acentos[0], opacity: o, transform: 'scale(' + kd + ')' }} />);
407
527
  });
408
528
  }
409
529
  for (const bl of blocos) {
410
530
  const f = bl.f;
411
531
  if (b.tipo === 'passos' && extra) continue;
532
+ if (imagem && imagem.jeito === 'vazado' && bl === imagem.texto) continue; // as letras são a foto (Foto desenha)
412
533
  const op = f.papel === 'nao' && risca ? 1 - 0.55 * ed(c01((t - risca) / 0.4)) : 1;
413
534
  if (f.papel === 'numero' && b.numero) {
414
535
  const k = ed(c01((t - f.de) / 1.1));
@@ -18,14 +18,17 @@
18
18
 
19
19
  'use strict';
20
20
 
21
- let ATUAL = { modo: null, escopo: '' };
21
+ let ATUAL = { modo: null, escopo: '', explicito: false };
22
22
 
23
- function definir(modo, escopo = '') {
24
- ATUAL = { modo: ['aberto', 'fechado', 'sem'].includes(modo) ? modo : null, escopo: String(escopo || '').trim() };
23
+ function definir(modo, escopo = '', explicito = true) {
24
+ ATUAL = { modo: ['aberto', 'fechado', 'sem'].includes(modo) ? modo : null, escopo: String(escopo || '').trim(), explicito: !!explicito };
25
25
  return ATUAL;
26
26
  }
27
27
  function atual() { return ATUAL; }
28
- function limpar() { ATUAL = { modo: null, escopo: '' }; }
28
+ function limpar() { ATUAL = { modo: null, escopo: '', explicito: false }; }
29
+
30
+ /** Sem foto nem vídeo de fora: o "sem" e também ninguém ter dito nada (o padrão, 07/10). */
31
+ function semImagem({ modo } = ATUAL) { return modo !== 'aberto' && modo !== 'fechado'; }
29
32
 
30
33
  /** Os termos do escopo fechado: "site:anthropic.com, claude ai, sonnet" → lista. */
31
34
  function termos(escopo = ATUAL.escopo) {
@@ -41,33 +44,40 @@ function entender(resposta) {
41
44
  return null;
42
45
  }
43
46
 
44
- /* O MODO DEDUZIDO DO PEDIDO. Quem diz no pedido não é perguntado:
45
- "só motion", "sem referência", "sem foto" → sem
46
- "só do site x.com", "usa apenas anthropic.com e …" → fechado (o escopo)
47
- "web aberta", "escopo aberto", "pesquisa à vontade" → aberto
48
- O resto volta com `explicito: false` e o terminal pergunta antes de
49
- começar: "eu posso pedir um vídeo mas estar me referindo ao motion"
50
- (Joel, 02/10) — chutar o aberto fazia vídeo com foto e b-roll quando a
51
- pessoa queria só motion. */
47
+ /* O MODO DEDUZIDO DO PEDIDO. O padrão é SEM IMAGEM: "por padrão, NÃO
48
+ usar imagens. Somente quando o usuário pedir explicitamente" (Joel, 07/10).
49
+ O menu 1/2/3 do terminal saiu; quem quer imagem diz no pedido:
50
+ "adicione imagens de apoio", "quero imagens durante o vídeo",
51
+ "use imagens reais", "web aberta" → aberto
52
+ "só do site x.com", "usa apenas anthropic.com e …" → fechado (o escopo)
53
+ "só motion", "sem referência", "sem foto" → sem (explícito)
54
+ o resto → sem (o padrão) */
55
+ const PEDE_IMAGEM = /(adicion|coloc|use|usa|usar|quero|bota|pon|insir|inclu|com)\w*\s+(\w+\s+){0,3}(imagens?|fotos?|b-?roll|videos? de (apoio|banco)|cenas reais)\b|(imagens?|fotos?) (de apoio|reais|durante|para ilustrar|pra ilustrar|da (web|internet))|escopo aberto|web aberta|pesquis\w* (a vontade|na web|na internet)|busc\w* (imagens|fotos|videos) na (web|internet)/;
56
+ const NEGA_IMAGEM = /so (o )?motion|motion puro|sem refer|sem (foto|imagem|imagens|video de banco|pesquisa)|nao (pesquis|busqu)|so anima|(nao|nada de) (quero |use |usar |coloc\w* )?(imagens?|fotos?)/;
57
+
52
58
  function inferir(texto) {
53
- const t = String(texto || '').toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '');
54
- if (/so (o )?motion|motion puro|sem refer|sem (foto|imagem|imagens|video de banco|pesquisa)|nao (pesquis|busqu)|so anima/.test(t)) return { modo: 'sem', escopo: '', explicito: true };
59
+ const t = String(texto || '').toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '');
60
+ if (NEGA_IMAGEM.test(t)) return { modo: 'sem', escopo: '', explicito: true };
55
61
  const dominios = String(texto || '').match(/\b[a-z0-9-]+(\.[a-z0-9-]+)+\.?[a-z]{2,}(\/[^\s,]*)?/gi) || [];
56
62
  if (/\b(so|apenas|somente|unicamente)\b[^.]{0,30}\b(site|sites|link|links|fonte|fontes|pagina|de|do|da|em)\b/.test(t) && dominios.length) {
57
63
  return { modo: 'fechado', escopo: dominios.join(', '), explicito: true };
58
64
  }
59
- if (/escopo aberto|web aberta|pesquis\w* (a vontade|na web|na internet)|busc\w* (imagens|fotos|videos) na (web|internet)|com (imagens|fotos) da (web|internet)/.test(t)) {
60
- return { modo: 'aberto', escopo: '', explicito: true };
61
- }
62
- return { modo: 'aberto', escopo: '', explicito: false };
65
+ if (PEDE_IMAGEM.test(t)) return { modo: 'aberto', escopo: '', explicito: true };
66
+ return { modo: 'sem', escopo: '', explicito: false };
63
67
  }
64
68
 
65
69
  /** A instrução para o agente, conforme o modo. */
66
- function instrucao({ modo, escopo } = ATUAL) {
67
- if (modo === 'sem') {
70
+ function instrucao({ modo, escopo, explicito } = ATUAL) {
71
+ // O vídeo sem imagem é motion: o agente escolhe o estilo de motion que serve ao roteiro, não um fixo.
72
+ const motion = 'O vídeo é MOTION: tipografia, formas, ícones, animação e som. Escolha em roteiro.estilo o motion que serve a este '
73
+ + 'roteiro (gerativo, fluido, impacto, vox, marca, lancamento) ou deixe vazio para o rodízio — não repita sempre o mesmo.';
74
+ if (modo === 'sem' && explicito) {
68
75
  return '[REFERÊNCIAS: NENHUMA] A pessoa quer SÓ MOTION: não pesquise (nada de pesquisar_referencia nem estudio_site), '
69
- + 'não busque foto nem vídeo, não use estudio_midia de banco. Escreva o roteiro com o que o pedido já diz e deixe '
70
- + 'o motion gerativo fazer o trabalho (roteiro.estilo "gerativo": a tipografia anima sozinha, sem template).';
76
+ + 'não busque foto nem vídeo, não use estudio_midia de banco. Escreva o roteiro com o que o pedido já diz. ' + motion;
77
+ }
78
+ if (modo === 'sem') {
79
+ return '[SEM IMAGEM — o padrão] A pessoa não pediu imagem: não busque foto nem vídeo, não use estudio_midia de banco '
80
+ + 'nem "busca" nas partes. Pesquisar FATOS em texto (pesquisar_referencia) pode, se o assunto pedir. ' + motion;
71
81
  }
72
82
  if (modo === 'fechado') {
73
83
  const t = termos(escopo);
@@ -77,8 +87,9 @@ function instrucao({ modo, escopo } = ATUAL) {
77
87
  + `No roteiro, ponha roteiro.busca = ${JSON.stringify(t)} para o b-roll.`;
78
88
  }
79
89
  if (modo === 'aberto') {
80
- return '[REFERÊNCIAS: ESCOPO ABERTO] Pesquise o assunto à vontade (pesquisar_referencia; estudio_site se for de uma marca) '
81
- + 'e dê em cada parte do roteiro um "busca" em inglês com o que mostrar de fundo — gente, lugar, tela, objeto de verdade.';
90
+ return '[REFERÊNCIAS: ESCOPO ABERTO — a pessoa pediu imagens] Pesquise o assunto à vontade (pesquisar_referencia; estudio_site se for de uma marca). '
91
+ + 'A imagem é um elemento da narrativa, não o vídeo inteiro: dê "busca" (em inglês: gente, lugar, tela, objeto de verdade) '
92
+ + 'SÓ nas partes em que uma imagem real mostra o que o texto não mostra. Não troque de imagem a cada frase; o resto é motion.';
82
93
  }
83
94
  return '';
84
95
  }
@@ -102,4 +113,4 @@ function filtrar(r, { modo, escopo } = ATUAL) {
102
113
  return { ...r, fontes: refs.length, referencias: refs, ...(fora ? { foraDoEscopo: fora } : {}) };
103
114
  }
104
115
 
105
- module.exports = { definir, atual, limpar, termos, entender, inferir, instrucao, dominios, filtrar };
116
+ module.exports = { definir, atual, limpar, semImagem, termos, entender, inferir, instrucao, dominios, filtrar };
@@ -719,6 +719,8 @@ export const Video = () => {
719
719
  return vs[vs.length - 1].v; }
720
720
  : (plano.audio.trilha.volume ?? 0.18)}
721
721
  loop={plano.audio.trilha.loop !== false}
722
+ /* O início da faixa que casa as batidas com os cortes (batida.js). */
723
+ startFrom={Math.round((plano.audio.trilha.inicio || 0) * fps)}
722
724
  />
723
725
  )}
724
726
  {/* Os TRECHOS: pedaços de áudio no tempo certo — a narração partida entre
@@ -0,0 +1,117 @@
1
+ /**
2
+ * revisor.js — a revisão do plano antes do render: o olho de editor que
3
+ * procura receita, excesso e pressa, e conserta o que dá sem perguntar.
4
+ *
5
+ * "Antes de finalizar, procure padrões repetitivos, escolhas previsíveis,
6
+ * templates rígidos, excesso de efeitos, excesso de imagens, cortes
7
+ * artificiais, problemas de ritmo." (Joel, 07/10)
8
+ *
9
+ * plano.js confere se o plano RODA; este confere se ele está BOM. O que tem
10
+ * conserto seguro é consertado aqui (e dito); o que pede decisão vira aviso,
11
+ * que vai para o agente e para as notas do projeto:
12
+ * conserta a mesma transição 3+ vezes seguidas → a do meio vira corte seco
13
+ * conserta o MESMO efeito sonoro colado nele (< 0,3 s) → fica um (camadas de sons diferentes são desenho, ficam)
14
+ * conserta câmera mexendo em 4 cenas seguidas → a mais fraca para (respiro)
15
+ * avisa texto que não dá tempo de ler (0,3 s/palavra, mínimo 0,8 s)
16
+ * avisa a mesma sequência de elementos em 4+ cenas seguidas (cara de template)
17
+ * avisa imagem sem a pessoa pedir, ou imagem em mais da metade das cenas
18
+ * conserta efeito sonoro demais (mais de 1 a cada 1,5 s) → ficam os mais altos
19
+ * Puro: o plano entra, o plano consertado e a lista saem.
20
+ */
21
+
22
+ 'use strict';
23
+
24
+ const r2 = (n) => Math.round(n * 100) / 100;
25
+
26
+ const tipoDaTransicao = (c) => (c && c.transicao ? (c.transicao.tipo || c.transicao) : 'corte');
27
+ const mexe = (cam) => {
28
+ if (!cam) return false;
29
+ return Object.values(cam).some((ks) => Array.isArray(ks) && ks.length > 1 && ks.some((k) => Math.abs(Number(k.v) - Number(ks[0].v)) > 0.005));
30
+ };
31
+ const forcaDaCamera = (cam) => Math.max(0, ...Object.values(cam || {}).filter(Array.isArray)
32
+ .map((ks) => Math.max(...ks.map((k) => Number(k.v) || 0)) - Math.min(...ks.map((k) => Number(k.v) || 0))));
33
+ const palavras = (t) => String(t || '').split(/\s+/).filter(Boolean).length;
34
+ // Imagem ou vídeo DE FORA: o vídeo da própria gravação (deOrigem) e o recorte da pessoa não contam.
35
+ const imagemDeFora = (el) => el && (el.tipo === 'imagem' || (el.tipo === 'video' && el.deOrigem == null && !/pessoa-/.test(el.src || '')));
36
+
37
+ function revisar(plano, { semImagem = false } = {}) {
38
+ const consertos = [], avisos = [];
39
+ const cenas = (plano && plano.cenas) || [];
40
+ if (!cenas.length) return { plano, consertos, avisos };
41
+
42
+ // 1. A mesma transição (que não seja corte seco) 3+ vezes seguidas: receita.
43
+ for (let i = 2; i < cenas.length; i++) {
44
+ const t = tipoDaTransicao(cenas[i]);
45
+ if (t !== 'corte' && t === tipoDaTransicao(cenas[i - 1]) && t === tipoDaTransicao(cenas[i - 2])) {
46
+ cenas[i - 1].transicao = { tipo: 'corte' };
47
+ consertos.push(`cena ${i}: "${t}" 3 vezes seguidas — a do meio virou corte seco`);
48
+ }
49
+ }
50
+
51
+ // 2. Câmera mexendo em 4 cenas seguidas: a de movimento mais fraco para.
52
+ for (let i = 3; i < cenas.length; i++) {
53
+ const janela = [i - 3, i - 2, i - 1, i];
54
+ if (janela.every((k) => mexe(cenas[k].camera))) {
55
+ const k = janela.filter((j) => j > 0 && !cenas[j].impacto) // a abertura (o gancho) e o golpe não param
56
+
57
+ // Empate: a mais à frente — parar a primeira da janela fazia a janela seguinte pedir outra, e paravam todas (08/10).
58
+ .sort((a, b) => forcaDaCamera(cenas[a].camera) - forcaDaCamera(cenas[b].camera) || b - a)[0];
59
+ if (k == null) continue;
60
+ const cam = cenas[k].camera;
61
+ for (const prop of Object.keys(cam)) if (Array.isArray(cam[prop]) && cam[prop].length) cam[prop] = [{ t: 0, v: cam[prop][0].v }, { t: 1, v: cam[prop][0].v }];
62
+ consertos.push(`cena ${k + 1}: 4 cenas seguidas com a câmera mexendo — esta respira`);
63
+ }
64
+ }
65
+
66
+ // 3. Efeitos sonoros: colados um no outro, fica o mais alto; e a densidade.
67
+ const efeitos = ((plano.audio && plano.audio.efeitos) || []).slice().sort((a, b) => (a.t || 0) - (b.t || 0));
68
+ if (efeitos.length) {
69
+ const fica = [];
70
+ for (const e of efeitos) {
71
+ const ult = fica[fica.length - 1];
72
+ if (ult && e.src === ult.src && (e.t || 0) - (ult.t || 0) < 0.3) {
73
+ if ((e.volume ?? 1) > (ult.volume ?? 1)) fica[fica.length - 1] = e;
74
+ consertos.push(`efeito em ${r2(e.t || 0)}s colado no de ${r2(ult.t || 0)}s — ficou um`);
75
+ } else fica.push(e);
76
+ }
77
+ const total = cenas.reduce((n, c) => n + (Number(c.duracao) || 0), 0);
78
+ /* Som demais vira ruído: acima de 1 a cada 1,5 s, ficam os mais altos
79
+ (o desenho de som marca o mais importante com volume), um a cada 0,7 s. */
80
+ let final = fica;
81
+ if (total > 0 && fica.length / total > 1 / 1.5) {
82
+ const teto = Math.max(1, Math.floor(total / 1.5));
83
+ const guardados = [];
84
+ for (const e of fica.slice().sort((a, b) => (b.volume ?? 1) - (a.volume ?? 1))) {
85
+ if (guardados.length >= teto) break;
86
+ if (guardados.every((g) => Math.abs((g.t || 0) - (e.t || 0)) >= 0.7)) guardados.push(e);
87
+ }
88
+ final = guardados.sort((a, b) => (a.t || 0) - (b.t || 0));
89
+ consertos.push(`${fica.length} efeitos sonoros em ${r2(total)}s eram ruído — ficaram os ${final.length} mais fortes`);
90
+ }
91
+ plano.audio.efeitos = final;
92
+ }
93
+
94
+ // 4. Texto que não dá tempo de ler.
95
+ cenas.forEach((c, i) => {
96
+ const n = (c.elementos || []).filter((e) => e && e.tipo === 'texto').reduce((s, e) => s + palavras(e.texto), 0);
97
+ const precisa = Math.max(0.8, 0.3 * n);
98
+ if (n && Number(c.duracao) < precisa) avisos.push(`cena ${i + 1}: ${n} palavras em ${c.duracao}s (precisa de ${r2(precisa)}s para ler)`);
99
+ });
100
+
101
+ // 5. A mesma sequência de elementos em 4+ cenas seguidas: cara de template.
102
+ const assinatura = (c) => (c.elementos || []).map((e) => e && (e.tipo + (e.peca ? ':' + e.peca : ''))).join(',');
103
+ for (let i = 0, j = 1; j <= cenas.length; j++) {
104
+ if (j < cenas.length && assinatura(cenas[j]) === assinatura(cenas[i])) continue;
105
+ if (j - i >= 4 && assinatura(cenas[i])) avisos.push(`cenas ${i + 1} a ${j}: a mesma montagem (${assinatura(cenas[i])}) ${j - i} vezes seguidas — varie a composição`);
106
+ i = j;
107
+ }
108
+
109
+ // 6. Imagem: sem pedido, nenhuma; com pedido, não em toda cena.
110
+ const comImagem = cenas.filter((c) => (c.elementos || []).some(imagemDeFora) || (c.fundo && (c.fundo.tipo === 'imagem' || c.fundo.tipo === 'video'))).length;
111
+ if (semImagem && comImagem) avisos.push(`${comImagem} cena(s) com imagem de fora, mas a pessoa não pediu imagem`);
112
+ else if (cenas.length >= 4 && comImagem / cenas.length > 0.5) avisos.push(`imagem em ${comImagem} de ${cenas.length} cenas — a imagem é um momento, não o vídeo inteiro`);
113
+
114
+ return { plano, consertos, avisos };
115
+ }
116
+
117
+ module.exports = { revisar };
@@ -21,23 +21,75 @@ const os = require('os');
21
21
  const path = require('path');
22
22
 
23
23
  const PASTA = path.join(os.homedir(), '.primocode', 'studio');
24
- const MEMORIA = { trilha: 16, sfx: 40, broll: 80, icones: 4, estilo: 8, '3d': 24 };
24
+ const MEMORIA = { trilha: 16, sfx: 40, broll: 80, icones: 4, estilo: 8, '3d': 24, 'icone-nome': 30, 'paleta-fluido': 6 };
25
+
26
+ /* A DIVERSIDADE POR PONTOS (07/10): "o sistema não deve simplesmente proibir
27
+ repetição; escolhas usadas recentemente recebem penalidade, as muito usadas
28
+ recebem penalidade maior, as antigas podem voltar, e o que é adequado ao
29
+ conteúdo continua tendo prioridade" (Joel). Antes era uma janela fixa: o
30
+ que saía da janela voltava como se nunca tivesse sido usado.
31
+ pontos = PESO_ADEQUACAO × adequação − recência − frequência
32
+ - recência: 1 para a última usada, cai pela metade a cada MEIA_VIDA_USOS
33
+ escolhas do mesmo tipo (exp), e some quando sai da lista;
34
+ - frequência: log2(1 + usos), com os usos perdendo metade a cada
35
+ MEIA_VIDA_DIAS — o muito usado pesa mais, e o tempo perdoa;
36
+ - adequação: 0 a 1, de quem chama (o clima, o assunto). Com peso 2, o que
37
+ serve ao roteiro ganha do que só é novo: a variedade não quebra a coerência. */
38
+ const PESO_ADEQUACAO = 2;
39
+ const PESO_FREQUENCIA = 0.25;
40
+ const MEIA_VIDA_DIAS = 21;
41
+ const EMPATE = 0.05; // dentro disso, é sorteio
25
42
 
26
43
  function arquivo(tipo) { return path.join(PASTA, `${tipo}-usadas.json`); }
44
+ function arquivoHistorico(tipo) { return path.join(PASTA, `${tipo}-historico.json`); }
27
45
 
28
46
  function usadas(tipo) {
29
47
  try { const l = JSON.parse(fs.readFileSync(arquivo(tipo), 'utf8')); return Array.isArray(l) ? l : []; } catch { return []; }
30
48
  }
31
49
 
32
- function marcar(tipo, id) {
50
+ /** { id: { n: usos, t: ms da última } } — a frequência, que a lista de recentes não guarda. */
51
+ function historico(tipo) {
52
+ try { const h = JSON.parse(fs.readFileSync(arquivoHistorico(tipo), 'utf8')); return h && typeof h === 'object' && !Array.isArray(h) ? h : {}; } catch { return {}; }
53
+ }
54
+
55
+ const usosHoje = (reg, agora) => (reg ? reg.n * Math.pow(0.5, Math.max(0, agora - reg.t) / (MEIA_VIDA_DIAS * 864e5)) : 0);
56
+
57
+ function marcar(tipo, id, agora = Date.now()) {
33
58
  if (!id) return;
34
59
  try {
35
60
  fs.mkdirSync(PASTA, { recursive: true });
36
- const l = [id, ...usadas(tipo).filter((x) => x !== id)].slice(0, MEMORIA[tipo] || 20);
61
+ // A lista de recentes é maior que a janela de antes: a recência decai, não corta.
62
+ const l = [id, ...usadas(tipo).filter((x) => x !== id)].slice(0, (MEMORIA[tipo] || 20) * 2);
37
63
  fs.writeFileSync(arquivo(tipo), JSON.stringify(l));
64
+ const h = historico(tipo);
65
+ h[id] = { n: usosHoje(h[id], agora) + 1, t: agora };
66
+ // Guarda os 400 mais recentes, para o arquivo não crescer sem fim.
67
+ const ids = Object.keys(h).sort((x, y) => h[y].t - h[x].t).slice(0, 400);
68
+ fs.writeFileSync(arquivoHistorico(tipo), JSON.stringify(Object.fromEntries(ids.map((k) => [k, h[k]]))));
38
69
  } catch { /* sem memória: só perde a garantia de não repetir */ }
39
70
  }
40
71
 
72
+ /** Quanto pesa ter usado `id`: recência + frequência. 0 = nunca usado. */
73
+ function penalidade(id, tipo, { lista = usadas(tipo), hist = historico(tipo), agora = Date.now() } = {}) {
74
+ const pos = lista.indexOf(id);
75
+ const meiaVida = Math.max(1, (MEMORIA[tipo] || 20) / 4);
76
+ const recencia = pos < 0 ? 0 : Math.pow(0.5, pos / meiaVida);
77
+ return recencia + PESO_FREQUENCIA * Math.log2(1 + usosHoje(hist[id], agora));
78
+ }
79
+
80
+ /**
81
+ * Os pontos de cada opção: adequação menos penalidade. `adequacao` é uma
82
+ * função (opção → 0..1) ou um objeto {id: 0..1}; sem ela, tudo serve igual (1).
83
+ */
84
+ function pontuar(opcoes, tipo, { prefixo = '', adequacao = null, chave = (o) => o, agora = Date.now() } = {}) {
85
+ const lista = usadas(tipo), hist = historico(tipo);
86
+ const fit = (o) => {
87
+ const v = typeof adequacao === 'function' ? adequacao(o) : adequacao && typeof adequacao === 'object' ? adequacao[chave(o)] : 1;
88
+ return Number.isFinite(v) ? Math.max(0, Math.min(1, v)) : 0;
89
+ };
90
+ return opcoes.map((o) => ({ opcao: o, pontos: PESO_ADEQUACAO * fit(o) - penalidade(prefixo + chave(o), tipo, { lista, hist, agora }) }));
91
+ }
92
+
41
93
  function embaralhar(lista, aleatorio = Math.random) {
42
94
  const a = [...lista];
43
95
  for (let i = a.length - 1; i > 0; i--) {
@@ -47,25 +99,27 @@ function embaralhar(lista, aleatorio = Math.random) {
47
99
  return a;
48
100
  }
49
101
 
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));
102
+ /** Os itens da busca na ordem de tentar: mais pontos primeiro; quase empatados, sorteados. */
103
+ function ordenar(itens, tipo, aleatorio = Math.random, adequacao = null) {
53
104
  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))];
105
+ return pontuar(validos, tipo, { adequacao, chave: (x) => x.url })
106
+ .map((p) => ({ ...p, pontos: p.pontos + aleatorio() * EMPATE }))
107
+ .sort((a, b) => b.pontos - a.pontos)
108
+ .map((p) => p.opcao);
55
109
  }
56
110
 
57
111
  /**
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').
112
+ * Uma das opções, pelos pontos: as nunca usadas primeiro, a recente e a muito
113
+ * usada por último, e a adequada (opcional) acima de todas. Entre as quase
114
+ * empatadas, sorteia. Marca a escolhida na memória do tipo. Com `prefixo`,
115
+ * várias listas dividem o mesmo arquivo ('acabamento:fosco').
61
116
  */
62
- function sortear(opcoes, tipo, { prefixo = '', aleatorio = Math.random } = {}) {
117
+ function sortear(opcoes, tipo, { prefixo = '', aleatorio = Math.random, adequacao = null } = {}) {
63
118
  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];
119
+ const pts = pontuar(opcoes, tipo, { prefixo, adequacao });
120
+ const melhor = Math.max(...pts.map((p) => p.pontos));
121
+ const quase = pts.filter((p) => p.pontos >= melhor - EMPATE).map((p) => p.opcao);
122
+ const escolha = quase[Math.floor(aleatorio() * quase.length) % quase.length];
69
123
  marcar(tipo, prefixo + escolha);
70
124
  return escolha;
71
125
  }
@@ -179,5 +233,5 @@ function trocarIcones(plano, troca = {}) {
179
233
  return n;
180
234
  }
181
235
 
182
- module.exports = { usadas, marcar, embaralhar, ordenar, sortear, climaDe, termosTrilha, termosSfx, termosBroll, buscarSemRepetir,
236
+ module.exports = { usadas, historico, marcar, penalidade, pontuar, embaralhar, ordenar, sortear, climaDe, termosTrilha, termosSfx, termosBroll, buscarSemRepetir,
183
237
  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]}'`);