primocode 10.0.9 → 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.
Files changed (46) hide show
  1. package/README.md +4 -4
  2. package/bin/primocode.js +25 -28
  3. package/lib/act.js +5 -2
  4. package/lib/catalogo.js +7 -4
  5. package/lib/config.js +10 -2
  6. package/lib/estudio/assets.js +86 -6
  7. package/lib/estudio/batida.js +184 -0
  8. package/lib/estudio/biblioteca.js +214 -0
  9. package/lib/estudio/cinema.js +37 -17
  10. package/lib/estudio/composicao.js +2 -1
  11. package/lib/estudio/decisao.js +106 -0
  12. package/lib/estudio/edicao.js +36 -17
  13. package/lib/estudio/estilo.js +35 -8
  14. package/lib/estudio/fluido.js +352 -0
  15. package/lib/estudio/gerativo.js +21 -3
  16. package/lib/estudio/hibrido.js +16 -12
  17. package/lib/estudio/impacto.js +66 -31
  18. package/lib/estudio/index.js +245 -42
  19. package/lib/estudio/kit/fluido.jsx +614 -0
  20. package/lib/estudio/kit/gerativo.jsx +15 -5
  21. package/lib/estudio/kit/index.jsx +2 -1
  22. package/lib/estudio/kit/tres.jsx +88 -21
  23. package/lib/estudio/kit.js +3 -1
  24. package/lib/estudio/kokoro.js +111 -0
  25. package/lib/estudio/lancamento.js +3 -4
  26. package/lib/estudio/legibilidade.js +20 -0
  27. package/lib/estudio/referencias.js +36 -25
  28. package/lib/estudio/remotion.js +53 -6
  29. package/lib/estudio/revisor.js +117 -0
  30. package/lib/estudio/variacao3d.js +46 -0
  31. package/lib/estudio/variedade.js +140 -8
  32. package/lib/portao-skill.js +150 -0
  33. package/lib/skills.js +15 -1
  34. package/lib/tools.js +31 -5
  35. package/lib/video/filters.js +6 -1
  36. package/lib/video/ops.js +4 -3
  37. package/lib/video/timeline.js +1 -1
  38. package/lib/video/zoom.js +275 -0
  39. package/package.json +2 -2
  40. package/skills/escrever-em-portugues/SKILL.md +3 -0
  41. package/skills/interface-revisada/SKILL.md +7 -1
  42. package/skills/motion-de-primeira/SKILL.md +3 -5
  43. package/skills/projeto-no-github/SKILL.md +6 -0
  44. package/skills/publicar-na-internet/SKILL.md +3 -0
  45. package/skills/site-com-motion/SKILL.md +2 -0
  46. package/studio/voz_kokoro.py +98 -0
@@ -14,6 +14,8 @@
14
14
  * hibrido O PADRÃO do vídeo: o cinema com o kit por cima — foto e vídeo reais
15
15
  * que não param, e o motion por cima (hibrido.js)
16
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.
17
19
  *
18
20
  * Quem manda, em ordem: o estilo que o AGENTE pediu; o do roteiro (a menos
19
21
  * que o pedido diga motion); o que o pedido descreve; e o motion.
@@ -21,15 +23,18 @@
21
23
 
22
24
  'use strict';
23
25
 
24
- const ESTILOS = ['hibrido', 'gerativo', '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'];
25
29
  // Nomes que o modelo usa para os mesmos estilos.
26
30
  /* "motion" agora é o GERATIVO ("não pode ter template", 29–30/09); o motion
27
31
  antigo segue pelo nome "classico" (e "limpo", o nome mais antigo dele). */
28
32
  const APELIDOS = { motion: 'gerativo', generativo: 'gerativo', kinetic: 'gerativo', cinetico: 'gerativo', 'cinético': 'gerativo',
29
33
  tipografico: 'gerativo', 'tipográfico': 'gerativo', animacao: 'gerativo', 'animação': 'gerativo',
30
34
  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' };
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' };
32
36
 
37
+ const PEDE_FLUIDO = /\bfluid[oa]\b|minimalista|\bminimal\b|\bclean\b|\bapple\b/i;
33
38
  const PEDE_MOTION = /\bmotion\b|anima[çc][ãa]o|animad[oa]/i;
34
39
  const PEDE_VOX = /\bvox\b|explica|explainer|document[aá]rio|editorial|hist[oó]ria d[aoe]s?\b/i;
35
40
  const PEDE_MARCA = /\bbrand|identidade visual|revela[çc][ãa]o d[ae] marca|\blogo(tipo|marca)?\b|vinheta de marca/i;
@@ -43,24 +48,46 @@ const normal = (e) => {
43
48
  return ESTILOS.includes(n) ? n : null;
44
49
  };
45
50
 
46
- 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
+ /* Com imagem pedida (08/10): o padrão é o FLUIDO MISTO — motion com a
55
+ imagem em poucos momentos, cada uma entrando de um jeito. O híbrido e o
56
+ cinema ("10 mil imagens de banco e a mesma animação") ficam pelo nome. */
57
+ const RODIZIO = ['fluido'];
58
+ /* Sem imagem (o padrão desde 07/10), o rodízio fica nos dois motions que
59
+ nasceram das referências boas (motions-exemplos/motion-god): o fluido e o
60
+ gerativo. O impacto (emoji 3D em cada batida, cara de anúncio) saiu do
61
+ rodízio em 08/10 — "horríveis, nada a ver com as referências" — e fica
62
+ para quem o pede pelo nome (o agente ou o roteiro). */
63
+ const RODIZIO_SEM_IMAGEM = ['fluido', 'gerativo'];
64
+
65
+ function estiloDaVez(aleatorio = Math.random, semImagem = false) {
66
+ const lista = semImagem ? RODIZIO_SEM_IMAGEM : RODIZIO;
67
+ return variedade.sortear(lista, 'estilo', { aleatorio }) || lista[0];
68
+ }
69
+
70
+ function escolherEstilo({ pedido = '', agente = null, roteiro = null, girar = false, semImagem = false, aleatorio } = {}) {
47
71
  const pedidoAgente = normal(agente);
48
72
  if (pedidoAgente) return pedidoAgente;
49
73
  const doRoteiro = normal(roteiro);
50
74
  /* Pedir MOTION (ou animação) é pedir o gerativo — antes do vox, da marca e
51
75
  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;
76
+ if (doRoteiro && !(doRoteiro !== 'gerativo' && doRoteiro !== 'fluido' && PEDE_MOTION.test(pedido))) return doRoteiro;
77
+ // O fluido é um jeito de motion: "motion minimalista" é o fluido, não o gerativo.
78
+ if (PEDE_FLUIDO.test(pedido)) return 'fluido';
53
79
  if (PEDE_MOTION.test(pedido)) return 'gerativo';
54
80
  if (PEDE_VOX.test(pedido)) return 'vox';
55
81
  if (PEDE_MARCA.test(pedido)) return 'marca';
56
82
  if (PEDE_LANCAMENTO.test(pedido)) return 'lancamento';
57
83
  /* Anúncio, reels e o resto: o HÍBRIDO. "Um mix da versão antiga (o reels
58
84
  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';
85
+ que foi IA" (29/09). Com `girar`, o padrão roda (RODIZIO). */
86
+ if (girar) return estiloDaVez(aleatorio, semImagem);
87
+ return semImagem ? 'gerativo' : 'hibrido';
61
88
  }
62
89
 
63
90
  /** Os estilos que usam os efeitos leves e a trilha do ende.app (os de motion, não os de corte). */
64
- const LEVES = new Set(['gerativo', 'motion', 'vox', 'marca', 'lancamento']);
91
+ const LEVES = new Set(['gerativo', 'motion', 'vox', 'marca', 'lancamento', 'fluido']);
65
92
 
66
- module.exports = { escolherEstilo, ESTILOS, LEVES, PEDE_MOTION };
93
+ module.exports = { escolherEstilo, estiloDaVez, ESTILOS, LEVES, PEDE_MOTION, PEDE_FLUIDO, RODIZIO, RODIZIO_SEM_IMAGEM };
@@ -0,0 +1,352 @@
1
+ /**
2
+ * fluido.js — o estilo FLUIDO: uma ideia por batida, muito respiro, e nada
3
+ * de corte. O elemento de uma batida VIRA o da próxima.
4
+ *
5
+ * "Uma ideia por quadro, muito espaço vazio. Texto pequeno, caixa normal,
6
+ * sans limpa. Movimento contínuo: um elemento se transforma no próximo."
7
+ * — a referência de motion do Joel (05/10), os vídeos bons.
8
+ *
9
+ * Existe UMA forma que vive o vídeo inteiro (o "portador"): é o ponto da
10
+ * abertura, a pílula do título, o anel do número, a primeira pílula da lista,
11
+ * a linha que risca o "não", o cartão da citação e o primeiro círculo da
12
+ * marca no fim. Entre uma batida e outra ela muda de lugar, de tamanho, de
13
+ * raio e de preenchimento — o olho segue a forma, e o corte some.
14
+ *
15
+ * O texto entra palavra por palavra (stagger curto, ease-out forte, sem
16
+ * linear) e cada frase tem UM acento: uma palavra em serifa itálica na cor de
17
+ * acento. Fundo em gradiente grande e macio, de uma paleta curta (no máximo
18
+ * dois acentos e os neutros), clara ou escura.
19
+ *
20
+ * Proibido aqui, de propósito (é a lista do "cara de IA"): HUD, cantoneira,
21
+ * timecode, caixa na palavra-chave, neon, partícula e CAIXA ALTA.
22
+ *
23
+ * Puro: devolve a partitura (os testes olham) e o plano com UMA peça do kit
24
+ * (kit/fluido.jsx), que desenha a partitura quadro a quadro.
25
+ */
26
+
27
+ 'use strict';
28
+
29
+ const { volumesDaTrilha } = require('./ritmo.js');
30
+
31
+ const FPS = 30;
32
+ const r2 = (n) => Math.round(n * 100) / 100;
33
+ const POR_PALAVRA = 0.3, MINIMO = 0.8; // o tempo de leitura: 0,3 s por palavra, nunca menos de 0,8 s
34
+ const STAGGER = 0.07; // entre uma palavra e a próxima (a que a voz não diz)
35
+ const ANTES = 0.12; // a batida troca isto antes da primeira palavra da frase
36
+
37
+ /* As paletas: um fundo em três tons (o gradiente), a tinta, a tinta suave e
38
+ NO MÁXIMO dois acentos (o da palavra e o brilho difuso). */
39
+ const PALETAS = {
40
+ noite: { escuro: true, fundo: ['#020615', '#061a5c', '#1240c9'], tinta: '#eef2ff', suave: '#9fb0d9', acentos: ['#8fb8ff', '#2f6bff'] },
41
+ /* As CHAPADAS das referências boas (08/10): fundo de uma cor só, sem o
42
+ gradiente — o laranja da "criatividade", o branco com azul, o preto puro
43
+ e o cobalto. O "sempre o mesmo azul" era vício. */
44
+ laranja: { escuro: false, fundo: ['#f2621f', '#f06a28', '#ec7a3c'], tinta: '#1a0d05', suave: '#5a2a10', acentos: ['#fff4ea', '#ffd2b0'] },
45
+ branco: { escuro: false, fundo: ['#ffffff', '#f6f7fa', '#eceff6'], tinta: '#0b0d12', suave: '#6b7180', acentos: ['#1f4bff', '#9fb3ff'] },
46
+ preto: { escuro: true, fundo: ['#000000', '#050505', '#121212'], tinta: '#f5f5f5', suave: '#8a8a8a', acentos: ['#ff7a3d', '#ff9a66'] },
47
+ cobalto: { escuro: true, fundo: ['#1238e6', '#1640f0', '#2350ff'], tinta: '#ffffff', suave: '#c8d4ff', acentos: ['#ffe14d', '#ffffff'] },
48
+ breu: { escuro: true, fundo: ['#030304', '#0b0a12', '#2a2156'], tinta: '#f4f4f6', suave: '#8d8a9c', acentos: ['#b4a6ff', '#5b45ff'] },
49
+ lavanda: { escuro: false, fundo: ['#f5f4ff', '#e4e1ff', '#b9b2ff'], tinta: '#17153a', suave: '#6e6a92', acentos: ['#4a3ce0', '#8f86ff'] },
50
+ creme: { escuro: false, fundo: ['#fbf8f2', '#f2e8d8', '#e7c9a5'], tinta: '#1e1a15', suave: '#857a6c', acentos: ['#c2502a', '#f0a36b'] },
51
+ };
52
+ const ESCURAS = ['noite', 'breu', 'preto', 'cobalto'], CLARAS = ['lavanda', 'creme', 'laranja', 'branco'];
53
+
54
+ const hash = (s) => { let h = 2166136261; for (const c of String(s)) h = Math.imul(h ^ c.charCodeAt(0), 16777619); return h >>> 0; };
55
+
56
+ /** A paleta: a pedida pelo nome; senão o tema (claro/escuro); senão a semente. */
57
+ function paletaDe(roteiro, semente) {
58
+ const pedida = String(roteiro.paleta || '').toLowerCase();
59
+ const tema = String(roteiro.tema || '').toLowerCase();
60
+ const nome = PALETAS[pedida] ? pedida
61
+ : /claro|light/.test(tema) ? CLARAS[semente % CLARAS.length]
62
+ : /escuro|dark/.test(tema) ? ESCURAS[semente % ESCURAS.length]
63
+ : Object.keys(PALETAS)[semente % Object.keys(PALETAS).length];
64
+ const p = { nome, ...PALETAS[nome], acentos: [...PALETAS[nome].acentos] };
65
+ // A cor da marca vira o acento da palavra (e só ela: a paleta não cresce).
66
+ const dest = roteiro.marca && roteiro.marca.cores && roteiro.marca.cores.destaque;
67
+ if (/^#[0-9a-f]{6}$/i.test(dest || '')) p.acentos[0] = dest;
68
+ return p;
69
+ }
70
+
71
+ /* Caixa alta não entra: frase GRITADA vira caixa normal (sigla curta fica). */
72
+ function caixaNormal(t) {
73
+ const s = String(t || '').trim();
74
+ const letras = s.replace(/[^A-Za-zÀ-ÿ]/g, '');
75
+ if (letras.length < 4 || letras !== letras.toUpperCase()) return s;
76
+ const m = s.toLowerCase();
77
+ return m.charAt(0).toUpperCase() + m.slice(1);
78
+ }
79
+
80
+ const VAZIAS = new Set('a o as os um uma de da do das dos e é em no na nos nas que com por para pra se seu sua você ao à mais muito isso este esta não sim'.split(' '));
81
+ const limpa = (w) => w.toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '').replace(/[^a-z0-9]/g, '');
82
+
83
+ /** A frase em palavras, com UM acento: o destaque pedido, ou a palavra de mais peso. */
84
+ function frase(texto, { destaque = '', semAcento = false } = {}) {
85
+ const ws = caixaNormal(texto).split(/\s+/).filter(Boolean);
86
+ let ac = -1;
87
+ if (!semAcento && ws.length > 1) {
88
+ const d = limpa(String(destaque).split(/\s+/)[0] || '');
89
+ if (d) ac = ws.findIndex((w) => limpa(w) === d);
90
+ if (ac < 0) {
91
+ // A palavra mais longa que não é vazia; no empate, a mais tarde (a frase termina nela).
92
+ let melhor = 0;
93
+ ws.forEach((w, i) => { const n = limpa(w).length; if (!VAZIAS.has(limpa(w)) && n >= 4 && n >= melhor) { melhor = n; ac = i; } });
94
+ }
95
+ }
96
+ return ws.map((t, i) => ({ t, acento: i === ac }));
97
+ }
98
+
99
+ const numeroDe = (v) => {
100
+ const m = String(v == null ? '' : v).match(/^(\D*)([\d.,]+)(.*)$/);
101
+ if (!m) return null;
102
+ const n = Number(m[2].replace(/\./g, '').replace(',', '.'));
103
+ return Number.isFinite(n) ? { antes: m[1], ate: n, depois: m[3], casas: /,\d/.test(m[2]) ? 1 : 0 } : null;
104
+ };
105
+ const itensDe = (p) => (p.itens || []).map((i) => (typeof i === 'string' ? i : i.texto || i.titulo || '')).filter(Boolean);
106
+ const leitura = (n) => Math.max(MINIMO, POR_PALAVRA * n);
107
+
108
+ /* A forma do portador em cada tipo de parte (kit/fluido.jsx dá o tamanho). */
109
+ const FORMA = { abertura: 'ponto', titulo: 'pilula', numero: 'anel', lista: 'pilula', passos: 'ponto', contraste: 'linha', citacao: 'cartao', cta: 'marca' };
110
+ const TIPO = { logo: 'abertura', imagem: 'titulo', produto: 'titulo', grafico: 'lista', cores: 'lista' };
111
+
112
+ /**
113
+ * A partitura: as batidas, cada uma com as frases (e quando cada uma entra),
114
+ * a forma do portador e a transição. Sem áudio nem plano — é o que os testes olham.
115
+ */
116
+ function partituraDe({ roteiro, largura = 1920, altura = 1080, tempos = [], imagens = {} }) {
117
+ const partes = (roteiro.partes || []).filter((p) => p && typeof p === 'object');
118
+ const semente = Number.isFinite(roteiro.semente) ? roteiro.semente : hash(JSON.stringify(partes.map((p) => [p.tipo, p.titulo, p.valor])));
119
+ const paleta = paletaDe(roteiro, semente);
120
+ const marca = roteiro.marca || {};
121
+ const temVoz = tempos.length === partes.length && tempos.every((t) => t && t.ate > t.de);
122
+ /* O comando do terminal: o link da cta, se for comando; senão o nome da marca. */
123
+ const link = (partes.find((p) => p.tipo === 'cta') || {}).link || '';
124
+ const cmd = /^(npm|npx|pnpm|yarn|pip|brew|curl|git|bun)\b/.test(link) ? link : String(marca.nome || 'app').toLowerCase().replace(/\s+/g, '-');
125
+ let comCartao = 0;
126
+ /* O cartão de produto só quando o vídeo É de produto: tem marca, um
127
+ comando de instalar ou um link. Um vídeo sobre sono saía com um
128
+ terminal "$ app" ao lado do título (teste de 08/10). `p.ui` explícito vale sempre. */
129
+ const deProduto = !!(marca.nome || link || /\b(app|software|plataforma|ferramenta|produto|sistema|saas|api|cli)\b/i.test(String(roteiro.pedido || '')));
130
+
131
+ const batidas = partes.map((p, i) => {
132
+ /* Fecho sem marca não monta a "marca de bolinhas" (vício, 08/10): vira
133
+ uma frase — e, curta, a gigante do fim. */
134
+ const tipo = p.tipo === 'cta' && !marca.nome ? 'titulo' : TIPO[p.tipo] || (FORMA[p.tipo] ? p.tipo : 'titulo');
135
+ const titulo = p.titulo || p.texto || '';
136
+ const fr = [];
137
+ const poe = (papel, texto, o = {}) => { if (String(texto || '').trim()) fr.push({ id: `${i}-${fr.length}`, papel, palavras: frase(texto, o) }); };
138
+ const b = { i, tipo, forma: FORMA[tipo], transicao: p.corte === true && i > 0 ? 'corte' : 'morfa', frases: fr, lado: 'centro' };
139
+ /* O CARTÃO DE PRODUTO que flutua ao lado (terminal, chat, painel), feito
140
+ de forma e texto — e o texto vai para o outro lado. `ui: false` tira. */
141
+ const ui = p.ui === false ? null : typeof p.ui === 'string' ? p.ui
142
+ : !deProduto ? null
143
+ : tipo === 'titulo' || tipo === 'abertura' ? ['terminal', 'chat'][comCartao % 2] : tipo === 'numero' ? 'painel' : null;
144
+ if (ui) {
145
+ b.lado = comCartao++ % 2 ? 'dir' : 'esq';
146
+ b.ui = ui === 'terminal' ? { tipo: ui, linhas: ['$ ' + cmd, '✓ ' + caixaNormal(p.sub || titulo)] }
147
+ : ui === 'chat' ? { tipo: ui, pergunta: caixaNormal(titulo) }
148
+ : { tipo: 'painel', rotulo: caixaNormal(p.rotulo || titulo), barras: [0, 1, 2, 3, 4, 5, 6].map((k) => r2(0.35 + ((semente >>> (k * 3)) % 7) / 10)) };
149
+ }
150
+ switch (tipo) {
151
+ case 'numero': {
152
+ b.numero = numeroDe(p.valor);
153
+ poe('numero', p.valor, { semAcento: true });
154
+ poe('rotulo', p.rotulo || titulo, { destaque: p.destaque });
155
+ break;
156
+ }
157
+ case 'lista': case 'passos':
158
+ poe('principal', titulo, { destaque: p.destaque });
159
+ itensDe(p).slice(0, tipo === 'passos' ? 6 : 4).forEach((it) => poe('item', it, { semAcento: true }));
160
+ break;
161
+ case 'contraste':
162
+ poe('nao', p.nao, { semAcento: true });
163
+ poe('sim', p.sim || titulo, { destaque: p.destaque });
164
+ break;
165
+ case 'citacao':
166
+ poe('principal', titulo, { destaque: p.destaque });
167
+ poe('autor', p.autor ? `— ${p.autor}` : '', { semAcento: true });
168
+ break;
169
+ case 'cta':
170
+ b.marca = { nome: caixaNormal(marca.nome || titulo) };
171
+ poe('marca', b.marca.nome, { semAcento: true });
172
+ poe('principal', marca.nome ? titulo : '', { destaque: p.destaque });
173
+ poe('sub', p.link || p.sub, { semAcento: true });
174
+ break;
175
+ default:
176
+ poe('principal', titulo, { destaque: p.destaque });
177
+ poe('sub', p.sub, { semAcento: true });
178
+ }
179
+ return b;
180
+ });
181
+
182
+ /* ── A PALAVRA GIGANTE e a VIRADA (08/10): nas referências boas, o
183
+ momento forte é uma frase curta que ENCHE a tela, e o fundo vira
184
+ (o ponto cresce até cobrir tudo de cor). Sem isso o vídeo era a mesma
185
+ pílula pequena no mesmo azul por 36 s — "horríveis". Regras de editor:
186
+ só frase de até 4 palavras, abertura ou com destaque (a palavra-tese)
187
+ primeiro; gigante em até metade das batidas; a virada só nas que têm
188
+ destaque, 1 em 3 e nunca seguida. `p.gigante` explícito manda. */
189
+ const curta = (b) => { const f = b.frases.find((x) => x.papel === 'principal'); return f && f.palavras.length <= 4 && b.frases.length <= 2; };
190
+ const candidatas = batidas.map((b, i) => ({ b, i, p: partes[i] }))
191
+ .filter(({ b, p }) => p.gigante !== false && (b.tipo === 'titulo' || b.tipo === 'abertura') && !b.ui && !b.imagem && curta(b))
192
+ .sort((x, y) => (y.p.gigante === true) - (x.p.gigante === true) || !!y.p.destaque - !!x.p.destaque || (x.b.tipo === 'abertura' ? -1 : 0) || x.i - y.i);
193
+ /* ── AS IMAGENS, quando a pessoa pediu (08/10: "deve ser misto com imagens
194
+ e motion, e com variadas animações"; o híbrido era "10 mil imagens de
195
+ banco e a mesma animação"). A imagem é UM momento: só nas batidas que
196
+ têm imagem baixada, no máximo 1 em 3, nunca duas seguidas. E cada uma
197
+ entra de um JEITO diferente, sem repetir no vídeo:
198
+ janela a foto abre da esquerda e cobre a tela, o texto por cima
199
+ circulo o ponto cresce em círculo com a foto dentro
200
+ cartao a foto num cartão que gira em 3D, o texto acima
201
+ faixa uma faixa da foto abre na altura, o texto abaixo
202
+ vazado o título gigante com a foto por DENTRO das letras */
203
+ const JEITOS = ['janela', 'circulo', 'cartao', 'faixa', 'vazado'];
204
+ const ordemJeitos = JEITOS.map((j, k) => ({ j, o: hash(j + semente) })).sort((a, b) => a.o - b.o).map((x) => x.j);
205
+ const tetoImagem = Math.max(1, Math.floor(batidas.length / 3));
206
+ let nImagem = 0;
207
+ for (const b of batidas) {
208
+ const img = imagens[b.i];
209
+ if (!img || !img.src || b.tipo === 'cta' || nImagem >= tetoImagem) continue;
210
+ if (batidas[b.i - 1] && batidas[b.i - 1].imagem) continue;
211
+ const pr = b.frases.find((f) => f.papel === 'principal');
212
+ let jeito = ordemJeitos[nImagem % ordemJeitos.length];
213
+ if (jeito === 'vazado' && !(pr && pr.palavras.length <= 3)) jeito = ordemJeitos[(nImagem + 1) % ordemJeitos.length] === 'vazado' ? 'janela' : ordemJeitos[(nImagem + 1) % ordemJeitos.length];
214
+ b.imagem = { src: img.src, jeito };
215
+ b.ui = null; b.lado = 'centro';
216
+ nImagem++;
217
+ }
218
+
219
+ // A gigante pode seguir outra (as referências fazem isso); a VIRADA é o golpe: 1 em 3, nunca seguida.
220
+ const teto = Math.max(1, Math.ceil(batidas.length / 2)), tetoVirada = Math.max(1, Math.floor(batidas.length / 3));
221
+ for (const { b, i, p } of candidatas) {
222
+ if (batidas.filter((x) => x.gigante).length >= teto && p.gigante !== true) break;
223
+ b.gigante = true;
224
+ const viradas = batidas.filter((x) => x.virada).length;
225
+ const vizinha = (batidas[i - 1] && batidas[i - 1].virada) || (batidas[i + 1] && batidas[i + 1].virada);
226
+ if (i > 0 && viradas < tetoVirada && !vizinha && (p.destaque || p.gigante === true)) b.virada = true;
227
+ }
228
+
229
+ /* ── o tempo: a VOZ manda. A batida troca na fronteira da frase (um
230
+ pouco antes da primeira palavra da parte) e cada palavra na tela
231
+ aparece no instante em que é dita (os tempos por palavra da narração).
232
+ A palavra que a voz não diz entra em cascata curta. Se o texto pede
233
+ mais leitura do que a frase deu, a batida estica e a próxima volta a
234
+ casar com a voz na primeira folga; o atraso medido vai na partitura. */
235
+ let atraso = 0, sincronia = 0;
236
+ batidas.forEach((b, i) => {
237
+ const tp = temVoz ? tempos[i] : null;
238
+ const deVoz = tp ? (i === 0 ? 0 : Math.max(0, tp.de - ANTES)) : null;
239
+ b.de = r2(i === 0 ? 0 : Math.max(batidas[i - 1].ate, deVoz == null ? 0 : deVoz));
240
+ if (deVoz != null) atraso = Math.max(atraso, r2(b.de - deVoz));
241
+ const ditas = ((tp && tp.palavras) || []).map((w) => ({ k: limpa(w.texto), de: w.de }));
242
+ let cursor = 0, livre = b.de + (b.tipo === 'cta' ? 0.55 : 0.12);
243
+ const fimVoz = tp ? (i < batidas.length - 1 ? tempos[i + 1].de - ANTES : tp.ate + 0.2) : 0;
244
+ for (const f of b.frases) {
245
+ /* Casa as palavras da tela com as faladas, na ordem. Tenta começar de
246
+ cada palavra (a frase da tela pode vir em outra ordem que a falada)
247
+ e fica com o casamento que acha mais palavras. */
248
+ const casa = (k, d) => d === k || (k.length >= 4 && d.length >= 4 && d.slice(0, 5) === k.slice(0, 5));
249
+ /* Cada frase procura as suas palavras na fala INTEIRA da batida: com
250
+ um cursor só, o título que casava com o fim da fala deixava o
251
+ subtítulo sem achar as dele (entrava 0,45 s atrasado, 08/10). */
252
+ let ts = f.palavras.map(() => null), fim = cursor;
253
+ for (let ini = 0; ini < f.palavras.length; ini++) {
254
+ let c = 0;
255
+ const tt = f.palavras.map((w, j) => {
256
+ const k = limpa(w.t);
257
+ if (j < ini || !k) return null;
258
+ for (let q = c; q < Math.min(ditas.length, c + 40); q++) if (casa(k, ditas[q].k)) { c = q + 1; return ditas[q].de; }
259
+ return null;
260
+ });
261
+ if (tt.filter((x) => x != null).length > ts.filter((x) => x != null).length) { ts = tt; fim = c; }
262
+ }
263
+ cursor = fim;
264
+ f.falada = ts.filter((x) => x != null).length >= f.palavras.length / 2;
265
+ const ach = ts.findIndex((x) => x != null);
266
+ /* O que a voz não diz entra cedo o bastante para ser lido antes de a
267
+ frase acabar (e nunca antes da batida). */
268
+ /* O que a voz DIZ entra quando ela diz — nunca antes. Antes de 08/10
269
+ a frase entrava no começo da batida se a voz demorasse: "ontem à
270
+ noite" aparecia 2,8 s antes de ser dito, e o Joel viu "atrasos
271
+ entre a narração e o vídeo". */
272
+ let t = ach >= 0 && ts[ach] != null ? ts[ach] - ach * STAGGER
273
+ : tp ? Math.max(b.de + 0.25, Math.min(livre, fimVoz - leitura(f.palavras.length) - 0.05)) : livre;
274
+ f.palavras.forEach((w, j) => {
275
+ const falada = ts[j];
276
+ t = Math.max(b.de + 0.06, falada != null ? Math.max(t, falada - 0.05) : t);
277
+ w.em = r2(t);
278
+ if (falada != null) sincronia = Math.max(sincronia, r2(w.em - falada));
279
+ t += STAGGER;
280
+ });
281
+ f.de = f.palavras[0].em;
282
+ // O que não é dito (rótulo, item, link) entra logo depois do anterior.
283
+ livre = Math.max(livre, f.papel === 'item' && b.tipo === 'passos' ? f.de + leitura(f.palavras.length) * 0.8 : f.palavras[f.palavras.length - 1].em + 0.18);
284
+ }
285
+ /* O texto que a voz DIZ é lido junto com ela: fica até depois da última
286
+ palavra dita (e nunca menos de 0,8 s). O que a voz não diz pede 0,3 s por palavra. */
287
+ const precisa = Math.max(...b.frases.map((f) => f.de + (f.falada
288
+ ? Math.max(MINIMO, f.palavras[f.palavras.length - 1].em - f.de + 0.3) : leitura(f.palavras.length))), b.de + 0.9);
289
+ b.ate = r2(Math.max(precisa, fimVoz));
290
+ });
291
+ // A marca do fim segura um instante; o resto não espera.
292
+ const ult = batidas[batidas.length - 1];
293
+ ult.ate = r2(ult.ate + (ult.tipo === 'cta' ? 1.0 : 0.5));
294
+
295
+ return {
296
+ versao: 2, semente, fps: FPS, atraso, sincronia, largura, altura, duracao: ult.ate, paleta,
297
+ marcador: roteiro.marcador || ['brilho', 'numero', 'traco', 'ponto', 'nenhum'][hash('marcador' + semente) % 5],
298
+ fontes: { texto: 'Inter', serifa: 'Instrument Serif', mono: 'JetBrains Mono' },
299
+ logo: marca.logo || null,
300
+ batidas,
301
+ };
302
+ }
303
+
304
+ /**
305
+ * @param {object} op
306
+ * @param {object} op.roteiro { marca, paleta?: noite|breu|lavanda|creme, tema?: claro|escuro, partes }
307
+ * @param {Array} op.tempos [{de, ate, palavras}] por parte (da narração)
308
+ * @param {object} op.sons os efeitos leves (sons-leves.js)
309
+ */
310
+ function montarFluido({ roteiro, largura = 1920, altura = 1080, tempos = [], narracao = null, sons = {}, trilha = null, imagens = {} }) {
311
+ const partes = ((roteiro && roteiro.partes) || []).filter((p) => p && typeof p === 'object');
312
+ if (!partes.length) return { ok: false, error: 'o roteiro veio sem partes.' };
313
+ const partitura = partituraDe({ roteiro: { ...roteiro, partes }, largura, altura, tempos, imagens });
314
+ const DUR = partitura.duracao;
315
+ /* O SOM segue o movimento (sons-leves.js, tudo sintetizado): whoosh em
316
+ cada transformação, pop quando pílula ou cartão assenta, clique na roda e
317
+ no terminal, um bolha discreto na palavra de acento, o riser que leva à
318
+ marca e o impacto quando ela trava. Baixo: a voz vem primeiro. */
319
+ const efeitos = [];
320
+ const som = (t, nome, volume) => { if (sons[nome]) efeitos.push({ t: r2(Math.max(0, t)), src: sons[nome], volume }); };
321
+ for (const b of partitura.batidas) {
322
+ if (b.i > 0 && b.tipo !== 'cta') som(b.de - 0.18, 'whoosh', 0.2);
323
+ if (b.ui) { som(b.de + 0.6, 'pop', 0.16); if (b.ui.tipo === 'terminal') b.ui.linhas.forEach((_, k) => som(b.de + 0.75 + k * 0.5, 'clique', 0.1)); }
324
+ for (const f of b.frases) {
325
+ if (f.papel === 'item') som(f.de - 0.06, b.tipo === 'passos' ? 'clique' : 'pop', 0.15);
326
+ const ac = f.palavras.find((w) => w.acento);
327
+ if (ac && f.papel !== 'item') som(ac.em, 'bolha', 0.09);
328
+ }
329
+ if (b.tipo === 'citacao') som(b.de + 0.3, 'pop', 0.14);
330
+ if (b.tipo === 'cta') { som(b.de - 0.75, 'riser', 0.2); som(b.de + 0.45, 'impacto', 0.26); som(b.de + 0.5, 'brilho', 0.16); }
331
+ }
332
+ const volumes = volumesDaTrilha(tempos.length === partes.length ? tempos : [], DUR, { alto: 0.55, baixo: 0.22 });
333
+ const P = partitura.paleta;
334
+ const plano = {
335
+ formato: { largura, altura, fps: FPS },
336
+ fundoGeral: P.fundo[0],
337
+ fontesVariaveis: { Inter: 'wght@300..700', 'Instrument Serif': 'ital@0;1', 'JetBrains Mono': 'wght@400;500' },
338
+ cenas: [{
339
+ duracao: DUR,
340
+ fundo: P.fundo[0],
341
+ elementos: [{ tipo: 'kit', peca: 'fluido', partitura, fonte: partitura.fontes.texto, fonteSerifa: partitura.fontes.serifa, fonteMono: partitura.fontes.mono }],
342
+ }],
343
+ audio: {
344
+ ...(narracao ? { narracao } : {}),
345
+ ...(trilha ? { trilha: { src: trilha, volumes, loop: true } } : {}),
346
+ efeitos,
347
+ },
348
+ };
349
+ return { ok: true, plano, partitura, resumo: { cenas: 1, batidas: partitura.batidas.length, imagens: partitura.batidas.filter((b) => b.imagem).map((b) => b.imagem.jeito), segundos: DUR, fps: FPS, paleta: P.nome, atraso: partitura.atraso, sincronia: partitura.sincronia } };
350
+ }
351
+
352
+ module.exports = { montarFluido, partituraDe, caixaNormal, PALETAS, POR_PALAVRA, MINIMO };
@@ -827,6 +827,18 @@ function partituraDe({ roteiro, largura = 1920, altura = 1080, tempos = [] }) {
827
827
  };
828
828
  const registra = (b) => { usadas[b.comp] = (usadas[b.comp] || 0) + 1; recentes.push(b.comp); anterior = b.comp; batidas.push(b); };
829
829
 
830
+ /* PEDIU 3D, O 3D APARECE: o sorteio podia passar o vídeo inteiro sem a
831
+ batida do produto (3 partes, "pilha → contador → pilha", 05/10). Se
832
+ nenhuma escolher sozinha, a melhor parte ganha: a de produto, o cta ou
833
+ a primeira com título. */
834
+ const ultimaDe = (tipos) => { for (let i = brutas.length - 1; i >= 0; i--) if (tipos.includes(brutas[i].conteudo.tipo)) return i; return -1; };
835
+ let alvo3d = -1;
836
+ if (tem3d) {
837
+ alvo3d = ultimaDe(['produto']);
838
+ if (alvo3d < 0) alvo3d = ultimaDe(['cta']);
839
+ if (alvo3d < 0) alvo3d = brutas.findIndex((b) => ['titulo', 'marca'].includes(b.conteudo.tipo));
840
+ }
841
+
830
842
  brutas.forEach((br, bi) => {
831
843
  const c = br.conteudo;
832
844
  const tituloC = c.titulo || c.nome || c.texto || '';
@@ -836,6 +848,7 @@ function partituraDe({ roteiro, largura = 1920, altura = 1080, tempos = [] }) {
836
848
  pergunta: /\?/.test(tituloC), exclama: /!/.test(tituloC), numeroNoTitulo: /\d/.test(tituloC),
837
849
  };
838
850
  let nome = escolher(pesosDe(c, feat));
851
+ if (bi === alvo3d && !usadas.produto) nome = 'produto';
839
852
  const ctx = { s, W, H, M, deitado, base, larguraUtil, tamRotulo, fontes, P, palavras: br.palavras || c.palavras || [], parte: br.parte, marca, roteiro, memoria };
840
853
  if (nome === 'rajada' && br.ate - br.de < 1.6 * c.itens.length) nome = 'cascata';
841
854
  if (nome === 'rajada') {
@@ -1042,20 +1055,25 @@ function partituraDe({ roteiro, largura = 1920, altura = 1080, tempos = [] }) {
1042
1055
 
1043
1056
  // ── o produto em 3D: mora nas batidas que o hospedam ─────────────────
1044
1057
  const produtos = batidas.filter((b) => b.produto3d).map((b) => ({ ...b.produto3d, de: b.de, ate: b.ate, modelo: roteiro.modelo3d,
1045
- cor: roteiro.cor3d || '#1b1c20', corAcento: P.acento }));
1058
+ cor: roteiro.cor3d || '#1b1c20', corAcento: P.acento, ...(roteiro.variacao3d || {}) }));
1046
1059
 
1047
1060
  // ── o fundo vivo ────────────────────────────────────────────────────
1048
- const fundo = { textura: s.um(['grade', 'pontos', 'linhas', 'grade']), passo: Math.round(s.um([72, 96, 120]) * base), grao: luz(P.fundo) < 0.5 ? 0.07 : 0.05,
1061
+ /* SEM CARA DE IA (05/10): grade, pontinhos flutuando e o HUD só a pedido
1062
+ (roteiro.hud, roteiro.particulas). O sorteio continua igual — a semente
1063
+ dá o mesmo vídeo de antes, só sem o enfeite. */
1064
+ const texturaSorteada = s.um(['grade', 'pontos', 'linhas', 'grade']);
1065
+ const fundo = { textura: roteiro.hud === true ? texturaSorteada : null, passo: Math.round(s.um([72, 96, 120]) * base), grao: luz(P.fundo) < 0.5 ? 0.07 : 0.05,
1049
1066
  brilho: { x: s.entre(0.25, 0.75), y: s.entre(0.2, 0.5) },
1050
1067
  // Partículas em três profundidades: o espaço respira mesmo no repouso.
1051
1068
  particulas: Array.from({ length: 34 }, (_, i) => ({ x: r1(s.prox() * W), y: r1(s.prox() * H), z: [0.25, 0.5, 0.85][i % 3], r: r1((1.2 + s.prox() * 2.4) * base), v: r1(s.entre(6, 22) * base) })) };
1069
+ if (roteiro.particulas !== true) fundo.particulas = [];
1052
1070
 
1053
1071
  return {
1054
1072
  versao: 1, semente, fps: FPS, humor,
1055
1073
  musica: { genero: s.um(H0.generos), generos: H0.generos }, largura: W, altura: H, duracao, regras: REGRAS,
1056
1074
  paleta: { ...P, fundo: campos[0], tinta: campos[2], acento: campos[1] }, campos, fontes, eixos: Object.fromEntries(Object.values(fontes).map((f) => [f, (VARIAVEIS[f] || {}).eixos || { wght: [400, 700] }])),
1057
1075
  margem: M, fundo, batidas, camera, produtos,
1058
- hud: { marca: String((marca.nome || roteiro.titulo || 'PrimoCode')).toUpperCase().slice(0, 24), ano: '2026' },
1076
+ hud: roteiro.hud === true ? { marca: String((marca.nome || roteiro.titulo || 'PrimoCode')).toUpperCase().slice(0, 24), ano: '2026' } : null,
1059
1077
  };
1060
1078
  }
1061
1079
 
@@ -8,14 +8,14 @@
8
8
  *
9
9
  * O que cada lado traz:
10
10
  * do cinema (cinema.js) foto e vídeo REAIS ocupando a tela, escurecidos,
11
- * trocando por baixo do mesmo texto; letra grossa em
12
- * caixa alta com a palavra da tese no gradiente; o
11
+ * trocando por baixo do mesmo texto, escrito como a
12
+ * pessoa escreveu, com a palavra da tese na serifa; o
13
13
  * número que conta; a virada que bate no fundo; a
14
14
  * marca só no fim.
15
15
  * do kit (kit/*.jsx) a câmera que empurra e desliza em toda cena; a
16
- * etiqueta da fonte que se digita; partículas no
17
- * número e no fim; o HUD de motion designer; o
18
- * líquido entre blocos; grão e vinheta.
16
+ * líquido entre blocos; grão e vinheta. A etiqueta
17
+ * da fonte, o HUD e as partículas só a pedido
18
+ * (roteiro.hud, roteiro.particulas).
19
19
  *
20
20
  * Três passos, e nenhum repete o que o cinema já faz:
21
21
  * traduzir(roteiro) qualquer roteiro (vox, motion, composição) vira os
@@ -106,7 +106,10 @@ function camera(d, i, deitado, forte) {
106
106
  }
107
107
 
108
108
  /** O kit por cima do plano do cinema. Mexe no plano recebido e o devolve. */
109
- function enfeitar(plano, { extras = [], partes = [], marca = {}, largura, altura } = {}) {
109
+ /* SEM CARA DE IA (05/10): o HUD (cantos, timecode, fps, rótulos nas bordas)
110
+ e as partículas eram exatamente a lista do "ruim" que o Joel juntou. Agora
111
+ só entram quando o roteiro pede: roteiro.hud === true, roteiro.particulas === true. */
112
+ function enfeitar(plano, { extras = [], partes = [], marca = {}, largura, altura, hud = false, particulas = false } = {}) {
110
113
  if (!plano || !Array.isArray(plano.cenas)) return plano;
111
114
  const W = largura || (plano.formato && plano.formato.largura) || 1920;
112
115
  const H = altura || (plano.formato && plano.formato.altura) || 1080;
@@ -132,18 +135,18 @@ function enfeitar(plano, { extras = [], partes = [], marca = {}, largura, altura
132
135
  const forte = tipo === 'numero' || tipo === 'contraste' || tipo === 'abertura';
133
136
  if (!cena.camera) cena.camera = camera(d, c, deitado, forte);
134
137
 
135
- // A FONTE que se digita, com a barra do acento: vira prova, não enfeite.
136
- if (ex.fonte && d > 1.4) {
138
+ // A FONTE que se digita, com a barra do acento (é rótulo de borda: vem com o HUD).
139
+ if (hud && ex.fonte && d > 1.4) {
137
140
  els.push({ tipo: 'kit', peca: 'fonte', texto: String(ex.fonte).slice(0, 48), t: Math.min(0.9, d * 0.35),
138
141
  x: M, y: Math.round(H - M * (deitado ? 1.1 : 2.6)), tamanho: Math.round(base * 0.016), cor: 'rgba(245,245,247,0.72)', corAcento: ACENTO });
139
142
  }
140
143
  // O NÚMERO estoura em partículas quando assenta.
141
- if (tipo === 'numero' || tipo === 'grafico') {
144
+ if (particulas && (tipo === 'numero' || tipo === 'grafico')) {
142
145
  els.push({ tipo: 'kit', peca: 'particulas', t: 0.45, x: Math.round(W / 2), y: Math.round(H * 0.42), raio: Math.round(base * 0.34), n: 46,
143
146
  tamanho: Math.max(5, Math.round(base * 0.007)), cores: ['#ffffff', ACENTO, grad[0]] });
144
147
  }
145
148
  // O FIM: a marca entra com um brilho de partículas.
146
- if (tipo === 'cta' || tipo === 'logo' || tipo === 'fim') {
149
+ if (particulas && (tipo === 'cta' || tipo === 'logo' || tipo === 'fim')) {
147
150
  els.push({ tipo: 'kit', peca: 'particulas', t: 0.2, x: Math.round(W / 2), y: Math.round(H * 0.36), raio: Math.round(base * 0.45), n: 60,
148
151
  tamanho: Math.max(4, Math.round(base * 0.006)), cores: ['#ffffff', ACENTO], forma: 'circulo' });
149
152
  }
@@ -154,8 +157,9 @@ function enfeitar(plano, { extras = [], partes = [], marca = {}, largura, altura
154
157
  }
155
158
  });
156
159
 
157
- // O HUD de motion designer: cantos, timecode e a marca.
158
- plano.hud = plano.hud || { marca: String(marca.nome || '').toUpperCase().slice(0, 24) || undefined };
160
+ // O HUD de motion designer (cantos, timecode e a marca): só a pedido.
161
+ if (hud) plano.hud = plano.hud || { marca: String(marca.nome || '').toUpperCase().slice(0, 24) || undefined };
162
+ else delete plano.hud;
159
163
  // O acabamento de filme um pouco mais presente que o do cinema.
160
164
  plano.acabamento = { ...(plano.acabamento || {}), grao: Math.max(0.06, (plano.acabamento || {}).grao || 0), vinheta: Math.max(0.55, (plano.acabamento || {}).vinheta || 0) };
161
165
  plano.estilo = 'hibrido';