primocode 9.10.0 → 10.0.9
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 +30 -6
- package/bin/primocode.js +169 -342
- package/lib/act.js +360 -39
- package/lib/browser.js +105 -4
- package/lib/casar.js +108 -0
- package/lib/catalogo.js +6 -2
- package/lib/claude-engine.js +38 -17
- package/lib/codex-engine.js +27 -9
- package/lib/config.js +10 -3
- package/lib/cursor.js +10 -1
- package/lib/entrada.js +77 -11
- package/lib/estudio/cinema.js +27 -9
- package/lib/estudio/diretor.js +21 -1
- package/lib/estudio/ende.js +16 -5
- package/lib/estudio/estilo.js +66 -0
- package/lib/estudio/gerativo.js +1453 -0
- package/lib/estudio/glifos.js +215 -0
- package/lib/estudio/hibrido.js +165 -0
- package/lib/estudio/index.js +284 -53
- package/lib/estudio/kit/base.jsx +285 -0
- package/lib/estudio/kit/gerativo.jsx +980 -0
- package/lib/estudio/kit/index.jsx +13 -0
- package/lib/estudio/kit/marca.jsx +741 -0
- package/lib/estudio/kit/tres.jsx +308 -0
- package/lib/estudio/kit/vox.jsx +357 -0
- package/lib/estudio/kit.js +78 -0
- package/lib/estudio/lancamento.js +441 -0
- package/lib/estudio/motion.js +2 -5
- package/lib/estudio/plano.js +28 -3
- package/lib/estudio/referencias.js +105 -0
- package/lib/estudio/remotion.js +139 -51
- package/lib/estudio/ritmo.js +77 -0
- package/lib/estudio/sons-leves.js +38 -1
- package/lib/estudio/variedade.js +105 -0
- package/lib/estudio/vox.js +414 -0
- package/lib/fala.js +1 -94
- package/lib/mcp.js +156 -0
- package/lib/personas.js +67 -0
- package/lib/pipeline.js +55 -56
- package/lib/repeticao.js +3 -1
- package/lib/skills.js +44 -2
- package/lib/snapshot.js +111 -0
- package/lib/tools.js +100 -22
- package/lib/ui.js +50 -8
- package/lib/video/analise.js +89 -0
- package/lib/video/auto.js +329 -0
- package/lib/video/camadas.js +207 -0
- package/lib/video/cli.js +74 -0
- package/lib/video/delta.js +90 -0
- package/lib/video/edits.js +379 -0
- package/lib/video/filters.js +280 -0
- package/lib/video/fx3d/Camadas.jsx +64 -0
- package/lib/video/fx3d/Objetos.jsx +128 -0
- package/lib/video/fx3d/Root.jsx +18 -0
- package/lib/video/fx3d/modelos.jsx +304 -0
- package/lib/video/fx3d.js +108 -0
- package/lib/video/gerados.js +119 -0
- package/lib/video/objetos.js +111 -0
- package/lib/video/ops-fx.js +453 -0
- package/lib/video/ops.js +347 -0
- package/lib/video/paralelo.js +50 -0
- package/lib/video/pedido.js +191 -0
- package/lib/video/projeto.js +133 -0
- package/lib/video/rastrear.js +95 -0
- package/lib/video/rastro.js +276 -0
- package/lib/video/render.js +274 -0
- package/lib/video/texto.js +144 -0
- package/lib/video/timeline.js +318 -0
- package/lib/video/transcript.js +124 -0
- package/lib/voz.js +75 -223
- package/package.json +2 -3
- package/skills/motion-de-primeira/SKILL.md +5 -5
- package/studio/narrar.py +10 -7
- package/studio/rastrear.py +399 -0
- package/studio/texto.py +208 -0
- package/voz/primo_voz/__main__.py +96 -236
- package/voz/primo_voz/transcrever.py +31 -1
- package/app/ansi.js +0 -55
- package/app/app.css +0 -500
- package/app/app.js +0 -1017
- package/app/icone.svg +0 -6
- package/app/index.html +0 -220
- package/app/primo-marca.js +0 -192
- package/lib/app.js +0 -615
- package/lib/icone.js +0 -263
- package/lib/instalar-app.js +0 -231
- package/lib/painel.js +0 -291
- package/voz/primo_voz/desperta.py +0 -195
- package/voz/primo_voz/orbe.py +0 -406
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* render.js — onde o editor encosta no mundo: ffmpeg, Pillow e disco.
|
|
3
|
+
*
|
|
4
|
+
* Tudo que decide mora nos módulos puros (filters, texto, analise). Aqui só
|
|
5
|
+
* se mede, desenha e roda. O ffmpeg é achado pelo mesmo lib/estudio/video.js
|
|
6
|
+
* do Estúdio — um lugar só sabe onde o ffmpeg está e como instalar.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
'use strict';
|
|
10
|
+
|
|
11
|
+
const fs = require('fs');
|
|
12
|
+
const path = require('path');
|
|
13
|
+
const { spawn, execFileSync } = require('child_process');
|
|
14
|
+
const T = require('./timeline.js');
|
|
15
|
+
const F = require('./filters.js');
|
|
16
|
+
const TX = require('./texto.js');
|
|
17
|
+
const video = require('../estudio/video.js');
|
|
18
|
+
const { rodarPython } = require('./transcript.js');
|
|
19
|
+
|
|
20
|
+
const TEXTO_PY = path.join(__dirname, '..', '..', 'studio', 'texto.py');
|
|
21
|
+
const IMAGEM = /\.(png|jpe?g|webp|bmp|gif|tiff?)$/i;
|
|
22
|
+
|
|
23
|
+
// ── o que o ffmpeg desta máquina sabe ────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
let _versao = null;
|
|
26
|
+
function versaoMaior() {
|
|
27
|
+
if (_versao !== null) return _versao;
|
|
28
|
+
try {
|
|
29
|
+
const v = execFileSync(video.ffmpeg(), ['-version'], { stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000 }).toString();
|
|
30
|
+
const m = v.match(/ffmpeg version n?(\d+)\./);
|
|
31
|
+
_versao = m ? Number(m[1]) : 99; // build do git ("N-12345") é recente
|
|
32
|
+
} catch { _versao = 99; }
|
|
33
|
+
return _versao;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
let _codec = null;
|
|
37
|
+
function codecDeVideo() {
|
|
38
|
+
if (_codec) return _codec;
|
|
39
|
+
let lista = '';
|
|
40
|
+
try { lista = execFileSync(video.ffmpeg(), ['-hide_banner', '-encoders'], { stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000 }).toString(); } catch {}
|
|
41
|
+
for (const c of ['libx264', 'libopenh264', 'h264_videotoolbox', 'h264_mf', 'mpeg4']) {
|
|
42
|
+
if (new RegExp(`\\s${c}\\s`).test(lista)) { _codec = c; return c; }
|
|
43
|
+
}
|
|
44
|
+
return (_codec = 'mpeg4');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function argsDoCodec(codec, crf) {
|
|
48
|
+
if (codec === 'libx264') return ['-c:v', 'libx264', '-preset', 'veryfast', '-crf', String(crf)];
|
|
49
|
+
if (codec === 'h264_videotoolbox') return ['-c:v', codec, '-b:v', '8M'];
|
|
50
|
+
return ['-c:v', codec, '-b:v', '8M'];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function conferir() { return video.conferir(); }
|
|
54
|
+
|
|
55
|
+
// ── importar ─────────────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
async function medirAsset(tl, arquivo) {
|
|
58
|
+
const abs = path.resolve(arquivo);
|
|
59
|
+
if (!fs.existsSync(abs)) throw T.erro(`arquivo não existe: ${abs}`);
|
|
60
|
+
const m = await video.medir(abs);
|
|
61
|
+
if (!m.ok) throw T.erro(m.error);
|
|
62
|
+
const imagem = IMAGEM.test(abs);
|
|
63
|
+
const kind = imagem ? 'image' : m.largura ? 'video' : 'audio';
|
|
64
|
+
return {
|
|
65
|
+
id: T.novoAssetId(tl), path: abs, nome: path.basename(abs), kind,
|
|
66
|
+
seconds: imagem ? 0 : T.r2(m.segundos), width: m.largura || 0, height: m.altura || 0,
|
|
67
|
+
fps: m.fps || 0, hasAudio: !!m.temAudio,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ── o texto em png ───────────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
let _pillow = null;
|
|
74
|
+
function python() { return require('../voz.js').python(); }
|
|
75
|
+
|
|
76
|
+
async function temPillow() {
|
|
77
|
+
if (_pillow !== null) return _pillow;
|
|
78
|
+
const py = python();
|
|
79
|
+
_pillow = py ? !!(await rodarPython(py, [TEXTO_PY, 'conferir'], { cwd: path.dirname(TEXTO_PY), timeout: 30000 })).ok : false;
|
|
80
|
+
return _pillow;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** As camadas de texto da janela: {índiceDaFaixa: arquivo .ffconcat}. */
|
|
84
|
+
async function camadasDeTexto(pasta, tl, { from, to, escala }) {
|
|
85
|
+
const avisosPos = [];
|
|
86
|
+
const posicao = require('./gerados.js').posicoesDeLegenda(pasta, tl, avisosPos);
|
|
87
|
+
const faixas = tl.tracks.map((t, i) => ({ t, i })).filter(({ t }) => t.type === 'text' && t.clips.length && !t.hidden);
|
|
88
|
+
if (!faixas.length) return { camadas: {}, avisos: [] };
|
|
89
|
+
if (!(await temPillow())) {
|
|
90
|
+
return { camadas: {}, avisos: ['os textos NÃO foram desenhados: falta o Pillow (python3 -m pip install pillow)'] };
|
|
91
|
+
}
|
|
92
|
+
const W = T.par(tl.width * escala);
|
|
93
|
+
const H = T.par(tl.height * escala);
|
|
94
|
+
const dir = path.join(pasta, 'cache', 'texto', `${W}x${H}`);
|
|
95
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
96
|
+
const vazio = path.join(dir, 'vazio.png');
|
|
97
|
+
const arquivoDe = (chave) => (chave === 'vazio' ? vazio : path.join(dir, `${chave}.png`));
|
|
98
|
+
const estados = [];
|
|
99
|
+
const camadas = {};
|
|
100
|
+
for (const { t, i } of faixas) {
|
|
101
|
+
const { segmentos, desenhos } = TX.estadosDaFaixa(tl, t, { from, to, escala, posicao });
|
|
102
|
+
if (!Object.keys(desenhos).length) continue;
|
|
103
|
+
for (const [chave, items] of Object.entries(desenhos)) {
|
|
104
|
+
if (!fs.existsSync(arquivoDe(chave)) && !estados.some((e) => e.chave === chave)) estados.push({ chave, file: arquivoDe(chave), items });
|
|
105
|
+
}
|
|
106
|
+
const arq = path.join(dir, `faixa${i}-${from}-${to}.ffconcat`);
|
|
107
|
+
fs.writeFileSync(arq, TX.ffconcat(segmentos, tl.fps, arquivoDe));
|
|
108
|
+
camadas[i] = arq;
|
|
109
|
+
}
|
|
110
|
+
if (estados.length || !fs.existsSync(vazio)) {
|
|
111
|
+
const job = path.join(dir, `job-${process.pid}.json`);
|
|
112
|
+
fs.writeFileSync(job, JSON.stringify({ width: W, height: H, estados: estados.map(({ file, items }) => ({ file, items })), vazio }));
|
|
113
|
+
const r = await rodarPython(python(), [TEXTO_PY, 'estados', job], { cwd: path.dirname(TEXTO_PY) });
|
|
114
|
+
try { fs.unlinkSync(job); } catch {}
|
|
115
|
+
if (!r.ok) return { camadas: {}, avisos: [`os textos não foram desenhados: ${r.error}`] };
|
|
116
|
+
}
|
|
117
|
+
return { camadas, avisos: [...new Set(avisosPos)], estados: estados.length };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ── rodar o ffmpeg ───────────────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
async function rodarGrafo(pasta, g, saida, sufixo = '') {
|
|
123
|
+
const arq = path.join(pasta, 'cache', `grafo-${process.pid}${sufixo}.txt`);
|
|
124
|
+
fs.mkdirSync(path.dirname(arq), { recursive: true });
|
|
125
|
+
fs.writeFileSync(arq, g.grafo);
|
|
126
|
+
// O grafo vai por arquivo: com cinquenta clipes ele passa do teto de
|
|
127
|
+
// linha de comando do Windows (32 mil caracteres).
|
|
128
|
+
const flag = versaoMaior() >= 7 ? ['-/filter_complex', arq] : ['-filter_complex_script', arq];
|
|
129
|
+
const args = ['-hide_banner', '-loglevel', 'error', '-y', ...g.inputs.flat(), ...flag, ...saida];
|
|
130
|
+
const r = await video.rodar(video.ffmpeg(), args, { timeout: 60 * 60000 });
|
|
131
|
+
if (r.ok) { try { fs.unlinkSync(arq); } catch {} }
|
|
132
|
+
return { ...r, grafo: arq };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/* O EXPORT EM PARTES: o grafo do ffmpeg é quase todo de um núcleo só (cada
|
|
136
|
+
filtro roda numa thread), e um vídeo com pessoa recortada, fundo novo,
|
|
137
|
+
3D e legenda passa a maior parte do tempo nele. Então o VÍDEO sai em
|
|
138
|
+
pedaços de quadros, cada um num ffmpeg, ao mesmo tempo, e junta por
|
|
139
|
+
concat sem recodificar; o ÁUDIO sai inteiro, numa passada só (é barato,
|
|
140
|
+
e cortar AAC em pedaços deixa estalo na emenda). As emendas caem em
|
|
141
|
+
quadro inteiro: o quadro N sai igual com 1 ou com 9 pedaços. */
|
|
142
|
+
async function exportar(pasta, tl, { out, from = 0, to, preview = false, crf = 20, partes } = {}) {
|
|
143
|
+
const c = conferir();
|
|
144
|
+
if (!c.ok) return c;
|
|
145
|
+
const ger = await require('./gerados.js').garantir(pasta, tl);
|
|
146
|
+
if (!ger.ok) return ger;
|
|
147
|
+
const escala = preview ? 0.5 : 1;
|
|
148
|
+
const ate = to === undefined ? T.duracao(tl) : Number(to);
|
|
149
|
+
const destino = path.resolve(out || path.join(pasta, 'saida', `${tl.nome || 'video'}${preview ? '-preview' : ''}.mp4`));
|
|
150
|
+
fs.mkdirSync(path.dirname(destino), { recursive: true });
|
|
151
|
+
const inicio = Date.now();
|
|
152
|
+
const PAR = require('./paralelo.js');
|
|
153
|
+
const total = ate - from;
|
|
154
|
+
const n = partes !== undefined ? Math.max(1, Number(partes)) : Math.max(1, Math.min(PAR.quantos(), Math.floor(total / (tl.fps * 2))));
|
|
155
|
+
const codec = argsDoCodec(codecDeVideo(), crf);
|
|
156
|
+
const extra = ger.feitos ? { gerados: ger.feitos, geradosS: ger.tookS } : {};
|
|
157
|
+
if (n === 1) {
|
|
158
|
+
const tx = await camadasDeTexto(pasta, tl, { from, to: ate, escala });
|
|
159
|
+
const g = F.montarGrafo(tl, { from, to: ate, camadasDeTexto: tx.camadas, escala });
|
|
160
|
+
const r = await rodarGrafo(pasta, g, [
|
|
161
|
+
'-map', g.saidaV, '-map', g.saidaA, '-t', F.num(g.segundos), '-r', String(tl.fps),
|
|
162
|
+
...codec, '-pix_fmt', 'yuv420p', '-c:a', 'aac', '-b:a', '192k', '-movflags', '+faststart', destino,
|
|
163
|
+
]);
|
|
164
|
+
if (!r.ok) return { ok: false, error: 'o ffmpeg falhou', detalhe: r.erro.slice(-600), grafo: r.grafo };
|
|
165
|
+
return { ok: true, file: destino, s: T.r2(g.segundos), size: `${g.W}x${g.H}`, mb: T.r2(fs.statSync(destino).size / 1048576),
|
|
166
|
+
tookS: Math.round((Date.now() - inicio) / 100) / 10, partes: 1, ...extra, ...(tx.avisos.length ? { avisos: tx.avisos } : {}) };
|
|
167
|
+
}
|
|
168
|
+
const tmp = path.join(pasta, 'cache', `export-${process.pid}`);
|
|
169
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
170
|
+
fs.mkdirSync(tmp, { recursive: true });
|
|
171
|
+
const cortes = Array.from({ length: n + 1 }, (_, i) => from + Math.round((total * i) / n));
|
|
172
|
+
// Os PNGs do texto antes, em fila: dois pedaços desenhando o mesmo estado ao mesmo tempo se atropelariam.
|
|
173
|
+
const avisos = new Set();
|
|
174
|
+
const pedacos = [];
|
|
175
|
+
for (let i = 0; i < n; i++) {
|
|
176
|
+
const tx = await camadasDeTexto(pasta, tl, { from: cortes[i], to: cortes[i + 1], escala });
|
|
177
|
+
tx.avisos.forEach((a) => avisos.add(a));
|
|
178
|
+
pedacos.push({ de: cortes[i], ate: cortes[i + 1], tx, arq: path.join(tmp, `v${String(i).padStart(3, '0')}.mp4`) });
|
|
179
|
+
}
|
|
180
|
+
const res = await PAR.emParalelo([
|
|
181
|
+
...pedacos.map((p, i) => () => {
|
|
182
|
+
const g = F.montarGrafo(tl, { from: p.de, to: p.ate, camadasDeTexto: p.tx.camadas, escala, audio: false });
|
|
183
|
+
return rodarGrafo(pasta, g, ['-map', g.saidaV, '-frames:v', String(p.ate - p.de), '-r', String(tl.fps), ...codec, '-pix_fmt', 'yuv420p', p.arq], `-v${i}`);
|
|
184
|
+
}),
|
|
185
|
+
() => {
|
|
186
|
+
const g = F.montarGrafo(tl, { from, to: ate, escala, video: false });
|
|
187
|
+
return rodarGrafo(pasta, g, ['-map', g.saidaA, '-t', F.num(g.segundos), '-c:a', 'aac', '-b:a', '192k', path.join(tmp, 'audio.m4a')], '-a');
|
|
188
|
+
},
|
|
189
|
+
], n + 1);
|
|
190
|
+
const ruim = res.find((r) => !r.ok);
|
|
191
|
+
if (ruim) return { ok: false, error: 'o ffmpeg falhou', detalhe: ruim.erro.slice(-600), grafo: ruim.grafo };
|
|
192
|
+
const lista = path.join(tmp, 'lista.txt');
|
|
193
|
+
fs.writeFileSync(lista, pedacos.map((p) => `file '${p.arq.replace(/'/g, "'\\''")}'`).join('\n') + '\n');
|
|
194
|
+
const j = await video.rodar(video.ffmpeg(), ['-hide_banner', '-loglevel', 'error', '-y', '-f', 'concat', '-safe', '0', '-i', lista,
|
|
195
|
+
'-i', path.join(tmp, 'audio.m4a'), '-map', '0:v', '-map', '1:a', '-c', 'copy', '-movflags', '+faststart', destino]);
|
|
196
|
+
if (!j.ok) return { ok: false, error: 'não juntei os pedaços do export', detalhe: j.erro.slice(-400) };
|
|
197
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
198
|
+
return { ok: true, file: destino, s: T.r2(total / tl.fps), size: `${T.par(tl.width * escala)}x${T.par(tl.height * escala)}`,
|
|
199
|
+
mb: T.r2(fs.statSync(destino).size / 1048576), tookS: Math.round((Date.now() - inicio) / 100) / 10, partes: n, ...extra,
|
|
200
|
+
...(avisos.size ? { avisos: [...avisos] } : {}) };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Os quadros pedidos, com a grade 0–1 e o número do quadro, numa folha só. */
|
|
204
|
+
async function inspecionar(pasta, tl, { frames, escala = 0.5, out } = {}) {
|
|
205
|
+
const c = conferir();
|
|
206
|
+
if (!c.ok) return c;
|
|
207
|
+
const ger = await require('./gerados.js').garantir(pasta, tl);
|
|
208
|
+
if (!ger.ok) return ger;
|
|
209
|
+
const dur = T.duracao(tl);
|
|
210
|
+
const lista = [...new Set(frames.map((f) => Math.max(0, Math.min(dur - 1, Math.round(Number(f))))))].slice(0, 12);
|
|
211
|
+
const dir = path.join(pasta, 'cache', 'inspect');
|
|
212
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
213
|
+
const pngs = [];
|
|
214
|
+
const avisos = new Set();
|
|
215
|
+
for (const f of lista) {
|
|
216
|
+
const tx = await camadasDeTexto(pasta, tl, { from: f, to: f + 1, escala });
|
|
217
|
+
tx.avisos.forEach((a) => avisos.add(a));
|
|
218
|
+
const g = F.montarGrafo(tl, { from: f, to: f + 1, camadasDeTexto: tx.camadas, escala, audio: false });
|
|
219
|
+
const png = path.join(dir, `f${f}.png`);
|
|
220
|
+
const r = await rodarGrafo(pasta, g, ['-map', g.saidaV, '-frames:v', '1', '-update', '1', png]);
|
|
221
|
+
if (!r.ok) return { ok: false, error: `o quadro ${f} falhou`, detalhe: r.erro.slice(-500), grafo: r.grafo };
|
|
222
|
+
pngs.push({ png, frame: f, tempo: f / tl.fps });
|
|
223
|
+
}
|
|
224
|
+
const destino = path.resolve(out || path.join(pasta, 'saida', 'inspect.png'));
|
|
225
|
+
fs.mkdirSync(path.dirname(destino), { recursive: true });
|
|
226
|
+
if (await temPillow()) {
|
|
227
|
+
const job = path.join(dir, 'grade.json');
|
|
228
|
+
fs.writeFileSync(job, JSON.stringify({ frames: pngs, out: destino, altura: lista.length > 1 ? 640 : 960 }));
|
|
229
|
+
const r = await rodarPython(python(), [TEXTO_PY, 'grade', job], { cwd: path.dirname(TEXTO_PY) });
|
|
230
|
+
if (r.ok) return { ok: true, png: destino, frames: lista, ...(avisos.size ? { avisos: [...avisos] } : {}) };
|
|
231
|
+
avisos.add(`sem grade: ${r.error}`);
|
|
232
|
+
}
|
|
233
|
+
return { ok: true, pngs: pngs.map((p) => p.png), frames: lista, avisos: [...avisos, 'sem Pillow: quadros sem grade'] };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ── medir áudio ──────────────────────────────────────────────────────────
|
|
237
|
+
|
|
238
|
+
async function silenciosDe(pasta, asset, { threshold = -35, minSilence = 0.5 } = {}) {
|
|
239
|
+
const cache = path.join(pasta, 'midia', `silencio-${asset.id}-${threshold}-${minSilence}.json`);
|
|
240
|
+
if (fs.existsSync(cache)) { try { return JSON.parse(fs.readFileSync(cache, 'utf8')); } catch {} }
|
|
241
|
+
const r = await video.rodar(video.ffmpeg(), ['-hide_banner', '-i', asset.path, '-vn',
|
|
242
|
+
'-af', `silencedetect=noise=${threshold}dB:d=${minSilence}`, '-f', 'null', '-']);
|
|
243
|
+
if (!r.ok) throw T.erro(`o silencedetect falhou: ${r.erro.slice(-200)}`);
|
|
244
|
+
const lista = [];
|
|
245
|
+
let inicio = null;
|
|
246
|
+
for (const linha of r.erro.split('\n')) {
|
|
247
|
+
const i = linha.match(/silence_start:\s*(-?[\d.]+)/);
|
|
248
|
+
if (i) { inicio = Math.max(0, Number(i[1])); continue; }
|
|
249
|
+
const f = linha.match(/silence_end:\s*([\d.]+)/);
|
|
250
|
+
if (f && inicio !== null) { lista.push({ de: inicio, ate: Number(f[1]) }); inicio = null; }
|
|
251
|
+
}
|
|
252
|
+
// Silêncio que vai até o fim do arquivo não tem "silence_end": fecha no fim.
|
|
253
|
+
if (inicio !== null && inicio < asset.seconds) lista.push({ de: inicio, ate: asset.seconds });
|
|
254
|
+
fs.mkdirSync(path.dirname(cache), { recursive: true });
|
|
255
|
+
fs.writeFileSync(cache, JSON.stringify(lista));
|
|
256
|
+
return lista;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** As amostras cruas (mono, Float32) — para achar batida. */
|
|
260
|
+
function amostras(arquivo, taxa = 22050) {
|
|
261
|
+
return new Promise((resolve, reject) => {
|
|
262
|
+
const p = spawn(video.ffmpeg(), ['-hide_banner', '-loglevel', 'error', '-i', arquivo, '-vn', '-ac', '1', '-ar', String(taxa), '-f', 'f32le', '-']);
|
|
263
|
+
const partes = [];
|
|
264
|
+
p.stdout.on('data', (d) => partes.push(d));
|
|
265
|
+
p.on('error', reject);
|
|
266
|
+
p.on('close', (code) => {
|
|
267
|
+
if (code !== 0) return reject(T.erro('não consegui ler o áudio'));
|
|
268
|
+
const b = Buffer.concat(partes);
|
|
269
|
+
resolve(new Float32Array(b.buffer, b.byteOffset, Math.floor(b.length / 4)));
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
module.exports = { conferir, medirAsset, temPillow, camadasDeTexto, exportar, inspecionar, silenciosDe, amostras, codecDeVideo, versaoMaior };
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* texto.js — o que cada faixa de texto mostra em cada quadro.
|
|
3
|
+
*
|
|
4
|
+
* O ffmpeg que vem no Homebrew não tem mais drawtext nem libass (conferido
|
|
5
|
+
* em 29/09/2026, ffmpeg 9.0.1): contar com eles é contar com um vídeo sem
|
|
6
|
+
* legenda na máquina de metade das pessoas. Então o texto é desenhado FORA
|
|
7
|
+
* do ffmpeg — em PNG transparente, pelo studio/texto.py (Pillow) — e entra
|
|
8
|
+
* como uma camada por faixa.
|
|
9
|
+
*
|
|
10
|
+
* A animação (popIn, typewriter, wordReveal, highlightPop) é decidida AQUI,
|
|
11
|
+
* quadro a quadro, como dado: "neste quadro a palavra 3 está amarela e 18%
|
|
12
|
+
* maior". Quadros com o mesmo estado viram UM png com duração; uma legenda de
|
|
13
|
+
* três palavras com destaque vira uns quinze pngs, não noventa. O Python só
|
|
14
|
+
* desenha o que recebe — e por isso a animação é testável sem Pillow.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
'use strict';
|
|
18
|
+
|
|
19
|
+
const crypto = require('crypto');
|
|
20
|
+
const T = require('./timeline.js');
|
|
21
|
+
|
|
22
|
+
const ESTILO_PADRAO = {
|
|
23
|
+
font: 'bold', size: 0.048, color: '#FFFFFF', stroke: '#000000', strokeWidth: 0.14,
|
|
24
|
+
highlight: '#FFD400', bg: null, upper: false, align: 'center',
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const r3 = (n) => Math.round(n * 1000) / 1000;
|
|
28
|
+
|
|
29
|
+
/** As palavras com tempo (quadros relativos ao clipe). Sem tempo: divide igual. */
|
|
30
|
+
function palavrasDo(c) {
|
|
31
|
+
const ws = (c.text.words || []).filter((w) => w && w.t);
|
|
32
|
+
if (ws.length) return ws;
|
|
33
|
+
const partes = String(c.text.content || '').split(/\s+/).filter(Boolean);
|
|
34
|
+
const passo = c.durationFrames / Math.max(1, partes.length);
|
|
35
|
+
return partes.map((t, i) => ({ t, f: Math.round(i * passo), e: Math.round((i + 1) * passo) }));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** O estado de UM clipe de texto no quadro local `lf` — ou null (não aparece). */
|
|
39
|
+
function estado(tl, c, lf, pos = null) {
|
|
40
|
+
const st = { ...ESTILO_PADRAO, ...(c.text.style || {}) };
|
|
41
|
+
const d = c.durationFrames;
|
|
42
|
+
const anim = c.text.animation || 'none';
|
|
43
|
+
const up = (s) => (st.upper ? String(s).toUpperCase() : String(s));
|
|
44
|
+
let scale = 1;
|
|
45
|
+
let alpha = c.opacity;
|
|
46
|
+
let words = palavrasDo(c).map((w) => ({ t: up(w.t) }));
|
|
47
|
+
const tempos = palavrasDo(c);
|
|
48
|
+
|
|
49
|
+
const entrada = (quadros) => Math.min(1, (lf + 1) / quadros);
|
|
50
|
+
if (anim === 'fade') alpha *= Math.min(entrada(6), (d - lf) / 6);
|
|
51
|
+
if (anim === 'popIn' || anim === 'highlightPop') {
|
|
52
|
+
if (lf < 3) scale = 0.6 + 0.55 * ((lf + 1) / 3);
|
|
53
|
+
else if (lf < 6) scale = 1.15 - 0.05 * (lf - 2);
|
|
54
|
+
alpha *= entrada(2);
|
|
55
|
+
}
|
|
56
|
+
if (anim === 'typewriter') {
|
|
57
|
+
const total = words.reduce((s, w) => s + w.t.length, 0);
|
|
58
|
+
const porLetra = Math.max(0.5, Math.min(2, (0.7 * d) / Math.max(1, total)));
|
|
59
|
+
let sobra = Math.floor((lf + 1) / porLetra);
|
|
60
|
+
words = words.map((w) => {
|
|
61
|
+
const mostra = Math.max(0, Math.min(w.t.length, sobra));
|
|
62
|
+
sobra -= w.t.length;
|
|
63
|
+
return mostra === w.t.length ? w : { ...w, show: w.t.slice(0, mostra) };
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
if (anim === 'wordReveal') {
|
|
67
|
+
words = words.map((w, i) => {
|
|
68
|
+
const k = lf - tempos[i].f;
|
|
69
|
+
if (k < 0) return { ...w, a: 0 };
|
|
70
|
+
return k < 3 ? { ...w, s: r3(1.25 - 0.08 * k) } : w;
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
if (anim === 'highlightPop') {
|
|
74
|
+
let ativa = -1;
|
|
75
|
+
tempos.forEach((w, i) => { if (lf >= w.f) ativa = i; });
|
|
76
|
+
if (ativa >= 0 && lf < tempos[ativa].e + 6) {
|
|
77
|
+
const k = lf - tempos[ativa].f;
|
|
78
|
+
words[ativa] = { ...words[ativa], c: st.highlight, s: r3(k <= 2 ? 1 + 0.1 * (k + 1) : 1.22) };
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (c.fadeIn && lf < c.fadeIn) alpha *= (lf + 1) / c.fadeIn;
|
|
82
|
+
if (c.fadeOut && lf >= d - c.fadeOut) alpha *= (d - lf) / c.fadeOut;
|
|
83
|
+
if (alpha <= 0.01) return null;
|
|
84
|
+
|
|
85
|
+
const W = tl.width;
|
|
86
|
+
const H = tl.height;
|
|
87
|
+
const item = {
|
|
88
|
+
cx: r3((pos ? pos.cx : c.transform.cx) * W), cy: r3((pos ? pos.cy : c.transform.cy) * H), w: Math.round(c.transform.w * W),
|
|
89
|
+
size: Math.round(st.size * H), font: st.font, color: st.color, align: st.align,
|
|
90
|
+
stroke: st.stroke, sw: Math.round(st.strokeWidth * st.size * H), bg: st.bg,
|
|
91
|
+
scale: r3(scale), alpha: Math.round(alpha * 100) / 100, words,
|
|
92
|
+
};
|
|
93
|
+
if (c.transform.rotation) item.rot = c.transform.rotation;
|
|
94
|
+
return item;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Os estados de uma faixa na janela [from, to): segmentos consecutivos com o
|
|
99
|
+
* mesmo desenho viram um só. Devolve {segmentos:[{dur, chave}], desenhos}.
|
|
100
|
+
* `posicao(clipe, quadro)` → {cx, cy} | null: a legenda que SEGUE alguém
|
|
101
|
+
* (a cabeça) troca o lugar do transform pelo que ela devolver, quadro a quadro.
|
|
102
|
+
*/
|
|
103
|
+
function estadosDaFaixa(tl, faixa, { from = 0, to, escala = 1, posicao = null } = {}) {
|
|
104
|
+
const ate = to === undefined ? T.duracao(tl) : to;
|
|
105
|
+
const clips = faixa.clips.filter((c) => !c.hidden && T.fim(c) > from && c.startFrame < ate);
|
|
106
|
+
const escalar = (it) => (escala === 1 ? it : {
|
|
107
|
+
...it, cx: r3(it.cx * escala), cy: r3(it.cy * escala), w: Math.round(it.w * escala),
|
|
108
|
+
size: Math.max(4, Math.round(it.size * escala)), sw: Math.round(it.sw * escala),
|
|
109
|
+
});
|
|
110
|
+
const segmentos = [];
|
|
111
|
+
const desenhos = {};
|
|
112
|
+
let k = 0;
|
|
113
|
+
for (let f = from; f < ate; f++) {
|
|
114
|
+
while (k < clips.length && T.fim(clips[k]) <= f) k++;
|
|
115
|
+
const itens = [];
|
|
116
|
+
for (let j = k; j < clips.length && clips[j].startFrame <= f; j++) {
|
|
117
|
+
const c = clips[j];
|
|
118
|
+
if (f >= T.fim(c)) continue;
|
|
119
|
+
const it = estado(tl, c, f - c.startFrame, posicao ? posicao(c, f) : null);
|
|
120
|
+
if (it) itens.push(escalar(it));
|
|
121
|
+
}
|
|
122
|
+
const json = JSON.stringify(itens);
|
|
123
|
+
const chave = itens.length ? crypto.createHash('sha1').update(json).digest('hex').slice(0, 14) : 'vazio';
|
|
124
|
+
if (itens.length) desenhos[chave] = itens;
|
|
125
|
+
const ult = segmentos[segmentos.length - 1];
|
|
126
|
+
if (ult && ult.chave === chave) ult.dur++;
|
|
127
|
+
else segmentos.push({ dur: 1, chave });
|
|
128
|
+
}
|
|
129
|
+
return { segmentos, desenhos };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** O .ffconcat da faixa: cada estado com a duração dele. */
|
|
133
|
+
function ffconcat(segmentos, fps, arquivoDe) {
|
|
134
|
+
const linhas = ['ffconcat version 1.0'];
|
|
135
|
+
for (const s of segmentos) {
|
|
136
|
+
linhas.push(`file '${arquivoDe(s.chave).replace(/'/g, "'\\''")}'`);
|
|
137
|
+
linhas.push(`duration ${(s.dur / fps).toFixed(6)}`);
|
|
138
|
+
}
|
|
139
|
+
// O concat ignora a duração do último arquivo sem uma linha a mais dele.
|
|
140
|
+
if (segmentos.length) linhas.push(`file '${arquivoDe(segmentos[segmentos.length - 1].chave).replace(/'/g, "'\\''")}'`);
|
|
141
|
+
return linhas.join('\n') + '\n';
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
module.exports = { ESTILO_PADRAO, palavrasDo, estado, estadosDaFaixa, ffconcat };
|