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,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 };
|