primocode 10.0.9 → 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.
@@ -0,0 +1,283 @@
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
+ breu: { escuro: true, fundo: ['#030304', '#0b0a12', '#2a2156'], tinta: '#f4f4f6', suave: '#8d8a9c', acentos: ['#b4a6ff', '#5b45ff'] },
42
+ lavanda: { escuro: false, fundo: ['#f5f4ff', '#e4e1ff', '#b9b2ff'], tinta: '#17153a', suave: '#6e6a92', acentos: ['#4a3ce0', '#8f86ff'] },
43
+ creme: { escuro: false, fundo: ['#fbf8f2', '#f2e8d8', '#e7c9a5'], tinta: '#1e1a15', suave: '#857a6c', acentos: ['#c2502a', '#f0a36b'] },
44
+ };
45
+ const ESCURAS = ['noite', 'breu'], CLARAS = ['lavanda', 'creme'];
46
+
47
+ const hash = (s) => { let h = 2166136261; for (const c of String(s)) h = Math.imul(h ^ c.charCodeAt(0), 16777619); return h >>> 0; };
48
+
49
+ /** A paleta: a pedida pelo nome; senão o tema (claro/escuro); senão a semente. */
50
+ function paletaDe(roteiro, semente) {
51
+ const pedida = String(roteiro.paleta || '').toLowerCase();
52
+ const tema = String(roteiro.tema || '').toLowerCase();
53
+ const nome = PALETAS[pedida] ? pedida
54
+ : /claro|light/.test(tema) ? CLARAS[semente % 2]
55
+ : /escuro|dark/.test(tema) ? ESCURAS[semente % 2]
56
+ : Object.keys(PALETAS)[semente % 4];
57
+ const p = { nome, ...PALETAS[nome], acentos: [...PALETAS[nome].acentos] };
58
+ // A cor da marca vira o acento da palavra (e só ela: a paleta não cresce).
59
+ const dest = roteiro.marca && roteiro.marca.cores && roteiro.marca.cores.destaque;
60
+ if (/^#[0-9a-f]{6}$/i.test(dest || '')) p.acentos[0] = dest;
61
+ return p;
62
+ }
63
+
64
+ /* Caixa alta não entra: frase GRITADA vira caixa normal (sigla curta fica). */
65
+ function caixaNormal(t) {
66
+ const s = String(t || '').trim();
67
+ const letras = s.replace(/[^A-Za-zÀ-ÿ]/g, '');
68
+ if (letras.length < 4 || letras !== letras.toUpperCase()) return s;
69
+ const m = s.toLowerCase();
70
+ return m.charAt(0).toUpperCase() + m.slice(1);
71
+ }
72
+
73
+ 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(' '));
74
+ const limpa = (w) => w.toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '').replace(/[^a-z0-9]/g, '');
75
+
76
+ /** A frase em palavras, com UM acento: o destaque pedido, ou a palavra de mais peso. */
77
+ function frase(texto, { destaque = '', semAcento = false } = {}) {
78
+ const ws = caixaNormal(texto).split(/\s+/).filter(Boolean);
79
+ let ac = -1;
80
+ if (!semAcento && ws.length > 1) {
81
+ const d = limpa(String(destaque).split(/\s+/)[0] || '');
82
+ if (d) ac = ws.findIndex((w) => limpa(w) === d);
83
+ if (ac < 0) {
84
+ // A palavra mais longa que não é vazia; no empate, a mais tarde (a frase termina nela).
85
+ let melhor = 0;
86
+ ws.forEach((w, i) => { const n = limpa(w).length; if (!VAZIAS.has(limpa(w)) && n >= 4 && n >= melhor) { melhor = n; ac = i; } });
87
+ }
88
+ }
89
+ return ws.map((t, i) => ({ t, acento: i === ac }));
90
+ }
91
+
92
+ const numeroDe = (v) => {
93
+ const m = String(v == null ? '' : v).match(/^(\D*)([\d.,]+)(.*)$/);
94
+ if (!m) return null;
95
+ const n = Number(m[2].replace(/\./g, '').replace(',', '.'));
96
+ return Number.isFinite(n) ? { antes: m[1], ate: n, depois: m[3], casas: /,\d/.test(m[2]) ? 1 : 0 } : null;
97
+ };
98
+ const itensDe = (p) => (p.itens || []).map((i) => (typeof i === 'string' ? i : i.texto || i.titulo || '')).filter(Boolean);
99
+ const leitura = (n) => Math.max(MINIMO, POR_PALAVRA * n);
100
+
101
+ /* A forma do portador em cada tipo de parte (kit/fluido.jsx dá o tamanho). */
102
+ const FORMA = { abertura: 'ponto', titulo: 'pilula', numero: 'anel', lista: 'pilula', passos: 'ponto', contraste: 'linha', citacao: 'cartao', cta: 'marca' };
103
+ const TIPO = { logo: 'abertura', imagem: 'titulo', produto: 'titulo', grafico: 'lista', cores: 'lista' };
104
+
105
+ /**
106
+ * A partitura: as batidas, cada uma com as frases (e quando cada uma entra),
107
+ * a forma do portador e a transição. Sem áudio nem plano — é o que os testes olham.
108
+ */
109
+ function partituraDe({ roteiro, largura = 1920, altura = 1080, tempos = [] }) {
110
+ const partes = (roteiro.partes || []).filter((p) => p && typeof p === 'object');
111
+ const semente = Number.isFinite(roteiro.semente) ? roteiro.semente : hash(JSON.stringify(partes.map((p) => [p.tipo, p.titulo, p.valor])));
112
+ const paleta = paletaDe(roteiro, semente);
113
+ const marca = roteiro.marca || {};
114
+ const temVoz = tempos.length === partes.length && tempos.every((t) => t && t.ate > t.de);
115
+ /* O comando do terminal: o link da cta, se for comando; senão o nome da marca. */
116
+ const link = (partes.find((p) => p.tipo === 'cta') || {}).link || '';
117
+ const cmd = /^(npm|npx|pnpm|yarn|pip|brew|curl|git|bun)\b/.test(link) ? link : String(marca.nome || 'app').toLowerCase().replace(/\s+/g, '-');
118
+ let comCartao = 0;
119
+
120
+ const batidas = partes.map((p, i) => {
121
+ const tipo = TIPO[p.tipo] || (FORMA[p.tipo] ? p.tipo : 'titulo');
122
+ const titulo = p.titulo || p.texto || '';
123
+ const fr = [];
124
+ const poe = (papel, texto, o = {}) => { if (String(texto || '').trim()) fr.push({ id: `${i}-${fr.length}`, papel, palavras: frase(texto, o) }); };
125
+ const b = { i, tipo, forma: FORMA[tipo], transicao: p.corte === true && i > 0 ? 'corte' : 'morfa', frases: fr, lado: 'centro' };
126
+ /* O CARTÃO DE PRODUTO que flutua ao lado (terminal, chat, painel), feito
127
+ de forma e texto — e o texto vai para o outro lado. `ui: false` tira. */
128
+ const ui = p.ui === false ? null : typeof p.ui === 'string' ? p.ui
129
+ : tipo === 'titulo' || tipo === 'abertura' ? ['terminal', 'chat'][comCartao % 2] : tipo === 'numero' ? 'painel' : null;
130
+ if (ui) {
131
+ b.lado = comCartao++ % 2 ? 'dir' : 'esq';
132
+ b.ui = ui === 'terminal' ? { tipo: ui, linhas: ['$ ' + cmd, '✓ ' + caixaNormal(p.sub || titulo)] }
133
+ : ui === 'chat' ? { tipo: ui, pergunta: caixaNormal(titulo) }
134
+ : { 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)) };
135
+ }
136
+ switch (tipo) {
137
+ case 'numero': {
138
+ b.numero = numeroDe(p.valor);
139
+ poe('numero', p.valor, { semAcento: true });
140
+ poe('rotulo', p.rotulo || titulo, { destaque: p.destaque });
141
+ break;
142
+ }
143
+ case 'lista': case 'passos':
144
+ poe('principal', titulo, { destaque: p.destaque });
145
+ itensDe(p).slice(0, tipo === 'passos' ? 6 : 4).forEach((it) => poe('item', it, { semAcento: true }));
146
+ break;
147
+ case 'contraste':
148
+ poe('nao', p.nao, { semAcento: true });
149
+ poe('sim', p.sim || titulo, { destaque: p.destaque });
150
+ break;
151
+ case 'citacao':
152
+ poe('principal', titulo, { destaque: p.destaque });
153
+ poe('autor', p.autor ? `— ${p.autor}` : '', { semAcento: true });
154
+ break;
155
+ case 'cta':
156
+ b.marca = { nome: caixaNormal(marca.nome || titulo) };
157
+ poe('marca', b.marca.nome, { semAcento: true });
158
+ poe('principal', marca.nome ? titulo : '', { destaque: p.destaque });
159
+ poe('sub', p.link || p.sub, { semAcento: true });
160
+ break;
161
+ default:
162
+ poe('principal', titulo, { destaque: p.destaque });
163
+ poe('sub', p.sub, { semAcento: true });
164
+ }
165
+ return b;
166
+ });
167
+
168
+ /* ── o tempo: a VOZ manda. A batida troca na fronteira da frase (um
169
+ pouco antes da primeira palavra da parte) e cada palavra na tela
170
+ aparece no instante em que é dita (os tempos por palavra da narração).
171
+ A palavra que a voz não diz entra em cascata curta. Se o texto pede
172
+ mais leitura do que a frase deu, a batida estica e a próxima volta a
173
+ casar com a voz na primeira folga; o atraso medido vai na partitura. */
174
+ let atraso = 0, sincronia = 0;
175
+ batidas.forEach((b, i) => {
176
+ const tp = temVoz ? tempos[i] : null;
177
+ const deVoz = tp ? (i === 0 ? 0 : Math.max(0, tp.de - ANTES)) : null;
178
+ b.de = r2(i === 0 ? 0 : Math.max(batidas[i - 1].ate, deVoz == null ? 0 : deVoz));
179
+ if (deVoz != null) atraso = Math.max(atraso, r2(b.de - deVoz));
180
+ const ditas = ((tp && tp.palavras) || []).map((w) => ({ k: limpa(w.texto), de: w.de }));
181
+ let cursor = 0, livre = b.de + (b.tipo === 'cta' ? 0.55 : 0.12);
182
+ const fimVoz = tp ? (i < batidas.length - 1 ? tempos[i + 1].de - ANTES : tp.ate + 0.2) : 0;
183
+ for (const f of b.frases) {
184
+ /* Casa as palavras da tela com as faladas, na ordem. Tenta começar de
185
+ cada palavra (a frase da tela pode vir em outra ordem que a falada)
186
+ e fica com o casamento que acha mais palavras. */
187
+ const casa = (k, d) => d === k || (k.length >= 4 && d.length >= 4 && d.slice(0, 5) === k.slice(0, 5));
188
+ let ts = f.palavras.map(() => null), fim = cursor;
189
+ for (let ini = 0; ini < f.palavras.length; ini++) {
190
+ let c = cursor;
191
+ const tt = f.palavras.map((w, j) => {
192
+ const k = limpa(w.t);
193
+ if (j < ini || !k) return null;
194
+ for (let q = c; q < Math.min(ditas.length, c + 12); q++) if (casa(k, ditas[q].k)) { c = q + 1; return ditas[q].de; }
195
+ return null;
196
+ });
197
+ if (tt.filter((x) => x != null).length > ts.filter((x) => x != null).length) { ts = tt; fim = c; }
198
+ }
199
+ cursor = fim;
200
+ f.falada = ts.filter((x) => x != null).length >= f.palavras.length / 2;
201
+ const ach = ts.findIndex((x) => x != null);
202
+ /* O que a voz não diz entra cedo o bastante para ser lido antes de a
203
+ frase acabar (e nunca antes da batida). */
204
+ let t = ach >= 0 ? ts[ach] - ach * STAGGER
205
+ : tp ? Math.max(b.de + 0.25, Math.min(livre, fimVoz - leitura(f.palavras.length) - 0.05)) : livre;
206
+ f.palavras.forEach((w, j) => {
207
+ const falada = ts[j];
208
+ t = Math.max(b.de + 0.06, falada != null ? Math.max(t, falada - 0.05) : t);
209
+ w.em = r2(t);
210
+ if (falada != null) sincronia = Math.max(sincronia, r2(w.em - falada));
211
+ t += STAGGER;
212
+ });
213
+ f.de = f.palavras[0].em;
214
+ // O que não é dito (rótulo, item, link) entra logo depois do anterior.
215
+ 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);
216
+ }
217
+ /* O texto que a voz DIZ é lido junto com ela: fica até depois da última
218
+ palavra dita (e nunca menos de 0,8 s). O que a voz não diz pede 0,3 s por palavra. */
219
+ const precisa = Math.max(...b.frases.map((f) => f.de + (f.falada
220
+ ? Math.max(MINIMO, f.palavras[f.palavras.length - 1].em - f.de + 0.3) : leitura(f.palavras.length))), b.de + 0.9);
221
+ b.ate = r2(Math.max(precisa, fimVoz));
222
+ });
223
+ // A marca do fim segura um instante; o resto não espera.
224
+ const ult = batidas[batidas.length - 1];
225
+ ult.ate = r2(ult.ate + (ult.tipo === 'cta' ? 1.0 : 0.5));
226
+
227
+ return {
228
+ versao: 2, semente, fps: FPS, atraso, sincronia, largura, altura, duracao: ult.ate, paleta,
229
+ fontes: { texto: 'Inter', serifa: 'Instrument Serif', mono: 'JetBrains Mono' },
230
+ logo: marca.logo || null,
231
+ batidas,
232
+ };
233
+ }
234
+
235
+ /**
236
+ * @param {object} op
237
+ * @param {object} op.roteiro { marca, paleta?: noite|breu|lavanda|creme, tema?: claro|escuro, partes }
238
+ * @param {Array} op.tempos [{de, ate, palavras}] por parte (da narração)
239
+ * @param {object} op.sons os efeitos leves (sons-leves.js)
240
+ */
241
+ function montarFluido({ roteiro, largura = 1920, altura = 1080, tempos = [], narracao = null, sons = {}, trilha = null }) {
242
+ const partes = ((roteiro && roteiro.partes) || []).filter((p) => p && typeof p === 'object');
243
+ if (!partes.length) return { ok: false, error: 'o roteiro veio sem partes.' };
244
+ const partitura = partituraDe({ roteiro: { ...roteiro, partes }, largura, altura, tempos });
245
+ const DUR = partitura.duracao;
246
+ /* O SOM segue o movimento (sons-leves.js, tudo sintetizado): whoosh em
247
+ cada transformação, pop quando pílula ou cartão assenta, clique na roda e
248
+ no terminal, um bolha discreto na palavra de acento, o riser que leva à
249
+ marca e o impacto quando ela trava. Baixo: a voz vem primeiro. */
250
+ const efeitos = [];
251
+ const som = (t, nome, volume) => { if (sons[nome]) efeitos.push({ t: r2(Math.max(0, t)), src: sons[nome], volume }); };
252
+ for (const b of partitura.batidas) {
253
+ if (b.i > 0 && b.tipo !== 'cta') som(b.de - 0.18, 'whoosh', 0.2);
254
+ 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)); }
255
+ for (const f of b.frases) {
256
+ if (f.papel === 'item') som(f.de - 0.06, b.tipo === 'passos' ? 'clique' : 'pop', 0.15);
257
+ const ac = f.palavras.find((w) => w.acento);
258
+ if (ac && f.papel !== 'item') som(ac.em, 'bolha', 0.09);
259
+ }
260
+ if (b.tipo === 'citacao') som(b.de + 0.3, 'pop', 0.14);
261
+ 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); }
262
+ }
263
+ const volumes = volumesDaTrilha(tempos.length === partes.length ? tempos : [], DUR, { alto: 0.55, baixo: 0.22 });
264
+ const P = partitura.paleta;
265
+ const plano = {
266
+ formato: { largura, altura, fps: FPS },
267
+ fundoGeral: P.fundo[0],
268
+ fontesVariaveis: { Inter: 'wght@300..700', 'Instrument Serif': 'ital@0;1', 'JetBrains Mono': 'wght@400;500' },
269
+ cenas: [{
270
+ duracao: DUR,
271
+ fundo: P.fundo[0],
272
+ elementos: [{ tipo: 'kit', peca: 'fluido', partitura, fonte: partitura.fontes.texto, fonteSerifa: partitura.fontes.serifa, fonteMono: partitura.fontes.mono }],
273
+ }],
274
+ audio: {
275
+ ...(narracao ? { narracao } : {}),
276
+ ...(trilha ? { trilha: { src: trilha, volumes, loop: true } } : {}),
277
+ efeitos,
278
+ },
279
+ };
280
+ return { ok: true, plano, partitura, resumo: { cenas: 1, batidas: partitura.batidas.length, segundos: DUR, fps: FPS, paleta: P.nome, atraso: partitura.atraso, sincronia: partitura.sincronia } };
281
+ }
282
+
283
+ 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';
@@ -24,6 +24,7 @@
24
24
 
25
25
  const { caber, paleta } = require('./composicao.js');
26
26
  const { iconeDe, peso } = require('./edicao.js');
27
+ const { minimoLeitura, sobra } = require('./legibilidade.js');
27
28
 
28
29
  const r2 = (n) => Math.round(n * 100) / 100;
29
30
  const limpa = (t) => String(t || '').replace(/^[^\p{L}\p{N}]+|[^\p{L}\p{N}%!?]+$/gu, '');
@@ -72,11 +73,12 @@ function montarImpacto({ roteiro, largura = 1080, altura = 1920, tempos = [], na
72
73
  const icOk = (ic) => ic && (!iconesOk || iconesOk.has(ic));
73
74
 
74
75
  // A palavra que bate: letra gigante, a palavra forte na cor de destaque.
75
- const bater = (els, texto, { y = null, corTexto = cor.tinta, destaque = cor.destaque, max = (deitado ? H * 0.2 : W * 0.2), linhas = 3, glitch = false, girar = 0 } = {}) => {
76
- const t = String(texto || '').toUpperCase();
77
- /* Caixa alta em peso 900 é ~20% mais larga que a letra comum, e a
78
- batida ainda cresce 5%: mede numa caixa mais estreita (medido em
79
- 25/09: "CONSTRUÇÃO" e "DEMONSTRAÇÃO" saíam pela borda). */
76
+ const bater = (els, texto, { y = null, corTexto = cor.tinta, destaque = cor.destaque, max = (deitado ? H * 0.2 : W * 0.2), linhas = 3, glitch = false, girar = 0, entra = 0.22 } = {}) => {
77
+ // Como a pessoa escreveu: caixa alta forçada era a "cara de IA" (05/10).
78
+ const t = String(texto || '');
79
+ /* Peso 900 é mais largo que a letra comum, e a batida ainda cresce 5%:
80
+ mede numa caixa mais estreita (medido em 25/09: "CONSTRUÇÃO" e
81
+ "DEMONSTRAÇÃO" saíam pela borda). */
80
82
  const fit = caber(t, { largura: W0 * 0.8, linhas, max, fonte: F.titulo });
81
83
  const h = fit.tamanho * 0.98 * fit.linhas;
82
84
  const ws = t.split(/\s+/);
@@ -85,12 +87,14 @@ function montarImpacto({ roteiro, largura = 1080, altura = 1920, tempos = [], na
85
87
  els.push({
86
88
  tipo: 'texto', texto: t, fonte: F.titulo, peso: 900, tamanho: fit.tamanho, cor: corTexto, entrelinha: 0.98,
87
89
  x: M, y: Math.round(y == null ? (H - h) / 2 : y), largura: W0, alinhamento: 'center', espacamento: -1,
88
- ...(forte >= 0 && ws.length > 1 ? { revelar: ws.map(() => 0), destaques: [forte], corDestaque: destaque } : {}),
90
+ // A palavra forte é UM acento: a serifa em itálico, que chega um instante depois.
91
+ ...(forte >= 0 && ws.length > 1 ? { revelar: ws.map(() => 0), destaques: [forte], corDestaque: destaque, fonteDestaque: 'Instrument Serif' } : {}),
89
92
  ...(glitch ? { glitch: { forca: 14, dur: 0.3 } } : {}),
90
93
  anima: {
91
- escala: [{ t: 0, v: 1.45 }, { t: 0.13, v: 0.97, e: 'saida' }, { t: 0.22, v: 1, e: 'suave' }, { t: 3, v: 1.05, e: 'linear' }],
92
- opacidade: [{ t: 0, v: 0 }, { t: 0.05, v: 1 }],
93
- rotacao: [{ t: 0, v: girar * 2 }, { t: 0.2, v: girar, e: 'saida' }],
94
+ // A entrada encolhe junto (entra < 0,22) quando a batida é curta: a leitura não.
95
+ escala: [{ t: 0, v: 1.45 }, { t: r2(entra * 0.6), v: 0.97, e: 'saida' }, { t: r2(entra), v: 1, e: 'suave' }, { t: 3, v: 1.05, e: 'linear' }],
96
+ opacidade: [{ t: 0, v: 0 }, { t: r2(entra * 0.23), v: 1 }],
97
+ rotacao: [{ t: 0, v: girar * 2 }, { t: r2(entra * 0.9), v: girar, e: 'saida' }],
94
98
  },
95
99
  });
96
100
  return h;
@@ -126,18 +130,51 @@ function montarImpacto({ roteiro, largura = 1080, altura = 1920, tempos = [], na
126
130
  let bs = batidas((tp.palavras || []).filter((w) => w && w.texto), fim);
127
131
  if (!bs.length) bs = [[{ de: ini, ate: fim, texto: p.titulo || p.sim || p.valor || '' }]];
128
132
  const itens = (p.itens || []).map((it) => (typeof it === 'string' ? it : it.texto));
133
+ const semNaoE = (t) => String(t || '').replace(/^\s*(n[aã]o\s+[ée]\s+|[ée]\s+)/i, '');
134
+ // Os textos que a batida k (de n) mostra — os mesmos que o desenho abaixo usa.
135
+ const textosDe = (b, k, n) => {
136
+ const falado = b.map((w) => limpa(w.texto)).join(' ');
137
+ if (p.tipo === 'numero' && k === 0) return [String(p.valor), p.rotulo || ''];
138
+ if ((p.tipo === 'lista' || p.tipo === 'passos') && itens.length) {
139
+ const it = itens[Math.min(k, itens.length - 1)];
140
+ return [p.tipo === 'passos' ? `${k + 1}. ${it}` : it, k === 0 ? p.titulo || '' : ''];
141
+ }
142
+ if (p.tipo === 'cta' && k === n - 1) return [p.titulo || falado, p.link || ''];
143
+ if (p.tipo === 'contraste') return [k < n / 2 ? `não é ${semNaoE(p.nao)}` : semNaoE(p.sim)];
144
+ // O título no fim só se der tempo de ler; senão, a fala dessa batida.
145
+ return [k === n - 1 && p.titulo && k > 0 && vao(k) >= minimoLeitura(p.titulo) + 0.08 ? p.titulo : falado];
146
+ };
147
+ /* A lei do /brag: cada batida fica no ar a entrada mínima mais
148
+ max(0,8 s; 0,3 s por palavra) — dentro do tempo da parte, sem esticar
149
+ o vídeo. Batida curta pega a folga da vizinha (o corte anda um pouco);
150
+ sem folga, gruda nela. */
151
+ const inis = bs.map((b, k) => (k === 0 ? ini : b[0].de - 0.04));
152
+ const vao = (k) => (k < bs.length - 1 ? inis[k + 1] : fim) - inis[k];
153
+ const precisa = (k) => Math.max(...textosDe(bs[k], k, bs.length).filter(Boolean).map(minimoLeitura))
154
+ + (p.tipo === 'cta' && k === bs.length - 1 && p.link ? 0.23 : 0) + 0.08; // o link entra depois do título
155
+ for (let k = 0; bs.length > 1 && k < bs.length;) {
156
+ const falta = precisa(k) - vao(k);
157
+ if (falta <= 0) { k++; continue; }
158
+ if (k + 1 < bs.length && vao(k + 1) - falta >= precisa(k + 1)) { inis[k + 1] = r2(inis[k + 1] + falta); k++; continue; }
159
+ if (k > 0 && vao(k - 1) - falta >= precisa(k - 1)) { inis[k] = r2(inis[k] - falta); k++; continue; }
160
+ const a = k > 0 ? k - 1 : 0;
161
+ bs.splice(a, 2, [...bs[a], ...bs[a + 1]]);
162
+ inis.splice(a + 1, 1);
163
+ k = 0;
164
+ }
129
165
 
130
166
  // Impacto no começo de cada parte; o riser anuncia a chamada final.
131
167
  som(ini, 'impacto', i === 0 ? 0.9 : 0.6);
132
168
  if (p.tipo === 'cta') som(ini - 2, 'riser', 0.6);
133
169
 
134
170
  bs.forEach((b, k) => {
135
- const de = k === 0 ? ini : b[0].de - 0.04;
136
- const ate = k < bs.length - 1 ? bs[k + 1][0].de - 0.04 : fim;
171
+ const de = inis[k];
172
+ const ate = k < bs.length - 1 ? inis[k + 1] : fim;
137
173
  const falado = b.map((w) => limpa(w.texto)).join(' ');
138
174
  const els = [];
139
175
  const cena = { fundo: { tipo: 'cor', valor: cor.fundo }, elementos: els };
140
176
  const ultimo = k === bs.length - 1;
177
+ const entra = r2(sobra(vao(k), precisa(k) - 0.08, 0.08, 0.22));
141
178
  let tela; // 'palavra' | 'foto' | 'marca'
142
179
 
143
180
  if (p.tipo === 'numero' && k === 0) {
@@ -148,8 +185,8 @@ function montarImpacto({ roteiro, largura = 1080, altura = 1920, tempos = [], na
148
185
  const fit = caber(valor, { largura: W0, linhas: 1, max: H * (deitado ? 0.55 : 0.32), fonte: F.titulo });
149
186
  els.push({ tipo: 'texto', texto: valor, fonte: F.titulo, peso: 900, tamanho: fit.tamanho, cor: '#FFFFFF', x: M, y: Math.round(H * 0.5 - fit.tamanho * 0.62), largura: W0, alinhamento: 'center',
150
187
  ...(m ? { contar: { ate: Number(m[2].replace(/\./g, '').replace(',', '.')), dur: 0.7, antes: m[1], depois: m[3] } } : {}),
151
- anima: { escala: [{ t: 0, v: 1.6 }, { t: 0.18, v: 1, e: 'saida' }] } });
152
- bater(els, p.rotulo || '', { y: H * 0.5 + fit.tamanho * 0.45, max: H * 0.07, linhas: 2, corTexto: '#FFFFFF', destaque: '#FFFFFF' });
188
+ anima: { escala: [{ t: 0, v: 1.6 }, { t: r2(Math.min(0.18, entra)), v: 1, e: 'saida' }] } });
189
+ bater(els, p.rotulo || '', { y: H * 0.5 + fit.tamanho * 0.45, max: H * 0.07, linhas: 2, corTexto: '#FFFFFF', destaque: '#FFFFFF', entra });
153
190
  cena.tremor = [{ de: 0, ate: 0.5, forca: 26 }];
154
191
  tela = 'marca';
155
192
  } else if ((p.tipo === 'lista' || p.tipo === 'passos') && itens.length) {
@@ -159,9 +196,9 @@ function montarImpacto({ roteiro, largura = 1080, altura = 1920, tempos = [], na
159
196
  const rot = p.tipo === 'passos' ? `${k + 1}. ${it}` : it;
160
197
  const temE = emoji(els, it, { y: H * (deitado ? 0.28 : 0.3) });
161
198
  // No gradiente, o destaque é o escuro da marca: laranja sobre azul some.
162
- bater(els, rot, { y: temE ? H * (deitado ? 0.48 : 0.45) : null, max: deitado ? H * 0.11 : W * 0.12, linhas: 4, glitch: k === 0,
199
+ bater(els, rot, { y: temE ? H * (deitado ? 0.48 : 0.45) : null, max: deitado ? H * 0.11 : W * 0.12, linhas: 4, glitch: k === 0, entra,
163
200
  ...(k % 2 === 1 ? { corTexto: '#FFFFFF', destaque: cor.fundo } : {}) });
164
- if (k === 0 && p.titulo) els.push({ tipo: 'texto', texto: String(p.titulo).toUpperCase(), fonte: F.texto, peso: 800, tamanho: Math.round(Math.min(W, H) * 0.045), cor: cor.destaque, x: M, y: M * 2.2, largura: W0, alinhamento: 'center', espacamento: 3 });
201
+ if (k === 0 && p.titulo) els.push({ tipo: 'texto', texto: String(p.titulo), fonte: F.texto, peso: 700, tamanho: Math.round(Math.min(W, H) * 0.04), cor: cor.destaque, x: M, y: M * 2.2, largura: W0, alinhamento: 'center' });
165
202
  tela = k % 2 ? 'marca' : 'palavra';
166
203
  cena.socos = [{ t: 0, forca: 0.14 }];
167
204
  som(de, 'estalo', 0.55);
@@ -173,7 +210,7 @@ function montarImpacto({ roteiro, largura = 1080, altura = 1920, tempos = [], na
173
210
  els.push({ tipo: 'forma', forma: 'circulo', largura: R, altura: R, x: Math.round(W / 2 - R / 2), y: Math.round(H * 0.4 - R / 2), cor: grad[1], opacidade: 0.4,
174
211
  anima: { desfoque: [{ t: 0, v: Math.round(R * 0.2) }], escala: [{ t: 0, v: 0.7 }, { t: 1, v: 1.1, e: 'suave' }] } });
175
212
  const yT = H * 0.26;
176
- const hT = bater(els, p.titulo || falado, { y: yT, max: deitado ? H * 0.14 : W * 0.14, linhas: 3, corTexto: '#FFFFFF', destaque: cor.destaque });
213
+ const hT = bater(els, p.titulo || falado, { y: yT, max: deitado ? H * 0.14 : W * 0.14, linhas: 3, corTexto: '#FFFFFF', destaque: cor.destaque, entra });
177
214
  if (p.link) {
178
215
  const fl = Math.round(Math.min(W, H) * 0.055);
179
216
  const wP = Math.min(W0, String(p.link).length * fl * 0.62 + fl * 2.4);
@@ -193,39 +230,37 @@ function montarImpacto({ roteiro, largura = 1080, altura = 1920, tempos = [], na
193
230
  // O rodízio das três telas, com a primeira batida sempre na palavra.
194
231
  const ciclo = ['palavra', 'foto', 'marca', 'foto'];
195
232
  tela = k === 0 ? (p.tipo === 'imagem' || i === 0 ? 'foto' : 'palavra') : ciclo[(nBatida + k) % ciclo.length];
196
- const semNaoE = (t) => String(t || '').replace(/^\s*(n[aã]o\s+[ée]\s+|[ée]\s+)/i, '');
197
- const texto = p.tipo === 'contraste' ? (k < bs.length / 2 ? `não é ${semNaoE(p.nao)}` : semNaoE(p.sim))
198
- : (ultimo && p.titulo && k > 0 ? p.titulo : falado);
233
+ const texto = textosDe(b, k, bs.length)[0];
199
234
  if (tela === 'foto' && fotos.length) {
200
235
  fotoCheia(els, proxFoto());
201
- bater(els, texto, { y: H * (deitado ? 0.56 : 0.6), max: deitado ? H * 0.12 : W * 0.13, linhas: 3 });
236
+ bater(els, texto, { y: H * (deitado ? 0.56 : 0.6), max: deitado ? H * 0.12 : W * 0.13, linhas: 3, entra });
202
237
  if (k % 2 === 0) cena.flashes = [0];
203
238
  } else if (tela === 'marca') {
204
239
  cena.fundo = { tipo: 'gradiente', cores: [grad[0], grad[1]], angulo: 135 };
205
240
  const temE = emoji(els, texto, { y: H * (deitado ? 0.26 : 0.28) });
206
- bater(els, texto, { y: temE ? H * (deitado ? 0.46 : 0.44) : null, corTexto: '#FFFFFF', destaque: cor.fundo, max: deitado ? H * 0.16 : W * 0.16 });
241
+ bater(els, texto, { y: temE ? H * (deitado ? 0.46 : 0.44) : null, corTexto: '#FFFFFF', destaque: cor.fundo, max: deitado ? H * 0.16 : W * 0.16, entra });
207
242
  } else {
208
243
  tela = 'palavra';
209
244
  const temE = emoji(els, texto, { y: H * (deitado ? 0.25 : 0.27) });
210
245
  bater(els, texto, { y: temE ? H * (deitado ? 0.45 : 0.43) : null, glitch: p.tipo === 'contraste' || k % 3 === 0,
211
- girar: k % 2 ? -2 : 2, corTexto: p.tipo === 'contraste' && k < bs.length / 2 ? 'rgba(255,255,255,0.55)' : cor.tinta });
246
+ girar: k % 2 ? -2 : 2, entra, corTexto: p.tipo === 'contraste' && k < bs.length / 2 ? 'rgba(255,255,255,0.55)' : cor.tinta });
212
247
  cena.socos = [{ t: 0, forca: 0.2 }];
213
248
  }
214
249
  if (k === 0) cena.tremor = [{ de: 0, ate: 0.35, forca: 18 }];
215
250
  som(de, tela === 'foto' ? 'whoosh' : 'estalo', tela === 'foto' ? 0.5 : 0.5);
216
251
  }
217
252
  if (!(p.tipo === 'cta' && ultimo)) logoTopo(els);
218
- /* A FAIXA: o subtítulo da parte (é onde moram a data e o local) em
219
- letra pequena e espaçada no alto — o anúncio não pode esconder a data. */
253
+ /* A LINHA DE CIMA: o subtítulo da parte (é onde moram a data e o local)
254
+ em letra pequena no alto — o anúncio não pode esconder a data. Sem a
255
+ faixa de cor atrás (caixa de cor era a "cara de IA", 05/10): só o
256
+ texto, que sobe e aparece. */
220
257
  if (p.sub && p.tipo !== 'numero' && !(p.tipo === 'lista' || p.tipo === 'passos')) {
221
- const fk = Math.round(Math.min(W, H) * 0.042);
222
- els.push({ tipo: 'forma', forma: 'retangulo', x: M, y: Math.round(H * (deitado ? 0.14 : 0.1)), largura: W0, altura: Math.round(fk * 2), cor: cor.destaque, raio: Math.round(fk),
223
- anima: { escalaX: [{ t: 0.05, v: 0 }, { t: 0.3, v: 1, e: 'saida' }] }, origem: 'center' });
258
+ const fk = Math.round(Math.min(W, H) * 0.036);
224
259
  // Com largura, e não pela âncora: sem largura o texto quebrava na metade da tela.
225
- const tk = caber(String(p.sub).toUpperCase(), { largura: W0 * 0.78, linhas: 1, max: fk, fonte: F.texto }).tamanho;
226
- els.push({ tipo: 'texto', texto: String(p.sub).toUpperCase(), fonte: F.texto, peso: 800, tamanho: tk, entrelinha: 1,
227
- cor: cor.sobreDestaque === '#FFFFFF' ? '#FFFFFF' : '#121218', x: M, largura: W0, alinhamento: 'center', y: Math.round(H * (deitado ? 0.14 : 0.1) + fk - tk * 0.5), espacamento: 2,
228
- anima: { opacidade: [{ t: 0.2, v: 0 }, { t: 0.35, v: 1 }] } });
260
+ const tk = caber(String(p.sub), { largura: W0 * 0.78, linhas: 1, max: fk, fonte: F.texto }).tamanho;
261
+ els.push({ tipo: 'texto', texto: String(p.sub), fonte: F.texto, peso: 600, tamanho: tk, entrelinha: 1,
262
+ cor: tela === 'marca' ? '#FFFFFF' : cor.destaque, x: M, largura: W0, alinhamento: 'center', y: Math.round(H * (deitado ? 0.14 : 0.1)),
263
+ anima: { opacidade: [{ t: 0.2, v: 0 }, { t: 0.5, v: 1 }], y: [{ t: 0.2, v: 12 }, { t: 0.6, v: 0, e: 'saida' }] } });
229
264
  }
230
265
 
231
266
  /* Uma transição curta a cada tanto; o resto é corte seco — é o