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.
- package/README.md +2 -2
- package/lib/act.js +16 -9
- package/lib/api.js +8 -1
- package/lib/catalogo.js +11 -3
- package/lib/claude-engine.js +25 -3
- package/lib/estudio/acervo-site.js +157 -0
- package/lib/estudio/assets.js +77 -3
- package/lib/estudio/cinema.js +399 -0
- package/lib/estudio/composicao.js +470 -0
- package/lib/estudio/edicao.js +450 -0
- package/lib/estudio/impacto.js +263 -0
- package/lib/estudio/index.js +854 -5
- package/lib/estudio/plano.js +5 -2
- package/lib/estudio/remotion.js +228 -23
- package/lib/estudio/slides.js +34 -30
- package/lib/local/cerebro.json +361 -180
- package/lib/local/entrada.js +18 -8
- package/lib/local/motor-cli.js +1 -1
- package/package.json +2 -2
- package/studio/narrar.py +111 -10
- package/studio/rostos.py +52 -0
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* composicao.js — o roteiro vira cena, e quem faz o layout é o CÓDIGO.
|
|
3
|
+
*
|
|
4
|
+
* "Os motion estão bem quebrados, a voz está sem entonação, não tem música,
|
|
5
|
+
* efeitos sonoros, ícones… ele sobrepõe as coisas." · "Os slides entregou
|
|
6
|
+
* uma merda." (25/09/2026)
|
|
7
|
+
*
|
|
8
|
+
* O modelo escrevia a posição de cada texto em pixel, à mão, e errava: texto
|
|
9
|
+
* cortado na borda, letra minúscula, meia tela vazia, nada de som. Aqui o
|
|
10
|
+
* modelo escreve só o ROTEIRO — o que cada parte diz e de que tipo ela é — e
|
|
11
|
+
* o código monta a cena:
|
|
12
|
+
*
|
|
13
|
+
* - a letra é MEDIDA para caber na caixa (nunca sai da tela, nunca some de
|
|
14
|
+
* tão pequena), e tudo respeita a margem de segurança;
|
|
15
|
+
* - cada tipo de parte tem a composição dele (abertura, número que conta,
|
|
16
|
+
* lista com ícones, contraste, citação, imagem, gráfico, passos, chamada),
|
|
17
|
+
* e a variação vem da marca e da posição no roteiro;
|
|
18
|
+
* - no vídeo, cada parte dura o que a voz dela dura, e o som (whoosh na
|
|
19
|
+
* transição, pop no item, música de cama) entra sozinho;
|
|
20
|
+
* - no slide, é a MESMA cena, parada.
|
|
21
|
+
*
|
|
22
|
+
* Função pura: roteiro + tempos da voz entram, plano sai.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
'use strict';
|
|
26
|
+
|
|
27
|
+
const { iconeDe } = require('./edicao.js');
|
|
28
|
+
|
|
29
|
+
const TIPOS = ['abertura', 'titulo', 'numero', 'lista', 'contraste', 'citacao', 'imagem', 'grafico', 'passos', 'cta'];
|
|
30
|
+
|
|
31
|
+
// ── cor ──────────────────────────────────────────────────────────────────
|
|
32
|
+
function rgb(hex) {
|
|
33
|
+
const h = String(hex || '').replace('#', '');
|
|
34
|
+
const n = h.length === 3 ? h.split('').map((c) => c + c).join('') : h.padEnd(6, '0').slice(0, 6);
|
|
35
|
+
return [0, 2, 4].map((i) => parseInt(n.slice(i, i + 2), 16) || 0);
|
|
36
|
+
}
|
|
37
|
+
const hex = (c) => '#' + c.map((v) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0')).join('');
|
|
38
|
+
const misturar = (a, b, k) => hex(rgb(a).map((v, i) => v + (rgb(b)[i] - v) * k));
|
|
39
|
+
const luz = (c) => { const [r, g, b] = rgb(c); return (0.299 * r + 0.587 * g + 0.114 * b) / 255; };
|
|
40
|
+
const alfa = (c, a) => c + Math.round(a * 255).toString(16).padStart(2, '0');
|
|
41
|
+
|
|
42
|
+
function paleta(marca = {}) {
|
|
43
|
+
const c = marca.cores || {};
|
|
44
|
+
const fundo = c.fundo || '#0E0F14';
|
|
45
|
+
const escuro = luz(fundo) < 0.5;
|
|
46
|
+
const tinta = c.tinta || (escuro ? '#F4F2EC' : '#15161A');
|
|
47
|
+
let destaque = c.destaque || '#FF6A3D';
|
|
48
|
+
/* A cor da marca precisa aparecer no fundo: o azul #1e3fae da Paris Group
|
|
49
|
+
sumia no fundo escuro (medido em 25/09). Pouco contraste, e o destaque
|
|
50
|
+
clareia (no escuro) ou escurece (no claro) até aparecer. */
|
|
51
|
+
for (let k = 0; k < 6 && Math.abs(luz(destaque) - luz(fundo)) < 0.38; k++) {
|
|
52
|
+
destaque = misturar(destaque, escuro ? '#FFFFFF' : '#000000', 0.18);
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
fundo, tinta, destaque, escuro, marca: c.destaque || destaque,
|
|
56
|
+
fundo2: misturar(fundo, tinta, escuro ? 0.07 : 0.05),
|
|
57
|
+
apoio: c.apoio || misturar(destaque, tinta, 0.35),
|
|
58
|
+
suave: alfa(tinta, 0.68),
|
|
59
|
+
// Sobre o destaque, a tinta que dá contraste.
|
|
60
|
+
sobreDestaque: luz(destaque) > 0.6 ? '#15161A' : '#FFFFFF',
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/* ── a letra que cabe ─────────────────────────────────────────────────────
|
|
65
|
+
* Largura média do caractere em "em" por família. Não é exata — é o
|
|
66
|
+
* bastante para escolher o maior tamanho que cabe em N linhas, com folga. */
|
|
67
|
+
// Com folga: medido em 25/09, 0,52 deixava "Fundadores" quebrar no meio no card.
|
|
68
|
+
const LARGURA = { condensada: 0.46, display: 0.6, texto: 0.58, serifa: 0.54 };
|
|
69
|
+
const FAMILIA_TIPO = (f) => (/condensed|narrow|anton|oswald|bebas|teko|league gothic/i.test(f) ? 'condensada'
|
|
70
|
+
: /serif|fraunces|playfair|garamond|lora|newsreader|spectral|baskerville|dm serif/i.test(f) ? 'serifa'
|
|
71
|
+
: /black|bricolage|archivo|sora|grotesk|syne|unbounded/i.test(f) ? 'display' : 'texto');
|
|
72
|
+
|
|
73
|
+
function caber(texto, { largura, linhas = 2, max = 200, min = 18, fonte = 'Inter' }) {
|
|
74
|
+
const cw = LARGURA[FAMILIA_TIPO(fonte)];
|
|
75
|
+
const palavras = String(texto || '').split(/\s+/).filter(Boolean);
|
|
76
|
+
for (let t = Math.round(max); t >= min; t -= 2) {
|
|
77
|
+
// Quebra de verdade, palavra por palavra: é o que o navegador fará.
|
|
78
|
+
let n = 1, linha = 0;
|
|
79
|
+
let cabe = true;
|
|
80
|
+
for (const w of palavras) {
|
|
81
|
+
const lw = (w.length + 1) * cw * t;
|
|
82
|
+
if (lw > largura * 1.02) { cabe = false; break; } // uma palavra sozinha não cabe
|
|
83
|
+
if (linha + lw > largura) { n++; linha = lw; } else linha += lw;
|
|
84
|
+
}
|
|
85
|
+
if (cabe && n <= linhas) return { tamanho: t, linhas: n };
|
|
86
|
+
}
|
|
87
|
+
return { tamanho: min, linhas };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ── as partes do roteiro ─────────────────────────────────────────────────
|
|
91
|
+
const r2 = (n) => Math.round(n * 100) / 100;
|
|
92
|
+
const TRANSICOES = ['deslizar', 'zoom', 'mascara', 'esmaecer', 'morph'];
|
|
93
|
+
|
|
94
|
+
function textoDe(p) {
|
|
95
|
+
return [p.titulo, p.texto, p.valor, p.rotulo, p.sub, p.nao, p.sim, ...(p.itens || []).map((i) => (typeof i === 'string' ? i : i.texto))]
|
|
96
|
+
.filter(Boolean).join(' ');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** O que a voz diz nesta parte. Sem "fala", a voz lê o que está na tela. */
|
|
100
|
+
function falaDe(p) {
|
|
101
|
+
if (p.fala) return String(p.fala);
|
|
102
|
+
if (p.tipo === 'numero') return `${p.valor} ${p.rotulo || ''}.`;
|
|
103
|
+
return textoDe(p);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** O registro da voz para esta parte (ver REGISTRO em studio/narrar.py). */
|
|
107
|
+
function registroDe(p, i, n) {
|
|
108
|
+
if (i === 0) return 'gancho';
|
|
109
|
+
if (p.tipo === 'cta' || i === n - 1) return 'cta';
|
|
110
|
+
return { numero: 'numero', contraste: 'contraste', lista: 'lista', citacao: 'citacao', passos: 'lista' }[p.tipo] || 'normal';
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function iconesDoRoteiro(roteiro) {
|
|
114
|
+
const nomes = new Set();
|
|
115
|
+
for (const p of roteiro.partes || []) {
|
|
116
|
+
for (const t of [`${p.titulo || ''} ${p.sub || ''}`, p.sim]) { const ic = iconeDoTexto(t); if (ic) nomes.add(ic); }
|
|
117
|
+
for (const it of p.itens || []) {
|
|
118
|
+
const t = typeof it === 'string' ? it : it.texto;
|
|
119
|
+
const ic = (typeof it === 'object' && it.icone) || iconeDoTexto(t);
|
|
120
|
+
if (ic) nomes.add(ic);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return [...nomes];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function iconeDoTexto(t) {
|
|
127
|
+
for (const w of String(t || '').split(/\s+/)) { const ic = iconeDe(w); if (ic) return ic; }
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* O plano.
|
|
133
|
+
*
|
|
134
|
+
* @param {object} op
|
|
135
|
+
* @param {object} op.roteiro { partes: [{tipo, …}], marca: {cores, fontes, logo} }
|
|
136
|
+
* @param {string} op.modo 'video' | 'slides'
|
|
137
|
+
* @param {Array} op.tempos no vídeo: [{de, ate, palavras}] da voz de cada parte
|
|
138
|
+
* @param {string} op.narracao o mp3 da voz (vídeo)
|
|
139
|
+
* @param {object} op.sons { pop, whoosh }
|
|
140
|
+
* @param {string} op.trilha a música de cama (vídeo)
|
|
141
|
+
* @param {object} op.imagens { indiceDaParte: src }
|
|
142
|
+
* @param {Set} op.iconesOk os ícones que existem
|
|
143
|
+
*/
|
|
144
|
+
function comporRoteiro({ roteiro, modo = 'video', largura = 1920, altura = 1080, tempos = [], narracao = null,
|
|
145
|
+
sons = {}, trilha = null, imagens = {}, iconesOk = null, fundos = {} }) {
|
|
146
|
+
const partes = (roteiro && roteiro.partes) || [];
|
|
147
|
+
if (!partes.length) return { ok: false, error: 'o roteiro veio sem partes.' };
|
|
148
|
+
const ruins = partes.map((p, i) => (TIPOS.includes(p.tipo) ? null : `parte ${i + 1}: tipo "${p.tipo}" não existe (use ${TIPOS.join(', ')})`)).filter(Boolean);
|
|
149
|
+
if (ruins.length) return { ok: false, error: 'o roteiro tem partes de tipo desconhecido.', problemas: ruins };
|
|
150
|
+
|
|
151
|
+
const marca = roteiro.marca || {};
|
|
152
|
+
const cor = paleta(marca);
|
|
153
|
+
const F = {
|
|
154
|
+
titulo: (marca.fontes && marca.fontes.titulo) || 'Bricolage Grotesque',
|
|
155
|
+
texto: (marca.fontes && marca.fontes.texto) || 'Inter',
|
|
156
|
+
serifa: (marca.fontes && marca.fontes.serifa) || 'Fraunces',
|
|
157
|
+
};
|
|
158
|
+
const W = largura, H = altura;
|
|
159
|
+
const M = Math.round(Math.min(W, H) * 0.08); // a margem de segurança
|
|
160
|
+
const vid = modo === 'video';
|
|
161
|
+
const deitado = W >= H;
|
|
162
|
+
const cenas = [];
|
|
163
|
+
const efeitos = [];
|
|
164
|
+
|
|
165
|
+
// No slide tudo já entrou quando o quadro do meio é tirado (1,5 s).
|
|
166
|
+
const atraso = (t) => (vid ? t : Math.min(t, 0.9) * 0.6);
|
|
167
|
+
|
|
168
|
+
partes.forEach((p, i) => {
|
|
169
|
+
const tp = tempos[i] || {};
|
|
170
|
+
const proxDe = tempos[i + 1] ? tempos[i + 1].de : null;
|
|
171
|
+
const base = vid ? Math.max(1.6, (proxDe != null ? proxDe : (tp.ate || 0) + 0.9) - (tp.de || 0)) : 3;
|
|
172
|
+
const trans = !vid || i === 0 ? 'corte' : TRANSICOES[i % TRANSICOES.length];
|
|
173
|
+
const cruz = trans === 'corte' ? 0 : 0.35;
|
|
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);
|
|
177
|
+
const els = [];
|
|
178
|
+
const variante = i % 2;
|
|
179
|
+
|
|
180
|
+
// ── o fundo: cor, profundidade e movimento, nunca chapado e morto ──
|
|
181
|
+
let fundo = { tipo: 'cor', valor: variante ? cor.fundo2 : cor.fundo };
|
|
182
|
+
let tinta = cor.tinta, destaque = cor.destaque, suave = cor.suave;
|
|
183
|
+
if (p.tipo === 'numero') { fundo = { tipo: 'cor', valor: cor.destaque }; tinta = cor.sobreDestaque; destaque = cor.sobreDestaque; suave = alfa(cor.sobreDestaque, 0.75); }
|
|
184
|
+
/* A FOTO DA MARCA DE FUNDO: a cena de texto ganha a sala de verdade
|
|
185
|
+
atrás, com um véu da cor do fundo para o texto continuar legível. */
|
|
186
|
+
const fotoFundo = fundos[i];
|
|
187
|
+
if (fotoFundo && p.tipo !== 'imagem') {
|
|
188
|
+
const ehV = fotoFundo.tipo === 'video';
|
|
189
|
+
els.push({ tipo: ehV ? 'video' : 'imagem', src: fotoFundo.src, ...(ehV ? { volume: 0 } : {}), x: 0, y: 0, largura: '100%', altura: '100%', ajuste: 'cover', foco: 'center 28%',
|
|
190
|
+
anima: { escala: [{ t: 0, v: variante ? 1.14 : 1.02 }, { t: dur, v: variante ? 1.02 : 1.14, e: 'suave' }] } });
|
|
191
|
+
els.push({ tipo: 'forma', forma: 'retangulo', x: 0, y: 0, largura: '100%', altura: '100%',
|
|
192
|
+
fundo: { tipo: 'gradiente', cores: [alfa(cor.fundo, 0.93), alfa(cor.fundo, 0.72), alfa(cor.fundo, 0.55)], angulo: 90 } });
|
|
193
|
+
} else if (p.tipo !== 'imagem') {
|
|
194
|
+
const R = Math.round(Math.max(W, H) * 0.62);
|
|
195
|
+
const canto = [[W - R * 0.55, -R * 0.45], [-R * 0.45, H - R * 0.5], [W - R * 0.4, H - R * 0.55], [-R * 0.5, -R * 0.4]][i % 4];
|
|
196
|
+
els.push({
|
|
197
|
+
tipo: 'forma', forma: 'circulo', largura: R, altura: R, x: Math.round(canto[0]), y: Math.round(canto[1]),
|
|
198
|
+
// O brilho alterna entre o destaque e a cor de apoio: as duas cores da marca aparecem.
|
|
199
|
+
cor: p.tipo === 'numero' ? cor.sobreDestaque : (variante && marca.cores && marca.cores.apoio ? cor.apoio : cor.destaque), opacidade: p.tipo === 'numero' ? 0.12 : (cor.escuro ? 0.26 : 0.16),
|
|
200
|
+
anima: {
|
|
201
|
+
desfoque: [{ t: 0, v: Math.round(R * 0.22) }],
|
|
202
|
+
x: [{ t: 0, v: 0 }, { t: dur, v: variante ? 60 : -60, e: 'suave' }],
|
|
203
|
+
y: [{ t: 0, v: 0 }, { t: dur, v: variante ? -40 : 40, e: 'suave' }],
|
|
204
|
+
},
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const W0 = W - 2 * M;
|
|
209
|
+
const titulo = (texto, { y, larg = W0, linhas = 2, max = H * 0.12, x = M, alinhar = 'left', de = 0.15, cor: c = tinta, destaqueUltima = true }) => {
|
|
210
|
+
const fit = caber(texto, { largura: larg, linhas, max, fonte: F.titulo });
|
|
211
|
+
const ws = String(texto).split(/\s+/);
|
|
212
|
+
els.push({
|
|
213
|
+
tipo: 'texto', texto, fonte: F.titulo, peso: 800, tamanho: fit.tamanho, cor: c, entrelinha: 1.04,
|
|
214
|
+
x, y: Math.round(y), largura: Math.round(larg), alinhamento: alinhar,
|
|
215
|
+
revelar: ws.map((_, k) => r2(atraso(de + k * 0.07))),
|
|
216
|
+
...(destaqueUltima && ws.length > 2 ? { destaques: [ws.length - 1], corDestaque: destaque } : {}),
|
|
217
|
+
});
|
|
218
|
+
return fit.tamanho * 1.04 * fit.linhas;
|
|
219
|
+
};
|
|
220
|
+
const linhaFina = (texto, { y, larg = W0, x = M, max = H * 0.04, de = 0.5, alinhar = 'left', c = suave, linhas = 2 }) => {
|
|
221
|
+
const fit = caber(texto, { largura: larg, linhas, max, fonte: F.texto });
|
|
222
|
+
els.push({
|
|
223
|
+
tipo: 'texto', texto, fonte: F.texto, peso: 500, tamanho: fit.tamanho, cor: c, entrelinha: 1.3,
|
|
224
|
+
x, y: Math.round(y), largura: Math.round(larg), alinhamento: alinhar,
|
|
225
|
+
anima: { opacidade: [{ t: atraso(de), v: 0 }, { t: atraso(de) + 0.4, v: 1 }], y: [{ t: atraso(de), v: 18 }, { t: atraso(de) + 0.5, v: 0, e: 'saida' }] },
|
|
226
|
+
});
|
|
227
|
+
return fit.tamanho * 1.3 * fit.linhas;
|
|
228
|
+
};
|
|
229
|
+
const traco = (x, y, c = destaque, de = 0.05) => els.push({
|
|
230
|
+
tipo: 'forma', forma: 'retangulo', x, y: Math.round(y), largura: Math.round(W * 0.06), altura: Math.max(6, Math.round(H * 0.008)),
|
|
231
|
+
cor: c, origem: 'left', anima: { escalaX: [{ t: atraso(de), v: 0 }, { t: atraso(de) + 0.5, v: 1, e: 'saida' }] },
|
|
232
|
+
});
|
|
233
|
+
/* O ÍCONE DA IDEIA: um emoji grande, que salta e depois flutua, do
|
|
234
|
+
lado livre da cena — o que dá vida a cena de texto sem virar enfeite. */
|
|
235
|
+
const iconeDaIdeia = (texto, x, y, tam, de = 0.35) => {
|
|
236
|
+
const ic = iconeDoTexto(texto);
|
|
237
|
+
if (!ic || (iconesOk && !iconesOk.has(ic))) return false;
|
|
238
|
+
const q = atraso(de);
|
|
239
|
+
const t2 = r2(Math.max(q + 0.6, dur * 0.55)), t3 = r2(Math.max(t2 + 0.3, dur));
|
|
240
|
+
els.push({ tipo: 'icone', nome: ic, tamanho: Math.round(tam), x: Math.round(x), y: Math.round(y), ancora: 'centro',
|
|
241
|
+
anima: {
|
|
242
|
+
escala: [{ t: q, v: 0 }, { t: q + 0.25, v: 1.18, e: 'saida' }, { t: q + 0.42, v: 1, e: 'suave' }],
|
|
243
|
+
rotacao: [{ t: q, v: -18 }, { t: q + 0.42, v: 0, e: 'saida' }, { t: t3, v: 6, e: 'suave' }],
|
|
244
|
+
y: [{ t: q + 0.42, v: 0 }, { t: t2, v: -16, e: 'suave' }, { t: t3, v: 4, e: 'suave' }],
|
|
245
|
+
} });
|
|
246
|
+
if (vid && sons.pop) efeitos.push({ t: r2((tp.de || 0) + q), src: sons.pop, volume: 0.55 });
|
|
247
|
+
return true;
|
|
248
|
+
};
|
|
249
|
+
const logo = (x, y, alt) => { if (marca.logo) els.push({ tipo: 'imagem', src: marca.logo, x, y, altura: alt, largura: Math.round(alt * 4), ajuste: 'contain', foco: 'left center', anima: { opacidade: [{ t: atraso(0.3), v: 0 }, { t: atraso(0.3) + 0.4, v: 1 }] } }); };
|
|
250
|
+
|
|
251
|
+
switch (p.tipo) {
|
|
252
|
+
case 'abertura':
|
|
253
|
+
case 'titulo': {
|
|
254
|
+
const centro = p.tipo === 'titulo' && variante === 1;
|
|
255
|
+
const larg = centro ? W0 * 0.86 : W0 * (deitado ? 0.78 : 1);
|
|
256
|
+
const fit = caber(p.titulo || '', { largura: larg, linhas: 3, max: H * (p.tipo === 'abertura' ? 0.15 : 0.12), fonte: F.titulo });
|
|
257
|
+
const hT = fit.tamanho * 1.04 * fit.linhas;
|
|
258
|
+
const hS = p.sub ? H * 0.09 : 0;
|
|
259
|
+
let y = (H - hT - hS) / 2;
|
|
260
|
+
const x = centro ? (W - larg) / 2 : M;
|
|
261
|
+
if (p.tipo === 'abertura') logo(M, M, Math.round(H * 0.05));
|
|
262
|
+
if (!centro) traco(M, y - H * 0.05);
|
|
263
|
+
titulo(p.titulo || '', { y, larg, linhas: 3, max: fit.tamanho, x, alinhar: centro ? 'center' : 'left' });
|
|
264
|
+
if (p.sub) linhaFina(p.sub, { y: y + hT + H * 0.03, larg, x, alinhar: centro ? 'center' : 'left', de: 0.6 });
|
|
265
|
+
if (!centro && deitado) iconeDaIdeia(`${p.titulo || ''} ${p.sub || ''}`, W - M - W * 0.09, H * 0.5, H * 0.2, 0.5);
|
|
266
|
+
break;
|
|
267
|
+
}
|
|
268
|
+
case 'numero': {
|
|
269
|
+
const valor = String(p.valor == null ? '' : p.valor);
|
|
270
|
+
const m = valor.match(/^([^\d-]*)(-?[\d.,]+)(.*)$/);
|
|
271
|
+
const larg = deitado ? W0 * 0.5 : W0;
|
|
272
|
+
const fit = caber(valor, { largura: larg, linhas: 1, max: H * (deitado ? 0.5 : 0.3), fonte: F.titulo });
|
|
273
|
+
const yN = deitado ? (H - fit.tamanho) / 2 - H * 0.03 : H * 0.22;
|
|
274
|
+
const num = Number(m ? m[2].replace(/\./g, '').replace(',', '.') : NaN);
|
|
275
|
+
els.push({
|
|
276
|
+
tipo: 'texto', texto: valor, fonte: F.titulo, peso: 800, tamanho: fit.tamanho, cor: tinta, entrelinha: 1,
|
|
277
|
+
x: M, y: Math.round(yN), largura: Math.round(larg + M),
|
|
278
|
+
...(m && !Number.isNaN(num) ? { contar: { ate: num, dur: vid ? 1.3 : 0.6, antes: m[1], depois: m[3], casas: (m[2].split(',')[1] || '').length } } : {}),
|
|
279
|
+
anima: { escala: [{ t: 0, v: 0.86 }, { t: atraso(0.5), v: 1, e: 'saida' }], opacidade: [{ t: 0, v: 0 }, { t: 0.15, v: 1 }] },
|
|
280
|
+
});
|
|
281
|
+
const xR = deitado ? M + larg + W * 0.04 : M;
|
|
282
|
+
const lR = deitado ? W - xR - M : W0;
|
|
283
|
+
const yR = deitado ? H * 0.38 : yN + fit.tamanho + H * 0.04;
|
|
284
|
+
const hR = titulo(p.rotulo || '', { y: yR, larg: lR, linhas: 3, max: H * 0.075, x: xR, de: 0.4, destaqueUltima: false });
|
|
285
|
+
if (p.sub) linhaFina(p.sub, { y: yR + hR + H * 0.03, larg: lR, x: xR, de: 0.8 });
|
|
286
|
+
break;
|
|
287
|
+
}
|
|
288
|
+
case 'lista':
|
|
289
|
+
case 'passos': {
|
|
290
|
+
const itens = (p.itens || []).slice(0, 5).map((it) => (typeof it === 'string' ? { texto: it } : it));
|
|
291
|
+
const n = itens.length || 1;
|
|
292
|
+
const colunas = deitado && n <= 4 ? n : 1;
|
|
293
|
+
const gap = W * 0.025;
|
|
294
|
+
const wCard = colunas > 1 ? (W0 - gap * (colunas - 1)) / colunas : W0;
|
|
295
|
+
/* O bloco inteiro (título + itens) fica no meio da altura: com
|
|
296
|
+
o título no topo e os itens logo abaixo, sobrava meia tela
|
|
297
|
+
vazia embaixo. */
|
|
298
|
+
const fT = p.titulo ? caber(p.titulo, { largura: W0, linhas: 2, max: H * 0.1, fonte: F.titulo }) : { tamanho: 0, linhas: 0 };
|
|
299
|
+
const hTit = fT.tamanho * 1.04 * fT.linhas;
|
|
300
|
+
const hCard0 = colunas > 1 ? (p.tipo === 'passos' ? H * 0.26 : H * 0.44) : H * (deitado ? 0.14 : 0.13);
|
|
301
|
+
const hItens = colunas > 1 ? hCard0 : n * hCard0 + (n - 1) * gap;
|
|
302
|
+
const bloco = hTit + (hTit ? H * 0.07 : 0) + hItens;
|
|
303
|
+
const yTit = Math.max(M, (H - bloco) / 2);
|
|
304
|
+
if (p.titulo) titulo(p.titulo, { y: yTit, linhas: 2, max: fT.tamanho });
|
|
305
|
+
const topo = yTit + hTit + (hTit ? H * 0.07 : 0);
|
|
306
|
+
const hCard = Math.min(hCard0, colunas > 1 ? H - topo - M : (H - topo - M - gap * (n - 1)) / n);
|
|
307
|
+
const dur0 = vid ? Math.max(1, (tp.ate || dur) - (tp.de || 0)) : 1;
|
|
308
|
+
/* Um tamanho de letra para TODOS os itens: o do mais apertado. Cada
|
|
309
|
+
item com o seu tamanho deixava um card com a letra maior que os
|
|
310
|
+
outros, e esse texto saía do card (medido em 25/09). */
|
|
311
|
+
const padG = Math.round(Math.min(wCard, hCard) * 0.14);
|
|
312
|
+
const tamIG = Math.round(Math.min(hCard * (colunas > 1 ? 0.28 : 0.5), H * 0.09));
|
|
313
|
+
const lwG = colunas > 1 ? wCard - 2 * padG : wCard - (tamIG + 3 * padG);
|
|
314
|
+
// No vertical a linha é estreita: 3 linhas por item, para a letra caber grande.
|
|
315
|
+
const linhasG = colunas > 1 ? 4 : (deitado ? 2 : 3);
|
|
316
|
+
const hTexto = colunas > 1 ? hCard - (padG * 1.8 + tamIG + padG) : hCard - padG;
|
|
317
|
+
const maxItem = deitado ? Math.min(H * 0.05, hCard * 0.3) : Math.min(W * 0.062, hCard * 0.26);
|
|
318
|
+
const fits = itens.map((it) => caber(it.texto, { largura: lwG, linhas: linhasG, max: maxItem, fonte: F.texto }));
|
|
319
|
+
let tamItens = Math.min(...fits.map((f) => f.tamanho));
|
|
320
|
+
// Só na lista o texto mora DENTRO do card: ali a altura também manda.
|
|
321
|
+
if (p.tipo === 'lista') {
|
|
322
|
+
const linhasReais = Math.max(...itens.map((it) => caber(it.texto, { largura: lwG, linhas: linhasG, max: tamItens, min: tamItens, fonte: F.texto }).linhas));
|
|
323
|
+
tamItens = Math.min(tamItens, Math.floor(hTexto / (1.2 * linhasReais)));
|
|
324
|
+
}
|
|
325
|
+
itens.forEach((it, k) => {
|
|
326
|
+
const cx = colunas > 1 ? M + k * (wCard + gap) : M;
|
|
327
|
+
const cy = colunas > 1 ? topo : topo + k * (hCard + gap);
|
|
328
|
+
// O item entra quando a voz chega nele (ou em cascata, no slide).
|
|
329
|
+
const quando = atraso(vid ? 0.5 + (dur0 * 0.85 - 0.5) * (k / n) : 0.3 + k * 0.12);
|
|
330
|
+
// Entra de baixo e depois respira: nada fica parado como slide.
|
|
331
|
+
const entra = { opacidade: [{ t: quando, v: 0 }, { t: quando + 0.3, v: 1 }],
|
|
332
|
+
y: [{ t: quando, v: 40 }, { t: quando + 0.45, v: 0, e: 'saida' }, { t: Math.max(quando + 0.5, dur), v: k % 2 ? -8 : 8, e: 'suave' }] };
|
|
333
|
+
if (p.tipo === 'lista') {
|
|
334
|
+
els.push({ tipo: 'forma', forma: 'retangulo', x: Math.round(cx), y: Math.round(cy), largura: Math.round(wCard), altura: Math.round(hCard), cor: alfa(tinta, cor.escuro ? 0.07 : 0.06), raio: Math.round(H * 0.025), anima: entra });
|
|
335
|
+
} else if (k < n - 1 && colunas > 1) {
|
|
336
|
+
els.push({ tipo: 'forma', forma: 'retangulo', x: Math.round(cx + H * 0.05), y: Math.round(cy + H * 0.05), largura: Math.round(wCard + gap), altura: 4, cor: alfa(destaque, 0.5), origem: 'left', anima: { escalaX: [{ t: quando, v: 0 }, { t: quando + 0.6, v: 1, e: 'suave' }] } });
|
|
337
|
+
}
|
|
338
|
+
const tamI = Math.round(Math.min(hCard * (colunas > 1 ? 0.28 : 0.5), H * 0.09));
|
|
339
|
+
const ic = it.icone || iconeDoTexto(it.texto);
|
|
340
|
+
const pad = Math.round(Math.min(wCard, hCard) * 0.14);
|
|
341
|
+
if (p.tipo === 'passos') {
|
|
342
|
+
els.push({ tipo: 'forma', forma: 'circulo', x: Math.round(cx + pad * 0.3), y: Math.round(cy + pad * 0.3), largura: tamI, altura: tamI, cor: destaque, anima: { ...entra, escala: [{ t: quando, v: 0 }, { t: quando + 0.35, v: 1, e: 'elastico' }] } });
|
|
343
|
+
els.push({ tipo: 'texto', texto: String(k + 1), fonte: F.titulo, peso: 800, tamanho: Math.round(tamI * 0.5), cor: cor.sobreDestaque, x: Math.round(cx + pad * 0.3 + tamI / 2), y: Math.round(cy + pad * 0.3 + tamI / 2), ancora: 'centro', anima: entra });
|
|
344
|
+
} else if (ic && (!iconesOk || iconesOk.has(ic))) {
|
|
345
|
+
els.push({ tipo: 'icone', nome: ic, tamanho: tamI, x: Math.round(cx + pad), y: Math.round(cy + (colunas > 1 ? pad : (hCard - tamI) / 2)), anima: { ...entra, escala: [{ t: quando, v: 0 }, { t: quando + 0.3, v: 1.12, e: 'saida' }, { t: quando + 0.45, v: 1 }] } });
|
|
346
|
+
}
|
|
347
|
+
if (vid && sons.pop) efeitos.push({ t: r2((tp.de || 0) + quando), src: sons.pop, volume: 0.5 });
|
|
348
|
+
const tx = colunas > 1 ? cx + pad : cx + pad + tamI + pad;
|
|
349
|
+
const ty = colunas > 1 ? cy + pad + tamI + pad * 0.8 : cy + hCard / 2;
|
|
350
|
+
const lw = colunas > 1 ? wCard - 2 * pad : wCard - (tamI + 3 * pad);
|
|
351
|
+
const fit0 = caber(it.texto, { largura: lw, linhas: linhasG, max: tamItens, min: Math.min(18, tamItens), fonte: F.texto });
|
|
352
|
+
const fit = { tamanho: Math.min(fit0.tamanho, tamItens), linhas: fit0.linhas };
|
|
353
|
+
els.push({
|
|
354
|
+
tipo: 'texto', texto: it.texto, fonte: F.texto, peso: 650, tamanho: fit.tamanho, cor: tinta, entrelinha: 1.2,
|
|
355
|
+
x: Math.round(tx), y: Math.round(colunas > 1 ? ty : ty - fit.tamanho * 1.2 * fit.linhas / 2), largura: Math.round(lw), anima: entra,
|
|
356
|
+
});
|
|
357
|
+
});
|
|
358
|
+
break;
|
|
359
|
+
}
|
|
360
|
+
case 'contraste': {
|
|
361
|
+
const larg = W0 * 0.9;
|
|
362
|
+
const f1 = caber(p.nao || '', { largura: larg, linhas: 2, max: H * 0.07, fonte: F.texto });
|
|
363
|
+
const f2 = caber(p.sim || '', { largura: larg, linhas: 3, max: H * 0.13, fonte: F.titulo });
|
|
364
|
+
const hTot = f1.tamanho * 1.2 * f1.linhas + H * 0.05 + f2.tamanho * 1.04 * f2.linhas;
|
|
365
|
+
const y1 = (H - hTot) / 2;
|
|
366
|
+
const meio = vid ? Math.max(0.8, ((tp.ate || dur) - (tp.de || 0)) * 0.45) : 0.5;
|
|
367
|
+
els.push({ tipo: 'texto', texto: p.nao || '', fonte: F.texto, peso: 600, tamanho: f1.tamanho, cor: alfa(tinta, 0.5), x: M, y: Math.round(y1), largura: Math.round(larg), entrelinha: 1.2, anima: { opacidade: [{ t: 0.1, v: 0 }, { t: 0.4, v: 1 }] } });
|
|
368
|
+
els.push({ tipo: 'forma', forma: 'retangulo', x: M, y: Math.round(y1 + f1.tamanho * 0.62), largura: Math.round(Math.min(larg, (p.nao || '').length * f1.tamanho * 0.52)), altura: Math.max(5, Math.round(f1.tamanho * 0.09)), cor: destaque, origem: 'left', anima: { escalaX: [{ t: atraso(meio - 0.3), v: 0 }, { t: atraso(meio), v: 1, e: 'saida' }] } });
|
|
369
|
+
titulo(p.sim || '', { y: y1 + f1.tamanho * 1.2 * f1.linhas + H * 0.05, larg, linhas: 3, max: f2.tamanho, de: atraso(meio) });
|
|
370
|
+
if (deitado) iconeDaIdeia(p.sim || '', W - M - W * 0.06, H * 0.22, H * 0.14, meio + 0.3);
|
|
371
|
+
if (vid && sons.pop) efeitos.push({ t: r2((tp.de || 0) + meio), src: sons.pop, volume: 0.5 });
|
|
372
|
+
break;
|
|
373
|
+
}
|
|
374
|
+
case 'citacao': {
|
|
375
|
+
const larg = W0 * 0.84;
|
|
376
|
+
const fit = caber(p.texto || '', { largura: larg, linhas: 4, max: H * 0.085, fonte: F.serifa });
|
|
377
|
+
const hT = fit.tamanho * 1.18 * fit.linhas;
|
|
378
|
+
const y = (H - hT) / 2 + H * 0.04;
|
|
379
|
+
els.push({ tipo: 'texto', texto: '“', fonte: F.serifa, peso: 700, tamanho: Math.round(H * 0.3), cor: destaque, x: M, y: Math.round(y - H * 0.24), anima: { opacidade: [{ t: 0, v: 0 }, { t: 0.3, v: 1 }], escala: [{ t: 0, v: 0.7 }, { t: 0.5, v: 1, e: 'saida' }] } });
|
|
380
|
+
const ws = String(p.texto || '').split(/\s+/);
|
|
381
|
+
els.push({ tipo: 'texto', texto: p.texto || '', fonte: F.serifa, peso: 500, italico: true, tamanho: fit.tamanho, cor: tinta, entrelinha: 1.18, x: Math.round(M + W * 0.04), y: Math.round(y), largura: Math.round(larg), revelar: ws.map((_, k) => r2(atraso(0.2 + k * 0.09))) });
|
|
382
|
+
if (p.autor) linhaFina('— ' + p.autor, { y: y + hT + H * 0.04, x: Math.round(M + W * 0.04), larg, de: 0.9, c: destaque });
|
|
383
|
+
break;
|
|
384
|
+
}
|
|
385
|
+
case 'imagem': {
|
|
386
|
+
const m = imagens[i] || p.src;
|
|
387
|
+
const src = m && typeof m === 'object' ? m.src : m;
|
|
388
|
+
if (src) {
|
|
389
|
+
const ehVideo = m && typeof m === 'object' && m.tipo === 'video';
|
|
390
|
+
// O enquadramento puxa para cima: é onde ficam os rostos.
|
|
391
|
+
els.push({ tipo: ehVideo ? 'video' : 'imagem', src, ...(ehVideo ? { volume: 0 } : {}), x: 0, y: 0, largura: '100%', altura: '100%', ajuste: 'cover', foco: 'center 28%', anima: { escala: [{ t: 0, v: 1.12 }, { t: dur, v: 1, e: 'suave' }] } });
|
|
392
|
+
els.push({ tipo: 'forma', forma: 'retangulo', x: 0, y: 0, largura: '100%', altura: '100%', fundo: { tipo: 'gradiente', cores: ['rgba(0,0,0,0)', 'rgba(0,0,0,0.25)', 'rgba(0,0,0,0.85)'], angulo: 180 } });
|
|
393
|
+
}
|
|
394
|
+
const larg = W0 * (deitado ? 0.8 : 1);
|
|
395
|
+
const fit = caber(p.titulo || '', { largura: larg, linhas: 2, max: H * (deitado ? 0.1 : 0.06), fonte: F.titulo });
|
|
396
|
+
const hT = fit.tamanho * 1.04 * fit.linhas;
|
|
397
|
+
/* Vídeo da marca (depoimento, bastidor) costuma ter o nome da
|
|
398
|
+
pessoa gravado embaixo: o título vai para o alto, com o véu
|
|
399
|
+
de cima, para não escrever por cima dele. */
|
|
400
|
+
const noTopo = Boolean(m && typeof m === 'object' && m.tipo === 'video');
|
|
401
|
+
if (noTopo) els.push({ tipo: 'forma', forma: 'retangulo', x: 0, y: 0, largura: '100%', altura: '100%', fundo: { tipo: 'gradiente', cores: ['rgba(0,0,0,0.8)', 'rgba(0,0,0,0.2)', 'rgba(0,0,0,0)'], angulo: 180 } });
|
|
402
|
+
const yT = noTopo ? M * 1.2 : H - M - hT - (p.sub ? H * 0.08 : 0);
|
|
403
|
+
if (p.titulo) titulo(p.titulo, { y: yT, larg, max: fit.tamanho, cor: '#FFFFFF', de: 0.3 });
|
|
404
|
+
if (p.sub) linhaFina(p.sub, { y: yT + hT + H * 0.02, larg, c: 'rgba(255,255,255,0.8)', de: 0.7 });
|
|
405
|
+
break;
|
|
406
|
+
}
|
|
407
|
+
case 'grafico': {
|
|
408
|
+
const barras = (p.barras || []).slice(0, 7);
|
|
409
|
+
const hT = p.titulo ? titulo(p.titulo, { y: M * 1.1, linhas: 2, max: H * 0.08 }) : 0;
|
|
410
|
+
const topo = M * 1.1 + hT + H * 0.12;
|
|
411
|
+
const baseY = H - M - H * 0.08;
|
|
412
|
+
const hMax = baseY - topo;
|
|
413
|
+
const maior = Math.max(1, ...barras.map((b) => Number(b.valor) || 0));
|
|
414
|
+
const gap = W0 * 0.03;
|
|
415
|
+
const wB = (W0 - gap * (barras.length - 1)) / Math.max(1, barras.length);
|
|
416
|
+
barras.forEach((b, k) => {
|
|
417
|
+
const h = Math.max(6, hMax * (Number(b.valor) || 0) / maior);
|
|
418
|
+
const x = M + k * (wB + gap);
|
|
419
|
+
const q = atraso(0.4 + k * 0.12);
|
|
420
|
+
els.push({ tipo: 'forma', forma: 'retangulo', x: Math.round(x), y: Math.round(baseY - h), largura: Math.round(wB), altura: Math.round(h), cor: k === barras.length - 1 ? destaque : alfa(tinta, 0.25), raio: Math.round(wB * 0.08), origem: 'bottom', anima: { escalaY: [{ t: q, v: 0 }, { t: q + 0.6, v: 1, e: 'saida' }] } });
|
|
421
|
+
const fv = Math.round(Math.min(H * 0.05, wB * 0.35));
|
|
422
|
+
els.push({ tipo: 'texto', texto: String(b.valor), fonte: F.titulo, peso: 800, tamanho: fv, cor: tinta, x: Math.round(x + wB / 2), y: Math.round(baseY - h - fv * 1.4), ancora: 'centro-x', contar: { ate: Number(b.valor) || 0, dur: 0.8 }, anima: { opacidade: [{ t: q, v: 0 }, { t: q + 0.3, v: 1 }] } });
|
|
423
|
+
const fr = caber(b.rotulo || '', { largura: wB, linhas: 2, max: H * 0.03, fonte: F.texto });
|
|
424
|
+
els.push({ tipo: 'texto', texto: b.rotulo || '', fonte: F.texto, peso: 500, tamanho: fr.tamanho, cor: suave, x: Math.round(x), y: Math.round(baseY + H * 0.02), largura: Math.round(wB), alinhamento: 'center' });
|
|
425
|
+
});
|
|
426
|
+
break;
|
|
427
|
+
}
|
|
428
|
+
case 'cta': {
|
|
429
|
+
const larg = W0 * 0.84;
|
|
430
|
+
const fit = caber(p.titulo || '', { largura: larg, linhas: 3, max: H * 0.12, fonte: F.titulo });
|
|
431
|
+
const hT = fit.tamanho * 1.04 * fit.linhas;
|
|
432
|
+
const temLink = Boolean(p.link || p.sub);
|
|
433
|
+
const y = (H - hT - (temLink ? H * 0.16 : 0)) / 2;
|
|
434
|
+
titulo(p.titulo || '', { y, larg, max: fit.tamanho, x: (W - larg) / 2, alinhar: 'center' });
|
|
435
|
+
if (temLink) {
|
|
436
|
+
const txt = p.link || p.sub;
|
|
437
|
+
const fl = caber(txt, { largura: larg * 0.7, linhas: 1, max: H * 0.045, fonte: F.texto });
|
|
438
|
+
const wP = Math.min(larg, txt.length * fl.tamanho * 0.55 + fl.tamanho * 2.4);
|
|
439
|
+
const hP = fl.tamanho * 2.2;
|
|
440
|
+
const yP = y + hT + H * 0.06;
|
|
441
|
+
const q = atraso(0.8);
|
|
442
|
+
const entra = { opacidade: [{ t: q, v: 0 }, { t: q + 0.3, v: 1 }], escala: [{ t: q, v: 0.8 }, { t: q + 0.4, v: 1, e: 'elastico' }] };
|
|
443
|
+
els.push({ tipo: 'forma', forma: 'retangulo', x: Math.round((W - wP) / 2), y: Math.round(yP), largura: Math.round(wP), altura: Math.round(hP), cor: destaque, raio: Math.round(hP / 2), anima: entra });
|
|
444
|
+
els.push({ tipo: 'texto', texto: txt, fonte: F.texto, peso: 700, tamanho: fl.tamanho, cor: cor.sobreDestaque, x: Math.round(W / 2), y: Math.round(yP + hP / 2), ancora: 'centro', anima: entra });
|
|
445
|
+
if (vid && sons.pop) efeitos.push({ t: r2((tp.de || 0) + q), src: sons.pop, volume: 0.55 });
|
|
446
|
+
}
|
|
447
|
+
logo(Math.round(W / 2 - H * 0.1), Math.round(H - M - H * 0.05), Math.round(H * 0.05));
|
|
448
|
+
break;
|
|
449
|
+
}
|
|
450
|
+
default: break;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
const cena = { duracao: dur, fundo, elementos: els, transicao: { tipo: trans, duracao: cruz } };
|
|
454
|
+
if (vid) cena.camera = { zoom: variante ? [{ t: 0, v: 1.04 }, { t: dur, v: 1, e: 'suave' }] : [{ t: 0, v: 1 }, { t: dur, v: 1.04, e: 'suave' }] };
|
|
455
|
+
cenas.push(cena);
|
|
456
|
+
if (vid && i > 0 && sons.whoosh) efeitos.push({ t: r2(Math.max(0, (tp.de || 0) - 0.15)), src: sons.whoosh, volume: 0.4 });
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
const audio = {};
|
|
460
|
+
if (vid && narracao) audio.narracao = narracao;
|
|
461
|
+
if (vid && trilha) audio.trilha = { src: trilha, volume: 0.12, loop: true };
|
|
462
|
+
if (vid && efeitos.length) audio.efeitos = efeitos;
|
|
463
|
+
return {
|
|
464
|
+
ok: true,
|
|
465
|
+
plano: { formato: { largura: W, altura: H, fps: 30 }, fundoGeral: cor.fundo, cenas, ...(Object.keys(audio).length ? { audio } : {}) },
|
|
466
|
+
resumo: { cenas: cenas.length, efeitos: efeitos.length, musica: Boolean(audio.trilha), voz: Boolean(audio.narracao) },
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
module.exports = { comporRoteiro, caber, paleta, falaDe, registroDe, iconesDoRoteiro, TIPOS };
|