primocode 10.0.0 → 10.0.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/README.md +2 -2
  2. package/bin/primocode.js +69 -1
  3. package/lib/act.js +118 -19
  4. package/lib/browser.js +89 -5
  5. package/lib/catalogo.js +1 -1
  6. package/lib/claude-engine.js +38 -17
  7. package/lib/codex-engine.js +27 -9
  8. package/lib/entrada.js +73 -9
  9. package/lib/estudio/cinema.js +27 -9
  10. package/lib/estudio/diretor.js +13 -1
  11. package/lib/estudio/ende.js +16 -5
  12. package/lib/estudio/estilo.js +23 -8
  13. package/lib/estudio/gerativo.js +1453 -0
  14. package/lib/estudio/hibrido.js +165 -0
  15. package/lib/estudio/index.js +244 -36
  16. package/lib/estudio/kit/gerativo.jsx +980 -0
  17. package/lib/estudio/kit/index.jsx +3 -1
  18. package/lib/estudio/kit/tres.jsx +308 -0
  19. package/lib/estudio/kit.js +7 -1
  20. package/lib/estudio/lancamento.js +28 -0
  21. package/lib/estudio/referencias.js +105 -0
  22. package/lib/estudio/remotion.js +53 -9
  23. package/lib/estudio/ritmo.js +2 -2
  24. package/lib/estudio/sons-leves.js +38 -1
  25. package/lib/estudio/variedade.js +105 -0
  26. package/lib/estudio/vox.js +37 -8
  27. package/lib/mcp.js +156 -0
  28. package/lib/personas.js +67 -0
  29. package/lib/skills.js +36 -0
  30. package/lib/tools.js +22 -3
  31. package/lib/ui.js +41 -6
  32. package/lib/video/auto.js +329 -0
  33. package/lib/video/camadas.js +207 -0
  34. package/lib/video/cli.js +1 -1
  35. package/lib/video/filters.js +39 -6
  36. package/lib/video/fx3d/Camadas.jsx +64 -0
  37. package/lib/video/fx3d/Objetos.jsx +128 -0
  38. package/lib/video/fx3d/Root.jsx +18 -0
  39. package/lib/video/fx3d/modelos.jsx +304 -0
  40. package/lib/video/fx3d.js +108 -0
  41. package/lib/video/gerados.js +119 -0
  42. package/lib/video/objetos.js +111 -0
  43. package/lib/video/ops-fx.js +453 -0
  44. package/lib/video/ops.js +30 -5
  45. package/lib/video/paralelo.js +50 -0
  46. package/lib/video/pedido.js +191 -0
  47. package/lib/video/rastrear.js +95 -0
  48. package/lib/video/rastro.js +276 -0
  49. package/lib/video/render.js +67 -14
  50. package/lib/video/texto.js +6 -4
  51. package/lib/video/timeline.js +9 -1
  52. package/package.json +2 -2
  53. package/skills/motion-de-primeira/SKILL.md +5 -4
  54. package/studio/narrar.py +10 -7
  55. package/studio/rastrear.py +399 -0
@@ -131,22 +131,40 @@ function recusouOModelo(texto) {
131
131
  * `--model` e avisa — cair para o padrão da conta é melhor que a tarefa
132
132
  * morrer por causa de um nome que mudou de catálogo.
133
133
  */
134
- function chamarCodex(prompt, { executor, avisar = () => {} } = {}) {
134
+ /* O Codex ASSÍNCRONO, pelo mesmo motivo do claude -p (lib/claude-engine.js):
135
+ o execFileSync congelava o PrimoCode inteiro — spinner, recado, Esc Esc —
136
+ enquanto o Codex pensava. O erro mantém stdout/stderr para o diagnóstico. */
137
+ function rodarAssincrono(args, entrada) {
138
+ const { spawn } = require('child_process');
139
+ return new Promise((resolve, reject) => {
140
+ const p = spawn(CODEX, args, { env: process.env, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true });
141
+ let out = '', err = '';
142
+ const relogio = setTimeout(() => { try { p.kill(); } catch {} const e = new Error(`o Codex não respondeu em ${Math.round(TEMPO / 60000)} min`); e.stderr = err; e.stdout = out; reject(e); }, TEMPO);
143
+ p.stdout.on('data', (d) => { out += d; if (out.length > BUFFER) { try { p.kill(); } catch {} } });
144
+ p.stderr.on('data', (d) => { err += d; });
145
+ p.on('error', (e) => { clearTimeout(relogio); reject(e); });
146
+ p.on('close', (code) => {
147
+ clearTimeout(relogio);
148
+ if (code === 0) return resolve(out);
149
+ const e = new Error(`Command failed: codex (saiu com ${code})`); e.stderr = err; e.stdout = out; reject(e);
150
+ });
151
+ p.stdin.end(entrada);
152
+ });
153
+ }
154
+
155
+ async function chamarCodex(prompt, { executor, avisar = () => {} } = {}) {
135
156
  // A chave própria da pessoa, quando ela configurou uma. Sem configuração
136
157
  // o ambiente vai INTACTO — passar a variável vazia apagaria a chave que
137
158
  // ela já tem exportada no shell, e o Codex pararia de funcionar por ter
138
159
  // sido "configurado".
139
- const rodar = executor || ((args, entrada) => execFileSync(CODEX, args, {
140
- encoding: 'utf-8', timeout: TEMPO, input: entrada, maxBuffer: BUFFER,
141
- env: process.env, ...SEM_APARECER,
142
- }));
160
+ const rodar = executor || rodarAssincrono;
143
161
  try {
144
- return rodar(argsDoExec(), prompt);
162
+ return await rodar(argsDoExec(), prompt);
145
163
  } catch (e) {
146
164
  const detalhe = `${e.message || ''}\n${(e.stderr || '')}\n${(e.stdout || '')}`;
147
165
  if (MODELO_CODEX && recusouOModelo(detalhe)) {
148
166
  avisar(`o Codex não aceitou o modelo ${MODELO_CODEX} — indo com o padrão da sua conta`);
149
- return rodar(argsDoExec({ comModelo: false }), prompt);
167
+ return await rodar(argsDoExec({ comModelo: false }), prompt);
150
168
  }
151
169
  throw new Error(primeiraLinhaUtil(detalhe) || e.message);
152
170
  }
@@ -185,12 +203,12 @@ async function requestAct(server, payload, onEvent) {
185
203
  return;
186
204
  }
187
205
  const prompt = claude.promptDoProtocolo(payload);
188
- const saida = chamarCodex(prompt, { avisar: (m) => onEvent({ aviso: m }) });
206
+ const saida = await chamarCodex(prompt, { avisar: (m) => onEvent({ aviso: m }) });
189
207
 
190
208
  // A correção é uma segunda ida ao motor, com o mesmo protocolo e o
191
209
  // diagnóstico do que saiu errado. Barata, e evita que um erro de forma
192
210
  // do motor vire erro na cara de quem pediu a tarefa.
193
- const pedirCorrecao = (aviso) => chamarCodex(claude.promptDeCorrecao(aviso));
211
+ const pedirCorrecao = (aviso) => chamarCodex(claude.promptDeCorrecao(aviso)); // devolve promessa: quem chama dá await
194
212
 
195
213
  await claude.emitirDecisao(saida, onEvent, pedirCorrecao, 0,
196
214
  claude.nomesDasTools(payload));
package/lib/entrada.js CHANGED
@@ -43,6 +43,10 @@ const { c, visibleWidth, truncate } = require('./ui.js');
43
43
 
44
44
  // ── O desenho ────────────────────────────────────────────────────────────
45
45
 
46
+ /** A etiqueta de colagem em destaque (fininha, na cor da marca), sem mudar a largura. */
47
+ const RE_ETIQUETA = /\[Colado #\d+ · [^\]]+\]/g;
48
+ function pintarEtiquetas(s) { return String(s).replace(RE_ETIQUETA, (m) => c.brand(m) + '\x1b[38;5;254m'); }
49
+
46
50
  const BORDA = { ne: '╭', no: '╮', se: '╰', so: '╯', h: '─', v: '│' };
47
51
 
48
52
  /** Preenche até `w` colunas VISÍVEIS (códigos de cor não ocupam coluna). */
@@ -128,6 +132,12 @@ function montar(e) {
128
132
 
129
133
  const out = [];
130
134
  const regua = (bash ? c.warn : c.dim)(BORDA.h.repeat(largura));
135
+ /* O SPINNER MORA AQUI, na linha logo acima da caixa. Ele escrevia direto
136
+ no terminal com "\r" a cada 80 ms, sem quebra de linha — e a caixa,
137
+ que espera a linha fechar para se redesenhar, ficava apagada enquanto
138
+ ele girasse: o que se digitava não aparecia, e a barra de status
139
+ sumia ("não consigo digitar enquanto ele trabalha"). */
140
+ if (e.spinner) out.push(' ' + truncate(e.spinner, largura - 2));
131
141
  out.push(regua);
132
142
 
133
143
  const vazio = !String(e.texto).length;
@@ -137,7 +147,7 @@ function montar(e) {
137
147
  : c.dim('┆ ');
138
148
  // O "!" some do texto desenhado: quem vê "$ " já sabe que é shell, e
139
149
  // mostrar os dois é mostrar a mesma informação duas vezes.
140
- let conteudo = c.white(i === 0 && bash ? l.texto.replace(/^\s*!/, '') : l.texto);
150
+ let conteudo = pintarEtiquetas(c.white(i === 0 && bash ? l.texto.replace(/^\s*!/, '') : l.texto));
141
151
  // O fantasma só existe na última linha e só com o cursor no fim dela:
142
152
  // desenhado no meio do texto, ele empurraria o que vem depois.
143
153
  if (i === linhas.length - 1 && e.fantasma && cursor === String(e.texto).length) {
@@ -326,6 +336,15 @@ class Entrada extends EventEmitter {
326
336
 
327
337
  prompt() { if (!this.suspenso && this.tty && !this.fechado) this._desenhar(); }
328
338
 
339
+ /** O spinner (lib/ui.js) desenha por aqui quando há caixa: uma linha
340
+ * acima dela, redesenhada junto — nunca um "\r" solto por cima. */
341
+ mostrarSpinner(texto) {
342
+ const novo = texto || '';
343
+ if (novo === (this.spinnerTexto || '')) return;
344
+ this.spinnerTexto = novo;
345
+ if (this.desenhado && !this._meiaLinha) this._redesenhar();
346
+ }
347
+
329
348
  /** Escreve texto ACIMA da caixa — é por onde todo o resto do programa fala. */
330
349
  escreverAcima(texto) {
331
350
  if (!this.desenhado) { this.escreverCru(texto); return; }
@@ -342,7 +361,40 @@ class Entrada extends EventEmitter {
342
361
  this._mudou();
343
362
  }
344
363
 
345
- limpar() { this.line = ''; this.cursor = 0; this.menu = null; this._mudou(); }
364
+ limpar() { this.line = ''; this.cursor = 0; this.menu = null; this.colados = new Map(); this._mudou(); }
365
+
366
+ /* COLAR VIRA ETIQUETA, como no Claude Code: texto grande colado (várias
367
+ linhas, ou mais de 300 caracteres) entra na caixa como
368
+ "[Colado #1 · 42 linhas]" — a caixa não vira um paredão, dá para
369
+ escrever o pedido em volta, e no Enter a etiqueta volta a ser o texto
370
+ inteiro. O que é curto entra como texto mesmo. */
371
+ colar(texto) {
372
+ const t = String(texto || '');
373
+ if (!t) return;
374
+ const linhas = t.split('\n').length;
375
+ if (linhas < 3 && t.length <= 300) return this.write(t);
376
+ if (!this.colados) this.colados = new Map();
377
+ const n = this.colados.size + 1;
378
+ const etiqueta = `[Colado #${n} · ${linhas > 1 ? `${linhas} linhas` : `${t.length} caracteres`}]`;
379
+ this.colados.set(etiqueta, t);
380
+ this.write(etiqueta);
381
+ }
382
+
383
+ /** A linha com cada etiqueta trocada pelo texto que ela guarda. */
384
+ expandir(linha) {
385
+ if (!this.colados || !this.colados.size) return linha;
386
+ let out = linha;
387
+ for (const [et, t] of this.colados) out = out.split(et).join(t);
388
+ return out;
389
+ }
390
+
391
+ /** A etiqueta que termina exatamente no cursor (o backspace apaga ela inteira). */
392
+ _etiquetaAntes() {
393
+ if (!this.colados) return null;
394
+ const antes = this.line.slice(0, this.cursor);
395
+ for (const et of this.colados.keys()) if (antes.endsWith(et)) return et;
396
+ return null;
397
+ }
346
398
 
347
399
  /**
348
400
  * A PRÓXIMA linha é senha: a caixa desenha `•` no lugar das letras, não
@@ -363,6 +415,7 @@ class Entrada extends EventEmitter {
363
415
  status: this.op.status ? this.op.status() : [],
364
416
  menu: this.menu,
365
417
  ocupado: this.ocupado,
418
+ spinner: this.spinnerTexto || '',
366
419
  });
367
420
  }
368
421
 
@@ -436,11 +489,11 @@ class Entrada extends EventEmitter {
436
489
  const seq = (key && key.sequence) || ch || '';
437
490
 
438
491
  // Colagem entre marcas: tudo que vier até a marca de fim é texto.
439
- if (seq.includes('\x1b[200~')) { this.colando = true; }
492
+ if (seq.includes('\x1b[200~')) { this.colando = true; this.bufColagem = ''; }
440
493
  if (this.colando) {
441
494
  const limpo = seq.replace('\x1b[200~', '').replace('\x1b[201~', '');
442
- if (limpo) this.write(limpo.replace(/\r/g, '\n'));
443
- if (seq.includes('\x1b[201~')) this.colando = false;
495
+ if (limpo) this.bufColagem += limpo.replace(/\r\n?/g, '\n');
496
+ if (seq.includes('\x1b[201~')) { this.colando = false; this.colar(this.bufColagem); this.bufColagem = ''; }
444
497
  return;
445
498
  }
446
499
 
@@ -478,7 +531,15 @@ class Entrada extends EventEmitter {
478
531
  return;
479
532
  }
480
533
 
481
- case 'backspace':
534
+ case 'backspace': {
535
+ const et = this._etiquetaAntes();
536
+ if (et) {
537
+ this.line = this.line.slice(0, this.cursor - et.length) + this.line.slice(this.cursor);
538
+ this.cursor -= et.length;
539
+ this.colados.delete(et);
540
+ return this._mudou();
541
+ }
542
+ }
482
543
  if (this.cursor > 0) {
483
544
  this.line = this.line.slice(0, this.cursor - 1) + this.line.slice(this.cursor);
484
545
  this.cursor--;
@@ -585,7 +646,9 @@ class Entrada extends EventEmitter {
585
646
 
586
647
  _enviar() {
587
648
  if (this.menu) return this._escolherDoMenu();
588
- const texto = this.line;
649
+ const visto = this.line;
650
+ const texto = this.expandir(visto);
651
+ this.colados = new Map();
589
652
  const oculto = this.oculto;
590
653
  this.oculto = false;
591
654
  this.line = '';
@@ -599,7 +662,8 @@ class Entrada extends EventEmitter {
599
662
  this._apagar();
600
663
  if (texto.trim() && !oculto) {
601
664
  const marca = c.brand('❯ ');
602
- this.escreverCru(texto.split('\n').map((l, i) => (i ? ' ' : marca) + c.white(l)).join('\n') + '\n');
665
+ // Na tela fica a etiqueta (o que se viu ao mandar); quem vai ao agente é o texto inteiro.
666
+ this.escreverCru(visto.split('\n').map((l, i) => (i ? ' ' : marca) + pintarEtiquetas(c.white(l))).join('\n') + '\n');
603
667
  if (this.historico[this.historico.length - 1] !== texto) this.historico.push(texto);
604
668
  }
605
669
  this._desenhar();
@@ -609,4 +673,4 @@ class Entrada extends EventEmitter {
609
673
 
610
674
  function criar(op) { return new Entrada(op).iniciar(); }
611
675
 
612
- module.exports = { criar, Entrada, montar, quebrar, encher };
676
+ module.exports = { criar, Entrada, montar, quebrar, encher, pintarEtiquetas };
@@ -34,7 +34,7 @@ const tokens = (t) => String(t || '').normalize('NFD').replace(/[̀-ͯ]/g, '').t
34
34
  /** Quanto tempo um texto precisa ficar na tela para ser lido. Nunca menos que
35
35
  0,3 s por palavra: a regra de legibilidade do /brag (Arthur, 28/09). */
36
36
  const nPalavras = (t) => String(t || '').split(/\s+/).filter(Boolean).length;
37
- const leitura = (t) => Math.max(0.55 + 0.22 * nPalavras(t), 0.3 * nPalavras(t));
37
+ const leituraCheia = (t) => Math.max(0.55 + 0.22 * nPalavras(t), 0.3 * nPalavras(t));
38
38
 
39
39
  /** A fala desta parte (o que a voz diz): a "fala", ou os textos da tela. */
40
40
  /* A voz lê EXATAMENTE o que está na tela, na ordem da tela ("a narração tem
@@ -94,12 +94,23 @@ function quando(textos, palavras, dur, t0 = 0.25) {
94
94
  });
95
95
  }
96
96
 
97
- function montarCinema({ roteiro, largura = 1920, altura = 1080, tempos = [], narracao = null, sons = {}, trilha = null, fotos = [], fortes = {} }) {
97
+ function montarCinema({ roteiro, largura = 1920, altura = 1080, tempos = [], narracao = null, sons = {}, trilha = null, fotos = [], fortes = {}, ritmoLeitura = 1 }) {
98
+ /* O tempo de ler cada texto, escalado: o cinema puro dá o tempo inteiro
99
+ (1); o híbrido, que tem b-roll trocando por baixo e texto curto, dá
100
+ pouco mais da metade — senão a cena espera muito depois da voz. */
101
+ const leitura = (t) => leituraCheia(t) * ritmoLeitura;
98
102
  const partes = (roteiro && roteiro.partes) || [];
99
103
  if (!partes.length) return { ok: false, error: 'o roteiro veio sem partes.' };
100
104
  const marca = roteiro.marca || {};
101
- const grad = marca.gradiente && marca.gradiente.length >= 2 ? marca.gradiente : ['#2E81ED', '#9567DB'];
102
- const PRETO = (marca.cores && marca.cores.fundo) || '#07070A';
105
+ /* O destaque é o da MARCA: sem gradiente declarado, a cor de destaque dela
106
+ vira o gradiente (antes caía no roxo da Paris em toda marca). */
107
+ const corD = marca.cores && /^#[0-9a-f]{6}$/i.test(marca.cores.destaque || '') ? marca.cores.destaque : null;
108
+ const grad = marca.gradiente && marca.gradiente.length >= 2 ? marca.gradiente : corD ? [corD, corD] : ['#2E81ED', '#9567DB'];
109
+ /* O cinema é ESCURO: o texto é branco. Marca de fundo claro (a Anthropic é
110
+ branca) deixava o texto invisível — o fundo dela só vale se for escuro. */
111
+ const luz = (h) => { const n = parseInt(String(h).slice(1), 16); return ((n >> 16 & 255) * 0.299 + (n >> 8 & 255) * 0.587 + (n & 255) * 0.114) / 255; };
112
+ const fundoMarca = marca.cores && /^#[0-9a-f]{6}$/i.test(marca.cores.fundo || '') ? marca.cores.fundo : null;
113
+ const PRETO = fundoMarca && luz(fundoMarca) < 0.25 ? fundoMarca : '#07070A';
103
114
  const BRANCO = '#F5F5F7';
104
115
  const DEST = grad[1];
105
116
  const F = { titulo: (marca.fontes && marca.fontes.titulo) || 'Inter', texto: (marca.fontes && marca.fontes.texto) || 'Inter' };
@@ -279,12 +290,19 @@ function montarCinema({ roteiro, largura = 1920, altura = 1080, tempos = [], nar
279
290
  vol(t, 0.3);
280
291
  for (let a = 0; a < dur; a += 1.2) pFoto(els, foto(), a, Math.min(dur, a + 1.2), { veu: 0.45 });
281
292
  textosDaParte.forEach((x, k) => {
282
- const m = String(x).match(/^(\d+)\s+(.*)$/);
293
+ /* O valor pode vir com prefixo e unidade: "até 30% mais barato", "+120
294
+ marcas", "2,5x mais rápido". O contador sobe só a parte numérica e
295
+ mantém o resto em volta (contar.antes / contar.depois). */
296
+ const mv = String(x).match(/^((?:at[ée]|mais de|\+|-)?\s*)(\d+(?:[.,]\d+)?)(\s?(?:%|x|mil|mi|bi|k|h|s|min)?)\s+(.*)$/i);
297
+ const m = mv ? [x, mv[2], mv[4]] : null;
283
298
  if (k === 0 && p.antes) { pTexto(els, x, inicios[k], fimDe(k), { max: base * 0.08, peso: 700, pal: palDo(x) }); return; }
284
299
  if (!m) { pTexto(els, x, inicios[k], fimDe(k), { pal: palDo(x) }); return; }
285
- const tam = Math.round(deitado ? H * 0.24 : W * 0.3);
286
- els.push({ tipo: 'texto', texto: m[1], fonte: F.titulo, peso: 800, tamanho: tam, cor: BRANCO, x: M, y: Math.round(H * 0.5 - tam * 0.75), largura: W0, alinhamento: 'center',
287
- de: inicios[k], ate: fimDe(k), contar: { ate: Number(m[1]), dur: 0.45 }, gradienteTexto: undefined,
300
+ // O número inteiro numa linha: "ATÉ 30%" em pé quebrava em duas, por cima do rótulo.
301
+ const larguraNum = String(mv[1] + m[1] + mv[3]).length * 0.8; // serifa larga: 0,62 ainda quebrava
302
+ const tam = Math.round(Math.min(deitado ? H * 0.24 : W * 0.3, (W0 * 0.96) / Math.max(1, larguraNum)));
303
+ const casas = /[.,]/.test(m[1]) ? 1 : 0;
304
+ els.push({ tipo: 'texto', texto: mv[1] + m[1] + mv[3], fonte: F.titulo, peso: 800, tamanho: tam, cor: BRANCO, x: M, y: Math.round(H * 0.5 - tam * 0.75), largura: W0, alinhamento: 'center',
305
+ de: inicios[k], ate: fimDe(k), contar: { ate: Number(m[1].replace(',', '.')), dur: 0.6, casas, antes: mv[1].toUpperCase(), depois: mv[3] }, gradienteTexto: undefined,
288
306
  anima: { opacidade: [{ t: 0, v: 0 }, { t: 0.25, v: 1 }], escala: [{ t: 0, v: 0.92 }, { t: 0.6, v: 1, e: 'saida' }] } });
289
307
  pTexto(els, m[2], inicios[k] + 0.1, fimDe(k), { y: H * 0.5 + tam * 0.3, max: base * 0.085, peso: 700, cor: DEST });
290
308
  });
@@ -398,4 +416,4 @@ function montarCinema({ roteiro, largura = 1920, altura = 1080, tempos = [], nar
398
416
  };
399
417
  }
400
418
 
401
- module.exports = { montarCinema, falaDe, paraFala, quando, leitura };
419
+ module.exports = { montarCinema, falaDe, paraFala, quando, leitura: leituraCheia };
@@ -66,8 +66,20 @@ function precisaDeProducao(texto) {
66
66
  editor por linha do tempo (`primocode video`, lib/video/), não o roteiro. */
67
67
  const PEDE_EDICAO = /\b(edit[ae]r?|edi[cç][aã]o|cort[ae]r?|legend[ae]r?|tir[ae]r?\s+(o|os)\s+sil[eê]ncios?)\b[\s\S]{0,80}\b(grava[cç][aã]o|meu\s+v[ií]deo|esse\s+v[ií]deo|este\s+v[ií]deo|[\w-]+\.(mp4|mov|mkv|webm|m4v))\b|\b[\w-]+\.(mp4|mov|mkv|webm|m4v)\b[\s\S]{0,80}\b(edit|cort|legend|sil[eê]nci)/i;
68
68
 
69
+ /* Os pedidos de EFEITO sobre a gravação (lib/video/ops-fx.js): fundo, camadas,
70
+ estalo, objeto na mão, legenda na cabeça. Falam da pessoa ("me", "minha
71
+ mão"), não do arquivo — por isso não casavam com a regra de cima. */
72
+ const PEDE_EFEITO = /\b(tir[ae]r?|remov\w*|troc\w*)\s+(o\s+)?fundo\b|\bsepar\w*\s+em\s+camadas\b|\bestal\w*\s+(o\s+)?dedo\b|\b(logo|objeto)\b[\s\S]{0,40}\bm[aã]os?\b|\bjunt\w*\s+as\s+m[aã]os\b|\blegenda\b[\s\S]{0,40}\b(azul|vermelh|verde|amarel|rosa|roxa|branc|cabe[cç]a|em cima|embaixo)/i;
73
+
74
+ /* "edite: /Users/…/Fototeca do Photo Booth/…/Filme em 30-09-2026 às 23.56.mov": o caminho
75
+ do Photo Booth tem mais de 80 caracteres, e o pedido ia para o Estúdio de
76
+ motion — que tratava a gravação como b-roll (01/10). Verbo de editar + um
77
+ arquivo de vídeo em qualquer lugar da frase é edição. */
78
+ const EDITA_ARQUIVO = /\b(edit\w*|edi[cç][aã]o|p[oõ]e\s+(ele\s+)?pra\s+editar)\b[\s\S]*\.(mp4|mov|mkv|webm|m4v)\b|\.(mp4|mov|mkv|webm|m4v)\b[\s\S]*\b(edit\w*|edi[cç][aã]o)\b/i;
79
+
69
80
  function precisaDeEdicao(texto) {
70
- return PEDE_EDICAO.test(String(texto || ''));
81
+ const t = String(texto || '');
82
+ return PEDE_EDICAO.test(t) || PEDE_EFEITO.test(t) || EDITA_ARQUIVO.test(t);
71
83
  }
72
84
 
73
85
  // ── 2. as decisões que não são do modelo ─────────────────────────────────
@@ -60,13 +60,18 @@ function lerLista(html) {
60
60
  }).filter((f) => f.titulo && f.segundos > 0);
61
61
  }
62
62
 
63
- /** A faixa que cobre o vídeo e mais combina com o clima. Nenhuma cobre: null. */
64
- function escolher(faixas, { clima = 'neutro', segundos = 0 } = {}) {
63
+ /** A faixa que cobre o vídeo e mais combina com o clima. Nenhuma cobre: null.
64
+ * Com `semente`, sorteia entre as 5 melhores que não estão em `evitar` e não
65
+ * têm voz nas tags — "não repetir a mesma faixa em todo vídeo" (30/09). */
66
+ function escolher(faixas, { clima = 'neutro', segundos = 0, semente = null, evitar = [] } = {}) {
65
67
  const palavras = CLIMAS[clima] || CLIMAS.neutro;
66
68
  const nota = (f) => f.tags.reduce((n, t) => n + palavras.filter((p) => t.includes(p)).length, 0);
67
69
  const cabem = (faixas || []).filter((f) => f.segundos >= segundos);
68
70
  if (!cabem.length) return null;
69
- return cabem.reduce((a, b) => (nota(b) > nota(a) ? b : a));
71
+ if (semente == null) return cabem.reduce((a, b) => (nota(b) > nota(a) ? b : a));
72
+ const semVoz = cabem.filter((f) => !f.tags.some((t) => /vocal|singing|voice|rap\b|song/.test(t)) && !evitar.includes(f.id));
73
+ const pool = (semVoz.length ? semVoz : cabem).slice().sort((a, b) => nota(b) - nota(a)).slice(0, 5);
74
+ return pool[Math.abs(Number(semente) | 0) % pool.length];
70
75
  }
71
76
 
72
77
  async function buscarPadrao(url) {
@@ -93,8 +98,14 @@ function provedor({ buscar = buscarPadrao, baixar, pasta = 'midia' } = {}) {
93
98
  const salvar = baixar || baixarEm(pasta);
94
99
  return {
95
100
  nome: 'ende', tipo: 'musica',
96
- async gerar({ clima, segundos = 0 } = {}) {
97
- const faixa = escolher(lerLista(await buscar(LISTA)), { clima, segundos });
101
+ async gerar({ clima, segundos = 0, genero = null, semente = null, evitar = [] } = {}) {
102
+ // O GÊNERO (ende.app/en/genres/<genero>): jazz, lofi, funk, ambient… — escolhido pelo assunto.
103
+ const url = genero ? 'https://ende.app/en/genres/' + encodeURIComponent(genero) : LISTA;
104
+ let faixas = [];
105
+ // A página do gênero falhou: a busca geral (que, sem rede, lança — e a corrente cai no próximo provedor).
106
+ if (genero) { try { faixas = lerLista(await buscar(url)); } catch { faixas = []; } }
107
+ if (!faixas.length) faixas = lerLista(await buscar(LISTA));
108
+ const faixa = escolher(faixas, { clima, segundos, semente, evitar });
98
109
  if (!faixa) return null;
99
110
  const meta = {
100
111
  fonte: faixa.pagina, titulo: faixa.titulo, autor: 'Sascha Ende',
@@ -1,12 +1,18 @@
1
1
  /**
2
2
  * estilo.js — qual estilo de vídeo um roteiro vira.
3
3
  *
4
- * motion o padrão: um plano contínuo em 3D, o protagonista que se deforma (motion.js)
4
+ * gerativo O MOTION: sem template, a tipografia é o filme, numa linha do tempo
5
+ * contínua sorteada do roteiro (gerativo.js). É o que "motion" e
6
+ * "animação" pedem, e o que o modo "sem referências" usa.
7
+ * motion o motion antigo: o protagonista 3D que se deforma (motion.js),
8
+ * só pelo nome (classico, limpo).
5
9
  * vox explainer editorial: tinta sobre papel, os oito dispositivos VOX (vox.js)
6
10
  * marca brand reveal: o brilho, o vetor, a marca cromada, o padrão (lancamento.js)
7
11
  * lancamento filme de lançamento de produto: cartões, painel, calendário, mural (lancamento.js)
8
12
  * impacto anúncio: corte a cada batida (impacto.js)
9
13
  * cinema filme de marca premium com fotos reais (cinema.js)
14
+ * hibrido O PADRÃO do vídeo: o cinema com o kit por cima — foto e vídeo reais
15
+ * que não param, e o motion por cima (hibrido.js)
10
16
  * composicao a composição antiga, uma cena por parte (composicao.js)
11
17
  *
12
18
  * Quem manda, em ordem: o estilo que o AGENTE pediu; o do roteiro (a menos
@@ -15,9 +21,14 @@
15
21
 
16
22
  'use strict';
17
23
 
18
- const ESTILOS = ['motion', 'vox', 'marca', 'lancamento', 'impacto', 'cinema', 'composicao'];
24
+ const ESTILOS = ['hibrido', 'gerativo', 'motion', 'vox', 'marca', 'lancamento', 'impacto', 'cinema', 'composicao'];
19
25
  // Nomes que o modelo usa para os mesmos estilos.
20
- const APELIDOS = { limpo: 'motion', explainer: 'vox', editorial: 'vox', brand: 'marca', branding: 'marca', 'brand-reveal': 'marca', launch: 'lancamento', 'lançamento': 'lancamento', produto: 'lancamento' };
26
+ /* "motion" agora é o GERATIVO ("não pode ter template", 29–30/09); o motion
27
+ antigo segue pelo nome "classico" (e "limpo", o nome mais antigo dele). */
28
+ const APELIDOS = { motion: 'gerativo', generativo: 'gerativo', kinetic: 'gerativo', cinetico: 'gerativo', 'cinético': 'gerativo',
29
+ tipografico: 'gerativo', 'tipográfico': 'gerativo', animacao: 'gerativo', 'animação': 'gerativo',
30
+ classico: 'motion', 'clássico': 'motion', 'motion-classico': 'motion', motion3d: 'motion',
31
+ absurdo: 'hibrido', misto: 'hibrido', mix: 'hibrido', 'híbrido': 'hibrido', limpo: 'motion', explainer: 'vox', editorial: 'vox', brand: 'marca', branding: 'marca', 'brand-reveal': 'marca', launch: 'lancamento', 'lançamento': 'lancamento', produto: 'lancamento' };
21
32
 
22
33
  const PEDE_MOTION = /\bmotion\b|anima[çc][ãa]o|animad[oa]/i;
23
34
  const PEDE_VOX = /\bvox\b|explica|explainer|document[aá]rio|editorial|hist[oó]ria d[aoe]s?\b/i;
@@ -36,16 +47,20 @@ function escolherEstilo({ pedido = '', agente = null, roteiro = null } = {}) {
36
47
  const pedidoAgente = normal(agente);
37
48
  if (pedidoAgente) return pedidoAgente;
38
49
  const doRoteiro = normal(roteiro);
39
- if (doRoteiro && !(doRoteiro !== 'motion' && PEDE_MOTION.test(pedido) && !PEDE_VOX.test(pedido))) return doRoteiro;
50
+ /* Pedir MOTION (ou animação) é pedir o gerativo — antes do vox, da marca e
51
+ do lançamento, que são leiaute fixo ("não pode ter template", 30/09). */
52
+ if (doRoteiro && !(doRoteiro !== 'gerativo' && PEDE_MOTION.test(pedido))) return doRoteiro;
53
+ if (PEDE_MOTION.test(pedido)) return 'gerativo';
40
54
  if (PEDE_VOX.test(pedido)) return 'vox';
41
55
  if (PEDE_MARCA.test(pedido)) return 'marca';
42
56
  if (PEDE_LANCAMENTO.test(pedido)) return 'lancamento';
43
- if (PEDE_MOTION.test(pedido)) return 'motion';
44
- if (PEDE_IMPACTO.test(pedido)) return 'impacto';
45
- return 'motion';
57
+ /* Anúncio, reels e o resto: o HÍBRIDO. "Um mix da versão antiga (o reels
58
+ da imersão, no cinema) e da última (o kit) — absurdo, que nem parece
59
+ que foi IA" (29/09). O impacto segue existindo, se pedido pelo nome. */
60
+ return 'hibrido';
46
61
  }
47
62
 
48
63
  /** Os estilos que usam os efeitos leves e a trilha do ende.app (os de motion, não os de corte). */
49
- const LEVES = new Set(['motion', 'vox', 'marca', 'lancamento']);
64
+ const LEVES = new Set(['gerativo', 'motion', 'vox', 'marca', 'lancamento']);
50
65
 
51
66
  module.exports = { escolherEstilo, ESTILOS, LEVES, PEDE_MOTION };