primocode 9.8.0-beta.18 → 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/index.js +120 -5
- package/lib/estudio/remotion.js +21 -1
- 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 };
|
package/lib/estudio/index.js
CHANGED
|
@@ -44,6 +44,7 @@ const edicao = require('./edicao.js');
|
|
|
44
44
|
const composicao = require('./composicao.js');
|
|
45
45
|
const acervoSite = require('./acervo-site.js');
|
|
46
46
|
const impacto = require('./impacto.js');
|
|
47
|
+
const cinema = require('./cinema.js');
|
|
47
48
|
|
|
48
49
|
const PY = path.join(__dirname, '..', '..', 'studio');
|
|
49
50
|
|
|
@@ -217,8 +218,10 @@ function roteiroValido(r) {
|
|
|
217
218
|
if (typeof x === 'string') { try { x = JSON.parse(x); } catch { return null; } }
|
|
218
219
|
const partes = Array.isArray(x) ? x : x && x.partes;
|
|
219
220
|
if (!Array.isArray(partes) || partes.length < 2) return null;
|
|
220
|
-
|
|
221
|
-
|
|
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;
|
|
222
225
|
}
|
|
223
226
|
|
|
224
227
|
// O último site lido, para o projeto que nasceu DEPOIS da leitura.
|
|
@@ -592,7 +595,7 @@ async function ferramentaVideo(args, ctx = {}) {
|
|
|
592
595
|
let trilha = null;
|
|
593
596
|
if (args.musica !== false) {
|
|
594
597
|
try {
|
|
595
|
-
const b = await assets.
|
|
598
|
+
const b = await assets.procurarMusica(args.trilha || 'upbeat corporate', { quantas: 6, duracaoMin: 45 });
|
|
596
599
|
for (const item of (b.ok ? b.sons : [])) {
|
|
597
600
|
const d = await assets.baixar(item, { pasta: p.pasta, nome: 'trilha' });
|
|
598
601
|
if (d.ok) { trilha = d.relativo; break; }
|
|
@@ -734,6 +737,11 @@ async function narrarComGemini(lista, mp3, { voz, direcao } = {}) {
|
|
|
734
737
|
try { fs.unlinkSync(pcm); } catch { /* fica */ }
|
|
735
738
|
if (!c.ok) return { ok: false, error: 'não consegui converter a voz' };
|
|
736
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)` };
|
|
737
745
|
const tr = await api.requestTranscricao(cfg.server, { token: cfg.token, audio: fs.readFileSync(mp3).toString('base64'), nome: 'voz.mp3', idioma: 'pt' });
|
|
738
746
|
const palavras = (tr && tr.palavras) || [];
|
|
739
747
|
if (!palavras.length) return { ok: false, error: 'sem a transcrição da voz para achar as partes' };
|
|
@@ -743,6 +751,33 @@ async function narrarComGemini(lista, mp3, { voz, direcao } = {}) {
|
|
|
743
751
|
}
|
|
744
752
|
}
|
|
745
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
|
+
|
|
746
781
|
const PROPORCOES = { '16:9': [1920, 1080], '9:16': [1080, 1920], '1:1': [1080, 1080], '4:5': [1080, 1350] };
|
|
747
782
|
|
|
748
783
|
/** O roteiro vira plano: baixa o que falta, narra, e chama o compositor. */
|
|
@@ -812,6 +847,71 @@ async function prepararRoteiro(p, args) {
|
|
|
812
847
|
}
|
|
813
848
|
|
|
814
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
|
+
}
|
|
815
915
|
if (modo === 'video') {
|
|
816
916
|
const lista = partes.map((pt, i) => ({ texto: composicao.falaDe(pt), registro: composicao.registroDe(pt, i, partes.length) }));
|
|
817
917
|
const arq = path.join(p.pasta, 'midia', `roteiro-${Date.now().toString(36)}.json`);
|
|
@@ -829,7 +929,7 @@ async function prepararRoteiro(p, args) {
|
|
|
829
929
|
projeto.anotar(p.id, 'narracao', `${v.segundos}s em ${partes.length} partes, voz ${v.voz}`);
|
|
830
930
|
sons = await edicao.prepararSons(p.pasta, video);
|
|
831
931
|
try {
|
|
832
|
-
const b = await assets.
|
|
932
|
+
const b = await assets.procurarMusica(roteiro.musica || args.trilha || (estilo === 'impacto' ? 'epic energetic' : 'inspiring corporate'), { quantas: 6, duracaoMin: 45 });
|
|
833
933
|
for (const item of (b.ok ? b.sons : [])) {
|
|
834
934
|
const d = await assets.baixar(item, { pasta: p.pasta, nome: 'trilha' });
|
|
835
935
|
if (d.ok) { trilha = d.relativo; break; }
|
|
@@ -972,6 +1072,17 @@ async function ferramentaRender(args, ctx = {}) {
|
|
|
972
1072
|
});
|
|
973
1073
|
if (!r.ok) return { ...r, faltaram };
|
|
974
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
|
+
|
|
975
1086
|
const bytes = fs.existsSync(saida) ? fs.statSync(saida).size : 0;
|
|
976
1087
|
projeto.salvar(args.id, { renders: [...(p.renders || []), { quando: new Date().toISOString(), arquivo: saida, bytes }] });
|
|
977
1088
|
projeto.anotar(args.id, 'render', `${(bytes / 1048576).toFixed(1)} MB, ${conferida.duracao}s`);
|
|
@@ -1088,7 +1199,11 @@ async function ferramentaSite(args) {
|
|
|
1088
1199
|
if (marca.logo && /\.svg$/i.test(marca.logo)) {
|
|
1089
1200
|
const svg = fs.readFileSync(path.join(p.pasta, marca.logo), 'utf8');
|
|
1090
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()))];
|
|
1091
|
-
if (stops.length >= 2)
|
|
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
|
+
}
|
|
1092
1207
|
}
|
|
1093
1208
|
} catch { /* sem gradiente */ }
|
|
1094
1209
|
projeto.salvar(args.id, { acervo: { fotos: a.fotos, videos: a.videos, fatos: a.fatos, site: r.url }, marca });
|
package/lib/estudio/remotion.js
CHANGED
|
@@ -570,6 +570,12 @@ export const Video = () => {
|
|
|
570
570
|
+ plano.acabamento.luz[0] + ' 0%, transparent 45%), radial-gradient(circle at ' + (50 - 40 * Math.sin(frame / 44)) + '% '
|
|
571
571
|
+ (75 - 20 * Math.sin(frame / 33)) + '%, ' + (plano.acabamento.luz[1] || plano.acabamento.luz[0]) + ' 0%, transparent 40%)' }} />
|
|
572
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
|
+
)}
|
|
573
579
|
{plano.acabamento && plano.acabamento.vinheta && (
|
|
574
580
|
<AbsoluteFill style={{ pointerEvents: 'none', background: 'radial-gradient(ellipse at center, transparent 55%, rgba(0,0,0,' + plano.acabamento.vinheta + ') 100%)' }} />
|
|
575
581
|
)}
|
|
@@ -588,10 +594,24 @@ export const Video = () => {
|
|
|
588
594
|
{plano.audio && plano.audio.trilha && (
|
|
589
595
|
<Audio
|
|
590
596
|
src={staticFile(plano.audio.trilha.src || plano.audio.trilha)}
|
|
591
|
-
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)}
|
|
592
605
|
loop={plano.audio.trilha.loop !== false}
|
|
593
606
|
/>
|
|
594
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
|
+
))}
|
|
595
615
|
{(plano.audio && plano.audio.efeitos ? plano.audio.efeitos : []).map((e, i) => (
|
|
596
616
|
<Sequence key={'sfx' + i} from={Math.round((e.t || 0) * fps)}>
|
|
597
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 && node test/impacto.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"
|