primocode 9.8.0-beta.9 → 9.8.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,263 @@
1
+ /**
2
+ * impacto.js — o estilo de anúncio: corte a cada batida, palavra que bate
3
+ * na tela, foto que pisca, tremor, soco, chicote, flash e som de impacto.
4
+ *
5
+ * "Falta mais ação, o vídeo ser mais agressivo! Mais vendável, mais
6
+ * atraente… falta aplicar a identidade visual… falta mais efeito, e o
7
+ * vídeo parece um vídeo de 2010." (25/09/2026)
8
+ *
9
+ * O estilo normal (composicao.js) dá uma cena por parte, de 4 a 5 s, com
10
+ * entrada suave: é apresentação. Anúncio de 2026 corta a cada 0,5 a 1,5 s.
11
+ * Aqui cada parte do roteiro vira várias BATIDAS, no tempo das palavras da
12
+ * voz, e cada batida é uma de três telas:
13
+ *
14
+ * PALAVRA a fala em letra gigante no preto, com soco de zoom e estalo;
15
+ * FOTO a foto real da marca, rápida, com a fala por cima;
16
+ * MARCA o gradiente da marca em tela cheia, com o emoji 3D.
17
+ *
18
+ * O número conta com tremor, a lista mostra um item por batida, e a chamada
19
+ * final vem com riser, impacto e a logo. Por cima de tudo: granulação,
20
+ * vinheta e a luz da marca passando.
21
+ */
22
+
23
+ 'use strict';
24
+
25
+ const { caber, paleta } = require('./composicao.js');
26
+ const { iconeDe, peso } = require('./edicao.js');
27
+
28
+ const r2 = (n) => Math.round(n * 100) / 100;
29
+ const limpa = (t) => String(t || '').replace(/^[^\p{L}\p{N}]+|[^\p{L}\p{N}%!?]+$/gu, '');
30
+
31
+ /* As batidas de uma parte: grupos de 1 a 4 palavras, de ~0,6 s ou mais,
32
+ quebrando na pontuação. Batida curta demais gruda na seguinte. */
33
+ function batidas(palavras, fim) {
34
+ const out = [];
35
+ let atual = [];
36
+ palavras.forEach((p, i) => {
37
+ atual.push(p);
38
+ const prox = palavras[i + 1];
39
+ const dur = (prox ? prox.de : fim) - atual[0].de;
40
+ // A batida não termina em palavra solta ("CLIQUE E | confirme"): ela gruda na seguinte.
41
+ const solta = /^(e|de|da|do|a|o|as|os|que|com|sua|seu|no|na|em|pra|para|por|um|uma|não|é)$/i.test(limpa(p.texto));
42
+ if (!prox || /[,.!?;:…]$/.test(p.texto) || atual.length >= 5 || (!solta && (atual.length >= 4 || (dur >= 0.9 && atual.length >= 2)))) {
43
+ out.push(atual);
44
+ atual = [];
45
+ }
46
+ });
47
+ const juntas = [];
48
+ for (const b of out) {
49
+ const ult = juntas[juntas.length - 1];
50
+ const dur = b[b.length - 1].ate - b[0].de;
51
+ if (ult && dur < 0.45 && ult.length + b.length <= 5) ult.push(...b);
52
+ else juntas.push([...b]);
53
+ }
54
+ return juntas;
55
+ }
56
+
57
+ function montarImpacto({ roteiro, largura = 1080, altura = 1920, tempos = [], narracao = null, sons = {}, trilha = null,
58
+ fotos = [], iconesOk = null }) {
59
+ const partes = (roteiro && roteiro.partes) || [];
60
+ if (!partes.length) return { ok: false, error: 'o roteiro veio sem partes.' };
61
+ const marca = roteiro.marca || {};
62
+ const cor = paleta(marca);
63
+ const grad = (marca.gradiente && marca.gradiente.length >= 2) ? marca.gradiente : [cor.apoio, cor.destaque];
64
+ const F = { titulo: (marca.fontes && marca.fontes.titulo) || 'Inter', texto: (marca.fontes && marca.fontes.texto) || 'Inter' };
65
+ const W = largura, H = altura, deitado = W >= H;
66
+ const M = Math.round(Math.min(W, H) * 0.07);
67
+ const W0 = W - 2 * M;
68
+ const cenas = [], efeitos = [];
69
+ let fotoI = 0, nBatida = 0;
70
+ const proxFoto = () => (fotos.length ? fotos[fotoI++ % fotos.length] : null);
71
+ const som = (t, nome, volume) => { if (sons[nome]) efeitos.push({ t: r2(Math.max(0, t)), src: sons[nome], volume }); };
72
+ const icOk = (ic) => ic && (!iconesOk || iconesOk.has(ic));
73
+
74
+ // 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). */
80
+ const fit = caber(t, { largura: W0 * 0.8, linhas, max, fonte: F.titulo });
81
+ const h = fit.tamanho * 0.98 * fit.linhas;
82
+ const ws = t.split(/\s+/);
83
+ let forte = -1, pf = 0;
84
+ ws.forEach((w, k) => { const v = peso(w); if (v > pf) { pf = v; forte = k; } });
85
+ els.push({
86
+ tipo: 'texto', texto: t, fonte: F.titulo, peso: 900, tamanho: fit.tamanho, cor: corTexto, entrelinha: 0.98,
87
+ 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 } : {}),
89
+ ...(glitch ? { glitch: { forca: 14, dur: 0.3 } } : {}),
90
+ 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
+ },
95
+ });
96
+ return h;
97
+ };
98
+ const emoji = (els, texto, { x = W / 2, y = H * 0.24, tam = Math.min(W, H) * 0.26 } = {}) => {
99
+ let ic = null;
100
+ for (const w of String(texto || '').split(/\s+/)) { ic = iconeDe(w); if (icOk(ic)) break; ic = null; }
101
+ if (!ic) return false;
102
+ els.push({ tipo: 'icone', nome: ic, tamanho: Math.round(tam), x: Math.round(x), y: Math.round(y), ancora: 'centro',
103
+ anima: { escala: [{ t: 0.05, v: 0 }, { t: 0.22, v: 1.2, e: 'saida' }, { t: 0.36, v: 1 }], rotacao: [{ t: 0.05, v: -25 }, { t: 0.36, v: 0, e: 'saida' }, { t: 3, v: 8 }],
104
+ y: [{ t: 0.36, v: 0 }, { t: 3, v: -30, e: 'suave' }] } });
105
+ return true;
106
+ };
107
+ const fotoCheia = (els, f, { veu = 0.55 } = {}) => {
108
+ if (!f) return;
109
+ const v = f.tipo === 'video';
110
+ els.push({ tipo: v ? 'video' : 'imagem', src: f.src, ...(v ? { volume: 0 } : {}), x: 0, y: 0, largura: '100%', altura: '100%', ajuste: 'cover', foco: 'center 28%',
111
+ anima: { escala: [{ t: 0, v: 1.28 }, { t: 0.25, v: 1.1, e: 'saida' }, { t: 3, v: 1.02, e: 'linear' }] } });
112
+ els.push({ tipo: 'forma', forma: 'retangulo', x: 0, y: 0, largura: '100%', altura: '100%',
113
+ fundo: { tipo: 'gradiente', cores: [`rgba(0,0,0,${veu * 0.4})`, `rgba(0,0,0,${veu * 0.6})`, `rgba(0,0,0,${veu + 0.25})`], angulo: 180 } });
114
+ };
115
+ const logoTopo = (els, forte = false) => {
116
+ if (!marca.logo) return;
117
+ const h = Math.round(H * (forte ? 0.07 : (deitado ? 0.05 : 0.028)));
118
+ els.push({ tipo: 'imagem', src: marca.logo, x: Math.round(W / 2 - h * 2), y: forte ? Math.round(H * 0.78) : M, largura: h * 4, altura: h, ajuste: 'contain', opacidade: forte ? 1 : 0.9,
119
+ ...(forte ? { anima: { escala: [{ t: 0, v: 0.6 }, { t: 0.3, v: 1.08, e: 'saida' }, { t: 0.5, v: 1 }], opacidade: [{ t: 0, v: 0 }, { t: 0.15, v: 1 }] } } : {}) });
120
+ };
121
+
122
+ partes.forEach((p, i) => {
123
+ const tp = tempos[i] || { de: i * 3, ate: i * 3 + 2.6, palavras: [] };
124
+ const fim = tempos[i + 1] ? tempos[i + 1].de : (tp.ate || 0) + 0.9;
125
+ const ini = i === 0 ? 0 : tp.de;
126
+ let bs = batidas((tp.palavras || []).filter((w) => w && w.texto), fim);
127
+ if (!bs.length) bs = [[{ de: ini, ate: fim, texto: p.titulo || p.sim || p.valor || '' }]];
128
+ const itens = (p.itens || []).map((it) => (typeof it === 'string' ? it : it.texto));
129
+
130
+ // Impacto no começo de cada parte; o riser anuncia a chamada final.
131
+ som(ini, 'impacto', i === 0 ? 0.9 : 0.6);
132
+ if (p.tipo === 'cta') som(ini - 2, 'riser', 0.6);
133
+
134
+ 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;
137
+ const falado = b.map((w) => limpa(w.texto)).join(' ');
138
+ const els = [];
139
+ const cena = { fundo: { tipo: 'cor', valor: cor.fundo }, elementos: els };
140
+ const ultimo = k === bs.length - 1;
141
+ let tela; // 'palavra' | 'foto' | 'marca'
142
+
143
+ if (p.tipo === 'numero' && k === 0) {
144
+ // O número em tela cheia, contando, com a câmera tremendo.
145
+ cena.fundo = { tipo: 'gradiente', cores: [grad[0], grad[1]], angulo: 150 };
146
+ const valor = String(p.valor);
147
+ const m = valor.match(/^([^\d-]*)(-?[\d.,]+)(.*)$/);
148
+ const fit = caber(valor, { largura: W0, linhas: 1, max: H * (deitado ? 0.55 : 0.32), fonte: F.titulo });
149
+ 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
+ ...(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' });
153
+ cena.tremor = [{ de: 0, ate: 0.5, forca: 26 }];
154
+ tela = 'marca';
155
+ } else if ((p.tipo === 'lista' || p.tipo === 'passos') && itens.length) {
156
+ // Um item por batida: o emoji 3D grande e o item batendo.
157
+ const it = itens[Math.min(k, itens.length - 1)];
158
+ if (k % 2 === 1) cena.fundo = { tipo: 'gradiente', cores: [grad[0], grad[1]], angulo: 135 };
159
+ const rot = p.tipo === 'passos' ? `${k + 1}. ${it}` : it;
160
+ const temE = emoji(els, it, { y: H * (deitado ? 0.28 : 0.3) });
161
+ // 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,
163
+ ...(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 });
165
+ tela = k % 2 ? 'marca' : 'palavra';
166
+ cena.socos = [{ t: 0, forca: 0.14 }];
167
+ som(de, 'estalo', 0.55);
168
+ } else if (p.tipo === 'cta' && ultimo) {
169
+ // O fecho: a logo grande, a chamada e o link.
170
+ /* O fecho no escuro da marca, com o brilho do gradiente atrás: a logo
171
+ tem as cores do gradiente e sumia sobre ele. */
172
+ const R = Math.round(Math.max(W, H) * 0.9);
173
+ 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
+ anima: { desfoque: [{ t: 0, v: Math.round(R * 0.2) }], escala: [{ t: 0, v: 0.7 }, { t: 1, v: 1.1, e: 'suave' }] } });
175
+ 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 });
177
+ if (p.link) {
178
+ const fl = Math.round(Math.min(W, H) * 0.055);
179
+ const wP = Math.min(W0, String(p.link).length * fl * 0.62 + fl * 2.4);
180
+ // O botão vem DEPOIS do título, com folga — nunca encostado nele.
181
+ const yP = Math.round(yT + hT * 1.08 + H * 0.04);
182
+ els.push({ tipo: 'forma', forma: 'retangulo', x: Math.round((W - wP) / 2), y: yP, largura: Math.round(wP), altura: Math.round(fl * 2.1), cor: cor.destaque, raio: Math.round(fl * 1.05),
183
+ anima: { escala: [{ t: 0.2, v: 0 }, { t: 0.45, v: 1.1, e: 'saida' }, { t: 0.6, v: 1 }] } });
184
+ els.push({ tipo: 'texto', texto: String(p.link), fonte: F.texto, peso: 800, tamanho: fl, cor: cor.sobreDestaque, x: Math.round(W / 2), y: Math.round(yP + fl * 1.05), ancora: 'centro',
185
+ anima: { opacidade: [{ t: 0.3, v: 0 }, { t: 0.45, v: 1 }] } });
186
+ }
187
+ logoTopo(els, true);
188
+ cena.tremor = [{ de: 0, ate: 0.4, forca: 20 }];
189
+ cena.flashes = [0];
190
+ som(de, 'impacto', 0.9);
191
+ tela = 'marca';
192
+ } else {
193
+ // O rodízio das três telas, com a primeira batida sempre na palavra.
194
+ const ciclo = ['palavra', 'foto', 'marca', 'foto'];
195
+ 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);
199
+ if (tela === 'foto' && fotos.length) {
200
+ fotoCheia(els, proxFoto());
201
+ bater(els, texto, { y: H * (deitado ? 0.56 : 0.6), max: deitado ? H * 0.12 : W * 0.13, linhas: 3 });
202
+ if (k % 2 === 0) cena.flashes = [0];
203
+ } else if (tela === 'marca') {
204
+ cena.fundo = { tipo: 'gradiente', cores: [grad[0], grad[1]], angulo: 135 };
205
+ 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 });
207
+ } else {
208
+ tela = 'palavra';
209
+ const temE = emoji(els, texto, { y: H * (deitado ? 0.25 : 0.27) });
210
+ 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 });
212
+ cena.socos = [{ t: 0, forca: 0.2 }];
213
+ }
214
+ if (k === 0) cena.tremor = [{ de: 0, ate: 0.35, forca: 18 }];
215
+ som(de, tela === 'foto' ? 'whoosh' : 'estalo', tela === 'foto' ? 0.5 : 0.5);
216
+ }
217
+ 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. */
220
+ 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' });
224
+ // 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 }] } });
229
+ }
230
+
231
+ /* Uma transição curta a cada tanto; o resto é corte seco — é o
232
+ corte que dá o ritmo. */
233
+ const nT = nBatida++;
234
+ const trans = nT === 0 ? 'corte' : (nT % 4 === 1 ? 'chicote' : nT % 4 === 3 ? 'soco' : 'corte');
235
+ const cruz = trans === 'corte' ? 0 : 0.16;
236
+ if (trans === 'chicote') som(de - 0.08, 'whoosh', 0.55);
237
+ cena._base = Math.max(0.3, ate - de);
238
+ cena.transicao = { tipo: trans, duracao: cruz };
239
+ cenas.push(cena);
240
+ });
241
+ });
242
+
243
+ // Cada batida fica no ar até a seguinte terminar de entrar (o cruzamento é da que entra).
244
+ cenas.forEach((c, k) => {
245
+ c.duracao = r2(c._base + (cenas[k + 1] ? cenas[k + 1].transicao.duracao : 0));
246
+ delete c._base;
247
+ c.camera = { zoom: [{ t: 0, v: 1 }, { t: c.duracao, v: 1.06, e: 'linear' }] };
248
+ });
249
+
250
+ const audio = { efeitos };
251
+ if (narracao) audio.narracao = narracao;
252
+ if (trilha) audio.trilha = { src: trilha, volume: 0.17, loop: true };
253
+ return {
254
+ ok: true,
255
+ plano: {
256
+ formato: { largura: W, altura: H, fps: 30 }, fundoGeral: cor.fundo, cenas, audio,
257
+ acabamento: { grao: 0.07, vinheta: 0.55, luz: [grad[0], cor.destaque], luzForca: 0.16 },
258
+ },
259
+ resumo: { cenas: cenas.length, efeitos: efeitos.length, musica: Boolean(trilha), voz: Boolean(narracao), estilo: 'impacto' },
260
+ };
261
+ }
262
+
263
+ module.exports = { montarImpacto, batidas };