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,399 @@
1
+ /**
2
+ * cinema.js — o estilo cinematográfico, premium e humano.
3
+ *
4
+ * "Os efeitos sonoros estão genéricos e sem impacto, a paleta da Paris não
5
+ * é laranja, as cenas têm muito texto e não dá de ler antes de trocar, e
6
+ * os textos não têm nada a ver com o roteiro." — e veio o roteiro dele:
7
+ * "cinematográfico, premium, moderno e humano… poucas palavras por tela…
8
+ * fotos nunca estáticas… NÃO usar excesso de efeitos… a abertura não
9
+ * mostra o logo." (25/09/2026)
10
+ *
11
+ * O contrário do impacto: o texto entra com calma e FICA enquanto é falado
12
+ * (a voz lê o que está na tela), as fotos se movem sempre (zoom, pan,
13
+ * lateral), o preto é quase absoluto, o destaque é o gradiente da marca, a
14
+ * música cresce e silencia antes do clímax, e a marca só aparece no fim.
15
+ *
16
+ * Tipos de parte (todos com "fala" opcional; sem ela, a voz lê os textos):
17
+ * frase {linhas} preto, linha fina, texto
18
+ * montagem {linhas, ritmo} fotos rápidas + texto por cima
19
+ * numeros {antes, itens:["3 EDIÇÕES"]} os números contando
20
+ * escada {itens, final} "UMA SALA." "20 CADEIRAS." …
21
+ * dia {num, titulo, linhas} 01 PREPARAR + as três linhas
22
+ * depoimento {clipes:[{src,de,ate,frase,nome}]} o aluno falando
23
+ * climax {linhas} montagem acelerada, preto, silêncio
24
+ * final {linhas} a logo e a chamada
25
+ */
26
+
27
+ 'use strict';
28
+
29
+ const { caber } = require('./composicao.js');
30
+
31
+ const r2 = (n) => Math.round(n * 100) / 100;
32
+ const tokens = (t) => String(t || '').normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
33
+
34
+ /** Quanto tempo um texto precisa ficar na tela para ser lido. */
35
+ const leitura = (t) => 0.55 + 0.22 * String(t || '').split(/\s+/).filter(Boolean).length;
36
+
37
+ /** A fala desta parte (o que a voz diz): a "fala", ou os textos da tela. */
38
+ /* A voz lê EXATAMENTE o que está na tela, na ordem da tela ("a narração tem
39
+ que ser a mesma dos textos", 25/09). Caixa alta vira frase normal (senão a
40
+ voz soletra), "IA" fica "IA", o separador vira pausa e "/SC" sai da fala. */
41
+ function paraFala(x) {
42
+ let t = String(x || '').replace(/\b0(\d)\b/g, '$1').replace(/[·→]/g, ',').replace(/\/(SC|SP|RJ|PR|RS|MG)\b/g, '').replace(/\s+,/g, ',').trim();
43
+ if (t && t === t.toUpperCase()) {
44
+ // Sigla de até 3 letras fica sigla ("AI" minúsculo a voz lê como "ai").
45
+ const SIGLAS = new Set(['IA', 'AI', 'TI', 'RH', 'CEO', 'KPI', 'BI', 'CRM', 'ERP', 'SC', 'SP', 'PR', 'RS', 'RJ', 'MG']);
46
+ t = t.split(/(\s+)/).map((w) => (SIGLAS.has(w.replace(/[:.,!?]$/, '')) ? w : w.toLowerCase())).join('')
47
+ .replace(/(^|[.!?:]\s+)(\p{L})/gu, (m, a, b) => a + b.toUpperCase());
48
+ }
49
+ t = t.replace(/[,;:\s]+$/, '');
50
+ if (!/[.!?]$/.test(t)) t += '.';
51
+ return t;
52
+ }
53
+ function falaDe(p) {
54
+ if (p.fala === '' || p.mudo) return ''; // parte MUDA: a tela fala sozinha
55
+ if (p.fala) return p.fala;
56
+ if (p.tipo === 'depoimento') return '';
57
+ const tela = p.tipo === 'dia' ? [`Dia ${p.num}`, p.titulo, ...(p.linhas || [])]
58
+ : p.tipo === 'escada' ? [...(p.itens || []), p.final]
59
+ : p.tipo === 'numeros' ? [p.antes, ...(p.itens || [])]
60
+ : (p.linhas || []);
61
+ /* A linha só emenda na seguinte quando não termina em pontuação E a
62
+ seguinte começa em minúscula ("E se 3 dias fossem suficientes" / "para
63
+ mudar…"); o resto é frase inteira. A frase repetida em seguida
64
+ ("CONSTRUIR" / "Construir.") a voz diz uma vez só. */
65
+ const linhas = tela.filter(Boolean).map((x) => String(x).trim());
66
+ const frases = [];
67
+ let atual = '';
68
+ linhas.forEach((bruto, k) => {
69
+ const prox = linhas[k + 1];
70
+ const emenda = !/[.!?…·→:]$/.test(bruto) && prox && /^[a-zà-ú]/.test(prox);
71
+ let pedaco = paraFala(bruto);
72
+ if (emenda) pedaco = pedaco.replace(/\.$/, '');
73
+ atual = atual ? `${atual} ${pedaco.charAt(0).toLowerCase()}${pedaco.slice(1)}` : pedaco;
74
+ if (!emenda) { frases.push(atual); atual = ''; }
75
+ });
76
+ if (atual) frases.push(atual);
77
+ const chave = (f) => f.toLowerCase().replace(/[^\p{L}\p{N}]/gu, '');
78
+ return frases.filter((f, k) => k === 0 || chave(f) !== chave(frases[k - 1])).join(' ');
79
+ }
80
+
81
+ /* Quando cada texto começa, pelo tempo das palavras da voz: procura a
82
+ primeira palavra do texto a partir de onde parou. */
83
+ function quando(textos, palavras, dur, t0 = 0.25) {
84
+ const ws = palavras.map((w) => ({ ...w, k: tokens(w.texto)[0] || '' }));
85
+ let cursor = 0;
86
+ return textos.map((txt, i) => {
87
+ const alvo = tokens(txt).find((w) => w.length >= 3) || tokens(txt)[0];
88
+ let achado = -1;
89
+ for (let j = cursor; j < ws.length; j++) if (ws[j].k === alvo || (alvo && ws[j].k.startsWith(alvo.slice(0, 5)))) { achado = j; break; }
90
+ if (achado >= 0) { cursor = achado + 1; return r2(ws[achado].de); }
91
+ return r2(t0 + (dur - t0) * (i / Math.max(1, textos.length)));
92
+ });
93
+ }
94
+
95
+ function montarCinema({ roteiro, largura = 1920, altura = 1080, tempos = [], narracao = null, sons = {}, trilha = null, fotos = [], fortes = {} }) {
96
+ const partes = (roteiro && roteiro.partes) || [];
97
+ if (!partes.length) return { ok: false, error: 'o roteiro veio sem partes.' };
98
+ const marca = roteiro.marca || {};
99
+ const grad = marca.gradiente && marca.gradiente.length >= 2 ? marca.gradiente : ['#2E81ED', '#9567DB'];
100
+ const PRETO = (marca.cores && marca.cores.fundo) || '#07070A';
101
+ const BRANCO = '#F5F5F7';
102
+ const DEST = grad[1];
103
+ const F = { titulo: (marca.fontes && marca.fontes.titulo) || 'Inter', texto: (marca.fontes && marca.fontes.texto) || 'Inter' };
104
+ const W = largura, H = altura, deitado = W >= H;
105
+ const M = Math.round(Math.min(W, H) * 0.08), W0 = W - 2 * M;
106
+ const base = Math.min(W, H);
107
+ const cenas = [], trechos = [], efeitos = [], volumes = [];
108
+ let t = 0, fotoI = 0;
109
+ /* A foto certa para o texto: "projetos", "construir", "funcionando" pedem
110
+ a TELA (o dashboard); o resto pede GENTE. Foto de tela em "uma sala, 20
111
+ cadeiras" era o que "não tem nada a ver" (25/09). */
112
+ const pool = { pessoas: fotos.filter((f) => f.cat !== 'tela'), tela: fotos.filter((f) => f.cat === 'tela') };
113
+ const idx = { pessoas: 0, tela: 0 };
114
+ const foto = (cat = 'pessoas') => {
115
+ const l = pool[cat] && pool[cat].length ? pool[cat] : (pool.pessoas.length ? pool.pessoas : fotos);
116
+ const k = pool[cat] && pool[cat].length ? cat : 'pessoas';
117
+ return l.length ? l[idx[k]++ % l.length] : null;
118
+ };
119
+ const catDe = (texto) => (/projet|constru|funcion|dashboard|sistema|ferramenta/i.test(String(texto || '')) ? 'tela' : 'pessoas');
120
+ const som = (tt, nome, volume = 0.6) => { if (sons[nome]) efeitos.push({ t: r2(Math.max(0, tt)), src: sons[nome], volume }); };
121
+ const vol = (tt, v) => volumes.push({ t: r2(tt), v });
122
+
123
+ // Os movimentos da foto, em rodízio: nunca parada.
124
+ const movs = [
125
+ (d) => ({ escala: [{ t: 0, v: 1.02 }, { t: d, v: 1.16, e: 'linear' }] }),
126
+ (d) => ({ escala: [{ t: 0, v: 1.18 }, { t: d, v: 1.06, e: 'linear' }] }),
127
+ (d) => ({ escala: [{ t: 0, v: 1.14 }, { t: d, v: 1.14 }], x: [{ t: 0, v: -W * 0.04 }, { t: d, v: W * 0.04, e: 'linear' }] }),
128
+ (d) => ({ escala: [{ t: 0, v: 1.14 }, { t: d, v: 1.14 }], x: [{ t: 0, v: W * 0.04 }, { t: d, v: -W * 0.04, e: 'linear' }] }),
129
+ ];
130
+ let mov = 0;
131
+ /* O ENQUADRAMENTO pelas pessoas: com os rostos achados (studio/rostos.py),
132
+ o recorte centra neles; se eles não cabem no recorte (a foto deitada num
133
+ quadro em pé perde 2/3 da largura), a foto entra INTEIRA, com ela mesma
134
+ borrada de fundo. "Aparecem três homens ali, está cortada, não dá pra
135
+ ver ninguém" (25/09). */
136
+ const enquadrar = (f) => {
137
+ const ro = f.rosto;
138
+ if (!ro || !ro.largura) return { foco: 'center 30%', inteira: false };
139
+ const aF = W / H, aI = ro.largura / ro.altura;
140
+ if (aI > aF) { // a foto é mais larga que o quadro: sobra largura
141
+ const vis = aF / aI;
142
+ if (ro.n && ro.x1 - ro.x0 > vis * 0.92) return { inteira: true };
143
+ const cx = ro.n ? ro.cx : 0.5;
144
+ const px = vis >= 1 ? 50 : Math.round(Math.max(0, Math.min(1, (cx - vis / 2) / (1 - vis))) * 100);
145
+ return { foco: `${px}% ${ro.n ? Math.round(Math.min(0.6, ro.cy) * 100) : 35}%`, inteira: false };
146
+ }
147
+ const vis = aI / aF; // mais alta que o quadro: sobra altura
148
+ const cy = ro.n ? ro.cy : 0.35;
149
+ const py = vis >= 1 ? 50 : Math.round(Math.max(0, Math.min(1, (cy - vis / 2) / (1 - vis))) * 100);
150
+ return { foco: `50% ${py}%`, inteira: false };
151
+ };
152
+ const pFoto = (els, f, de, ate, { veu = 0.5, entrada = 'corte' } = {}) => {
153
+ if (!f) return;
154
+ const d = r2(ate - de), v = f.tipo === 'video';
155
+ const q = enquadrar(f);
156
+ const suave = entrada === 'suave' ? { opacidade: [{ t: 0, v: 0 }, { t: 0.4, v: 1 }] } : {};
157
+ if (q.inteira) {
158
+ els.push({ tipo: 'imagem', src: f.src, x: 0, y: 0, largura: '100%', altura: '100%', ajuste: 'cover', de: r2(de), ate: r2(ate),
159
+ anima: { desfoque: [{ t: 0, v: 36 }], brilho: [{ t: 0, v: 0.5 }], escala: [{ t: 0, v: 1.15 }, { t: d, v: 1.25, e: 'linear' }] } });
160
+ els.push({ tipo: 'imagem', src: f.src, x: 0, y: Math.round(H * 0.22), largura: '100%', altura: Math.round(H * 0.56), ajuste: 'contain', de: r2(de), ate: r2(ate),
161
+ anima: { escala: [{ t: 0, v: 1.0 }, { t: d, v: 1.07, e: 'linear' }], ...suave } });
162
+ } else {
163
+ els.push({ tipo: v ? 'video' : 'imagem', src: f.src, ...(v ? { volume: 0 } : {}), x: 0, y: 0, largura: '100%', altura: '100%', ajuste: 'cover', foco: q.foco, de: r2(de), ate: r2(ate),
164
+ anima: { ...(deitado ? movs[mov++ % movs.length](d) : movs[(mov++ % 2)](d)), ...suave } });
165
+ }
166
+ if (veu > 0) els.push({ tipo: 'forma', forma: 'retangulo', x: 0, y: 0, largura: '100%', altura: '100%', de: r2(de), ate: r2(ate),
167
+ fundo: { tipo: 'gradiente', cores: [`rgba(7,7,10,${veu * 0.7})`, `rgba(7,7,10,${veu})`, `rgba(7,7,10,${Math.min(0.95, veu + 0.25)})`], angulo: 180 } });
168
+ };
169
+ // O texto: entra palavra por palavra no tempo da voz e fica; as palavras marcadas no gradiente.
170
+ const pTexto = (els, texto, de, ate, { y = null, max = deitado ? H * 0.11 : W * 0.13, linhas = 3, peso = 700, caixa = true, marcar = [], cor = BRANCO, pal = [], alinhar = 'center', x = M, larg = W0, bater = false } = {}) => {
171
+ // Frase longa (a narração na tela) vai em letra normal: em caixa alta ela encolhe e cansa.
172
+ if (caixa && String(texto).split(/\s+/).length > 7) { caixa = false; peso = Math.min(peso, 700); }
173
+ // Frase longa ganha linhas (no vertical, até 5), para a letra não encolher.
174
+ if (String(texto).split(/\s+/).length > 7) linhas = Math.max(linhas, deitado ? 4 : 5);
175
+ const txt = caixa ? String(texto).toUpperCase() : String(texto);
176
+ const fit = caber(txt, { largura: larg * 0.86, linhas, max, fonte: F.titulo });
177
+ const h = fit.tamanho * 1.08 * fit.linhas;
178
+ const ws = txt.split(/\s+/);
179
+ /* Centralizado, a linha entra inteira: palavra por palavra, as que ainda
180
+ não foram ditas ocupam espaço invisível e o começo da frase aparece
181
+ torto, puxado para a esquerda. Alinhado à esquerda, palavra por palavra. */
182
+ const rev = ws.map((_, k) => (alinhar === 'center' ? 0 : r2(Math.max(0, (pal[k] != null ? pal[k] : de + 0.08 * k) - de))));
183
+ const idx = ws.map((w, k) => (marcar.some((m) => tokens(w)[0] && tokens(m).includes(tokens(w)[0])) ? k : -1)).filter((k) => k >= 0);
184
+ els.push({ tipo: 'texto', texto: txt, fonte: F.titulo, peso, tamanho: fit.tamanho, cor, entrelinha: 1.08, espacamento: caixa ? 1 : 0,
185
+ x: Math.round(x), y: Math.round(y == null ? (H - h) / 2 : y), largura: Math.round(larg), alinhamento: alinhar, de: r2(de), ate: r2(ate),
186
+ revelar: rev, ...(idx.length ? { destaques: idx, corDestaque: DEST } : {}),
187
+ anima: { opacidade: [{ t: 0, v: 1 }, { t: Math.max(0.01, ate - de - 0.25), v: 1 }, { t: ate - de, v: 0 }], y: [{ t: 0, v: 10 }, { t: ate - de, v: -6, e: 'linear' }],
188
+ // O texto que BATE no fundo: entra grande e assenta de uma vez.
189
+ ...(bater ? { escala: [{ t: 0, v: 1.35 }, { t: 0.12, v: 0.97, e: 'saida' }, { t: 0.22, v: 1 }] } : {}) } });
190
+ return h;
191
+ };
192
+ const linhaFina = (els, y, de, larg = W * 0.18) => els.push({ tipo: 'forma', forma: 'retangulo', x: Math.round((W - larg) / 2), y: Math.round(y), largura: Math.round(larg), altura: 2,
193
+ fundo: { tipo: 'gradiente', cores: [grad[0], grad[1]], angulo: 90 }, origem: 'center', de: r2(de), anima: { escalaX: [{ t: 0, v: 0 }, { t: 0.8, v: 1, e: 'saida' }] } });
194
+ const pontos = (els, de) => { for (let k = 0; k < 3; k++) els.push({ tipo: 'forma', forma: 'circulo', largura: 6, altura: 6, cor: k === 1 ? DEST : 'rgba(255,255,255,0.4)', x: Math.round(W / 2 - 18 + k * 15), y: Math.round(H - M * 1.2), de: r2(de), anima: { opacidade: [{ t: 0.2 * k, v: 0 }, { t: 0.2 * k + 0.3, v: 1 }] } }); };
195
+
196
+ let narrI = 0;
197
+ partes.forEach((p, i) => {
198
+ const els = [];
199
+ const cena = { fundo: { tipo: 'cor', valor: PRETO }, elementos: els, transicao: { tipo: 'corte', duracao: 0 } };
200
+ // A parte de voz forte tem a voz dela (fortes[i]); a narração principal não a inclui.
201
+ const narrada = p.tipo !== 'depoimento' && falaDe(p) !== '' && !(p.falaForte && fortes[i]);
202
+ const tp = narrada ? (tempos[narrI++] || null) : null;
203
+ const off = 0.2; // a voz entra um instante depois do corte
204
+ const falaDur = fortes[i] ? fortes[i].segundos + 0.3 : (tp ? (tempos[narrI] ? tempos[narrI].de : tp.ate + 0.8) - tp.de : 0);
205
+ const rel = (w) => r2(off + w - (tp ? tp.de : 0)); // tempo da voz → tempo da cena
206
+ const pals = tp ? tp.palavras || [] : [];
207
+ let dur;
208
+
209
+ /* O fim da fala: a transcrição marca o fim da última palavra um pouco
210
+ antes do fim de verdade, e cortar ali comia a última sílaba ("parece
211
+ que a narração não terminou", 25/09). O trecho vai até perto do
212
+ começo da próxima fala, com folga. */
213
+ const proxFala = tempos[narrI] ? tempos[narrI].de : null;
214
+ // Até o começo da próxima fala, sem teto: o áudio é contínuo, e cortar antes comia sílaba.
215
+ const forte = fortes[i];
216
+ const fimFala = tp ? r2(proxFala != null ? proxFala - 0.02 : tp.ate + 0.8) : 0;
217
+ if (tp && narracao && !forte && !p.mudo) trechos.push({ src: narracao, t: r2(t + off), de: r2(tp.de), ate: fimFala, volume: 1 });
218
+ if (forte) trechos.push({ src: forte.src, t: r2(t + (p.tipo === 'climax' ? 0 : off)), de: 0, ate: r2(forte.segundos + 0.3), volume: 1 });
219
+
220
+ const textosDaParte = p.tipo === 'escada' ? [...(p.itens || []), p.final].filter(Boolean)
221
+ : p.tipo === 'numeros' ? [p.antes, ...(p.itens || [])].filter(Boolean)
222
+ : p.tipo === 'dia' ? [p.num, p.titulo, ...(p.linhas || [])].filter(Boolean)
223
+ : (p.linhas || []);
224
+ const somaLeitura = textosDaParte.reduce((n, x) => n + leitura(x), 0);
225
+ // A cena dura a fala (com um respiro) — e nunca menos que o tempo de ler.
226
+ dur = r2(Math.max(falaDur + off + 0.2, somaLeitura * 0.7, 1.8));
227
+ const inicios = quando(textosDaParte, pals.map((w) => ({ ...w, de: rel(w.de) })), dur, off);
228
+ /* Cada texto garante o SEU tempo de leitura e empurra o seguinte: sincronia
229
+ com a voz é bom, ler é obrigatório. Se a leitura pede mais que a fala,
230
+ a cena cresce (medido: "EDIÇÕES" ficava meio segundo na tela). */
231
+ // Texto curto (um número, "3 DIAS.") se lê de relance: não espera a leitura cheia e não atrasa a voz.
232
+ const minimo = (x) => Math.min(leitura(x), String(x).split(/\s+/).length <= 3 ? 0.75 : 1.6);
233
+ for (let k = 1; k < inicios.length; k++) inicios[k] = r2(Math.max(inicios[k], inicios[k - 1] + minimo(textosDaParte[k - 1])));
234
+ if (inicios.length) dur = r2(Math.max(dur, inicios[inicios.length - 1] + leitura(textosDaParte[inicios.length - 1]) + 0.3));
235
+ const fimDe = (k) => (k < inicios.length - 1 ? inicios[k + 1] : dur);
236
+ const palDo = (txt) => { const ws = String(txt).split(/\s+/); const a = pals.findIndex((w) => tokens(w.texto)[0] === tokens(ws[0])[0]); return a < 0 ? [] : ws.map((_, k) => (pals[a + k] ? rel(pals[a + k].de) : null)); };
237
+
238
+ if (p.tipo === 'frase') {
239
+ if (i === 0) { som(t + 0.1, 'teclado', 0.5); vol(t, 0.12); }
240
+ linhaFina(els, H * 0.5 - base * 0.12, 0.2);
241
+ textosDaParte.forEach((x, k) => pTexto(els, x, inicios[k], fimDe(k), { caixa: false, peso: 600, max: deitado ? H * 0.1 : W * 0.12, linhas: deitado ? 3 : 4, marcar: p.marcar || [], pal: palDo(x) }));
242
+ } else if (p.tipo === 'montagem' || p.tipo === 'climax') {
243
+ const rap = p.tipo === 'climax' ? 0.38 : (p.ritmo || 0.75);
244
+ let fimFotos = p.tipo === 'climax' && textosDaParte.length ? Math.max(1.8, inicios[0] - 0.4) : dur;
245
+ const fz = p.tipo === 'climax' ? fortes[i] : null;
246
+ if (fz) {
247
+ /* A voz forte entra DEPOIS da montagem, junto com o texto que bate. */
248
+ fimFotos = 1.4;
249
+ const ini2 = quando(textosDaParte, (fz.palavras || []).map((w) => ({ ...w, de: w.de + fimFotos })), fz.segundos, fimFotos);
250
+ ini2.forEach((v0, k) => { inicios[k] = k === 0 ? fimFotos : Math.max(v0, inicios[k - 1] + 1.2); });
251
+ dur = r2(Math.max(fimFotos + fz.segundos + 0.5, inicios[inicios.length - 1] + 1.6));
252
+ const tr = trechos[trechos.length - 1];
253
+ if (tr && tr.src === fz.src) tr.t = r2(t + fimFotos - 0.05);
254
+ }
255
+ for (let a = 0; a < fimFotos; a += rap) {
256
+ // A foto do momento acompanha o texto que está na tela.
257
+ let ativo = textosDaParte[0];
258
+ textosDaParte.forEach((x, k) => { if (a >= inicios[k] - 0.1) ativo = x; });
259
+ pFoto(els, foto(catDe(ativo)), a, Math.min(fimFotos, a + rap), { veu: p.tipo === 'climax' ? 0.15 : 0.5 });
260
+ }
261
+ if (p.tipo === 'climax') {
262
+ /* A virada: a música só ABAIXA (fica perceptível), e o texto bate
263
+ no fundo com um impacto forte — "quero um som de impacto bem
264
+ forte, do texto batendo no fundo" (25/09). */
265
+ vol(t, 0.5); vol(t + fimFotos - 0.3, 0.75); vol(t + fimFotos, 0.16); vol(t + dur - 0.2, 0.2);
266
+ som(t + fimFotos + 0.02, 'estalo', 0.8);
267
+ cena.tremor = [{ de: fimFotos, ate: fimFotos + 0.45, forca: 16 }];
268
+ cena.socos = [{ t: fimFotos, forca: 0.12 }];
269
+ } else { vol(t, 0.28); som(t, 'whoosh', 0.35); }
270
+ textosDaParte.forEach((x, k) => {
271
+ if (p.tipo === 'climax') { som(t + inicios[k], 'impacto', k === 0 ? 1.0 : 0.85); if (k > 0) cena.tremor = [...(cena.tremor || []), { de: inicios[k], ate: inicios[k] + 0.4, forca: 14 }]; }
272
+ pTexto(els, x, Math.max(inicios[k], p.tipo === 'climax' ? fimFotos : 0), fimDe(k), { peso: 800, marcar: p.marcar || [], pal: palDo(x), bater: p.tipo === 'climax',
273
+ // A virada é o texto maior do vídeo.
274
+ ...(p.tipo === 'climax' ? { max: deitado ? H * 0.13 : W * 0.17, linhas: deitado ? 3 : 5 } : {}) });
275
+ });
276
+ } else if (p.tipo === 'numeros') {
277
+ vol(t, 0.3);
278
+ for (let a = 0; a < dur; a += 1.2) pFoto(els, foto(), a, Math.min(dur, a + 1.2), { veu: 0.45 });
279
+ textosDaParte.forEach((x, k) => {
280
+ const m = String(x).match(/^(\d+)\s+(.*)$/);
281
+ if (k === 0 && p.antes) { pTexto(els, x, inicios[k], fimDe(k), { max: base * 0.08, peso: 700, pal: palDo(x) }); return; }
282
+ if (!m) { pTexto(els, x, inicios[k], fimDe(k), { pal: palDo(x) }); return; }
283
+ const tam = Math.round(deitado ? H * 0.24 : W * 0.3);
284
+ els.push({ tipo: 'texto', texto: m[1], fonte: F.titulo, peso: 800, tamanho: tam, cor: BRANCO, x: M, y: Math.round(H * 0.5 - tam * 0.75), largura: W0, alinhamento: 'center',
285
+ de: inicios[k], ate: fimDe(k), contar: { ate: Number(m[1]), dur: 0.45 }, gradienteTexto: undefined,
286
+ anima: { opacidade: [{ t: 0, v: 0 }, { t: 0.25, v: 1 }], escala: [{ t: 0, v: 0.92 }, { t: 0.6, v: 1, e: 'saida' }] } });
287
+ pTexto(els, m[2], inicios[k] + 0.1, fimDe(k), { y: H * 0.5 + tam * 0.3, max: base * 0.085, peso: 700, cor: DEST });
288
+ });
289
+ } else if (p.tipo === 'escada') {
290
+ vol(t, 0.22);
291
+ if (sons.sala) trechos.push({ src: sons.sala, t: r2(t), de: 0, ate: dur, volume: 0.25 });
292
+ // As fotos passam devagar numa janela, à direita (ou em cima, no vertical).
293
+ // No vertical a janela desce da zona do topo do Instagram e fica maior.
294
+ const jan = deitado ? { x: W * 0.56, y: M, l: W * 0.44 - M, a: H - 2 * M } : { x: M, y: H * 0.1, l: W0, a: H * 0.42 };
295
+ const itens = p.itens || [];
296
+ const hItem = Math.round(deitado ? H * 0.1 : W * 0.12);
297
+ const fimItens = p.final ? inicios[itens.length] : dur;
298
+ for (let a = 0; a < fimItens; a += 1.3) {
299
+ const f = foto();
300
+ if (f) els.push({ tipo: f.tipo === 'video' ? 'video' : 'imagem', src: f.src, ...(f.tipo === 'video' ? { volume: 0 } : {}), x: Math.round(jan.x), y: Math.round(jan.y), largura: Math.round(jan.l), altura: Math.round(jan.a), ajuste: 'cover', foco: 'center 30%', raio: 18, de: r2(a), ate: r2(Math.min(fimItens, a + 1.3)),
301
+ anima: { ...movs[mov++ % movs.length](1.3), opacidade: [{ t: 0, v: 0 }, { t: 0.3, v: 1 }] } });
302
+ }
303
+ itens.forEach((x, k) => {
304
+ const yy = deitado ? H * 0.5 - (itens.length * hItem * 1.25) / 2 + k * hItem * 1.25 : H * 0.56 + k * hItem * 1.25;
305
+ pTexto(els, x, inicios[k], fimItens, { y: yy, max: hItem, linhas: 1, alinhar: 'left', larg: deitado ? W * 0.48 : W0, peso: 800, pal: palDo(x) });
306
+ });
307
+ if (p.final) pTexto(els, p.final, fimItens, dur, { marcar: p.marcar || [], pal: palDo(p.final) });
308
+ } else if (p.tipo === 'dia') {
309
+ vol(t, 0.32);
310
+ pFoto(els, foto(), 0, dur, { veu: 0.66, entrada: 'suave' });
311
+ som(t, 'whoosh', 0.3);
312
+ const tamN = Math.round(deitado ? H * 0.34 : W * 0.42);
313
+ els.push({ tipo: 'texto', texto: String(p.num), fonte: F.titulo, peso: 800, tamanho: tamN, cor: 'rgba(149,103,219,0.12)', contorno: `${Math.max(3, Math.round(base * 0.004))}px ${DEST}`,
314
+ x: M, y: Math.round(deitado ? H * 0.12 : H * 0.14), largura: W0, alinhamento: deitado ? 'left' : 'center',
315
+ anima: { opacidade: [{ t: 0, v: 0 }, { t: 0.6, v: 1 }], x: [{ t: 0, v: -20 }, { t: dur, v: 20, e: 'linear' }] } });
316
+ const yT = deitado ? H * 0.5 : H * 0.42;
317
+ const hT = pTexto(els, p.titulo, inicios[1] || 0.4, dur, { y: yT, max: deitado ? H * 0.11 : W * 0.13, linhas: 1, alinhar: deitado ? 'left' : 'center', larg: W0, peso: 800, pal: palDo(p.titulo) });
318
+ // Frase longa (a narração) troca no mesmo lugar, em várias linhas; frase curta empilha.
319
+ const longas = (p.linhas || []).some((x) => String(x).split(/\s+/).length > 5);
320
+ if (longas) (p.linhas || []).forEach((x, k) => pTexto(els, x, inicios[2 + k] || 0.8 + k * 0.6, k < p.linhas.length - 1 ? (inicios[3 + k] || dur) : dur,
321
+ { y: yT + hT + H * 0.03, max: base * (deitado ? 0.06 : 0.075), linhas: 4, caixa: false, peso: 600, cor: 'rgba(245,245,247,0.92)', alinhar: deitado ? 'left' : 'center', larg: W0, pal: palDo(x) }));
322
+ else (p.linhas || []).forEach((x, k) => pTexto(els, x, inicios[2 + k] || 0.8 + k * 0.6, dur, { y: yT + hT + H * 0.03 + k * base * (deitado ? 0.085 : 0.1), max: base * (deitado ? 0.06 : 0.07), linhas: 1, caixa: false, peso: 500, cor: 'rgba(245,245,247,0.8)',
323
+ alinhar: deitado ? 'left' : 'center', larg: W0, pal: palDo(x) }));
324
+ } else if (p.tipo === 'depoimento') {
325
+ /* O aluno falando, com a voz dele; a música quase some. CADA depoimento
326
+ é uma cena: o vídeo conta o tempo do início da cena, e com os três
327
+ na mesma cena o segundo e o terceiro já tinham passado do fim (preto). */
328
+ vol(t, 0.035);
329
+ const clipes = p.clipes || [];
330
+ clipes.forEach((c, n) => {
331
+ const d = r2(c.ate - c.de + 0.3);
332
+ // Entre um depoimento e outro, esmaecer: o corte seco pegava o "é…" de quem começa.
333
+ const ce = { fundo: { tipo: 'cor', valor: PRETO }, elementos: [], transicao: { tipo: 'esmaecer', duracao: 0.4 }, duracao: d };
334
+ const el2 = ce.elementos;
335
+ if (deitado) {
336
+ const wV = Math.round(H * 0.88 * 9 / 16), xV = Math.round(W * 0.08), xT = xV + wV + Math.round(W * 0.05);
337
+ el2.push({ tipo: 'video', src: c.src, volume: 0, deOrigem: c.de, ateOrigem: c.ate + 0.3, x: 0, y: 0, largura: '100%', altura: '100%', ajuste: 'cover', anima: { desfoque: [{ t: 0, v: 40 }], brilho: [{ t: 0, v: 0.4 }] } });
338
+ el2.push({ tipo: 'video', src: c.src, volume: 1, deOrigem: c.de, ateOrigem: c.ate + 0.3, x: xV, y: Math.round(H * 0.06), largura: wV, altura: Math.round(H * 0.88), ajuste: 'cover', raio: 20, anima: { opacidade: [{ t: 0, v: 0 }, { t: 0.2, v: 1 }] } });
339
+ pTexto(el2, `“${c.frase}”`, 0.3, d, { x: xT, larg: W - xT - M, alinhar: 'left', caixa: false, peso: 600, max: H * 0.075, linhas: 5 });
340
+ if (c.nome) el2.push({ tipo: 'texto', texto: c.nome.toUpperCase(), fonte: F.texto, peso: 700, tamanho: Math.round(H * 0.03), cor: DEST, espacamento: 3, x: xT, y: Math.round(H * 0.8), de: 0.5 });
341
+ } else {
342
+ /* O vídeo do aluno vem com a legenda gravada embaixo (~73% da
343
+ altura): aproximado pelo topo, ela sai do quadro e fica só a
344
+ nossa frase — antes eram dois textos um sobre o outro. */
345
+ el2.push({ tipo: 'video', src: c.src, volume: 1, deOrigem: c.de, ateOrigem: c.ate + 0.3, x: 0, y: 0, largura: '100%', altura: '100%', ajuste: 'cover', foco: 'center top',
346
+ origem: 'center top', anima: { escala: [{ t: 0, v: 1.42 }, { t: d, v: 1.48, e: 'linear' }] } });
347
+ el2.push({ tipo: 'forma', forma: 'retangulo', x: 0, y: 0, largura: '100%', altura: '100%', fundo: { tipo: 'gradiente', cores: ['rgba(7,7,10,0)', 'rgba(7,7,10,0.35)', 'rgba(7,7,10,0.9)'], angulo: 180 } });
348
+ // Tudo acima de 75% da altura: embaixo disso o Instagram põe a legenda e os botões.
349
+ pTexto(el2, `“${c.frase}”`, 0.3, d, { y: H * 0.54, caixa: false, peso: 600, max: W * 0.08, linhas: 4 });
350
+ if (c.nome) el2.push({ tipo: 'texto', texto: c.nome.toUpperCase(), fonte: F.texto, peso: 700, tamanho: Math.round(W * 0.035), cor: DEST, espacamento: 3, x: M, y: Math.round(H * 0.72), largura: W0, alinhamento: 'center', de: 0.5 });
351
+ }
352
+ if (n < clipes.length - 1) { cenas.push(ce); t += d; } else { Object.assign(cena, ce); dur = d; }
353
+ });
354
+ if (!clipes.length) dur = 1;
355
+ } else if (p.tipo === 'final') {
356
+ // A marca, enfim: a logo aparece devagar, as linhas em sequência, a logo some.
357
+ vol(t, 0.55); vol(t + dur - 1.8, 0.45); vol(t + dur, 0);
358
+ som(t + 0.05, 'impacto', 0.55);
359
+ const R = Math.round(Math.max(W, H) * 0.7);
360
+ els.push({ tipo: 'forma', forma: 'circulo', largura: R, altura: R, x: Math.round(W / 2 - R / 2), y: Math.round(H * 0.42 - R / 2), cor: grad[1], opacidade: 0.18, anima: { desfoque: [{ t: 0, v: Math.round(R * 0.25) }], escala: [{ t: 0, v: 0.8 }, { t: dur, v: 1.15, e: 'suave' }] } });
361
+ if (marca.logo) {
362
+ const lh = Math.round(base * (deitado ? 0.13 : 0.16));
363
+ els.push({ tipo: 'imagem', src: marca.logo, x: Math.round(W / 2 - lh * 1.75), y: Math.round(H * (deitado ? 0.2 : 0.3)), largura: Math.round(lh * 3.5), altura: lh, ajuste: 'contain',
364
+ anima: { opacidade: [{ t: 0, v: 0 }, { t: 1.0, v: 1 }, { t: dur - 1.2, v: 1 }, { t: dur, v: 0 }], escala: [{ t: 0, v: 0.94 }, { t: dur, v: 1.02, e: 'linear' }] } });
365
+ }
366
+ const y0 = H * (deitado ? 0.44 : 0.42);
367
+ textosDaParte.forEach((x, k) => pTexto(els, x, inicios[k], k < textosDaParte.length - 1 ? fimDe(k) : dur, { y: y0 + (k % 2) * 0, max: k === textosDaParte.length - 1 ? base * (deitado ? 0.1 : 0.12) : base * (deitado ? 0.08 : 0.1), peso: k === textosDaParte.length - 1 ? 800 : 600,
368
+ marcar: p.marcar || [], cor: k === textosDaParte.length - 1 ? DEST : BRANCO, pal: palDo(x) }));
369
+ pontos(els, 0.3);
370
+ cena.transicao = { tipo: 'esmaecer', duracao: 0.6 };
371
+ }
372
+
373
+ // Entre as partes, um esmaecer curto: corte seco em tudo parecia fala interrompida.
374
+ if (i > 0 && cena.transicao.tipo === 'corte' && !['montagem', 'climax'].includes(p.tipo)) cena.transicao = { tipo: 'esmaecer', duracao: 0.35 };
375
+ cena.duracao = dur;
376
+ cenas.push(cena);
377
+ t += dur;
378
+ });
379
+
380
+ // Os cruzamentos são da cena que entra: a anterior fica no ar até ela entrar.
381
+ let acum = 0;
382
+ cenas.forEach((c, k) => {
383
+ const prox = cenas[k + 1];
384
+ c.duracao = r2(c.duracao + (prox && prox.transicao.tipo !== 'corte' ? prox.transicao.duracao : 0));
385
+ acum += c.duracao;
386
+ });
387
+ volumes.sort((a, b) => a.t - b.t);
388
+ return {
389
+ ok: true,
390
+ plano: {
391
+ formato: { largura: W, altura: H, fps: 30 }, fundoGeral: PRETO, cenas,
392
+ audio: { ...(trilha ? { trilha: { src: trilha, volumes: volumes.length ? volumes : [{ t: 0, v: 0.3 }], loop: true } } : {}), trechos, efeitos },
393
+ acabamento: { grao: 0.05, vinheta: 0.5, grade: 0.035, passoGrade: Math.round(base / 9) },
394
+ },
395
+ resumo: { cenas: cenas.length, estilo: 'cinema', segundos: r2(t) },
396
+ };
397
+ }
398
+
399
+ module.exports = { montarCinema, falaDe, paraFala, quando, leitura };