primocode 9.8.0-beta.17 → 9.8.0-beta.18

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.
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # PrimoCode v9.8.0-beta.17
1
+ # PrimoCode v9.8.0-beta.18
2
2
 
3
3
  Agente de engenharia brasileiro para o terminal, no estilo Claude Code.
4
4
  Cria arquivos de verdade, roda comandos, **controla o navegador e o desktop** —
@@ -9,7 +9,7 @@ OpenRouter, e cada apelido (`top`/`main`/`fast`) é uma **corrente**: se um mode
9
9
  está no limite de pedidos, o próximo atende. Você não vê o erro, e não paga nada.
10
10
 
11
11
  ```
12
- ▐▛███▜▌ Primo Code v9.8.0-beta.17
12
+ ▐▛███▜▌ Primo Code v9.8.0-beta.18
13
13
  ▝▜█████▛▘ Bem-vindo de volta, Joel
14
14
  ▘▘ ▝▝ ~/primocode · /dir <pasta> muda
15
15
  ```
@@ -171,7 +171,9 @@ function comporRoteiro({ roteiro, modo = 'video', largura = 1920, altura = 1080,
171
171
  const base = vid ? Math.max(1.6, (proxDe != null ? proxDe : (tp.ate || 0) + 0.9) - (tp.de || 0)) : 3;
172
172
  const trans = !vid || i === 0 ? 'corte' : TRANSICOES[i % TRANSICOES.length];
173
173
  const cruz = trans === 'corte' ? 0 : 0.35;
174
- const dur = r2(base + cruz);
174
+ // A cena fica no ar até a SEGUINTE terminar de entrar (o cruzamento é dela).
175
+ const cruzProx = vid && i + 1 < partes.length ? 0.35 : 0;
176
+ const dur = r2(base + cruzProx);
175
177
  const els = [];
176
178
  const variante = i % 2;
177
179
 
@@ -419,6 +419,15 @@ const SONS = {
419
419
  pop: ['-f', 'lavfi', '-i', "aevalsrc='0.9*sin(2*PI*(1500-7000*t)*t)*exp(-38*t)':s=44100:d=0.14"],
420
420
  whoosh: ['-f', 'lavfi', '-i', 'anoisesrc=d=0.55:c=pink:a=0.6',
421
421
  '-af', 'highpass=f=500,lowpass=f=5000,afade=t=in:st=0:d=0.3:curve=exp,afade=t=out:st=0.3:d=0.25,volume=0.9'],
422
+ // O impacto: um grave que cai de 60 para 30 Hz, com o estalo do ataque.
423
+ impacto: ['-f', 'lavfi', '-i', "aevalsrc='0.95*sin(2*PI*(62-30*t)*t)*exp(-3.6*t)+0.3*(random(0)*2-1)*exp(-45*t)':s=44100:d=1.3",
424
+ '-af', 'lowpass=f=2500,volume=1.4'],
425
+ // O riser: sobe dois segundos e corta — anuncia o que vem.
426
+ riser: ['-f', 'lavfi', '-i', "aevalsrc='0.4*sin(2*PI*(160+700*t*t)*t)*(t/2)+0.3*(random(0)*2-1)*pow(t/2,2)':s=44100:d=2",
427
+ '-af', 'highpass=f=120,volume=1.2'],
428
+ // O estalo do texto batendo na tela.
429
+ estalo: ['-f', 'lavfi', '-i', "aevalsrc='(random(0)*2-1)*exp(-38*t)*0.9':s=44100:d=0.22",
430
+ '-af', 'highpass=f=900,lowpass=f=9000'],
422
431
  };
423
432
 
424
433
  async function prepararSons(pasta, video) {
@@ -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 };
@@ -43,6 +43,7 @@ const slides = require('./slides.js');
43
43
  const edicao = require('./edicao.js');
44
44
  const composicao = require('./composicao.js');
45
45
  const acervoSite = require('./acervo-site.js');
46
+ const impacto = require('./impacto.js');
46
47
 
47
48
  const PY = path.join(__dirname, '..', '..', 'studio');
48
49
 
@@ -250,6 +251,7 @@ async function roteiroDoServidor(p, args) {
250
251
  fotos: (acervo.fotos || []).length ? `${acervo.fotos.length} fotos reais` : '',
251
252
  site: acervo.site ? new URL(acervo.site).hostname.replace(/^www\./, '') : '',
252
253
  rascunho: typeof args.roteiro === 'string' ? args.roteiro : '',
254
+ estilo: args.estilo || (/an[uú]nci|reels?|divulg|promo|lan[çc]a|vend|campanha|convite|inscri/i.test(pedido) ? 'impacto' : undefined),
253
255
  }, cfg.token);
254
256
  if (!r || !r.ok) return { ok: false, error: (r && r.erro) || 'o roteirista não respondeu.' };
255
257
  return { ok: true, roteiro: { ...r.roteiro, marca: (args.roteiro && args.roteiro.marca) || p.marca || undefined } };
@@ -708,17 +710,20 @@ function alinharPartes(lista, palavras, total) {
708
710
  }
709
711
  return lista.map((_, i) => {
710
712
  const a = ws[inicios[i]], b = ws[(inicios[i + 1] || ws.length) - 1];
711
- return { de: i === 0 ? 0 : (a ? a.de : 0), ate: b ? b.ate : total, palavras: [] };
713
+ // As palavras da parte vão junto: o estilo impacto corta no tempo delas.
714
+ const palavras = ws.slice(inicios[i], inicios[i + 1] || ws.length).map(({ de, ate, texto }) => ({ de, ate, texto }));
715
+ return { de: i === 0 ? 0 : (a ? a.de : 0), ate: b ? b.ate : total, palavras };
712
716
  });
713
717
  }
714
718
 
715
- async function narrarComGemini(lista, mp3, { voz } = {}) {
719
+ async function narrarComGemini(lista, mp3, { voz, direcao } = {}) {
716
720
  try {
717
721
  const cfg = require('../config').loadConfig();
718
722
  const api = require('../api');
719
723
  const r = await api.request(cfg.server, '/api/voz', {
720
724
  texto: lista.map((x) => x.texto).join('\n\n'),
721
725
  voz: VOZ_GEMINI[String(voz || '').toLowerCase()] || voz || 'Charon',
726
+ ...(direcao ? { direcao } : {}),
722
727
  }, cfg.token);
723
728
  if (!r || !r.ok) return { ok: false, error: (r && r.erro) || 'sem voz no servidor' };
724
729
  const pcm = mp3.replace(/\.mp3$/, '.pcm');
@@ -756,6 +761,11 @@ async function prepararRoteiro(p, args) {
756
761
  if (roteiro.marca.logo && !existe(roteiro.marca.logo)) roteiro.marca.logo = (p.marca && existe(p.marca.logo)) ? p.marca.logo : undefined;
757
762
  } else if (p.marca) roteiro.marca = p.marca;
758
763
  const partes = roteiro.partes || [];
764
+ /* O ESTILO: "impacto" (anúncio, corte a cada batida) ou "limpo" (uma cena
765
+ por parte). Pedido de anúncio, reels, divulgação ou venda já vem em
766
+ impacto — é o que se espera de uma peça dessas em 2026. */
767
+ const estilo = args.estilo || roteiro.estilo
768
+ || (/an[uú]nci|reels?|divulg|promo|lan[çc]a|vend|campanha|convite|inscri/i.test(`${p.pedido || ''} ${args.pedido || ''}`) ? 'impacto' : 'limpo');
759
769
  const modo = (args.modo || roteiro.modo || (p.formato === 'slides' || p.formato === 'arte' ? 'slides' : 'video'));
760
770
  const [largura, altura] = PROPORCOES[roteiro.proporcao || args.proporcao] || (modo === 'slides' ? PROPORCOES['16:9'] : PROPORCOES['16:9']);
761
771
 
@@ -809,7 +819,9 @@ async function prepararRoteiro(p, args) {
809
819
  fs.writeFileSync(arq, JSON.stringify(lista));
810
820
  const mp3 = path.join(p.pasta, 'midia', `narracao-${Date.now().toString(36)}.mp3`);
811
821
  // A voz de gente primeiro (Gemini, pelo servidor); o Edge é a reserva.
812
- let v = await narrarComGemini(lista, mp3, { voz: args.voz || roteiro.voz });
822
+ let v = await narrarComGemini(lista, mp3, { voz: args.voz || roteiro.voz,
823
+ direcao: estilo === 'impacto' ? 'Leia em português do Brasil como locutor de comercial agressivo e vendedor: energia alta, ritmo rápido, '
824
+ + 'voz encorpada, ênfase forte nas palavras de impacto, pausas curtas e secas entre as frases, sorrindo na voz.' : undefined });
813
825
  if (!v.ok) v = await rodarPython('narrar.py', ['roteiro', '--arquivo', arq, '--saida', mp3, '--voz', String(args.voz || roteiro.voz || 'thalita')], { timeout: 10 * 60000 });
814
826
  if (!v.ok) return v;
815
827
  tempos = v.partes;
@@ -817,13 +829,29 @@ async function prepararRoteiro(p, args) {
817
829
  projeto.anotar(p.id, 'narracao', `${v.segundos}s em ${partes.length} partes, voz ${v.voz}`);
818
830
  sons = await edicao.prepararSons(p.pasta, video);
819
831
  try {
820
- const b = await assets.procurarSom(roteiro.musica || args.trilha || 'inspiring corporate background', { quantas: 6, duracaoMin: 45 });
832
+ const b = await assets.procurarSom(roteiro.musica || args.trilha || (estilo === 'impacto' ? 'epic energetic trailer' : 'inspiring corporate background'), { quantas: 6, duracaoMin: 45 });
821
833
  for (const item of (b.ok ? b.sons : [])) {
822
834
  const d = await assets.baixar(item, { pasta: p.pasta, nome: 'trilha' });
823
835
  if (d.ok) { trilha = d.relativo; break; }
824
836
  }
825
837
  } catch { /* sem música */ }
826
838
  }
839
+ if (estilo === 'impacto' && modo === 'video') {
840
+ // Os emojis das palavras FALADAS também (a batida mostra a fala).
841
+ const faladas = tempos.flatMap((t) => (t.palavras || []).map((w) => w.texto));
842
+ for (const nome of new Set(faladas.map((w) => edicao.iconeDe(w)).filter(Boolean))) {
843
+ if (iconesOk.has(nome)) continue;
844
+ try { const r = await assets.icone(nome); if (r && r.ok) iconesOk.add(nome); } catch { /* sem */ }
845
+ }
846
+ const fotos = [
847
+ ...acervo.fotos.map((f) => ({ src: f.src, tipo: 'imagem' })),
848
+ ...(acervo.videos || []).map((v) => ({ src: v.src, tipo: 'video' })),
849
+ ...Object.values(imagens),
850
+ ];
851
+ const r = impacto.montarImpacto({ roteiro, largura, altura, tempos, narracao, sons, trilha, fotos, iconesOk });
852
+ if (r.ok) projeto.anotar(p.id, 'plano', `estilo impacto: ${r.resumo.cenas} batidas`);
853
+ return r;
854
+ }
827
855
  return composicao.comporRoteiro({ roteiro, modo, largura, altura, tempos, narracao, sons, trilha, imagens, iconesOk, fundos });
828
856
  }
829
857
 
@@ -1054,6 +1082,15 @@ async function ferramentaSite(args) {
1054
1082
  buscar: identidade.buscar, baixar: assets.baixar,
1055
1083
  });
1056
1084
  const marca = { ...ultimoSite.marca, ...(r.logo && r.logo.ok ? { logo: r.logo.src } : {}) };
1085
+ /* O GRADIENTE da marca sai da própria logo, quando ela é SVG com
1086
+ gradiente (a da Paris Group vai de #2E81ED a #9567DB). */
1087
+ try {
1088
+ if (marca.logo && /\.svg$/i.test(marca.logo)) {
1089
+ const svg = fs.readFileSync(path.join(p.pasta, marca.logo), 'utf8');
1090
+ const stops = [...new Set((svg.match(/stop-color=["'](#[0-9a-f]{3,6})["']/gi) || []).map((x) => x.match(/#[0-9a-f]{3,6}/i)[0].toUpperCase()))];
1091
+ if (stops.length >= 2) marca.gradiente = stops.slice(0, 2);
1092
+ }
1093
+ } catch { /* sem gradiente */ }
1057
1094
  projeto.salvar(args.id, { acervo: { fotos: a.fotos, videos: a.videos, fatos: a.fatos, site: r.url }, marca });
1058
1095
  projeto.anotar(args.id, 'midia', `acervo do site: ${a.fotos.length} fotos, ${a.videos.length} vídeos, ${a.paginas.length} páginas lidas`);
1059
1096
  let orcamento = 4200;
@@ -51,7 +51,7 @@ const path = require('path');
51
51
  const TIPOS = new Set(['texto', 'forma', 'imagem', 'video', 'icone', 'legenda', 'componente']);
52
52
  const FORMAS = new Set(['retangulo', 'circulo', 'linha', 'poligono', 'traco']);
53
53
  const AJUSTES = new Set(['cover', 'contain', 'preencher']);
54
- const TRANSICOES = new Set(['corte', 'esmaecer', 'deslizar', 'zoom', 'giro', 'mascara', 'morph']);
54
+ const TRANSICOES = new Set(['corte', 'esmaecer', 'deslizar', 'zoom', 'giro', 'mascara', 'morph', 'chicote', 'soco', 'flash']);
55
55
 
56
56
  /* As propriedades que a animação por quadro-chave alcança. Cada uma é um
57
57
  número no tempo; a curva entre dois quadros é a suavização declarada.
@@ -283,6 +283,12 @@ function Elemento({ el, t, dados }) {
283
283
  WebkitBackgroundClip: el.gradienteTexto ? 'text' : undefined,
284
284
  WebkitTextFillColor: el.gradienteTexto ? 'transparent' : undefined,
285
285
  fontStyle: el.italico ? 'italic' : undefined,
286
+ WebkitTextStroke: el.contorno || undefined,
287
+ // O GLITCH: o texto se parte em vermelho e ciano por um instante.
288
+ textShadow: el.glitch && tLocal < (el.glitch.dur || 0.35)
289
+ ? (() => { const f = Math.round(t * 30); const d = (Math.sin(f * 7.1) * 0.5 + 0.5) * (el.glitch.forca || 10) * (1 - tLocal / (el.glitch.dur || 0.35));
290
+ return d.toFixed(1) + 'px 0 #ff2d55, ' + (-d).toFixed(1) + 'px 0 #00e5ff'; })()
291
+ : (el.sombraTexto || undefined),
286
292
  textDecoration: el.riscado ? 'line-through' : undefined,
287
293
  textDecorationThickness: el.riscado ? '0.08em' : undefined,
288
294
  wordBreak: 'normal', overflowWrap: 'break-word',
@@ -419,10 +425,26 @@ export function Cena({ cena, dados, tFixo }) {
419
425
  const t = tFixo != null ? tFixo : frame / fps;
420
426
 
421
427
  const cam = animado(cena.camera, t);
428
+ /* O IMPACTO: tremor (a câmera treme e assenta), soco (o zoom que bate e
429
+ volta) e flash (o branco que estoura e some). O tremor é pseudoaleatório
430
+ pelo QUADRO, então o mesmo render sai sempre igual. */
431
+ const acaso = (n) => { const x = Math.sin(n * 12.9898 + 78.233) * 43758.5453; return x - Math.floor(x); };
432
+ let tx = 0, ty = 0, tr = 0, soco = 1;
433
+ for (const w of cena.tremor || []) {
434
+ if (t < w.de || t > w.ate) continue;
435
+ const k = (w.forca || 14) * (1 - (t - w.de) / Math.max(0.01, w.ate - w.de));
436
+ tx += (acaso(frame) - 0.5) * 2 * k; ty += (acaso(frame + 91) - 0.5) * 2 * k; tr += (acaso(frame + 37) - 0.5) * k * 0.08;
437
+ }
438
+ for (const s0 of cena.socos || []) {
439
+ if (t >= s0.t) soco *= 1 + (s0.forca || 0.18) * Math.exp(-14 * (t - s0.t));
440
+ }
441
+ let flash = 0;
442
+ for (const f of cena.flashes || []) if (t >= f && t < f + 0.25) flash = Math.max(flash, Math.exp(-18 * (t - f)));
422
443
  const camTransform = [
423
444
  cam.x != null || cam.y != null ? 'translate(' + (-(cam.x || 0)) + 'px,' + (-(cam.y || 0)) + 'px)' : '',
424
- cam.zoom != null ? 'scale(' + cam.zoom + ')' : '',
425
- cam.rotacao != null ? 'rotate(' + cam.rotacao + 'deg)' : '',
445
+ tx || ty ? 'translate(' + tx + 'px,' + ty + 'px)' : '',
446
+ cam.zoom != null || soco !== 1 ? 'scale(' + ((cam.zoom ?? 1) * soco) + ')' : '',
447
+ cam.rotacao != null || tr ? 'rotate(' + ((cam.rotacao || 0) + tr) + 'deg)' : '',
426
448
  ].filter(Boolean).join(' ');
427
449
 
428
450
  return (
@@ -451,6 +473,7 @@ export function Cena({ cena, dados, tFixo }) {
451
473
  <Elemento key={i} el={el} t={t} dados={dados} />
452
474
  ))}
453
475
  </AbsoluteFill>
476
+ {flash > 0.01 && <AbsoluteFill style={{ backgroundColor: cena.corFlash || '#fff', opacity: flash * 0.85 }} />}
454
477
  </AbsoluteFill>
455
478
  );
456
479
  }
@@ -474,6 +497,11 @@ function Transicao({ tipo, progresso, children }) {
474
497
  else if (tipo === 'giro') estilo = { transform: 'rotateY(' + (1 - p) * 35 + 'deg) scale(' + (0.9 + 0.1 * p) + ')', opacity: p };
475
498
  else if (tipo === 'mascara') estilo = { clipPath: 'circle(' + (p * 75) + '% at 50% 50%)' };
476
499
  else if (tipo === 'morph') estilo = { opacity: p, filter: 'blur(' + (1 - p) * 14 + 'px)', transform: 'scale(' + (1.06 - 0.06 * p) + ')' };
500
+ // O CHICOTE: a cena chega rasgando de lado, borrada de velocidade.
501
+ else if (tipo === 'chicote') estilo = { transform: 'translateX(' + (1 - p) * 70 + '%) skewX(' + (1 - p) * -12 + 'deg)', filter: 'blur(' + (1 - p) * 40 + 'px)' };
502
+ // O SOCO: a cena entra grande e bate no lugar.
503
+ else if (tipo === 'soco') estilo = { transform: 'scale(' + (1.35 - 0.35 * p) + ')', opacity: Math.min(1, p * 3) };
504
+ else if (tipo === 'flash') estilo = { opacity: Math.min(1, p * 2), filter: 'brightness(' + (1 + (1 - p) * 3) + ')' };
477
505
  return <AbsoluteFill style={estilo}>{children}</AbsoluteFill>;
478
506
  }
479
507
 
@@ -497,14 +525,24 @@ export const Video = () => {
497
525
  const frame = useCurrentFrame();
498
526
  const dados = { fps, icones };
499
527
 
528
+ /* O cruzamento é da cena que ENTRA: ela começa "cruz" quadros antes de a
529
+ anterior acabar, e as duas ficam no ar juntas. Contar pela que sai deixava
530
+ a que entra começando sobre o preto (medido em 25/09: o "soco" abria com
531
+ um quadro preto). */
532
+ const cruzDe = (cena, i) => {
533
+ if (!cena || i === 0) return 0;
534
+ const t = cena.transicao || {};
535
+ const tipo = t.tipo || t || 'corte';
536
+ return tipo === 'corte' ? 0 : Math.round((t.duracao ?? 0.4) * fps);
537
+ };
500
538
  let inicio = 0;
501
539
  const pedacos = plano.cenas.map((cena, i) => {
502
540
  const dur = Math.max(1, Math.round(cena.duracao * fps));
503
541
  const t = cena.transicao || {};
504
542
  const tipo = t.tipo || t || 'corte';
505
- const cruz = tipo === 'corte' ? 0 : Math.round((t.duracao ?? 0.4) * fps);
543
+ const cruz = cruzDe(cena, i);
506
544
  const de = inicio;
507
- inicio += dur - cruz;
545
+ inicio += dur - cruzDe(plano.cenas[i + 1], i + 1);
508
546
  return { cena, de, dur, tipo, cruz, i };
509
547
  });
510
548
 
@@ -524,6 +562,26 @@ export const Video = () => {
524
562
  );
525
563
  })}
526
564
 
565
+ {/* O ACABAMENTO: granulação de filme, vinheta e a luz da marca passando.
566
+ É o que tira a cara de apresentação e dá a cara de peça de 2026. */}
567
+ {plano.acabamento && plano.acabamento.luz && (
568
+ <AbsoluteFill style={{ pointerEvents: 'none', mixBlendMode: 'screen', opacity: plano.acabamento.luzForca ?? 0.22,
569
+ background: 'radial-gradient(circle at ' + (50 + 40 * Math.sin(frame / 38)) + '% ' + (30 + 25 * Math.cos(frame / 51)) + '%, '
570
+ + plano.acabamento.luz[0] + ' 0%, transparent 45%), radial-gradient(circle at ' + (50 - 40 * Math.sin(frame / 44)) + '% '
571
+ + (75 - 20 * Math.sin(frame / 33)) + '%, ' + (plano.acabamento.luz[1] || plano.acabamento.luz[0]) + ' 0%, transparent 40%)' }} />
572
+ )}
573
+ {plano.acabamento && plano.acabamento.vinheta && (
574
+ <AbsoluteFill style={{ pointerEvents: 'none', background: 'radial-gradient(ellipse at center, transparent 55%, rgba(0,0,0,' + plano.acabamento.vinheta + ') 100%)' }} />
575
+ )}
576
+ {plano.acabamento && plano.acabamento.grao && (
577
+ <AbsoluteFill style={{ pointerEvents: 'none', opacity: plano.acabamento.grao, mixBlendMode: 'overlay' }}>
578
+ <svg width="100%" height="100%">
579
+ <filter id="grao"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" seed={frame % 60} /></filter>
580
+ <rect width="100%" height="100%" filter="url(#grao)" />
581
+ </svg>
582
+ </AbsoluteFill>
583
+ )}
584
+
527
585
  {plano.audio && plano.audio.narracao && (
528
586
  <Audio src={staticFile(plano.audio.narracao)} volume={plano.audio.volumeNarracao ?? 1} />
529
587
  )}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "primocode",
3
- "version": "9.8.0-beta.17",
3
+ "version": "9.8.0-beta.18",
4
4
  "description": "PrimoCode — agente de engenharia com IA e cursor próprio. Requer conta Conecta Primo AI (Premium ou Super). Cria arquivos, roda comandos, controla navegador e desktop: abre apps, clica em botões e ícones pelo nome, digita e usa atalhos.",
5
5
  "main": "bin/primocode.js",
6
6
  "bin": {
@@ -8,7 +8,7 @@
8
8
  },
9
9
  "scripts": {
10
10
  "start": "node bin/primocode.js",
11
- "test": "node test/tools.test.js && node test/ui.test.js && node test/entrada.test.js && node test/transparencia.test.js && node test/permissao.test.js && node test/compatibilidade.test.js && node test/skills.test.js && node test/skills-de-fora.test.js && node test/skills-novas.test.js && node test/comandos.test.js && node test/estudio.test.js && node test/voz.test.js && node test/voz-conversa.test.js && node test/modo.test.js && node test/pasta.test.js && node test/projeto.test.js && node test/nome-projeto.test.js && node test/api-propria.test.js && node test/claude-engine.test.js && node test/codex.test.js && node test/conferir.test.js && node test/catalogo.test.js && node test/desktop.test.js && node test/seta.test.js && node test/effort.test.js && node test/regressao.test.js && node test/memoria-conversa.test.js && node test/repeticao.test.js && node test/autonomia.test.js && node test/desfazer.test.js && node test/desfazer-git.test.js && node test/referencia.test.js && node test/parar.test.js && node test/continuar.test.js && node test/pipeline.test.js && node test/app.test.js && node test/app-janela.test.js && node test/aplicativo.test.js && node test/conta.test.mjs && node test/nuvem.test.js && node test/prazo.test.js && node test/primeira-vez.test.js && node test/janela.test.js && node test/fala.test.js && node test/especialistas.test.js && node test/empurrao.test.js && node test/terminal-novo.test.js && node test/markdown.test.js && node test/coerencia.test.js && node test/contexto.test.js && node test/perigo.test.js && node test/rapidez.test.js && node test/mascotes.test.js && node test/comandos-extra.test.js && node test/offline.test.js && node test/edicao.test.js && node test/composicao.test.js && node test/acervo-site.test.js"
11
+ "test": "node test/tools.test.js && node test/ui.test.js && node test/entrada.test.js && node test/transparencia.test.js && node test/permissao.test.js && node test/compatibilidade.test.js && node test/skills.test.js && node test/skills-de-fora.test.js && node test/skills-novas.test.js && node test/comandos.test.js && node test/estudio.test.js && node test/voz.test.js && node test/voz-conversa.test.js && node test/modo.test.js && node test/pasta.test.js && node test/projeto.test.js && node test/nome-projeto.test.js && node test/api-propria.test.js && node test/claude-engine.test.js && node test/codex.test.js && node test/conferir.test.js && node test/catalogo.test.js && node test/desktop.test.js && node test/seta.test.js && node test/effort.test.js && node test/regressao.test.js && node test/memoria-conversa.test.js && node test/repeticao.test.js && node test/autonomia.test.js && node test/desfazer.test.js && node test/desfazer-git.test.js && node test/referencia.test.js && node test/parar.test.js && node test/continuar.test.js && node test/pipeline.test.js && node test/app.test.js && node test/app-janela.test.js && node test/aplicativo.test.js && node test/conta.test.mjs && node test/nuvem.test.js && node test/prazo.test.js && node test/primeira-vez.test.js && node test/janela.test.js && node test/fala.test.js && node test/especialistas.test.js && node test/empurrao.test.js && node test/terminal-novo.test.js && node test/markdown.test.js && node test/coerencia.test.js && node test/contexto.test.js && node test/perigo.test.js && node test/rapidez.test.js && node test/mascotes.test.js && node test/comandos-extra.test.js && node test/offline.test.js && node test/edicao.test.js && node test/composicao.test.js && node test/acervo-site.test.js && node test/impacto.test.js"
12
12
  },
13
13
  "engines": {
14
14
  "node": ">=18.17.0"