primocode 10.0.0 → 10.1.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 (73) hide show
  1. package/README.md +4 -4
  2. package/bin/primocode.js +88 -3
  3. package/lib/act.js +122 -20
  4. package/lib/browser.js +89 -5
  5. package/lib/catalogo.js +6 -3
  6. package/lib/claude-engine.js +38 -17
  7. package/lib/codex-engine.js +27 -9
  8. package/lib/config.js +10 -2
  9. package/lib/entrada.js +73 -9
  10. package/lib/estudio/assets.js +12 -6
  11. package/lib/estudio/biblioteca.js +174 -0
  12. package/lib/estudio/cinema.js +63 -25
  13. package/lib/estudio/composicao.js +2 -1
  14. package/lib/estudio/diretor.js +13 -1
  15. package/lib/estudio/edicao.js +2 -1
  16. package/lib/estudio/ende.js +16 -5
  17. package/lib/estudio/estilo.js +41 -10
  18. package/lib/estudio/fluido.js +283 -0
  19. package/lib/estudio/gerativo.js +1471 -0
  20. package/lib/estudio/hibrido.js +169 -0
  21. package/lib/estudio/impacto.js +66 -31
  22. package/lib/estudio/index.js +366 -47
  23. package/lib/estudio/kit/fluido.jsx +493 -0
  24. package/lib/estudio/kit/gerativo.jsx +990 -0
  25. package/lib/estudio/kit/index.jsx +4 -1
  26. package/lib/estudio/kit/tres.jsx +375 -0
  27. package/lib/estudio/kit.js +9 -1
  28. package/lib/estudio/kokoro.js +111 -0
  29. package/lib/estudio/lancamento.js +27 -0
  30. package/lib/estudio/legibilidade.js +20 -0
  31. package/lib/estudio/referencias.js +105 -0
  32. package/lib/estudio/remotion.js +103 -14
  33. package/lib/estudio/ritmo.js +2 -2
  34. package/lib/estudio/sons-leves.js +38 -1
  35. package/lib/estudio/variacao3d.js +46 -0
  36. package/lib/estudio/variedade.js +183 -0
  37. package/lib/estudio/vox.js +37 -8
  38. package/lib/mcp.js +156 -0
  39. package/lib/personas.js +67 -0
  40. package/lib/portao-skill.js +150 -0
  41. package/lib/skills.js +51 -1
  42. package/lib/tools.js +53 -8
  43. package/lib/ui.js +41 -6
  44. package/lib/video/auto.js +329 -0
  45. package/lib/video/camadas.js +207 -0
  46. package/lib/video/cli.js +1 -1
  47. package/lib/video/filters.js +39 -6
  48. package/lib/video/fx3d/Camadas.jsx +64 -0
  49. package/lib/video/fx3d/Objetos.jsx +128 -0
  50. package/lib/video/fx3d/Root.jsx +18 -0
  51. package/lib/video/fx3d/modelos.jsx +304 -0
  52. package/lib/video/fx3d.js +108 -0
  53. package/lib/video/gerados.js +119 -0
  54. package/lib/video/objetos.js +111 -0
  55. package/lib/video/ops-fx.js +453 -0
  56. package/lib/video/ops.js +30 -5
  57. package/lib/video/paralelo.js +50 -0
  58. package/lib/video/pedido.js +191 -0
  59. package/lib/video/rastrear.js +95 -0
  60. package/lib/video/rastro.js +276 -0
  61. package/lib/video/render.js +67 -14
  62. package/lib/video/texto.js +6 -4
  63. package/lib/video/timeline.js +9 -1
  64. package/package.json +2 -2
  65. package/skills/escrever-em-portugues/SKILL.md +3 -0
  66. package/skills/interface-revisada/SKILL.md +7 -1
  67. package/skills/motion-de-primeira/SKILL.md +5 -4
  68. package/skills/projeto-no-github/SKILL.md +6 -0
  69. package/skills/publicar-na-internet/SKILL.md +3 -0
  70. package/skills/site-com-motion/SKILL.md +2 -0
  71. package/studio/narrar.py +10 -7
  72. package/studio/rastrear.py +399 -0
  73. package/studio/voz_kokoro.py +98 -0
@@ -27,6 +27,7 @@
27
27
  'use strict';
28
28
 
29
29
  const { caber } = require('./composicao.js');
30
+ const { minimoLeitura, sobra } = require('./legibilidade.js');
30
31
 
31
32
  const r2 = (n) => Math.round(n * 100) / 100;
32
33
  const tokens = (t) => String(t || '').normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
@@ -34,7 +35,8 @@ const tokens = (t) => String(t || '').normalize('NFD').replace(/[̀-ͯ]/g, '').t
34
35
  /** Quanto tempo um texto precisa ficar na tela para ser lido. Nunca menos que
35
36
  0,3 s por palavra: a regra de legibilidade do /brag (Arthur, 28/09). */
36
37
  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));
38
+ const SAIDA_MIN = 0.1, ENTRA_NUM = 0.4; // a saída mais curta; o número contando antes de ser lido
39
+ const leituraCheia = (t) => Math.max(0.55 + 0.22 * nPalavras(t), 0.3 * nPalavras(t));
38
40
 
39
41
  /** A fala desta parte (o que a voz diz): a "fala", ou os textos da tela. */
40
42
  /* A voz lê EXATAMENTE o que está na tela, na ordem da tela ("a narração tem
@@ -94,15 +96,26 @@ function quando(textos, palavras, dur, t0 = 0.25) {
94
96
  });
95
97
  }
96
98
 
97
- function montarCinema({ roteiro, largura = 1920, altura = 1080, tempos = [], narracao = null, sons = {}, trilha = null, fotos = [], fortes = {} }) {
99
+ function montarCinema({ roteiro, largura = 1920, altura = 1080, tempos = [], narracao = null, sons = {}, trilha = null, fotos = [], fortes = {}, ritmoLeitura = 1 }) {
100
+ /* O tempo de ler cada texto, escalado: o cinema puro dá o tempo inteiro
101
+ (1); o híbrido, que tem b-roll trocando por baixo e texto curto, dá
102
+ pouco mais da metade — senão a cena espera muito depois da voz. */
103
+ const leitura = (t) => leituraCheia(t) * ritmoLeitura;
98
104
  const partes = (roteiro && roteiro.partes) || [];
99
105
  if (!partes.length) return { ok: false, error: 'o roteiro veio sem partes.' };
100
106
  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';
107
+ /* O destaque é o da MARCA: sem gradiente declarado, a cor de destaque dela
108
+ vira o gradiente (antes caía no roxo da Paris em toda marca). */
109
+ const corD = marca.cores && /^#[0-9a-f]{6}$/i.test(marca.cores.destaque || '') ? marca.cores.destaque : null;
110
+ const grad = marca.gradiente && marca.gradiente.length >= 2 ? marca.gradiente : corD ? [corD, corD] : ['#2E81ED', '#9567DB'];
111
+ /* O cinema é ESCURO: o texto é branco. Marca de fundo claro (a Anthropic é
112
+ branca) deixava o texto invisível — o fundo dela só vale se for escuro. */
113
+ 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; };
114
+ const fundoMarca = marca.cores && /^#[0-9a-f]{6}$/i.test(marca.cores.fundo || '') ? marca.cores.fundo : null;
115
+ const PRETO = fundoMarca && luz(fundoMarca) < 0.25 ? fundoMarca : '#07070A';
103
116
  const BRANCO = '#F5F5F7';
104
117
  const DEST = grad[1];
105
- const F = { titulo: (marca.fontes && marca.fontes.titulo) || 'Inter', texto: (marca.fontes && marca.fontes.texto) || 'Inter' };
118
+ const F = { titulo: (marca.fontes && marca.fontes.titulo) || 'Inter', texto: (marca.fontes && marca.fontes.texto) || 'Inter', destaque: 'Instrument Serif' };
106
119
  const W = largura, H = altura, deitado = W >= H;
107
120
  const M = Math.round(Math.min(W, H) * 0.08), W0 = W - 2 * M;
108
121
  const base = Math.min(W, H);
@@ -169,26 +182,36 @@ function montarCinema({ roteiro, largura = 1920, altura = 1080, tempos = [], nar
169
182
  fundo: { tipo: 'gradiente', cores: [`rgba(7,7,10,${veu * 0.7})`, `rgba(7,7,10,${veu})`, `rgba(7,7,10,${Math.min(0.95, veu + 0.25)})`], angulo: 180 } });
170
183
  };
171
184
  // O texto: entra palavra por palavra no tempo da voz e fica; as palavras marcadas no gradiente.
172
- const pTexto = (els, texto, de, ate, { y = null, max = deitado ? H * 0.11 : W * 0.13, linhas = 3, peso = 700, caixa = true, marcar = [], cor = BRANCO, pal = [], alinhar = 'center', x = M, larg = W0, bater = false } = {}) => {
185
+ /* SEM CARA DE IA (05/10): o texto sai como a pessoa escreveu (nada de caixa
186
+ alta forçada), um pouco menor e com mais respiro — e a palavra marcada é
187
+ UM acento, na serifa em itálico. Caixa alta só com "caixa: true". */
188
+ const pTexto = (els, texto, de, ate, { y = null, max = deitado ? H * 0.11 : W * 0.13, linhas = 3, peso = 700, caixa = false, marcar = [], cor = BRANCO, pal = [], alinhar = 'center', x = M, larg = W0, bater = false } = {}) => {
189
+ max *= 0.85; peso = Math.min(peso, 700);
173
190
  // Frase longa (a narração na tela) vai em letra normal: em caixa alta ela encolhe e cansa.
174
- if (caixa && String(texto).split(/\s+/).length > 7) { caixa = false; peso = Math.min(peso, 700); }
191
+ if (caixa && String(texto).split(/\s+/).length > 7) caixa = false;
175
192
  // Frase longa ganha linhas (no vertical, até 5), para a letra não encolher.
176
193
  if (String(texto).split(/\s+/).length > 7) linhas = Math.max(linhas, deitado ? 4 : 5);
177
194
  const txt = caixa ? String(texto).toUpperCase() : String(texto);
178
- const fit = caber(txt, { largura: larg * 0.86, linhas, max, fonte: F.titulo });
179
- const h = fit.tamanho * 1.08 * fit.linhas;
195
+ const fit = caber(txt, { largura: larg * 0.78, linhas, max, fonte: F.titulo });
196
+ const h = fit.tamanho * 1.14 * fit.linhas;
180
197
  const ws = txt.split(/\s+/);
181
198
  /* Centralizado, a linha entra inteira: palavra por palavra, as que ainda
182
199
  não foram ditas ocupam espaço invisível e o começo da frase aparece
183
200
  torto, puxado para a esquerda. Alinhado à esquerda, palavra por palavra. */
184
201
  const rev = ws.map((_, k) => (alinhar === 'center' ? 0 : r2(Math.max(0, (pal[k] != null ? pal[k] : de + 0.08 * k) - de))));
185
- const idx = ws.map((w, k) => (marcar.some((m) => tokens(w)[0] && tokens(m).includes(tokens(w)[0])) ? k : -1)).filter((k) => k >= 0);
186
- els.push({ tipo: 'texto', texto: txt, fonte: F.titulo, peso, tamanho: fit.tamanho, cor, entrelinha: 1.08, espacamento: caixa ? 1 : 0,
202
+ // Uma palavra só, e só em frase de mais de uma palavra (a palavra sozinha não tem com quem contrastar).
203
+ const idx = ws.length < 2 ? [] : ws.map((w, k) => (marcar.some((m) => tokens(w)[0] && tokens(m).includes(tokens(w)[0])) ? k : -1)).filter((k) => k >= 0).slice(0, 1);
204
+ /* Entra rápido, FICA parado o tempo de ler, e só então sai (a lei do
205
+ /brag). Cena curta encolhe a entrada e a saída, nunca a leitura. */
206
+ const vida = ate - de, ler = minimoLeitura(txt);
207
+ const ent = bater ? sobra(vida - SAIDA_MIN, ler, 0.08, 0.22) : 0, fe = ent / 0.22;
208
+ const saida = r2(sobra(vida - ent, ler, SAIDA_MIN, 0.25));
209
+ els.push({ tipo: 'texto', texto: txt, fonte: F.titulo, peso, tamanho: fit.tamanho, cor, entrelinha: 1.14, espacamento: caixa ? 1 : -0.5,
187
210
  x: Math.round(x), y: Math.round(y == null ? (H - h) / 2 : y), largura: Math.round(larg), alinhamento: alinhar, de: r2(de), ate: r2(ate),
188
- revelar: rev, ...(idx.length ? { destaques: idx, corDestaque: DEST } : {}),
189
- anima: { opacidade: [{ t: 0, v: 1 }, { t: Math.max(0.01, ate - de - 0.25), v: 1 }, { t: ate - de, v: 0 }], y: [{ t: 0, v: 10 }, { t: ate - de, v: -6, e: 'linear' }],
211
+ revelar: rev, ...(idx.length ? { destaques: idx, corDestaque: DEST, fonteDestaque: F.destaque } : {}),
212
+ anima: { opacidade: [{ t: 0, v: 1 }, { t: r2(Math.max(0.01, vida - saida)), v: 1 }, { t: r2(vida), v: 0 }], y: [{ t: 0, v: 10 }, { t: ate - de, v: -6, e: 'linear' }],
190
213
  // O texto que BATE no fundo: entra grande e assenta de uma vez.
191
- ...(bater ? { escala: [{ t: 0, v: 1.35 }, { t: 0.12, v: 0.97, e: 'saida' }, { t: 0.22, v: 1 }] } : {}) } });
214
+ ...(bater ? { escala: [{ t: 0, v: 1.35 }, { t: r2(0.12 * fe), v: 0.97, e: 'saida' }, { t: r2(ent), v: 1 }] } : {}) } });
192
215
  return h;
193
216
  };
194
217
  const linhaFina = (els, y, de, larg = W * 0.18) => els.push({ tipo: 'forma', forma: 'retangulo', x: Math.round((W - larg) / 2), y: Math.round(y), largura: Math.round(larg), altura: 2,
@@ -231,9 +254,14 @@ function montarCinema({ roteiro, largura = 1920, altura = 1080, tempos = [], nar
231
254
  com a voz é bom, ler é obrigatório. Se a leitura pede mais que a fala,
232
255
  a cena cresce (medido: "EDIÇÕES" ficava meio segundo na tela). */
233
256
  // Texto curto (um número, "3 DIAS.") se lê de relance: não espera a leitura cheia e não atrasa a voz.
234
- const minimo = (x) => Math.max(0.3 * nPalavras(x), Math.min(leitura(x), nPalavras(x) <= 3 ? 0.75 : 1.6));
235
- for (let k = 1; k < inicios.length; k++) inicios[k] = r2(Math.max(inicios[k], inicios[k - 1] + minimo(textosDaParte[k - 1])));
236
- if (inicios.length) dur = r2(Math.max(dur, inicios[inicios.length - 1] + leitura(textosDaParte[inicios.length - 1]) + 0.3));
257
+ /* E nunca menos que a lei do /brag (0,8 s parado) mais a saída mínima; o
258
+ número ainda conta antes de ser lido (ENTRA_NUM). */
259
+ const minimo = (x) => Math.max(minimoLeitura(x) + SAIDA_MIN, Math.min(leitura(x), nPalavras(x) <= 3 ? 0.75 : 1.6)) + (p.tipo === 'numeros' ? ENTRA_NUM : 0);
260
+ const espacar = (passo = 0) => {
261
+ for (let k = 1; k < inicios.length; k++) inicios[k] = r2(Math.max(inicios[k], inicios[k - 1] + Math.max(passo, minimo(textosDaParte[k - 1]))));
262
+ if (inicios.length) dur = r2(Math.max(dur, inicios[inicios.length - 1] + Math.max(leitura(textosDaParte[inicios.length - 1]), minimo(textosDaParte[inicios.length - 1])) + 0.3));
263
+ };
264
+ espacar();
237
265
  const fimDe = (k) => (k < inicios.length - 1 ? inicios[k + 1] : dur);
238
266
  const palDo = (txt) => { const ws = String(txt).split(/\s+/); const a = pals.findIndex((w) => tokens(w.texto)[0] === tokens(ws[0])[0]); return a < 0 ? [] : ws.map((_, k) => (pals[a + k] ? rel(pals[a + k].de) : null)); };
239
267
 
@@ -245,11 +273,13 @@ function montarCinema({ roteiro, largura = 1920, altura = 1080, tempos = [], nar
245
273
  const rap = p.tipo === 'climax' ? 0.38 : (p.ritmo || 0.75);
246
274
  let fimFotos = p.tipo === 'climax' && textosDaParte.length ? Math.max(1.8, inicios[0] - 0.4) : dur;
247
275
  const fz = p.tipo === 'climax' ? fortes[i] : null;
276
+ // A virada começa quando as fotos acabam: o texto não perde a leitura para a montagem.
277
+ if (p.tipo === 'climax' && !fz && inicios.length) { inicios[0] = r2(Math.max(inicios[0], fimFotos)); espacar(1.2); }
248
278
  if (fz) {
249
279
  /* A voz forte entra DEPOIS da montagem, junto com o texto que bate. */
250
280
  fimFotos = 1.4;
251
281
  const ini2 = quando(textosDaParte, (fz.palavras || []).map((w) => ({ ...w, de: w.de + fimFotos })), fz.segundos, fimFotos);
252
- ini2.forEach((v0, k) => { inicios[k] = k === 0 ? fimFotos : Math.max(v0, inicios[k - 1] + 1.2); });
282
+ ini2.forEach((v0, k) => { inicios[k] = k === 0 ? fimFotos : Math.max(v0, inicios[k - 1] + Math.max(1.2, minimo(textosDaParte[k - 1]))); });
253
283
  dur = r2(Math.max(fimFotos + fz.segundos + 0.5, inicios[inicios.length - 1] + 1.6));
254
284
  const tr = trechos[trechos.length - 1];
255
285
  if (tr && tr.src === fz.src) tr.t = r2(t + fimFotos - 0.05);
@@ -279,13 +309,21 @@ function montarCinema({ roteiro, largura = 1920, altura = 1080, tempos = [], nar
279
309
  vol(t, 0.3);
280
310
  for (let a = 0; a < dur; a += 1.2) pFoto(els, foto(), a, Math.min(dur, a + 1.2), { veu: 0.45 });
281
311
  textosDaParte.forEach((x, k) => {
282
- const m = String(x).match(/^(\d+)\s+(.*)$/);
312
+ /* O valor pode vir com prefixo e unidade: "até 30% mais barato", "+120
313
+ marcas", "2,5x mais rápido". O contador sobe só a parte numérica e
314
+ mantém o resto em volta (contar.antes / contar.depois). */
315
+ const mv = String(x).match(/^((?:at[ée]|mais de|\+|-)?\s*)(\d+(?:[.,]\d+)?)(\s?(?:%|x|mil|mi|bi|k|h|s|min)?)\s+(.*)$/i);
316
+ const m = mv ? [x, mv[2], mv[4]] : null;
283
317
  if (k === 0 && p.antes) { pTexto(els, x, inicios[k], fimDe(k), { max: base * 0.08, peso: 700, pal: palDo(x) }); return; }
284
318
  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,
288
- anima: { opacidade: [{ t: 0, v: 0 }, { t: 0.25, v: 1 }], escala: [{ t: 0, v: 0.92 }, { t: 0.6, v: 1, e: 'saida' }] } });
319
+ // O número inteiro numa linha: "ATÉ 30%" em pé quebrava em duas, por cima do rótulo.
320
+ const larguraNum = String(mv[1] + m[1] + mv[3]).length * 0.8; // serifa larga: 0,62 ainda quebrava
321
+ const tam = Math.round(Math.min(deitado ? H * 0.24 : W * 0.3, (W0 * 0.96) / Math.max(1, larguraNum)));
322
+ const casas = /[.,]/.test(m[1]) ? 1 : 0;
323
+ const entN = r2(sobra(fimDe(k) - inicios[k], minimoLeitura(m[1]), 0.3, 0.6));
324
+ 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',
325
+ de: inicios[k], ate: fimDe(k), contar: { ate: Number(m[1].replace(',', '.')), dur: entN, casas, antes: mv[1].toUpperCase(), depois: mv[3] }, gradienteTexto: undefined,
326
+ anima: { opacidade: [{ t: 0, v: 0 }, { t: Math.min(0.25, entN), v: 1 }], escala: [{ t: 0, v: 0.92 }, { t: entN, v: 1, e: 'saida' }] } });
289
327
  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
328
  });
291
329
  } else if (p.tipo === 'escada') {
@@ -312,7 +350,7 @@ function montarCinema({ roteiro, largura = 1920, altura = 1080, tempos = [], nar
312
350
  pFoto(els, foto(), 0, dur, { veu: 0.66, entrada: 'suave' });
313
351
  som(t, 'whoosh', 0.3);
314
352
  const tamN = Math.round(deitado ? H * 0.34 : W * 0.42);
315
- els.push({ tipo: 'texto', texto: String(p.num), fonte: F.titulo, peso: 800, tamanho: tamN, cor: 'rgba(149,103,219,0.12)', contorno: `${Math.max(3, Math.round(base * 0.004))}px ${DEST}`,
353
+ els.push({ tipo: 'texto', texto: String(p.num), fonte: F.titulo, peso: 800, tamanho: tamN, cor: `${DEST}1F`, contorno: `${Math.max(3, Math.round(base * 0.004))}px ${DEST}`,
316
354
  x: M, y: Math.round(deitado ? H * 0.12 : H * 0.14), largura: W0, alinhamento: deitado ? 'left' : 'center',
317
355
  anima: { opacidade: [{ t: 0, v: 0 }, { t: 0.6, v: 1 }], x: [{ t: 0, v: -20 }, { t: dur, v: 20, e: 'linear' }] } });
318
356
  const yT = deitado ? H * 0.5 : H * 0.42;
@@ -398,4 +436,4 @@ function montarCinema({ roteiro, largura = 1920, altura = 1080, tempos = [], nar
398
436
  };
399
437
  }
400
438
 
401
- module.exports = { montarCinema, falaDe, paraFala, quando, leitura };
439
+ module.exports = { montarCinema, falaDe, paraFala, quando, leitura: leituraCheia };
@@ -71,7 +71,8 @@ const FAMILIA_TIPO = (f) => (/condensed|narrow|anton|oswald|bebas|teko|league go
71
71
  : /black|bricolage|archivo|sora|grotesk|syne|unbounded/i.test(f) ? 'display' : 'texto');
72
72
 
73
73
  function caber(texto, { largura, linhas = 2, max = 200, min = 18, fonte = 'Inter' }) {
74
- const cw = LARGURA[FAMILIA_TIPO(fonte)];
74
+ // A fonte da biblioteca sabe a própria largura; a de fora, pelo nome.
75
+ const cw = require('./biblioteca.js').larguraDe(fonte) || LARGURA[FAMILIA_TIPO(fonte)];
75
76
  const palavras = String(texto || '').split(/\s+/).filter(Boolean);
76
77
  for (let t = Math.round(max); t >= min; t -= 2) {
77
78
  // Quebra de verdade, palavra por palavra: é o que o navegador fará.
@@ -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 ─────────────────────────────────
@@ -389,7 +389,8 @@ function montarEdicao({ src, duracao, palavras, largura = 1080, altura = 1920, e
389
389
  tipo: 'legenda', x: '50%', y: deitado ? '76%' : '68%', ancora: 'centro', fala,
390
390
  estilo: {
391
391
  fonte: 'Montserrat', peso: 900, tamanho: Math.round(base * (deitado ? 0.078 : 0.072)),
392
- destaque: e.destaque, corForte: e.forte, caixa: 'uppercase', largura: '92%',
392
+ // Caixa normal (o que a pessoa disse, como disse) e a palavra forte na serifa em itálico.
393
+ destaque: e.destaque, corForte: e.forte, fonteForte: 'Instrument Serif', largura: '92%',
393
394
  },
394
395
  });
395
396
 
@@ -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,13 +1,21 @@
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)
17
+ * fluido minimalista: uma ideia por batida, a forma de uma vira a da próxima,
18
+ * sem corte (fluido.js). É o que "fluido", "minimalista", "clean" e "apple" pedem.
11
19
  *
12
20
  * Quem manda, em ordem: o estilo que o AGENTE pediu; o do roteiro (a menos
13
21
  * que o pedido diga motion); o que o pedido descreve; e o motion.
@@ -15,10 +23,18 @@
15
23
 
16
24
  'use strict';
17
25
 
18
- const ESTILOS = ['motion', 'vox', 'marca', 'lancamento', 'impacto', 'cinema', 'composicao'];
26
+ const variedade = require('./variedade.js');
27
+
28
+ const ESTILOS = ['hibrido', 'gerativo', 'motion', 'vox', 'marca', 'lancamento', 'impacto', 'cinema', 'composicao', 'fluido'];
19
29
  // 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' };
30
+ /* "motion" agora é o GERATIVO ("não pode ter template", 29–30/09); o motion
31
+ antigo segue pelo nome "classico" (e "limpo", o nome mais antigo dele). */
32
+ const APELIDOS = { motion: 'gerativo', generativo: 'gerativo', kinetic: 'gerativo', cinetico: 'gerativo', 'cinético': 'gerativo',
33
+ tipografico: 'gerativo', 'tipográfico': 'gerativo', animacao: 'gerativo', 'animação': 'gerativo',
34
+ classico: 'motion', 'clássico': 'motion', 'motion-classico': 'motion', motion3d: 'motion',
35
+ absurdo: 'hibrido', misto: 'hibrido', mix: 'hibrido', 'híbrido': 'hibrido', limpo: 'motion', explainer: 'vox', editorial: 'vox', brand: 'marca', branding: 'marca', 'brand-reveal': 'marca', minimalista: 'fluido', minimal: 'fluido', clean: 'fluido', apple: 'fluido', launch: 'lancamento', 'lançamento': 'lancamento', produto: 'lancamento' };
21
36
 
37
+ const PEDE_FLUIDO = /\bfluid[oa]\b|minimalista|\bminimal\b|\bclean\b|\bapple\b/i;
22
38
  const PEDE_MOTION = /\bmotion\b|anima[çc][ãa]o|animad[oa]/i;
23
39
  const PEDE_VOX = /\bvox\b|explica|explainer|document[aá]rio|editorial|hist[oó]ria d[aoe]s?\b/i;
24
40
  const PEDE_MARCA = /\bbrand|identidade visual|revela[çc][ãa]o d[ae] marca|\blogo(tipo|marca)?\b|vinheta de marca/i;
@@ -32,20 +48,35 @@ const normal = (e) => {
32
48
  return ESTILOS.includes(n) ? n : null;
33
49
  };
34
50
 
35
- function escolherEstilo({ pedido = '', agente = null, roteiro = null } = {}) {
51
+ /* O RODÍZIO: quando ninguém escolheu ("vários estilos... que não sejam padrão
52
+ da IA", Joel 05/10), o padrão não é sempre o híbrido — gira entre os que
53
+ servem a qualquer roteiro, sem repetir os últimos (memória 'estilo'). */
54
+ const RODIZIO = ['hibrido', 'cinema', 'gerativo', 'impacto', 'fluido'];
55
+
56
+ function estiloDaVez(aleatorio = Math.random) {
57
+ return variedade.sortear(RODIZIO, 'estilo', { aleatorio }) || 'hibrido';
58
+ }
59
+
60
+ function escolherEstilo({ pedido = '', agente = null, roteiro = null, girar = false, aleatorio } = {}) {
36
61
  const pedidoAgente = normal(agente);
37
62
  if (pedidoAgente) return pedidoAgente;
38
63
  const doRoteiro = normal(roteiro);
39
- if (doRoteiro && !(doRoteiro !== 'motion' && PEDE_MOTION.test(pedido) && !PEDE_VOX.test(pedido))) return doRoteiro;
64
+ /* Pedir MOTION (ou animação) é pedir o gerativo — antes do vox, da marca e
65
+ do lançamento, que são leiaute fixo ("não pode ter template", 30/09). */
66
+ if (doRoteiro && !(doRoteiro !== 'gerativo' && doRoteiro !== 'fluido' && PEDE_MOTION.test(pedido))) return doRoteiro;
67
+ // O fluido é um jeito de motion: "motion minimalista" é o fluido, não o gerativo.
68
+ if (PEDE_FLUIDO.test(pedido)) return 'fluido';
69
+ if (PEDE_MOTION.test(pedido)) return 'gerativo';
40
70
  if (PEDE_VOX.test(pedido)) return 'vox';
41
71
  if (PEDE_MARCA.test(pedido)) return 'marca';
42
72
  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';
73
+ /* Anúncio, reels e o resto: o HÍBRIDO. "Um mix da versão antiga (o reels
74
+ da imersão, no cinema) e da última (o kit) — absurdo, que nem parece
75
+ que foi IA" (29/09). Com `girar`, o padrão roda (RODIZIO). */
76
+ return girar ? estiloDaVez(aleatorio) : 'hibrido';
46
77
  }
47
78
 
48
79
  /** 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']);
80
+ const LEVES = new Set(['gerativo', 'motion', 'vox', 'marca', 'lancamento', 'fluido']);
50
81
 
51
- module.exports = { escolherEstilo, ESTILOS, LEVES, PEDE_MOTION };
82
+ module.exports = { escolherEstilo, estiloDaVez, ESTILOS, LEVES, PEDE_MOTION, PEDE_FLUIDO, RODIZIO };