primocode 9.8.0-beta.17 → 9.8.0-beta.19
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/estudio/assets.js +21 -2
- package/lib/estudio/cinema.js +267 -0
- package/lib/estudio/composicao.js +3 -1
- package/lib/estudio/edicao.js +9 -0
- package/lib/estudio/impacto.js +263 -0
- package/lib/estudio/index.js +159 -7
- package/lib/estudio/plano.js +1 -1
- package/lib/estudio/remotion.js +83 -5
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# PrimoCode v9.8.0-beta.
|
|
1
|
+
# PrimoCode v9.8.0-beta.19
|
|
2
2
|
|
|
3
3
|
Agente de engenharia brasileiro para o terminal, no estilo Claude Code.
|
|
4
4
|
Cria arquivos de verdade, roda comandos, **controla o navegador e o desktop** —
|
|
@@ -9,7 +9,7 @@ OpenRouter, e cada apelido (`top`/`main`/`fast`) é uma **corrente**: se um mode
|
|
|
9
9
|
está no limite de pedidos, o próximo atende. Você não vê o erro, e não paga nada.
|
|
10
10
|
|
|
11
11
|
```
|
|
12
|
-
▐▛███▜▌ Primo Code v9.8.0-beta.
|
|
12
|
+
▐▛███▜▌ Primo Code v9.8.0-beta.19
|
|
13
13
|
▝▜█████▛▘ Bem-vindo de volta, Joel
|
|
14
14
|
▘▘ ▝▝ ~/primocode · /dir <pasta> muda
|
|
15
15
|
```
|
package/lib/estudio/assets.js
CHANGED
|
@@ -311,14 +311,33 @@ async function procurarImagens(termo, { quantas = 8, transparente = false, orien
|
|
|
311
311
|
* `duracaoMin` existe para separar as duas coisas sem o usuário dizer: um
|
|
312
312
|
* "whoosh" tem menos de dois segundos, uma trilha tem mais de trinta.
|
|
313
313
|
*/
|
|
314
|
+
/* A MÚSICA vem da Jamendo (pelo Openverse): música de verdade, moderna, de
|
|
315
|
+
licença aberta. O Openverse geral devolvia concerto antigo, vento e trilha
|
|
316
|
+
de terror para "cinematic" (medido em 25/09). Licença "nd" (sem obra
|
|
317
|
+
derivada) fica de fora da música: pôr a faixa num vídeo editado é derivar. */
|
|
318
|
+
async function procurarMusica(termo, { quantas = 8, duracaoMin = 45 } = {}) {
|
|
319
|
+
try {
|
|
320
|
+
const r = await pegar(`https://api.openverse.org/v1/audio/?q=${encodeURIComponent(termo)}&source=jamendo`
|
|
321
|
+
+ `&page_size=${Math.max(quantas * 2, 10)}&license_type=commercial`, { json: true });
|
|
322
|
+
const sons = (r.results || [])
|
|
323
|
+
.filter((x) => !/nd/.test(x.license || '') && (!duracaoMin || (x.duration || 0) / 1000 >= duracaoMin))
|
|
324
|
+
.slice(0, quantas)
|
|
325
|
+
.map((x) => ({ url: x.url, titulo: x.title || termo, autor: x.creator, licenca: x.license, creditos: x.attribution,
|
|
326
|
+
fonte: 'jamendo', segundos: x.duration ? Math.round(x.duration / 1000) : null }));
|
|
327
|
+
if (sons.length) return { ok: true, sons };
|
|
328
|
+
} catch { /* cai no Openverse geral */ }
|
|
329
|
+
return procurarSom(termo, { quantas, duracaoMin });
|
|
330
|
+
}
|
|
331
|
+
|
|
314
332
|
async function procurarSom(termo, { quantas = 8, duracaoMin = 0, duracaoMax = 0 } = {}) {
|
|
315
333
|
try {
|
|
316
334
|
let url = `https://api.openverse.org/v1/audio/?q=${encodeURIComponent(termo)}`
|
|
317
335
|
+ `&page_size=${quantas}&license_type=commercial`;
|
|
318
|
-
if (duracaoMin
|
|
336
|
+
if (duracaoMin && duracaoMin < 30) url += '&length=short';
|
|
319
337
|
const r = await pegar(url, { json: true });
|
|
320
338
|
const sons = (r.results || [])
|
|
321
339
|
.filter((x) => !duracaoMax || !x.duration || x.duration / 1000 <= duracaoMax)
|
|
340
|
+
.filter((x) => !duracaoMin || !x.duration || x.duration / 1000 >= duracaoMin)
|
|
322
341
|
.map((x) => ({
|
|
323
342
|
url: x.url, titulo: x.title || termo, autor: x.creator,
|
|
324
343
|
licenca: x.license, creditos: x.attribution, fonte: 'openverse',
|
|
@@ -392,7 +411,7 @@ async function conferirFonte(familia) {
|
|
|
392
411
|
module.exports = {
|
|
393
412
|
extensaoPorConteudo,
|
|
394
413
|
pegar, guardar,
|
|
395
|
-
icone, procurarIcones, iconesDoPlano, COLECOES, procurarVideos,
|
|
414
|
+
icone, procurarIcones, iconesDoPlano, COLECOES, procurarVideos, procurarMusica,
|
|
396
415
|
procurarImagens, procurarSom, baixar,
|
|
397
416
|
FONTES, conferirFonte,
|
|
398
417
|
};
|
|
@@ -0,0 +1,267 @@
|
|
|
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
|
+
function falaDe(p) {
|
|
39
|
+
if (p.fala) return p.fala;
|
|
40
|
+
if (p.tipo === 'depoimento') return '';
|
|
41
|
+
const partes = [p.antes, ...(p.linhas || []), ...(p.itens || []), p.num ? `Dia ${p.num}.` : '', p.titulo, p.final];
|
|
42
|
+
return partes.filter(Boolean).map((x) => String(x).replace(/[·→]/g, ',')).join(' ');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/* Quando cada texto começa, pelo tempo das palavras da voz: procura a
|
|
46
|
+
primeira palavra do texto a partir de onde parou. */
|
|
47
|
+
function quando(textos, palavras, dur, t0 = 0.25) {
|
|
48
|
+
const ws = palavras.map((w) => ({ ...w, k: tokens(w.texto)[0] || '' }));
|
|
49
|
+
let cursor = 0;
|
|
50
|
+
return textos.map((txt, i) => {
|
|
51
|
+
const alvo = tokens(txt).find((w) => w.length >= 3) || tokens(txt)[0];
|
|
52
|
+
let achado = -1;
|
|
53
|
+
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; }
|
|
54
|
+
if (achado >= 0) { cursor = achado + 1; return r2(ws[achado].de); }
|
|
55
|
+
return r2(t0 + (dur - t0) * (i / Math.max(1, textos.length)));
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function montarCinema({ roteiro, largura = 1920, altura = 1080, tempos = [], narracao = null, sons = {}, trilha = null, fotos = [] }) {
|
|
60
|
+
const partes = (roteiro && roteiro.partes) || [];
|
|
61
|
+
if (!partes.length) return { ok: false, error: 'o roteiro veio sem partes.' };
|
|
62
|
+
const marca = roteiro.marca || {};
|
|
63
|
+
const grad = marca.gradiente && marca.gradiente.length >= 2 ? marca.gradiente : ['#2E81ED', '#9567DB'];
|
|
64
|
+
const PRETO = (marca.cores && marca.cores.fundo) || '#07070A';
|
|
65
|
+
const BRANCO = '#F5F5F7';
|
|
66
|
+
const DEST = grad[1];
|
|
67
|
+
const F = { titulo: (marca.fontes && marca.fontes.titulo) || 'Inter', texto: (marca.fontes && marca.fontes.texto) || 'Inter' };
|
|
68
|
+
const W = largura, H = altura, deitado = W >= H;
|
|
69
|
+
const M = Math.round(Math.min(W, H) * 0.08), W0 = W - 2 * M;
|
|
70
|
+
const base = Math.min(W, H);
|
|
71
|
+
const cenas = [], trechos = [], efeitos = [], volumes = [];
|
|
72
|
+
let t = 0, fotoI = 0;
|
|
73
|
+
const foto = () => fotos[fotoI++ % Math.max(1, fotos.length)];
|
|
74
|
+
const som = (tt, nome, volume = 0.6) => { if (sons[nome]) efeitos.push({ t: r2(Math.max(0, tt)), src: sons[nome], volume }); };
|
|
75
|
+
const vol = (tt, v) => volumes.push({ t: r2(tt), v });
|
|
76
|
+
|
|
77
|
+
// Os movimentos da foto, em rodízio: nunca parada.
|
|
78
|
+
const movs = [
|
|
79
|
+
(d) => ({ escala: [{ t: 0, v: 1.02 }, { t: d, v: 1.16, e: 'linear' }] }),
|
|
80
|
+
(d) => ({ escala: [{ t: 0, v: 1.18 }, { t: d, v: 1.06, e: 'linear' }] }),
|
|
81
|
+
(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' }] }),
|
|
82
|
+
(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' }] }),
|
|
83
|
+
];
|
|
84
|
+
let mov = 0;
|
|
85
|
+
const pFoto = (els, f, de, ate, { veu = 0.5, entrada = 'corte' } = {}) => {
|
|
86
|
+
if (!f) return;
|
|
87
|
+
const d = r2(ate - de), v = f.tipo === 'video';
|
|
88
|
+
els.push({ tipo: v ? 'video' : 'imagem', src: f.src, ...(v ? { volume: 0 } : {}), x: 0, y: 0, largura: '100%', altura: '100%', ajuste: 'cover', foco: 'center 30%', de: r2(de), ate: r2(ate),
|
|
89
|
+
anima: { ...movs[mov++ % movs.length](d), ...(entrada === 'suave' ? { opacidade: [{ t: 0, v: 0 }, { t: 0.4, v: 1 }] } : {}) } });
|
|
90
|
+
if (veu > 0) els.push({ tipo: 'forma', forma: 'retangulo', x: 0, y: 0, largura: '100%', altura: '100%', de: r2(de), ate: r2(ate),
|
|
91
|
+
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 } });
|
|
92
|
+
};
|
|
93
|
+
// O texto: entra palavra por palavra no tempo da voz e fica; as palavras marcadas no gradiente.
|
|
94
|
+
const pTexto = (els, texto, de, ate, { y = null, max = deitado ? H * 0.11 : W * 0.1, linhas = 3, peso = 700, caixa = true, marcar = [], cor = BRANCO, pal = [], alinhar = 'center', x = M, larg = W0 } = {}) => {
|
|
95
|
+
const txt = caixa ? String(texto).toUpperCase() : String(texto);
|
|
96
|
+
const fit = caber(txt, { largura: larg * 0.86, linhas, max, fonte: F.titulo });
|
|
97
|
+
const h = fit.tamanho * 1.08 * fit.linhas;
|
|
98
|
+
const ws = txt.split(/\s+/);
|
|
99
|
+
/* Centralizado, a linha entra inteira: palavra por palavra, as que ainda
|
|
100
|
+
não foram ditas ocupam espaço invisível e o começo da frase aparece
|
|
101
|
+
torto, puxado para a esquerda. Alinhado à esquerda, palavra por palavra. */
|
|
102
|
+
const rev = ws.map((_, k) => (alinhar === 'center' ? 0 : r2(Math.max(0, (pal[k] != null ? pal[k] : de + 0.08 * k) - de))));
|
|
103
|
+
const idx = ws.map((w, k) => (marcar.some((m) => tokens(w)[0] && tokens(m).includes(tokens(w)[0])) ? k : -1)).filter((k) => k >= 0);
|
|
104
|
+
els.push({ tipo: 'texto', texto: txt, fonte: F.titulo, peso, tamanho: fit.tamanho, cor, entrelinha: 1.08, espacamento: caixa ? 1 : 0,
|
|
105
|
+
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),
|
|
106
|
+
revelar: rev, ...(idx.length ? { destaques: idx, corDestaque: DEST } : {}),
|
|
107
|
+
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' }] } });
|
|
108
|
+
return h;
|
|
109
|
+
};
|
|
110
|
+
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,
|
|
111
|
+
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' }] } });
|
|
112
|
+
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 }] } }); };
|
|
113
|
+
|
|
114
|
+
let narrI = 0;
|
|
115
|
+
partes.forEach((p, i) => {
|
|
116
|
+
const els = [];
|
|
117
|
+
const cena = { fundo: { tipo: 'cor', valor: PRETO }, elementos: els, transicao: { tipo: 'corte', duracao: 0 } };
|
|
118
|
+
const narrada = p.tipo !== 'depoimento';
|
|
119
|
+
const tp = narrada ? (tempos[narrI++] || null) : null;
|
|
120
|
+
const off = 0.35; // a voz entra um instante depois do corte
|
|
121
|
+
const falaDur = tp ? tp.ate - tp.de : 0;
|
|
122
|
+
const rel = (w) => r2(off + w - (tp ? tp.de : 0)); // tempo da voz → tempo da cena
|
|
123
|
+
const pals = tp ? tp.palavras || [] : [];
|
|
124
|
+
let dur;
|
|
125
|
+
|
|
126
|
+
if (tp && narracao) trechos.push({ src: narracao, t: r2(t + off), de: r2(tp.de), ate: r2(tp.ate + 0.05), volume: 1 });
|
|
127
|
+
|
|
128
|
+
const textosDaParte = p.tipo === 'escada' ? [...(p.itens || []), p.final].filter(Boolean)
|
|
129
|
+
: p.tipo === 'numeros' ? [p.antes, ...(p.itens || [])].filter(Boolean)
|
|
130
|
+
: p.tipo === 'dia' ? [p.num, p.titulo, ...(p.linhas || [])].filter(Boolean)
|
|
131
|
+
: (p.linhas || []);
|
|
132
|
+
const somaLeitura = textosDaParte.reduce((n, x) => n + leitura(x), 0);
|
|
133
|
+
// A cena dura a fala (com um respiro) — e nunca menos que o tempo de ler.
|
|
134
|
+
dur = r2(Math.max(falaDur + off + 0.45, somaLeitura * 0.8, 2));
|
|
135
|
+
const inicios = quando(textosDaParte, pals.map((w) => ({ ...w, de: rel(w.de) })), dur, off);
|
|
136
|
+
/* Cada texto garante o SEU tempo de leitura e empurra o seguinte: sincronia
|
|
137
|
+
com a voz é bom, ler é obrigatório. Se a leitura pede mais que a fala,
|
|
138
|
+
a cena cresce (medido: "EDIÇÕES" ficava meio segundo na tela). */
|
|
139
|
+
for (let k = 1; k < inicios.length; k++) inicios[k] = r2(Math.max(inicios[k], inicios[k - 1] + leitura(textosDaParte[k - 1])));
|
|
140
|
+
if (inicios.length) dur = r2(Math.max(dur, inicios[inicios.length - 1] + leitura(textosDaParte[inicios.length - 1]) + 0.3));
|
|
141
|
+
const fimDe = (k) => (k < inicios.length - 1 ? inicios[k + 1] : dur);
|
|
142
|
+
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)); };
|
|
143
|
+
|
|
144
|
+
if (p.tipo === 'frase') {
|
|
145
|
+
if (i === 0) { som(t + 0.1, 'teclado', 0.5); vol(t, 0.12); }
|
|
146
|
+
linhaFina(els, H * 0.5 - base * 0.12, 0.2);
|
|
147
|
+
textosDaParte.forEach((x, k) => pTexto(els, x, inicios[k], fimDe(k), { caixa: false, peso: 600, max: deitado ? H * 0.1 : W * 0.095, marcar: p.marcar || [], pal: palDo(x) }));
|
|
148
|
+
} else if (p.tipo === 'montagem' || p.tipo === 'climax') {
|
|
149
|
+
const rap = p.tipo === 'climax' ? 0.38 : (p.ritmo || 0.75);
|
|
150
|
+
const fimFotos = p.tipo === 'climax' && textosDaParte.length ? Math.max(1.8, inicios[0] - 0.4) : dur;
|
|
151
|
+
for (let a = 0; a < fimFotos; a += rap) pFoto(els, foto(), a, Math.min(fimFotos, a + rap), { veu: p.tipo === 'climax' ? 0.15 : 0.5 });
|
|
152
|
+
if (p.tipo === 'climax') {
|
|
153
|
+
vol(t, 0.5); vol(t + fimFotos - 0.3, 0.75); vol(t + fimFotos, 0.0); vol(t + dur - 0.2, 0.0);
|
|
154
|
+
som(t + fimFotos, 'impacto', 0.7);
|
|
155
|
+
} else { vol(t, 0.28); som(t, 'whoosh', 0.35); }
|
|
156
|
+
textosDaParte.forEach((x, k) => pTexto(els, x, Math.max(inicios[k], p.tipo === 'climax' ? fimFotos : 0), fimDe(k), { peso: 800, marcar: p.marcar || [], pal: palDo(x) }));
|
|
157
|
+
} else if (p.tipo === 'numeros') {
|
|
158
|
+
vol(t, 0.3);
|
|
159
|
+
for (let a = 0; a < dur; a += 1.2) pFoto(els, foto(), a, Math.min(dur, a + 1.2), { veu: 0.62 });
|
|
160
|
+
textosDaParte.forEach((x, k) => {
|
|
161
|
+
const m = String(x).match(/^(\d+)\s+(.*)$/);
|
|
162
|
+
if (k === 0 && p.antes) { pTexto(els, x, inicios[k], fimDe(k), { max: base * 0.08, peso: 700, pal: palDo(x) }); return; }
|
|
163
|
+
if (!m) { pTexto(els, x, inicios[k], fimDe(k), { pal: palDo(x) }); return; }
|
|
164
|
+
const tam = Math.round(deitado ? H * 0.24 : W * 0.3);
|
|
165
|
+
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',
|
|
166
|
+
de: inicios[k], ate: fimDe(k), contar: { ate: Number(m[1]), dur: 0.9 }, gradienteTexto: undefined,
|
|
167
|
+
anima: { opacidade: [{ t: 0, v: 0 }, { t: 0.25, v: 1 }], escala: [{ t: 0, v: 0.92 }, { t: 0.6, v: 1, e: 'saida' }] } });
|
|
168
|
+
pTexto(els, m[2], inicios[k] + 0.3, fimDe(k), { y: H * 0.5 + tam * 0.3, max: base * 0.085, peso: 700, cor: DEST });
|
|
169
|
+
});
|
|
170
|
+
} else if (p.tipo === 'escada') {
|
|
171
|
+
vol(t, 0.22);
|
|
172
|
+
if (sons.sala) trechos.push({ src: sons.sala, t: r2(t), de: 0, ate: dur, volume: 0.25 });
|
|
173
|
+
// As fotos passam devagar numa janela, à direita (ou em cima, no vertical).
|
|
174
|
+
const jan = deitado ? { x: W * 0.56, y: M, l: W * 0.44 - M, a: H - 2 * M } : { x: M, y: M, l: W0, a: H * 0.36 };
|
|
175
|
+
const itens = p.itens || [];
|
|
176
|
+
const hItem = Math.round(deitado ? H * 0.1 : W * 0.12);
|
|
177
|
+
const fimItens = p.final ? inicios[itens.length] : dur;
|
|
178
|
+
for (let a = 0; a < fimItens; a += 1.3) {
|
|
179
|
+
const f = foto();
|
|
180
|
+
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)),
|
|
181
|
+
anima: { ...movs[mov++ % movs.length](1.3), opacidade: [{ t: 0, v: 0 }, { t: 0.3, v: 1 }] } });
|
|
182
|
+
}
|
|
183
|
+
itens.forEach((x, k) => {
|
|
184
|
+
const yy = deitado ? H * 0.5 - (itens.length * hItem * 1.25) / 2 + k * hItem * 1.25 : H * 0.48 + k * hItem * 1.3;
|
|
185
|
+
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) });
|
|
186
|
+
});
|
|
187
|
+
if (p.final) pTexto(els, p.final, fimItens, dur, { marcar: p.marcar || [], pal: palDo(p.final) });
|
|
188
|
+
} else if (p.tipo === 'dia') {
|
|
189
|
+
vol(t, 0.32);
|
|
190
|
+
pFoto(els, foto(), 0, dur, { veu: 0.66, entrada: 'suave' });
|
|
191
|
+
som(t, 'whoosh', 0.3);
|
|
192
|
+
const tamN = Math.round(deitado ? H * 0.34 : W * 0.42);
|
|
193
|
+
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}`,
|
|
194
|
+
x: M, y: Math.round(deitado ? H * 0.12 : H * 0.14), largura: W0, alinhamento: deitado ? 'left' : 'center',
|
|
195
|
+
anima: { opacidade: [{ t: 0, v: 0 }, { t: 0.6, v: 1 }], x: [{ t: 0, v: -20 }, { t: dur, v: 20, e: 'linear' }] } });
|
|
196
|
+
const yT = deitado ? H * 0.5 : H * 0.44;
|
|
197
|
+
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) });
|
|
198
|
+
(p.linhas || []).forEach((x, k) => pTexto(els, x, inicios[2 + k] || 0.8 + k * 0.6, dur, { y: yT + hT + H * 0.04 + k * base * 0.085, max: base * 0.06, linhas: 1, caixa: false, peso: 500, cor: 'rgba(245,245,247,0.8)',
|
|
199
|
+
alinhar: deitado ? 'left' : 'center', larg: W0, pal: palDo(x) }));
|
|
200
|
+
} else if (p.tipo === 'depoimento') {
|
|
201
|
+
/* O aluno falando, com a voz dele; a música quase some. CADA depoimento
|
|
202
|
+
é uma cena: o vídeo conta o tempo do início da cena, e com os três
|
|
203
|
+
na mesma cena o segundo e o terceiro já tinham passado do fim (preto). */
|
|
204
|
+
vol(t, 0.06);
|
|
205
|
+
const clipes = p.clipes || [];
|
|
206
|
+
clipes.forEach((c, n) => {
|
|
207
|
+
const d = r2(c.ate - c.de + 0.3);
|
|
208
|
+
const ce = { fundo: { tipo: 'cor', valor: PRETO }, elementos: [], transicao: { tipo: n === 0 ? 'esmaecer' : 'corte', duracao: n === 0 ? 0.4 : 0 }, duracao: d };
|
|
209
|
+
const el2 = ce.elementos;
|
|
210
|
+
if (deitado) {
|
|
211
|
+
const wV = Math.round(H * 0.88 * 9 / 16), xV = Math.round(W * 0.08), xT = xV + wV + Math.round(W * 0.05);
|
|
212
|
+
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 }] } });
|
|
213
|
+
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 }] } });
|
|
214
|
+
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 });
|
|
215
|
+
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 });
|
|
216
|
+
} else {
|
|
217
|
+
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 30%' });
|
|
218
|
+
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.1)', 'rgba(7,7,10,0.85)'], angulo: 180 } });
|
|
219
|
+
pTexto(el2, `“${c.frase}”`, 0.3, d, { y: H * 0.68, caixa: false, peso: 600, max: W * 0.075, linhas: 4 });
|
|
220
|
+
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.9), largura: W0, alinhamento: 'center', de: 0.5 });
|
|
221
|
+
}
|
|
222
|
+
if (n < clipes.length - 1) { cenas.push(ce); t += d; } else { Object.assign(cena, ce); dur = d; }
|
|
223
|
+
});
|
|
224
|
+
if (!clipes.length) dur = 1;
|
|
225
|
+
} else if (p.tipo === 'final') {
|
|
226
|
+
// A marca, enfim: a logo aparece devagar, as linhas em sequência, a logo some.
|
|
227
|
+
vol(t, 0.55); vol(t + dur - 1.8, 0.45); vol(t + dur, 0);
|
|
228
|
+
som(t + 0.05, 'impacto', 0.55);
|
|
229
|
+
const R = Math.round(Math.max(W, H) * 0.7);
|
|
230
|
+
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' }] } });
|
|
231
|
+
if (marca.logo) {
|
|
232
|
+
const lh = Math.round(base * (deitado ? 0.13 : 0.1));
|
|
233
|
+
els.push({ tipo: 'imagem', src: marca.logo, x: Math.round(W / 2 - lh * 1.75), y: Math.round(H * (deitado ? 0.2 : 0.26)), largura: Math.round(lh * 3.5), altura: lh, ajuste: 'contain',
|
|
234
|
+
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' }] } });
|
|
235
|
+
}
|
|
236
|
+
const y0 = H * (deitado ? 0.44 : 0.42);
|
|
237
|
+
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 * 0.1 : base * 0.08, peso: k === textosDaParte.length - 1 ? 800 : 600,
|
|
238
|
+
marcar: p.marcar || [], cor: k === textosDaParte.length - 1 ? DEST : BRANCO, pal: palDo(x) }));
|
|
239
|
+
pontos(els, 0.3);
|
|
240
|
+
cena.transicao = { tipo: 'esmaecer', duracao: 0.6 };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
cena.duracao = dur;
|
|
244
|
+
cenas.push(cena);
|
|
245
|
+
t += dur;
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
// Os cruzamentos são da cena que entra: a anterior fica no ar até ela entrar.
|
|
249
|
+
let acum = 0;
|
|
250
|
+
cenas.forEach((c, k) => {
|
|
251
|
+
const prox = cenas[k + 1];
|
|
252
|
+
c.duracao = r2(c.duracao + (prox && prox.transicao.tipo !== 'corte' ? prox.transicao.duracao : 0));
|
|
253
|
+
acum += c.duracao;
|
|
254
|
+
});
|
|
255
|
+
volumes.sort((a, b) => a.t - b.t);
|
|
256
|
+
return {
|
|
257
|
+
ok: true,
|
|
258
|
+
plano: {
|
|
259
|
+
formato: { largura: W, altura: H, fps: 30 }, fundoGeral: PRETO, cenas,
|
|
260
|
+
audio: { ...(trilha ? { trilha: { src: trilha, volumes: volumes.length ? volumes : [{ t: 0, v: 0.3 }], loop: true } } : {}), trechos, efeitos },
|
|
261
|
+
acabamento: { grao: 0.05, vinheta: 0.5, grade: 0.035, passoGrade: Math.round(base / 9) },
|
|
262
|
+
},
|
|
263
|
+
resumo: { cenas: cenas.length, estilo: 'cinema', segundos: r2(t) },
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
module.exports = { montarCinema, falaDe, quando, leitura };
|
|
@@ -171,7 +171,9 @@ function comporRoteiro({ roteiro, modo = 'video', largura = 1920, altura = 1080,
|
|
|
171
171
|
const base = vid ? Math.max(1.6, (proxDe != null ? proxDe : (tp.ate || 0) + 0.9) - (tp.de || 0)) : 3;
|
|
172
172
|
const trans = !vid || i === 0 ? 'corte' : TRANSICOES[i % TRANSICOES.length];
|
|
173
173
|
const cruz = trans === 'corte' ? 0 : 0.35;
|
|
174
|
-
|
|
174
|
+
// A cena fica no ar até a SEGUINTE terminar de entrar (o cruzamento é dela).
|
|
175
|
+
const cruzProx = vid && i + 1 < partes.length ? 0.35 : 0;
|
|
176
|
+
const dur = r2(base + cruzProx);
|
|
175
177
|
const els = [];
|
|
176
178
|
const variante = i % 2;
|
|
177
179
|
|
package/lib/estudio/edicao.js
CHANGED
|
@@ -419,6 +419,15 @@ const SONS = {
|
|
|
419
419
|
pop: ['-f', 'lavfi', '-i', "aevalsrc='0.9*sin(2*PI*(1500-7000*t)*t)*exp(-38*t)':s=44100:d=0.14"],
|
|
420
420
|
whoosh: ['-f', 'lavfi', '-i', 'anoisesrc=d=0.55:c=pink:a=0.6',
|
|
421
421
|
'-af', 'highpass=f=500,lowpass=f=5000,afade=t=in:st=0:d=0.3:curve=exp,afade=t=out:st=0.3:d=0.25,volume=0.9'],
|
|
422
|
+
// O impacto: um grave que cai de 60 para 30 Hz, com o estalo do ataque.
|
|
423
|
+
impacto: ['-f', 'lavfi', '-i', "aevalsrc='0.95*sin(2*PI*(62-30*t)*t)*exp(-3.6*t)+0.3*(random(0)*2-1)*exp(-45*t)':s=44100:d=1.3",
|
|
424
|
+
'-af', 'lowpass=f=2500,volume=1.4'],
|
|
425
|
+
// O riser: sobe dois segundos e corta — anuncia o que vem.
|
|
426
|
+
riser: ['-f', 'lavfi', '-i', "aevalsrc='0.4*sin(2*PI*(160+700*t*t)*t)*(t/2)+0.3*(random(0)*2-1)*pow(t/2,2)':s=44100:d=2",
|
|
427
|
+
'-af', 'highpass=f=120,volume=1.2'],
|
|
428
|
+
// O estalo do texto batendo na tela.
|
|
429
|
+
estalo: ['-f', 'lavfi', '-i', "aevalsrc='(random(0)*2-1)*exp(-38*t)*0.9':s=44100:d=0.22",
|
|
430
|
+
'-af', 'highpass=f=900,lowpass=f=9000'],
|
|
422
431
|
};
|
|
423
432
|
|
|
424
433
|
async function prepararSons(pasta, video) {
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* impacto.js — o estilo de anúncio: corte a cada batida, palavra que bate
|
|
3
|
+
* na tela, foto que pisca, tremor, soco, chicote, flash e som de impacto.
|
|
4
|
+
*
|
|
5
|
+
* "Falta mais ação, o vídeo ser mais agressivo! Mais vendável, mais
|
|
6
|
+
* atraente… falta aplicar a identidade visual… falta mais efeito, e o
|
|
7
|
+
* vídeo parece um vídeo de 2010." (25/09/2026)
|
|
8
|
+
*
|
|
9
|
+
* O estilo normal (composicao.js) dá uma cena por parte, de 4 a 5 s, com
|
|
10
|
+
* entrada suave: é apresentação. Anúncio de 2026 corta a cada 0,5 a 1,5 s.
|
|
11
|
+
* Aqui cada parte do roteiro vira várias BATIDAS, no tempo das palavras da
|
|
12
|
+
* voz, e cada batida é uma de três telas:
|
|
13
|
+
*
|
|
14
|
+
* PALAVRA a fala em letra gigante no preto, com soco de zoom e estalo;
|
|
15
|
+
* FOTO a foto real da marca, rápida, com a fala por cima;
|
|
16
|
+
* MARCA o gradiente da marca em tela cheia, com o emoji 3D.
|
|
17
|
+
*
|
|
18
|
+
* O número conta com tremor, a lista mostra um item por batida, e a chamada
|
|
19
|
+
* final vem com riser, impacto e a logo. Por cima de tudo: granulação,
|
|
20
|
+
* vinheta e a luz da marca passando.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
'use strict';
|
|
24
|
+
|
|
25
|
+
const { caber, paleta } = require('./composicao.js');
|
|
26
|
+
const { iconeDe, peso } = require('./edicao.js');
|
|
27
|
+
|
|
28
|
+
const r2 = (n) => Math.round(n * 100) / 100;
|
|
29
|
+
const limpa = (t) => String(t || '').replace(/^[^\p{L}\p{N}]+|[^\p{L}\p{N}%!?]+$/gu, '');
|
|
30
|
+
|
|
31
|
+
/* As batidas de uma parte: grupos de 1 a 4 palavras, de ~0,6 s ou mais,
|
|
32
|
+
quebrando na pontuação. Batida curta demais gruda na seguinte. */
|
|
33
|
+
function batidas(palavras, fim) {
|
|
34
|
+
const out = [];
|
|
35
|
+
let atual = [];
|
|
36
|
+
palavras.forEach((p, i) => {
|
|
37
|
+
atual.push(p);
|
|
38
|
+
const prox = palavras[i + 1];
|
|
39
|
+
const dur = (prox ? prox.de : fim) - atual[0].de;
|
|
40
|
+
// A batida não termina em palavra solta ("CLIQUE E | confirme"): ela gruda na seguinte.
|
|
41
|
+
const solta = /^(e|de|da|do|a|o|as|os|que|com|sua|seu|no|na|em|pra|para|por|um|uma|não|é)$/i.test(limpa(p.texto));
|
|
42
|
+
if (!prox || /[,.!?;:…]$/.test(p.texto) || atual.length >= 5 || (!solta && (atual.length >= 4 || (dur >= 0.9 && atual.length >= 2)))) {
|
|
43
|
+
out.push(atual);
|
|
44
|
+
atual = [];
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
const juntas = [];
|
|
48
|
+
for (const b of out) {
|
|
49
|
+
const ult = juntas[juntas.length - 1];
|
|
50
|
+
const dur = b[b.length - 1].ate - b[0].de;
|
|
51
|
+
if (ult && dur < 0.45 && ult.length + b.length <= 5) ult.push(...b);
|
|
52
|
+
else juntas.push([...b]);
|
|
53
|
+
}
|
|
54
|
+
return juntas;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function montarImpacto({ roteiro, largura = 1080, altura = 1920, tempos = [], narracao = null, sons = {}, trilha = null,
|
|
58
|
+
fotos = [], iconesOk = null }) {
|
|
59
|
+
const partes = (roteiro && roteiro.partes) || [];
|
|
60
|
+
if (!partes.length) return { ok: false, error: 'o roteiro veio sem partes.' };
|
|
61
|
+
const marca = roteiro.marca || {};
|
|
62
|
+
const cor = paleta(marca);
|
|
63
|
+
const grad = (marca.gradiente && marca.gradiente.length >= 2) ? marca.gradiente : [cor.apoio, cor.destaque];
|
|
64
|
+
const F = { titulo: (marca.fontes && marca.fontes.titulo) || 'Inter', texto: (marca.fontes && marca.fontes.texto) || 'Inter' };
|
|
65
|
+
const W = largura, H = altura, deitado = W >= H;
|
|
66
|
+
const M = Math.round(Math.min(W, H) * 0.07);
|
|
67
|
+
const W0 = W - 2 * M;
|
|
68
|
+
const cenas = [], efeitos = [];
|
|
69
|
+
let fotoI = 0, nBatida = 0;
|
|
70
|
+
const proxFoto = () => (fotos.length ? fotos[fotoI++ % fotos.length] : null);
|
|
71
|
+
const som = (t, nome, volume) => { if (sons[nome]) efeitos.push({ t: r2(Math.max(0, t)), src: sons[nome], volume }); };
|
|
72
|
+
const icOk = (ic) => ic && (!iconesOk || iconesOk.has(ic));
|
|
73
|
+
|
|
74
|
+
// A palavra que bate: letra gigante, a palavra forte na cor de destaque.
|
|
75
|
+
const bater = (els, texto, { y = null, corTexto = cor.tinta, destaque = cor.destaque, max = (deitado ? H * 0.2 : W * 0.2), linhas = 3, glitch = false, girar = 0 } = {}) => {
|
|
76
|
+
const t = String(texto || '').toUpperCase();
|
|
77
|
+
/* Caixa alta em peso 900 é ~20% mais larga que a letra comum, e a
|
|
78
|
+
batida ainda cresce 5%: mede numa caixa mais estreita (medido em
|
|
79
|
+
25/09: "CONSTRUÇÃO" e "DEMONSTRAÇÃO" saíam pela borda). */
|
|
80
|
+
const fit = caber(t, { largura: W0 * 0.8, linhas, max, fonte: F.titulo });
|
|
81
|
+
const h = fit.tamanho * 0.98 * fit.linhas;
|
|
82
|
+
const ws = t.split(/\s+/);
|
|
83
|
+
let forte = -1, pf = 0;
|
|
84
|
+
ws.forEach((w, k) => { const v = peso(w); if (v > pf) { pf = v; forte = k; } });
|
|
85
|
+
els.push({
|
|
86
|
+
tipo: 'texto', texto: t, fonte: F.titulo, peso: 900, tamanho: fit.tamanho, cor: corTexto, entrelinha: 0.98,
|
|
87
|
+
x: M, y: Math.round(y == null ? (H - h) / 2 : y), largura: W0, alinhamento: 'center', espacamento: -1,
|
|
88
|
+
...(forte >= 0 && ws.length > 1 ? { revelar: ws.map(() => 0), destaques: [forte], corDestaque: destaque } : {}),
|
|
89
|
+
...(glitch ? { glitch: { forca: 14, dur: 0.3 } } : {}),
|
|
90
|
+
anima: {
|
|
91
|
+
escala: [{ t: 0, v: 1.45 }, { t: 0.13, v: 0.97, e: 'saida' }, { t: 0.22, v: 1, e: 'suave' }, { t: 3, v: 1.05, e: 'linear' }],
|
|
92
|
+
opacidade: [{ t: 0, v: 0 }, { t: 0.05, v: 1 }],
|
|
93
|
+
rotacao: [{ t: 0, v: girar * 2 }, { t: 0.2, v: girar, e: 'saida' }],
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
return h;
|
|
97
|
+
};
|
|
98
|
+
const emoji = (els, texto, { x = W / 2, y = H * 0.24, tam = Math.min(W, H) * 0.26 } = {}) => {
|
|
99
|
+
let ic = null;
|
|
100
|
+
for (const w of String(texto || '').split(/\s+/)) { ic = iconeDe(w); if (icOk(ic)) break; ic = null; }
|
|
101
|
+
if (!ic) return false;
|
|
102
|
+
els.push({ tipo: 'icone', nome: ic, tamanho: Math.round(tam), x: Math.round(x), y: Math.round(y), ancora: 'centro',
|
|
103
|
+
anima: { escala: [{ t: 0.05, v: 0 }, { t: 0.22, v: 1.2, e: 'saida' }, { t: 0.36, v: 1 }], rotacao: [{ t: 0.05, v: -25 }, { t: 0.36, v: 0, e: 'saida' }, { t: 3, v: 8 }],
|
|
104
|
+
y: [{ t: 0.36, v: 0 }, { t: 3, v: -30, e: 'suave' }] } });
|
|
105
|
+
return true;
|
|
106
|
+
};
|
|
107
|
+
const fotoCheia = (els, f, { veu = 0.55 } = {}) => {
|
|
108
|
+
if (!f) return;
|
|
109
|
+
const v = f.tipo === 'video';
|
|
110
|
+
els.push({ tipo: v ? 'video' : 'imagem', src: f.src, ...(v ? { volume: 0 } : {}), x: 0, y: 0, largura: '100%', altura: '100%', ajuste: 'cover', foco: 'center 28%',
|
|
111
|
+
anima: { escala: [{ t: 0, v: 1.28 }, { t: 0.25, v: 1.1, e: 'saida' }, { t: 3, v: 1.02, e: 'linear' }] } });
|
|
112
|
+
els.push({ tipo: 'forma', forma: 'retangulo', x: 0, y: 0, largura: '100%', altura: '100%',
|
|
113
|
+
fundo: { tipo: 'gradiente', cores: [`rgba(0,0,0,${veu * 0.4})`, `rgba(0,0,0,${veu * 0.6})`, `rgba(0,0,0,${veu + 0.25})`], angulo: 180 } });
|
|
114
|
+
};
|
|
115
|
+
const logoTopo = (els, forte = false) => {
|
|
116
|
+
if (!marca.logo) return;
|
|
117
|
+
const h = Math.round(H * (forte ? 0.07 : (deitado ? 0.05 : 0.028)));
|
|
118
|
+
els.push({ tipo: 'imagem', src: marca.logo, x: Math.round(W / 2 - h * 2), y: forte ? Math.round(H * 0.78) : M, largura: h * 4, altura: h, ajuste: 'contain', opacidade: forte ? 1 : 0.9,
|
|
119
|
+
...(forte ? { anima: { escala: [{ t: 0, v: 0.6 }, { t: 0.3, v: 1.08, e: 'saida' }, { t: 0.5, v: 1 }], opacidade: [{ t: 0, v: 0 }, { t: 0.15, v: 1 }] } } : {}) });
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
partes.forEach((p, i) => {
|
|
123
|
+
const tp = tempos[i] || { de: i * 3, ate: i * 3 + 2.6, palavras: [] };
|
|
124
|
+
const fim = tempos[i + 1] ? tempos[i + 1].de : (tp.ate || 0) + 0.9;
|
|
125
|
+
const ini = i === 0 ? 0 : tp.de;
|
|
126
|
+
let bs = batidas((tp.palavras || []).filter((w) => w && w.texto), fim);
|
|
127
|
+
if (!bs.length) bs = [[{ de: ini, ate: fim, texto: p.titulo || p.sim || p.valor || '' }]];
|
|
128
|
+
const itens = (p.itens || []).map((it) => (typeof it === 'string' ? it : it.texto));
|
|
129
|
+
|
|
130
|
+
// Impacto no começo de cada parte; o riser anuncia a chamada final.
|
|
131
|
+
som(ini, 'impacto', i === 0 ? 0.9 : 0.6);
|
|
132
|
+
if (p.tipo === 'cta') som(ini - 2, 'riser', 0.6);
|
|
133
|
+
|
|
134
|
+
bs.forEach((b, k) => {
|
|
135
|
+
const de = k === 0 ? ini : b[0].de - 0.04;
|
|
136
|
+
const ate = k < bs.length - 1 ? bs[k + 1][0].de - 0.04 : fim;
|
|
137
|
+
const falado = b.map((w) => limpa(w.texto)).join(' ');
|
|
138
|
+
const els = [];
|
|
139
|
+
const cena = { fundo: { tipo: 'cor', valor: cor.fundo }, elementos: els };
|
|
140
|
+
const ultimo = k === bs.length - 1;
|
|
141
|
+
let tela; // 'palavra' | 'foto' | 'marca'
|
|
142
|
+
|
|
143
|
+
if (p.tipo === 'numero' && k === 0) {
|
|
144
|
+
// O número em tela cheia, contando, com a câmera tremendo.
|
|
145
|
+
cena.fundo = { tipo: 'gradiente', cores: [grad[0], grad[1]], angulo: 150 };
|
|
146
|
+
const valor = String(p.valor);
|
|
147
|
+
const m = valor.match(/^([^\d-]*)(-?[\d.,]+)(.*)$/);
|
|
148
|
+
const fit = caber(valor, { largura: W0, linhas: 1, max: H * (deitado ? 0.55 : 0.32), fonte: F.titulo });
|
|
149
|
+
els.push({ tipo: 'texto', texto: valor, fonte: F.titulo, peso: 900, tamanho: fit.tamanho, cor: '#FFFFFF', x: M, y: Math.round(H * 0.5 - fit.tamanho * 0.62), largura: W0, alinhamento: 'center',
|
|
150
|
+
...(m ? { contar: { ate: Number(m[2].replace(/\./g, '').replace(',', '.')), dur: 0.7, antes: m[1], depois: m[3] } } : {}),
|
|
151
|
+
anima: { escala: [{ t: 0, v: 1.6 }, { t: 0.18, v: 1, e: 'saida' }] } });
|
|
152
|
+
bater(els, p.rotulo || '', { y: H * 0.5 + fit.tamanho * 0.45, max: H * 0.07, linhas: 2, corTexto: '#FFFFFF', destaque: '#FFFFFF' });
|
|
153
|
+
cena.tremor = [{ de: 0, ate: 0.5, forca: 26 }];
|
|
154
|
+
tela = 'marca';
|
|
155
|
+
} else if ((p.tipo === 'lista' || p.tipo === 'passos') && itens.length) {
|
|
156
|
+
// Um item por batida: o emoji 3D grande e o item batendo.
|
|
157
|
+
const it = itens[Math.min(k, itens.length - 1)];
|
|
158
|
+
if (k % 2 === 1) cena.fundo = { tipo: 'gradiente', cores: [grad[0], grad[1]], angulo: 135 };
|
|
159
|
+
const rot = p.tipo === 'passos' ? `${k + 1}. ${it}` : it;
|
|
160
|
+
const temE = emoji(els, it, { y: H * (deitado ? 0.28 : 0.3) });
|
|
161
|
+
// No gradiente, o destaque é o escuro da marca: laranja sobre azul some.
|
|
162
|
+
bater(els, rot, { y: temE ? H * (deitado ? 0.48 : 0.45) : null, max: deitado ? H * 0.11 : W * 0.12, linhas: 4, glitch: k === 0,
|
|
163
|
+
...(k % 2 === 1 ? { corTexto: '#FFFFFF', destaque: cor.fundo } : {}) });
|
|
164
|
+
if (k === 0 && p.titulo) els.push({ tipo: 'texto', texto: String(p.titulo).toUpperCase(), fonte: F.texto, peso: 800, tamanho: Math.round(Math.min(W, H) * 0.045), cor: cor.destaque, x: M, y: M * 2.2, largura: W0, alinhamento: 'center', espacamento: 3 });
|
|
165
|
+
tela = k % 2 ? 'marca' : 'palavra';
|
|
166
|
+
cena.socos = [{ t: 0, forca: 0.14 }];
|
|
167
|
+
som(de, 'estalo', 0.55);
|
|
168
|
+
} else if (p.tipo === 'cta' && ultimo) {
|
|
169
|
+
// O fecho: a logo grande, a chamada e o link.
|
|
170
|
+
/* O fecho no escuro da marca, com o brilho do gradiente atrás: a logo
|
|
171
|
+
tem as cores do gradiente e sumia sobre ele. */
|
|
172
|
+
const R = Math.round(Math.max(W, H) * 0.9);
|
|
173
|
+
els.push({ tipo: 'forma', forma: 'circulo', largura: R, altura: R, x: Math.round(W / 2 - R / 2), y: Math.round(H * 0.4 - R / 2), cor: grad[1], opacidade: 0.4,
|
|
174
|
+
anima: { desfoque: [{ t: 0, v: Math.round(R * 0.2) }], escala: [{ t: 0, v: 0.7 }, { t: 1, v: 1.1, e: 'suave' }] } });
|
|
175
|
+
const yT = H * 0.26;
|
|
176
|
+
const hT = bater(els, p.titulo || falado, { y: yT, max: deitado ? H * 0.14 : W * 0.14, linhas: 3, corTexto: '#FFFFFF', destaque: cor.destaque });
|
|
177
|
+
if (p.link) {
|
|
178
|
+
const fl = Math.round(Math.min(W, H) * 0.055);
|
|
179
|
+
const wP = Math.min(W0, String(p.link).length * fl * 0.62 + fl * 2.4);
|
|
180
|
+
// O botão vem DEPOIS do título, com folga — nunca encostado nele.
|
|
181
|
+
const yP = Math.round(yT + hT * 1.08 + H * 0.04);
|
|
182
|
+
els.push({ tipo: 'forma', forma: 'retangulo', x: Math.round((W - wP) / 2), y: yP, largura: Math.round(wP), altura: Math.round(fl * 2.1), cor: cor.destaque, raio: Math.round(fl * 1.05),
|
|
183
|
+
anima: { escala: [{ t: 0.2, v: 0 }, { t: 0.45, v: 1.1, e: 'saida' }, { t: 0.6, v: 1 }] } });
|
|
184
|
+
els.push({ tipo: 'texto', texto: String(p.link), fonte: F.texto, peso: 800, tamanho: fl, cor: cor.sobreDestaque, x: Math.round(W / 2), y: Math.round(yP + fl * 1.05), ancora: 'centro',
|
|
185
|
+
anima: { opacidade: [{ t: 0.3, v: 0 }, { t: 0.45, v: 1 }] } });
|
|
186
|
+
}
|
|
187
|
+
logoTopo(els, true);
|
|
188
|
+
cena.tremor = [{ de: 0, ate: 0.4, forca: 20 }];
|
|
189
|
+
cena.flashes = [0];
|
|
190
|
+
som(de, 'impacto', 0.9);
|
|
191
|
+
tela = 'marca';
|
|
192
|
+
} else {
|
|
193
|
+
// O rodízio das três telas, com a primeira batida sempre na palavra.
|
|
194
|
+
const ciclo = ['palavra', 'foto', 'marca', 'foto'];
|
|
195
|
+
tela = k === 0 ? (p.tipo === 'imagem' || i === 0 ? 'foto' : 'palavra') : ciclo[(nBatida + k) % ciclo.length];
|
|
196
|
+
const semNaoE = (t) => String(t || '').replace(/^\s*(n[aã]o\s+[ée]\s+|[ée]\s+)/i, '');
|
|
197
|
+
const texto = p.tipo === 'contraste' ? (k < bs.length / 2 ? `não é ${semNaoE(p.nao)}` : semNaoE(p.sim))
|
|
198
|
+
: (ultimo && p.titulo && k > 0 ? p.titulo : falado);
|
|
199
|
+
if (tela === 'foto' && fotos.length) {
|
|
200
|
+
fotoCheia(els, proxFoto());
|
|
201
|
+
bater(els, texto, { y: H * (deitado ? 0.56 : 0.6), max: deitado ? H * 0.12 : W * 0.13, linhas: 3 });
|
|
202
|
+
if (k % 2 === 0) cena.flashes = [0];
|
|
203
|
+
} else if (tela === 'marca') {
|
|
204
|
+
cena.fundo = { tipo: 'gradiente', cores: [grad[0], grad[1]], angulo: 135 };
|
|
205
|
+
const temE = emoji(els, texto, { y: H * (deitado ? 0.26 : 0.28) });
|
|
206
|
+
bater(els, texto, { y: temE ? H * (deitado ? 0.46 : 0.44) : null, corTexto: '#FFFFFF', destaque: cor.fundo, max: deitado ? H * 0.16 : W * 0.16 });
|
|
207
|
+
} else {
|
|
208
|
+
tela = 'palavra';
|
|
209
|
+
const temE = emoji(els, texto, { y: H * (deitado ? 0.25 : 0.27) });
|
|
210
|
+
bater(els, texto, { y: temE ? H * (deitado ? 0.45 : 0.43) : null, glitch: p.tipo === 'contraste' || k % 3 === 0,
|
|
211
|
+
girar: k % 2 ? -2 : 2, corTexto: p.tipo === 'contraste' && k < bs.length / 2 ? 'rgba(255,255,255,0.55)' : cor.tinta });
|
|
212
|
+
cena.socos = [{ t: 0, forca: 0.2 }];
|
|
213
|
+
}
|
|
214
|
+
if (k === 0) cena.tremor = [{ de: 0, ate: 0.35, forca: 18 }];
|
|
215
|
+
som(de, tela === 'foto' ? 'whoosh' : 'estalo', tela === 'foto' ? 0.5 : 0.5);
|
|
216
|
+
}
|
|
217
|
+
if (!(p.tipo === 'cta' && ultimo)) logoTopo(els);
|
|
218
|
+
/* A FAIXA: o subtítulo da parte (é onde moram a data e o local) em
|
|
219
|
+
letra pequena e espaçada no alto — o anúncio não pode esconder a data. */
|
|
220
|
+
if (p.sub && p.tipo !== 'numero' && !(p.tipo === 'lista' || p.tipo === 'passos')) {
|
|
221
|
+
const fk = Math.round(Math.min(W, H) * 0.042);
|
|
222
|
+
els.push({ tipo: 'forma', forma: 'retangulo', x: M, y: Math.round(H * (deitado ? 0.14 : 0.1)), largura: W0, altura: Math.round(fk * 2), cor: cor.destaque, raio: Math.round(fk),
|
|
223
|
+
anima: { escalaX: [{ t: 0.05, v: 0 }, { t: 0.3, v: 1, e: 'saida' }] }, origem: 'center' });
|
|
224
|
+
// Com largura, e não pela âncora: sem largura o texto quebrava na metade da tela.
|
|
225
|
+
const tk = caber(String(p.sub).toUpperCase(), { largura: W0 * 0.78, linhas: 1, max: fk, fonte: F.texto }).tamanho;
|
|
226
|
+
els.push({ tipo: 'texto', texto: String(p.sub).toUpperCase(), fonte: F.texto, peso: 800, tamanho: tk, entrelinha: 1,
|
|
227
|
+
cor: cor.sobreDestaque === '#FFFFFF' ? '#FFFFFF' : '#121218', x: M, largura: W0, alinhamento: 'center', y: Math.round(H * (deitado ? 0.14 : 0.1) + fk - tk * 0.5), espacamento: 2,
|
|
228
|
+
anima: { opacidade: [{ t: 0.2, v: 0 }, { t: 0.35, v: 1 }] } });
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/* Uma transição curta a cada tanto; o resto é corte seco — é o
|
|
232
|
+
corte que dá o ritmo. */
|
|
233
|
+
const nT = nBatida++;
|
|
234
|
+
const trans = nT === 0 ? 'corte' : (nT % 4 === 1 ? 'chicote' : nT % 4 === 3 ? 'soco' : 'corte');
|
|
235
|
+
const cruz = trans === 'corte' ? 0 : 0.16;
|
|
236
|
+
if (trans === 'chicote') som(de - 0.08, 'whoosh', 0.55);
|
|
237
|
+
cena._base = Math.max(0.3, ate - de);
|
|
238
|
+
cena.transicao = { tipo: trans, duracao: cruz };
|
|
239
|
+
cenas.push(cena);
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
// Cada batida fica no ar até a seguinte terminar de entrar (o cruzamento é da que entra).
|
|
244
|
+
cenas.forEach((c, k) => {
|
|
245
|
+
c.duracao = r2(c._base + (cenas[k + 1] ? cenas[k + 1].transicao.duracao : 0));
|
|
246
|
+
delete c._base;
|
|
247
|
+
c.camera = { zoom: [{ t: 0, v: 1 }, { t: c.duracao, v: 1.06, e: 'linear' }] };
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
const audio = { efeitos };
|
|
251
|
+
if (narracao) audio.narracao = narracao;
|
|
252
|
+
if (trilha) audio.trilha = { src: trilha, volume: 0.17, loop: true };
|
|
253
|
+
return {
|
|
254
|
+
ok: true,
|
|
255
|
+
plano: {
|
|
256
|
+
formato: { largura: W, altura: H, fps: 30 }, fundoGeral: cor.fundo, cenas, audio,
|
|
257
|
+
acabamento: { grao: 0.07, vinheta: 0.55, luz: [grad[0], cor.destaque], luzForca: 0.16 },
|
|
258
|
+
},
|
|
259
|
+
resumo: { cenas: cenas.length, efeitos: efeitos.length, musica: Boolean(trilha), voz: Boolean(narracao), estilo: 'impacto' },
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
module.exports = { montarImpacto, batidas };
|
package/lib/estudio/index.js
CHANGED
|
@@ -43,6 +43,8 @@ const slides = require('./slides.js');
|
|
|
43
43
|
const edicao = require('./edicao.js');
|
|
44
44
|
const composicao = require('./composicao.js');
|
|
45
45
|
const acervoSite = require('./acervo-site.js');
|
|
46
|
+
const impacto = require('./impacto.js');
|
|
47
|
+
const cinema = require('./cinema.js');
|
|
46
48
|
|
|
47
49
|
const PY = path.join(__dirname, '..', '..', 'studio');
|
|
48
50
|
|
|
@@ -216,8 +218,10 @@ function roteiroValido(r) {
|
|
|
216
218
|
if (typeof x === 'string') { try { x = JSON.parse(x); } catch { return null; } }
|
|
217
219
|
const partes = Array.isArray(x) ? x : x && x.partes;
|
|
218
220
|
if (!Array.isArray(partes) || partes.length < 2) return null;
|
|
219
|
-
|
|
220
|
-
|
|
221
|
+
const CINEMA = ['frase', 'montagem', 'numeros', 'escada', 'dia', 'depoimento', 'climax', 'final'];
|
|
222
|
+
if (!partes.every((pt) => pt && (composicao.TIPOS.includes(pt.tipo) || CINEMA.includes(pt.tipo)))) return null;
|
|
223
|
+
const obj = Array.isArray(x) ? { partes } : x;
|
|
224
|
+
return partes.some((pt) => CINEMA.includes(pt.tipo)) && !obj.estilo ? { ...obj, estilo: 'cinema' } : obj;
|
|
221
225
|
}
|
|
222
226
|
|
|
223
227
|
// O último site lido, para o projeto que nasceu DEPOIS da leitura.
|
|
@@ -250,6 +254,7 @@ async function roteiroDoServidor(p, args) {
|
|
|
250
254
|
fotos: (acervo.fotos || []).length ? `${acervo.fotos.length} fotos reais` : '',
|
|
251
255
|
site: acervo.site ? new URL(acervo.site).hostname.replace(/^www\./, '') : '',
|
|
252
256
|
rascunho: typeof args.roteiro === 'string' ? args.roteiro : '',
|
|
257
|
+
estilo: args.estilo || (/an[uú]nci|reels?|divulg|promo|lan[çc]a|vend|campanha|convite|inscri/i.test(pedido) ? 'impacto' : undefined),
|
|
253
258
|
}, cfg.token);
|
|
254
259
|
if (!r || !r.ok) return { ok: false, error: (r && r.erro) || 'o roteirista não respondeu.' };
|
|
255
260
|
return { ok: true, roteiro: { ...r.roteiro, marca: (args.roteiro && args.roteiro.marca) || p.marca || undefined } };
|
|
@@ -590,7 +595,7 @@ async function ferramentaVideo(args, ctx = {}) {
|
|
|
590
595
|
let trilha = null;
|
|
591
596
|
if (args.musica !== false) {
|
|
592
597
|
try {
|
|
593
|
-
const b = await assets.
|
|
598
|
+
const b = await assets.procurarMusica(args.trilha || 'upbeat corporate', { quantas: 6, duracaoMin: 45 });
|
|
594
599
|
for (const item of (b.ok ? b.sons : [])) {
|
|
595
600
|
const d = await assets.baixar(item, { pasta: p.pasta, nome: 'trilha' });
|
|
596
601
|
if (d.ok) { trilha = d.relativo; break; }
|
|
@@ -708,17 +713,20 @@ function alinharPartes(lista, palavras, total) {
|
|
|
708
713
|
}
|
|
709
714
|
return lista.map((_, i) => {
|
|
710
715
|
const a = ws[inicios[i]], b = ws[(inicios[i + 1] || ws.length) - 1];
|
|
711
|
-
|
|
716
|
+
// As palavras da parte vão junto: o estilo impacto corta no tempo delas.
|
|
717
|
+
const palavras = ws.slice(inicios[i], inicios[i + 1] || ws.length).map(({ de, ate, texto }) => ({ de, ate, texto }));
|
|
718
|
+
return { de: i === 0 ? 0 : (a ? a.de : 0), ate: b ? b.ate : total, palavras };
|
|
712
719
|
});
|
|
713
720
|
}
|
|
714
721
|
|
|
715
|
-
async function narrarComGemini(lista, mp3, { voz } = {}) {
|
|
722
|
+
async function narrarComGemini(lista, mp3, { voz, direcao } = {}) {
|
|
716
723
|
try {
|
|
717
724
|
const cfg = require('../config').loadConfig();
|
|
718
725
|
const api = require('../api');
|
|
719
726
|
const r = await api.request(cfg.server, '/api/voz', {
|
|
720
727
|
texto: lista.map((x) => x.texto).join('\n\n'),
|
|
721
728
|
voz: VOZ_GEMINI[String(voz || '').toLowerCase()] || voz || 'Charon',
|
|
729
|
+
...(direcao ? { direcao } : {}),
|
|
722
730
|
}, cfg.token);
|
|
723
731
|
if (!r || !r.ok) return { ok: false, error: (r && r.erro) || 'sem voz no servidor' };
|
|
724
732
|
const pcm = mp3.replace(/\.mp3$/, '.pcm');
|
|
@@ -729,6 +737,11 @@ async function narrarComGemini(lista, mp3, { voz } = {}) {
|
|
|
729
737
|
try { fs.unlinkSync(pcm); } catch { /* fica */ }
|
|
730
738
|
if (!c.ok) return { ok: false, error: 'não consegui converter a voz' };
|
|
731
739
|
const med = await video.medir(mp3);
|
|
740
|
+
/* A voz CORTADA: o Gemini às vezes devolve metade do roteiro, sem erro
|
|
741
|
+
(medido em 25/09: 27,5 s de áudio para um texto de ~55 s). Fala
|
|
742
|
+
normal passa de 11 caracteres por segundo; menos que isso é corte. */
|
|
743
|
+
const chars = lista.reduce((n, x) => n + x.texto.length, 0);
|
|
744
|
+
if (med.segundos < chars / 22) return { ok: false, error: `a voz veio cortada (${med.segundos.toFixed(1)} s para ${chars} caracteres)` };
|
|
732
745
|
const tr = await api.requestTranscricao(cfg.server, { token: cfg.token, audio: fs.readFileSync(mp3).toString('base64'), nome: 'voz.mp3', idioma: 'pt' });
|
|
733
746
|
const palavras = (tr && tr.palavras) || [];
|
|
734
747
|
if (!palavras.length) return { ok: false, error: 'sem a transcrição da voz para achar as partes' };
|
|
@@ -738,6 +751,33 @@ async function narrarComGemini(lista, mp3, { voz } = {}) {
|
|
|
738
751
|
}
|
|
739
752
|
}
|
|
740
753
|
|
|
754
|
+
/* OS SONS DE VERDADE: gravação real de acervo aberto (teclado, whoosh
|
|
755
|
+
cinematográfico, impacto grave, som de sala), e o sintetizado só quando a
|
|
756
|
+
busca falha. "Esses efeitos sonoros estão genéricos e sem impacto."
|
|
757
|
+
(25/09/2026) */
|
|
758
|
+
async function sonsReais(p, video) {
|
|
759
|
+
const sint = await edicao.prepararSons(p.pasta, video);
|
|
760
|
+
const quer = {
|
|
761
|
+
teclado: ['keyboard typing', 'typing keyboard'],
|
|
762
|
+
whoosh: ['cinematic whoosh', 'swoosh transition'],
|
|
763
|
+
impacto: ['cinematic boom impact', 'deep cinematic impact', 'trailer hit'],
|
|
764
|
+
sala: ['crowd murmur indoor', 'office ambience', 'people talking background'],
|
|
765
|
+
};
|
|
766
|
+
const sons = { ...sint };
|
|
767
|
+
for (const [nome, termos] of Object.entries(quer)) {
|
|
768
|
+
for (const termo of termos) {
|
|
769
|
+
try {
|
|
770
|
+
const b = await assets.procurarSom(termo, { quantas: 5 });
|
|
771
|
+
const item = (b.ok ? b.sons : []).find((x) => x.url);
|
|
772
|
+
if (!item) continue;
|
|
773
|
+
const d = await assets.baixar(item, { pasta: p.pasta, nome: 'sfx-real-' + nome });
|
|
774
|
+
if (d.ok) { sons[nome] = d.relativo; break; }
|
|
775
|
+
} catch { /* o próximo termo */ }
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
return sons;
|
|
779
|
+
}
|
|
780
|
+
|
|
741
781
|
const PROPORCOES = { '16:9': [1920, 1080], '9:16': [1080, 1920], '1:1': [1080, 1080], '4:5': [1080, 1350] };
|
|
742
782
|
|
|
743
783
|
/** O roteiro vira plano: baixa o que falta, narra, e chama o compositor. */
|
|
@@ -756,6 +796,11 @@ async function prepararRoteiro(p, args) {
|
|
|
756
796
|
if (roteiro.marca.logo && !existe(roteiro.marca.logo)) roteiro.marca.logo = (p.marca && existe(p.marca.logo)) ? p.marca.logo : undefined;
|
|
757
797
|
} else if (p.marca) roteiro.marca = p.marca;
|
|
758
798
|
const partes = roteiro.partes || [];
|
|
799
|
+
/* O ESTILO: "impacto" (anúncio, corte a cada batida) ou "limpo" (uma cena
|
|
800
|
+
por parte). Pedido de anúncio, reels, divulgação ou venda já vem em
|
|
801
|
+
impacto — é o que se espera de uma peça dessas em 2026. */
|
|
802
|
+
const estilo = args.estilo || roteiro.estilo
|
|
803
|
+
|| (/an[uú]nci|reels?|divulg|promo|lan[çc]a|vend|campanha|convite|inscri/i.test(`${p.pedido || ''} ${args.pedido || ''}`) ? 'impacto' : 'limpo');
|
|
759
804
|
const modo = (args.modo || roteiro.modo || (p.formato === 'slides' || p.formato === 'arte' ? 'slides' : 'video'));
|
|
760
805
|
const [largura, altura] = PROPORCOES[roteiro.proporcao || args.proporcao] || (modo === 'slides' ? PROPORCOES['16:9'] : PROPORCOES['16:9']);
|
|
761
806
|
|
|
@@ -802,6 +847,71 @@ async function prepararRoteiro(p, args) {
|
|
|
802
847
|
}
|
|
803
848
|
|
|
804
849
|
let tempos = [], narracao = null, sons = {}, trilha = null;
|
|
850
|
+
if (modo === 'video' && estilo === 'cinema') {
|
|
851
|
+
// Só as partes com voz vão para a narração; o depoimento tem a voz do aluno.
|
|
852
|
+
const lista = partes.filter((pt) => pt.tipo !== 'depoimento').map((pt) => ({ texto: cinema.falaDe(pt), registro: 'normal' }));
|
|
853
|
+
const mp3 = path.join(p.pasta, 'midia', `narracao-${Date.now().toString(36)}.mp3`);
|
|
854
|
+
/* O CACHE DA VOZ: a mesma fala, na mesma voz, não é gerada duas vezes.
|
|
855
|
+
Refazer o roteiro em outro formato, ou mexer numa cena, não gasta a
|
|
856
|
+
cota do Gemini — que acaba no meio do dia (medido em 25/09). */
|
|
857
|
+
const chaveVoz = require('crypto').createHash('sha1').update(JSON.stringify([lista.map((x) => x.texto), args.voz || roteiro.voz || '', 'cinema-v1'])).digest('hex').slice(0, 16);
|
|
858
|
+
const cacheDir = path.join(os.homedir(), '.primocode', 'studio', 'cache-voz');
|
|
859
|
+
const cacheMp3 = path.join(cacheDir, chaveVoz + '.mp3'), cacheJson = path.join(cacheDir, chaveVoz + '.json');
|
|
860
|
+
let v = null;
|
|
861
|
+
if (fs.existsSync(cacheMp3) && fs.existsSync(cacheJson)) {
|
|
862
|
+
fs.copyFileSync(cacheMp3, mp3);
|
|
863
|
+
v = { ok: true, ...JSON.parse(fs.readFileSync(cacheJson, 'utf8')), doCache: true };
|
|
864
|
+
} else {
|
|
865
|
+
v = await narrarComGemini(lista, mp3, { voz: args.voz || roteiro.voz,
|
|
866
|
+
direcao: 'Leia em português do Brasil como narrador de trailer de marca premium: voz grave, confiante e humana, '
|
|
867
|
+
+ 'ritmo firme, pausas curtas entre as frases, sem arrastar.' });
|
|
868
|
+
if (v.ok) {
|
|
869
|
+
try { fs.mkdirSync(cacheDir, { recursive: true }); fs.copyFileSync(mp3, cacheMp3); fs.writeFileSync(cacheJson, JSON.stringify({ voz: v.voz, segundos: v.segundos, partes: v.partes })); } catch { /* sem cache */ }
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
if (!v.ok) {
|
|
873
|
+
const arq = path.join(p.pasta, 'midia', `roteiro-${Date.now().toString(36)}.json`);
|
|
874
|
+
// Ritmo NORMAL: o "citação" (-10%, respiro longo) esticou o vídeo para 125 s.
|
|
875
|
+
fs.writeFileSync(arq, JSON.stringify(lista.map((x) => ({ ...x, registro: 'normal' }))));
|
|
876
|
+
v = await rodarPython('narrar.py', ['roteiro', '--arquivo', arq, '--saida', mp3, '--voz', String(args.voz || roteiro.voz || 'antonio')], { timeout: 10 * 60000 });
|
|
877
|
+
}
|
|
878
|
+
if (!v.ok) return v;
|
|
879
|
+
/* A DURAÇÃO que o roteiro pede ("60–75 s"): se a voz passa do que cabe,
|
|
880
|
+
ela acelera de leve — até 25%, sem mudar o tom (atempo) — e os tempos
|
|
881
|
+
de cada parte acompanham. Medido em 25/09: 98 s de voz num vídeo de 70. */
|
|
882
|
+
const alvo = Number(roteiro.duracao || args.segundos) || 0;
|
|
883
|
+
if (alvo) {
|
|
884
|
+
const depo = partes.filter((pt) => pt.tipo === 'depoimento').reduce((n, pt) => n + (pt.clipes || []).reduce((m, c) => m + (c.ate - c.de + 0.3), 0), 0);
|
|
885
|
+
const cabe = Math.max(15, alvo - depo - 0.7 * lista.length);
|
|
886
|
+
if (v.segundos > cabe * 1.05) {
|
|
887
|
+
const k = Math.min(1.25, v.segundos / cabe);
|
|
888
|
+
const rapido = mp3.replace(/\.mp3$/, '-r.mp3');
|
|
889
|
+
const c = await video.rodar(video.ffmpeg(), ['-y', '-i', mp3, '-filter:a', `atempo=${k.toFixed(3)}`, rapido], { timeout: 120000 });
|
|
890
|
+
if (c.ok) {
|
|
891
|
+
fs.renameSync(rapido, mp3);
|
|
892
|
+
const div = (x) => Math.round((x / k) * 1000) / 1000;
|
|
893
|
+
v.partes = v.partes.map((pt) => ({ de: div(pt.de), ate: div(pt.ate), palavras: (pt.palavras || []).map((w) => ({ ...w, de: div(w.de), ate: div(w.ate) })) }));
|
|
894
|
+
v.segundos = div(v.segundos);
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
projeto.anotar(p.id, 'narracao', `${v.segundos}s em ${lista.length} partes, voz ${v.voz}`);
|
|
899
|
+
const sonsC = await sonsReais(p, video);
|
|
900
|
+
let trilhaC = null;
|
|
901
|
+
// Vários termos e durações: vídeo sem música foi o que saiu na primeira vez (25/09).
|
|
902
|
+
for (const [termo, min] of [[roteiro.musica, 60], ['cinematic inspiring', 70], ['inspiring piano cinematic', 60], ['epic cinematic', 60], ['inspiring', 45]]) {
|
|
903
|
+
if (!termo || trilhaC) continue;
|
|
904
|
+
try {
|
|
905
|
+
const b = await assets.procurarMusica(termo, { quantas: 6, duracaoMin: min });
|
|
906
|
+
for (const item of (b.ok ? b.sons : [])) { const d = await assets.baixar(item, { pasta: p.pasta, nome: 'trilha' }); if (d.ok) { trilhaC = d.relativo; break; } }
|
|
907
|
+
} catch { /* o próximo termo */ }
|
|
908
|
+
}
|
|
909
|
+
const fotosC = acervo.fotos.map((f) => ({ src: f.src, tipo: 'imagem' }));
|
|
910
|
+
const r = cinema.montarCinema({ roteiro, largura, altura, tempos: v.partes, narracao: path.relative(p.pasta, mp3).replace(/\\/g, '/'),
|
|
911
|
+
sons: sonsC, trilha: trilhaC, fotos: fotosC });
|
|
912
|
+
if (r.ok) projeto.anotar(p.id, 'plano', `estilo cinema: ${r.resumo.cenas} cenas, ${r.resumo.segundos}s`);
|
|
913
|
+
return r;
|
|
914
|
+
}
|
|
805
915
|
if (modo === 'video') {
|
|
806
916
|
const lista = partes.map((pt, i) => ({ texto: composicao.falaDe(pt), registro: composicao.registroDe(pt, i, partes.length) }));
|
|
807
917
|
const arq = path.join(p.pasta, 'midia', `roteiro-${Date.now().toString(36)}.json`);
|
|
@@ -809,7 +919,9 @@ async function prepararRoteiro(p, args) {
|
|
|
809
919
|
fs.writeFileSync(arq, JSON.stringify(lista));
|
|
810
920
|
const mp3 = path.join(p.pasta, 'midia', `narracao-${Date.now().toString(36)}.mp3`);
|
|
811
921
|
// A voz de gente primeiro (Gemini, pelo servidor); o Edge é a reserva.
|
|
812
|
-
let v = await narrarComGemini(lista, mp3, { voz: args.voz || roteiro.voz
|
|
922
|
+
let v = await narrarComGemini(lista, mp3, { voz: args.voz || roteiro.voz,
|
|
923
|
+
direcao: estilo === 'impacto' ? 'Leia em português do Brasil como locutor de comercial agressivo e vendedor: energia alta, ritmo rápido, '
|
|
924
|
+
+ 'voz encorpada, ênfase forte nas palavras de impacto, pausas curtas e secas entre as frases, sorrindo na voz.' : undefined });
|
|
813
925
|
if (!v.ok) v = await rodarPython('narrar.py', ['roteiro', '--arquivo', arq, '--saida', mp3, '--voz', String(args.voz || roteiro.voz || 'thalita')], { timeout: 10 * 60000 });
|
|
814
926
|
if (!v.ok) return v;
|
|
815
927
|
tempos = v.partes;
|
|
@@ -817,13 +929,29 @@ async function prepararRoteiro(p, args) {
|
|
|
817
929
|
projeto.anotar(p.id, 'narracao', `${v.segundos}s em ${partes.length} partes, voz ${v.voz}`);
|
|
818
930
|
sons = await edicao.prepararSons(p.pasta, video);
|
|
819
931
|
try {
|
|
820
|
-
const b = await assets.
|
|
932
|
+
const b = await assets.procurarMusica(roteiro.musica || args.trilha || (estilo === 'impacto' ? 'epic energetic' : 'inspiring corporate'), { quantas: 6, duracaoMin: 45 });
|
|
821
933
|
for (const item of (b.ok ? b.sons : [])) {
|
|
822
934
|
const d = await assets.baixar(item, { pasta: p.pasta, nome: 'trilha' });
|
|
823
935
|
if (d.ok) { trilha = d.relativo; break; }
|
|
824
936
|
}
|
|
825
937
|
} catch { /* sem música */ }
|
|
826
938
|
}
|
|
939
|
+
if (estilo === 'impacto' && modo === 'video') {
|
|
940
|
+
// Os emojis das palavras FALADAS também (a batida mostra a fala).
|
|
941
|
+
const faladas = tempos.flatMap((t) => (t.palavras || []).map((w) => w.texto));
|
|
942
|
+
for (const nome of new Set(faladas.map((w) => edicao.iconeDe(w)).filter(Boolean))) {
|
|
943
|
+
if (iconesOk.has(nome)) continue;
|
|
944
|
+
try { const r = await assets.icone(nome); if (r && r.ok) iconesOk.add(nome); } catch { /* sem */ }
|
|
945
|
+
}
|
|
946
|
+
const fotos = [
|
|
947
|
+
...acervo.fotos.map((f) => ({ src: f.src, tipo: 'imagem' })),
|
|
948
|
+
...(acervo.videos || []).map((v) => ({ src: v.src, tipo: 'video' })),
|
|
949
|
+
...Object.values(imagens),
|
|
950
|
+
];
|
|
951
|
+
const r = impacto.montarImpacto({ roteiro, largura, altura, tempos, narracao, sons, trilha, fotos, iconesOk });
|
|
952
|
+
if (r.ok) projeto.anotar(p.id, 'plano', `estilo impacto: ${r.resumo.cenas} batidas`);
|
|
953
|
+
return r;
|
|
954
|
+
}
|
|
827
955
|
return composicao.comporRoteiro({ roteiro, modo, largura, altura, tempos, narracao, sons, trilha, imagens, iconesOk, fundos });
|
|
828
956
|
}
|
|
829
957
|
|
|
@@ -944,6 +1072,17 @@ async function ferramentaRender(args, ctx = {}) {
|
|
|
944
1072
|
});
|
|
945
1073
|
if (!r.ok) return { ...r, faltaram };
|
|
946
1074
|
|
|
1075
|
+
/* O VOLUME FINAL: a mistura (voz, música, efeitos, depoimento) sai no
|
|
1076
|
+
padrão de Reels e YouTube, -14 LUFS. Medido em 25/09: o vídeo pronto
|
|
1077
|
+
saía com -23,6 dB de média. A imagem não é recodificada. */
|
|
1078
|
+
if (pl.audio && fs.existsSync(saida)) {
|
|
1079
|
+
const tmp = saida.replace(/\.mp4$/, '.norm.mp4');
|
|
1080
|
+
const ln = await video.rodar(video.ffmpeg(), ['-y', '-i', saida, '-c:v', 'copy', '-af', 'loudnorm=I=-14:TP=-1.2:LRA=10',
|
|
1081
|
+
'-c:a', 'aac', '-b:a', '256k', tmp], { timeout: 15 * 60000 });
|
|
1082
|
+
if (ln.ok && fs.existsSync(tmp)) fs.renameSync(tmp, saida);
|
|
1083
|
+
else { try { fs.unlinkSync(tmp); } catch { /* não havia */ } }
|
|
1084
|
+
}
|
|
1085
|
+
|
|
947
1086
|
const bytes = fs.existsSync(saida) ? fs.statSync(saida).size : 0;
|
|
948
1087
|
projeto.salvar(args.id, { renders: [...(p.renders || []), { quando: new Date().toISOString(), arquivo: saida, bytes }] });
|
|
949
1088
|
projeto.anotar(args.id, 'render', `${(bytes / 1048576).toFixed(1)} MB, ${conferida.duracao}s`);
|
|
@@ -1054,6 +1193,19 @@ async function ferramentaSite(args) {
|
|
|
1054
1193
|
buscar: identidade.buscar, baixar: assets.baixar,
|
|
1055
1194
|
});
|
|
1056
1195
|
const marca = { ...ultimoSite.marca, ...(r.logo && r.logo.ok ? { logo: r.logo.src } : {}) };
|
|
1196
|
+
/* O GRADIENTE da marca sai da própria logo, quando ela é SVG com
|
|
1197
|
+
gradiente (a da Paris Group vai de #2E81ED a #9567DB). */
|
|
1198
|
+
try {
|
|
1199
|
+
if (marca.logo && /\.svg$/i.test(marca.logo)) {
|
|
1200
|
+
const svg = fs.readFileSync(path.join(p.pasta, marca.logo), 'utf8');
|
|
1201
|
+
const stops = [...new Set((svg.match(/stop-color=["'](#[0-9a-f]{3,6})["']/gi) || []).map((x) => x.match(/#[0-9a-f]{3,6}/i)[0].toUpperCase()))];
|
|
1202
|
+
if (stops.length >= 2) {
|
|
1203
|
+
marca.gradiente = stops.slice(0, 2);
|
|
1204
|
+
// A logo manda na identidade: o destaque é a cor dela, não a do CSS.
|
|
1205
|
+
marca.cores = { ...(marca.cores || {}), destaque: stops[1], apoio: stops[0] };
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
} catch { /* sem gradiente */ }
|
|
1057
1209
|
projeto.salvar(args.id, { acervo: { fotos: a.fotos, videos: a.videos, fatos: a.fatos, site: r.url }, marca });
|
|
1058
1210
|
projeto.anotar(args.id, 'midia', `acervo do site: ${a.fotos.length} fotos, ${a.videos.length} vídeos, ${a.paginas.length} páginas lidas`);
|
|
1059
1211
|
let orcamento = 4200;
|
package/lib/estudio/plano.js
CHANGED
|
@@ -51,7 +51,7 @@ const path = require('path');
|
|
|
51
51
|
const TIPOS = new Set(['texto', 'forma', 'imagem', 'video', 'icone', 'legenda', 'componente']);
|
|
52
52
|
const FORMAS = new Set(['retangulo', 'circulo', 'linha', 'poligono', 'traco']);
|
|
53
53
|
const AJUSTES = new Set(['cover', 'contain', 'preencher']);
|
|
54
|
-
const TRANSICOES = new Set(['corte', 'esmaecer', 'deslizar', 'zoom', 'giro', 'mascara', 'morph']);
|
|
54
|
+
const TRANSICOES = new Set(['corte', 'esmaecer', 'deslizar', 'zoom', 'giro', 'mascara', 'morph', 'chicote', 'soco', 'flash']);
|
|
55
55
|
|
|
56
56
|
/* As propriedades que a animação por quadro-chave alcança. Cada uma é um
|
|
57
57
|
número no tempo; a curva entre dois quadros é a suavização declarada.
|
package/lib/estudio/remotion.js
CHANGED
|
@@ -283,6 +283,12 @@ function Elemento({ el, t, dados }) {
|
|
|
283
283
|
WebkitBackgroundClip: el.gradienteTexto ? 'text' : undefined,
|
|
284
284
|
WebkitTextFillColor: el.gradienteTexto ? 'transparent' : undefined,
|
|
285
285
|
fontStyle: el.italico ? 'italic' : undefined,
|
|
286
|
+
WebkitTextStroke: el.contorno || undefined,
|
|
287
|
+
// O GLITCH: o texto se parte em vermelho e ciano por um instante.
|
|
288
|
+
textShadow: el.glitch && tLocal < (el.glitch.dur || 0.35)
|
|
289
|
+
? (() => { const f = Math.round(t * 30); const d = (Math.sin(f * 7.1) * 0.5 + 0.5) * (el.glitch.forca || 10) * (1 - tLocal / (el.glitch.dur || 0.35));
|
|
290
|
+
return d.toFixed(1) + 'px 0 #ff2d55, ' + (-d).toFixed(1) + 'px 0 #00e5ff'; })()
|
|
291
|
+
: (el.sombraTexto || undefined),
|
|
286
292
|
textDecoration: el.riscado ? 'line-through' : undefined,
|
|
287
293
|
textDecorationThickness: el.riscado ? '0.08em' : undefined,
|
|
288
294
|
wordBreak: 'normal', overflowWrap: 'break-word',
|
|
@@ -419,10 +425,26 @@ export function Cena({ cena, dados, tFixo }) {
|
|
|
419
425
|
const t = tFixo != null ? tFixo : frame / fps;
|
|
420
426
|
|
|
421
427
|
const cam = animado(cena.camera, t);
|
|
428
|
+
/* O IMPACTO: tremor (a câmera treme e assenta), soco (o zoom que bate e
|
|
429
|
+
volta) e flash (o branco que estoura e some). O tremor é pseudoaleatório
|
|
430
|
+
pelo QUADRO, então o mesmo render sai sempre igual. */
|
|
431
|
+
const acaso = (n) => { const x = Math.sin(n * 12.9898 + 78.233) * 43758.5453; return x - Math.floor(x); };
|
|
432
|
+
let tx = 0, ty = 0, tr = 0, soco = 1;
|
|
433
|
+
for (const w of cena.tremor || []) {
|
|
434
|
+
if (t < w.de || t > w.ate) continue;
|
|
435
|
+
const k = (w.forca || 14) * (1 - (t - w.de) / Math.max(0.01, w.ate - w.de));
|
|
436
|
+
tx += (acaso(frame) - 0.5) * 2 * k; ty += (acaso(frame + 91) - 0.5) * 2 * k; tr += (acaso(frame + 37) - 0.5) * k * 0.08;
|
|
437
|
+
}
|
|
438
|
+
for (const s0 of cena.socos || []) {
|
|
439
|
+
if (t >= s0.t) soco *= 1 + (s0.forca || 0.18) * Math.exp(-14 * (t - s0.t));
|
|
440
|
+
}
|
|
441
|
+
let flash = 0;
|
|
442
|
+
for (const f of cena.flashes || []) if (t >= f && t < f + 0.25) flash = Math.max(flash, Math.exp(-18 * (t - f)));
|
|
422
443
|
const camTransform = [
|
|
423
444
|
cam.x != null || cam.y != null ? 'translate(' + (-(cam.x || 0)) + 'px,' + (-(cam.y || 0)) + 'px)' : '',
|
|
424
|
-
|
|
425
|
-
cam.
|
|
445
|
+
tx || ty ? 'translate(' + tx + 'px,' + ty + 'px)' : '',
|
|
446
|
+
cam.zoom != null || soco !== 1 ? 'scale(' + ((cam.zoom ?? 1) * soco) + ')' : '',
|
|
447
|
+
cam.rotacao != null || tr ? 'rotate(' + ((cam.rotacao || 0) + tr) + 'deg)' : '',
|
|
426
448
|
].filter(Boolean).join(' ');
|
|
427
449
|
|
|
428
450
|
return (
|
|
@@ -451,6 +473,7 @@ export function Cena({ cena, dados, tFixo }) {
|
|
|
451
473
|
<Elemento key={i} el={el} t={t} dados={dados} />
|
|
452
474
|
))}
|
|
453
475
|
</AbsoluteFill>
|
|
476
|
+
{flash > 0.01 && <AbsoluteFill style={{ backgroundColor: cena.corFlash || '#fff', opacity: flash * 0.85 }} />}
|
|
454
477
|
</AbsoluteFill>
|
|
455
478
|
);
|
|
456
479
|
}
|
|
@@ -474,6 +497,11 @@ function Transicao({ tipo, progresso, children }) {
|
|
|
474
497
|
else if (tipo === 'giro') estilo = { transform: 'rotateY(' + (1 - p) * 35 + 'deg) scale(' + (0.9 + 0.1 * p) + ')', opacity: p };
|
|
475
498
|
else if (tipo === 'mascara') estilo = { clipPath: 'circle(' + (p * 75) + '% at 50% 50%)' };
|
|
476
499
|
else if (tipo === 'morph') estilo = { opacity: p, filter: 'blur(' + (1 - p) * 14 + 'px)', transform: 'scale(' + (1.06 - 0.06 * p) + ')' };
|
|
500
|
+
// O CHICOTE: a cena chega rasgando de lado, borrada de velocidade.
|
|
501
|
+
else if (tipo === 'chicote') estilo = { transform: 'translateX(' + (1 - p) * 70 + '%) skewX(' + (1 - p) * -12 + 'deg)', filter: 'blur(' + (1 - p) * 40 + 'px)' };
|
|
502
|
+
// O SOCO: a cena entra grande e bate no lugar.
|
|
503
|
+
else if (tipo === 'soco') estilo = { transform: 'scale(' + (1.35 - 0.35 * p) + ')', opacity: Math.min(1, p * 3) };
|
|
504
|
+
else if (tipo === 'flash') estilo = { opacity: Math.min(1, p * 2), filter: 'brightness(' + (1 + (1 - p) * 3) + ')' };
|
|
477
505
|
return <AbsoluteFill style={estilo}>{children}</AbsoluteFill>;
|
|
478
506
|
}
|
|
479
507
|
|
|
@@ -497,14 +525,24 @@ export const Video = () => {
|
|
|
497
525
|
const frame = useCurrentFrame();
|
|
498
526
|
const dados = { fps, icones };
|
|
499
527
|
|
|
528
|
+
/* O cruzamento é da cena que ENTRA: ela começa "cruz" quadros antes de a
|
|
529
|
+
anterior acabar, e as duas ficam no ar juntas. Contar pela que sai deixava
|
|
530
|
+
a que entra começando sobre o preto (medido em 25/09: o "soco" abria com
|
|
531
|
+
um quadro preto). */
|
|
532
|
+
const cruzDe = (cena, i) => {
|
|
533
|
+
if (!cena || i === 0) return 0;
|
|
534
|
+
const t = cena.transicao || {};
|
|
535
|
+
const tipo = t.tipo || t || 'corte';
|
|
536
|
+
return tipo === 'corte' ? 0 : Math.round((t.duracao ?? 0.4) * fps);
|
|
537
|
+
};
|
|
500
538
|
let inicio = 0;
|
|
501
539
|
const pedacos = plano.cenas.map((cena, i) => {
|
|
502
540
|
const dur = Math.max(1, Math.round(cena.duracao * fps));
|
|
503
541
|
const t = cena.transicao || {};
|
|
504
542
|
const tipo = t.tipo || t || 'corte';
|
|
505
|
-
const cruz =
|
|
543
|
+
const cruz = cruzDe(cena, i);
|
|
506
544
|
const de = inicio;
|
|
507
|
-
inicio += dur -
|
|
545
|
+
inicio += dur - cruzDe(plano.cenas[i + 1], i + 1);
|
|
508
546
|
return { cena, de, dur, tipo, cruz, i };
|
|
509
547
|
});
|
|
510
548
|
|
|
@@ -524,16 +562,56 @@ export const Video = () => {
|
|
|
524
562
|
);
|
|
525
563
|
})}
|
|
526
564
|
|
|
565
|
+
{/* O ACABAMENTO: granulação de filme, vinheta e a luz da marca passando.
|
|
566
|
+
É o que tira a cara de apresentação e dá a cara de peça de 2026. */}
|
|
567
|
+
{plano.acabamento && plano.acabamento.luz && (
|
|
568
|
+
<AbsoluteFill style={{ pointerEvents: 'none', mixBlendMode: 'screen', opacity: plano.acabamento.luzForca ?? 0.22,
|
|
569
|
+
background: 'radial-gradient(circle at ' + (50 + 40 * Math.sin(frame / 38)) + '% ' + (30 + 25 * Math.cos(frame / 51)) + '%, '
|
|
570
|
+
+ plano.acabamento.luz[0] + ' 0%, transparent 45%), radial-gradient(circle at ' + (50 - 40 * Math.sin(frame / 44)) + '% '
|
|
571
|
+
+ (75 - 20 * Math.sin(frame / 33)) + '%, ' + (plano.acabamento.luz[1] || plano.acabamento.luz[0]) + ' 0%, transparent 40%)' }} />
|
|
572
|
+
)}
|
|
573
|
+
{plano.acabamento && plano.acabamento.grade && (
|
|
574
|
+
<AbsoluteFill style={{ pointerEvents: 'none', opacity: plano.acabamento.grade,
|
|
575
|
+
backgroundImage: 'linear-gradient(rgba(255,255,255,0.5) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,0.5) 1px, transparent 1px)',
|
|
576
|
+
backgroundSize: (plano.acabamento.passoGrade || 120) + 'px ' + (plano.acabamento.passoGrade || 120) + 'px',
|
|
577
|
+
backgroundPosition: (frame * 0.2) + 'px ' + (frame * 0.1) + 'px' }} />
|
|
578
|
+
)}
|
|
579
|
+
{plano.acabamento && plano.acabamento.vinheta && (
|
|
580
|
+
<AbsoluteFill style={{ pointerEvents: 'none', background: 'radial-gradient(ellipse at center, transparent 55%, rgba(0,0,0,' + plano.acabamento.vinheta + ') 100%)' }} />
|
|
581
|
+
)}
|
|
582
|
+
{plano.acabamento && plano.acabamento.grao && (
|
|
583
|
+
<AbsoluteFill style={{ pointerEvents: 'none', opacity: plano.acabamento.grao, mixBlendMode: 'overlay' }}>
|
|
584
|
+
<svg width="100%" height="100%">
|
|
585
|
+
<filter id="grao"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" seed={frame % 60} /></filter>
|
|
586
|
+
<rect width="100%" height="100%" filter="url(#grao)" />
|
|
587
|
+
</svg>
|
|
588
|
+
</AbsoluteFill>
|
|
589
|
+
)}
|
|
590
|
+
|
|
527
591
|
{plano.audio && plano.audio.narracao && (
|
|
528
592
|
<Audio src={staticFile(plano.audio.narracao)} volume={plano.audio.volumeNarracao ?? 1} />
|
|
529
593
|
)}
|
|
530
594
|
{plano.audio && plano.audio.trilha && (
|
|
531
595
|
<Audio
|
|
532
596
|
src={staticFile(plano.audio.trilha.src || plano.audio.trilha)}
|
|
533
|
-
volume
|
|
597
|
+
/* O volume da música pode mudar no tempo ("volumes": [{t, v}]): começa
|
|
598
|
+
mínima, cresce, abaixa no depoimento e some antes do clímax. */
|
|
599
|
+
volume={Array.isArray(plano.audio.trilha.volumes)
|
|
600
|
+
? (f) => { const tt = f / fps, vs = plano.audio.trilha.volumes;
|
|
601
|
+
if (tt <= vs[0].t) return vs[0].v;
|
|
602
|
+
for (let k = 0; k < vs.length - 1; k++) if (tt >= vs[k].t && tt <= vs[k + 1].t) return vs[k].v + (vs[k + 1].v - vs[k].v) * ((tt - vs[k].t) / Math.max(0.001, vs[k + 1].t - vs[k].t));
|
|
603
|
+
return vs[vs.length - 1].v; }
|
|
604
|
+
: (plano.audio.trilha.volume ?? 0.18)}
|
|
534
605
|
loop={plano.audio.trilha.loop !== false}
|
|
535
606
|
/>
|
|
536
607
|
)}
|
|
608
|
+
{/* Os TRECHOS: pedaços de áudio no tempo certo — a narração partida entre
|
|
609
|
+
os depoimentos, o som de ambiente de uma cena. */}
|
|
610
|
+
{(plano.audio && plano.audio.trechos ? plano.audio.trechos : []).map((a, k) => (
|
|
611
|
+
<Sequence key={'tr' + k} from={Math.round((a.t || 0) * fps)} durationInFrames={Math.max(1, Math.round(((a.ate ?? 999) - (a.de || 0)) * fps))}>
|
|
612
|
+
<Audio src={staticFile(a.src)} startFrom={Math.round((a.de || 0) * fps)} volume={a.volume ?? 1} />
|
|
613
|
+
</Sequence>
|
|
614
|
+
))}
|
|
537
615
|
{(plano.audio && plano.audio.efeitos ? plano.audio.efeitos : []).map((e, i) => (
|
|
538
616
|
<Sequence key={'sfx' + i} from={Math.round((e.t || 0) * fps)}>
|
|
539
617
|
<Audio src={staticFile(e.src)} volume={e.volume ?? 0.7} />
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "primocode",
|
|
3
|
-
"version": "9.8.0-beta.
|
|
3
|
+
"version": "9.8.0-beta.19",
|
|
4
4
|
"description": "PrimoCode — agente de engenharia com IA e cursor próprio. Requer conta Conecta Primo AI (Premium ou Super). Cria arquivos, roda comandos, controla navegador e desktop: abre apps, clica em botões e ícones pelo nome, digita e usa atalhos.",
|
|
5
5
|
"main": "bin/primocode.js",
|
|
6
6
|
"bin": {
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
},
|
|
9
9
|
"scripts": {
|
|
10
10
|
"start": "node bin/primocode.js",
|
|
11
|
-
"test": "node test/tools.test.js && node test/ui.test.js && node test/entrada.test.js && node test/transparencia.test.js && node test/permissao.test.js && node test/compatibilidade.test.js && node test/skills.test.js && node test/skills-de-fora.test.js && node test/skills-novas.test.js && node test/comandos.test.js && node test/estudio.test.js && node test/voz.test.js && node test/voz-conversa.test.js && node test/modo.test.js && node test/pasta.test.js && node test/projeto.test.js && node test/nome-projeto.test.js && node test/api-propria.test.js && node test/claude-engine.test.js && node test/codex.test.js && node test/conferir.test.js && node test/catalogo.test.js && node test/desktop.test.js && node test/seta.test.js && node test/effort.test.js && node test/regressao.test.js && node test/memoria-conversa.test.js && node test/repeticao.test.js && node test/autonomia.test.js && node test/desfazer.test.js && node test/desfazer-git.test.js && node test/referencia.test.js && node test/parar.test.js && node test/continuar.test.js && node test/pipeline.test.js && node test/app.test.js && node test/app-janela.test.js && node test/aplicativo.test.js && node test/conta.test.mjs && node test/nuvem.test.js && node test/prazo.test.js && node test/primeira-vez.test.js && node test/janela.test.js && node test/fala.test.js && node test/especialistas.test.js && node test/empurrao.test.js && node test/terminal-novo.test.js && node test/markdown.test.js && node test/coerencia.test.js && node test/contexto.test.js && node test/perigo.test.js && node test/rapidez.test.js && node test/mascotes.test.js && node test/comandos-extra.test.js && node test/offline.test.js && node test/edicao.test.js && node test/composicao.test.js && node test/acervo-site.test.js"
|
|
11
|
+
"test": "node test/tools.test.js && node test/ui.test.js && node test/entrada.test.js && node test/transparencia.test.js && node test/permissao.test.js && node test/compatibilidade.test.js && node test/skills.test.js && node test/skills-de-fora.test.js && node test/skills-novas.test.js && node test/comandos.test.js && node test/estudio.test.js && node test/voz.test.js && node test/voz-conversa.test.js && node test/modo.test.js && node test/pasta.test.js && node test/projeto.test.js && node test/nome-projeto.test.js && node test/api-propria.test.js && node test/claude-engine.test.js && node test/codex.test.js && node test/conferir.test.js && node test/catalogo.test.js && node test/desktop.test.js && node test/seta.test.js && node test/effort.test.js && node test/regressao.test.js && node test/memoria-conversa.test.js && node test/repeticao.test.js && node test/autonomia.test.js && node test/desfazer.test.js && node test/desfazer-git.test.js && node test/referencia.test.js && node test/parar.test.js && node test/continuar.test.js && node test/pipeline.test.js && node test/app.test.js && node test/app-janela.test.js && node test/aplicativo.test.js && node test/conta.test.mjs && node test/nuvem.test.js && node test/prazo.test.js && node test/primeira-vez.test.js && node test/janela.test.js && node test/fala.test.js && node test/especialistas.test.js && node test/empurrao.test.js && node test/terminal-novo.test.js && node test/markdown.test.js && node test/coerencia.test.js && node test/contexto.test.js && node test/perigo.test.js && node test/rapidez.test.js && node test/mascotes.test.js && node test/comandos-extra.test.js && node test/offline.test.js && node test/edicao.test.js && node test/composicao.test.js && node test/acervo-site.test.js && node test/impacto.test.js && node test/cinema.test.js"
|
|
12
12
|
},
|
|
13
13
|
"engines": {
|
|
14
14
|
"node": ">=18.17.0"
|