vibeeditor-render 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -2
- package/cli/vibeeditor-render.js +19 -3
- package/core/audio.js +140 -18
- package/core/cache.js +21 -1
- package/core/config.js +5 -0
- package/core/ffmpeg.js +149 -0
- package/core/jobs.js +169 -24
- package/core/normalizar.js +454 -0
- package/core/nucleo.js +8 -3
- package/core/servidor.js +22 -1
- package/core/transcricao.js +197 -0
- package/core/voz.js +227 -0
- package/host-dist/15fb600d42056579.woff2 +0 -0
- package/host-dist/1895916a733c4d0e.woff2 +0 -0
- package/host-dist/1e96c5356ee905bc.woff2 +0 -0
- package/host-dist/2a8028ec716097bf.woff2 +0 -0
- package/host-dist/3061f5b6eb250216.woff2 +0 -0
- package/host-dist/376dd8dc38524313.woff2 +0 -0
- package/host-dist/41be664a74a50674.woff2 +0 -0
- package/host-dist/428.bundle.js +1 -1
- package/host-dist/46019c506ae1d42c.woff2 +0 -0
- package/host-dist/50129cb1dcd58da5.woff2 +0 -0
- package/host-dist/5ab57485954531d6.woff2 +0 -0
- package/host-dist/606cdd767ee835e0.woff2 +0 -0
- package/host-dist/66df0abfcfe8da3e.woff2 +0 -0
- package/host-dist/6903de9124a5cab6.woff2 +0 -0
- package/host-dist/693cf6c39ab15de9.woff2 +0 -0
- package/host-dist/72884976680cf128.woff2 +0 -0
- package/host-dist/7777133e901cd5ed.woff2 +0 -0
- package/host-dist/78db4f537e4b09d0.woff2 +0 -0
- package/host-dist/7a78f1ce0329757f.woff2 +0 -0
- package/host-dist/7c3e92e6007b76fd.woff2 +0 -0
- package/host-dist/81df42c325a360a6.woff2 +0 -0
- package/host-dist/8633e74025a037d3.woff2 +0 -0
- package/host-dist/872.bundle.js +1 -1
- package/host-dist/8cf68dfb15aca3e4.woff2 +0 -0
- package/host-dist/8fb1a96451cec840.woff2 +0 -0
- package/host-dist/916d3686010a8de2.woff2 +0 -0
- package/host-dist/92d1bbe48d4eec3e.woff2 +0 -0
- package/host-dist/93e25f31623ab732.woff2 +0 -0
- package/host-dist/95045d685be8648f.woff2 +0 -0
- package/host-dist/9545a3d1fceacf5c.woff2 +0 -0
- package/host-dist/96be080130d6f32c.woff2 +0 -0
- package/host-dist/9a881e2ac07d406b.woff2 +0 -0
- package/host-dist/ad20d13d2a924905.woff2 +0 -0
- package/host-dist/af2e87f5d61b5665.woff2 +0 -0
- package/host-dist/b19b14aaaeb386b9.woff2 +0 -0
- package/host-dist/b447814dc676cec1.woff2 +0 -0
- package/host-dist/b7b43acee81d05a8.woff2 +0 -0
- package/host-dist/bd427f25ac24d036.woff2 +0 -0
- package/host-dist/bundle.js +1509 -12
- package/host-dist/bundle.js.map +1 -1
- package/host-dist/c04551857776278f.woff2 +0 -0
- package/host-dist/c050c0dd427d93a6.woff2 +0 -0
- package/host-dist/cb2a73f0b29f1031.woff2 +0 -0
- package/host-dist/d36a2a2bb416f59e.woff2 +0 -0
- package/host-dist/d4df281bd2c98884.woff2 +0 -0
- package/host-dist/d869208648ca5469.woff2 +0 -0
- package/host-dist/db9d659a1acad1e2.woff2 +0 -0
- package/host-dist/ebeb24f1e7287d3a.woff2 +0 -0
- package/host-dist/ef6d3f52c547a6e3.woff2 +0 -0
- package/host-dist/f4646f71883e1c80.woff2 +0 -0
- package/package.json +13 -2
package/README.md
CHANGED
|
@@ -12,7 +12,8 @@ Precisa do [Node.js](https://nodejs.org) 20 ou mais novo.
|
|
|
12
12
|
npx vibeeditor-render@latest
|
|
13
13
|
```
|
|
14
14
|
|
|
15
|
-
Na primeira vez ele baixa o navegador de render (uns 100 MB) e
|
|
15
|
+
Na primeira vez ele baixa o navegador de render (uns 100 MB) e, em segundo plano, o ffmpeg do tratamento de áudio
|
|
16
|
+
(uns 30 MB; ver Licença) e cria o atalho **Vibe Render** na área de
|
|
16
17
|
trabalho (Windows e Mac). Depois:
|
|
17
18
|
|
|
18
19
|
1. Abra [plat.vibeeditor.com.br/editor/render](https://plat.vibeeditor.com.br/editor/render) e clique em **Conectar**.
|
|
@@ -27,10 +28,20 @@ ele instala o Node.js, se faltar, e cria o atalho.
|
|
|
27
28
|
| `npx vibeeditor-render@latest` | abre o Vibe Render |
|
|
28
29
|
| `… --codigo ABCD-2345` | pareia sem perguntar |
|
|
29
30
|
| `… --pasta "C:\Videos"` | muda a pasta dos vídeos (fica guardada) |
|
|
31
|
+
| `… --voicestudio http://127.0.0.1:3900` | endereço do VoiceStudio deste computador (fica guardado) |
|
|
30
32
|
| `… desparear` | desliga este computador da conta |
|
|
31
33
|
| `… atalho` | (re)cria o atalho na área de trabalho e sai |
|
|
32
34
|
| `… --ajuda` | mostra os comandos |
|
|
33
35
|
|
|
36
|
+
### Voz clonada (VoiceStudio)
|
|
37
|
+
|
|
38
|
+
A página VoiceStudio do site gera a narração com a sua voz clonada pelo [VoiceStudio](https://github.com/debpalash/VoiceStudio),
|
|
39
|
+
um app open source que também roda no seu computador (API em `http://127.0.0.1:3900`). O navegador não alcança
|
|
40
|
+
esse app sozinho, então o Vibe Render repassa os pedidos (`/v1/voz/...`) para ele, só em endereço local
|
|
41
|
+
(`127.0.0.1`, `localhost` ou `[::1]`). O endereço muda pelo site, por `--voicestudio` ou pela variável
|
|
42
|
+
`VIBE_RENDER_VOICESTUDIO` (esta manda) e fica em `voicestudio.json` na pasta de dados. Os pesos do modelo padrão
|
|
43
|
+
do VoiceStudio (OmniVoice) são CC-BY-NC: uso pessoal, não comercial.
|
|
44
|
+
|
|
34
45
|
## Segurança
|
|
35
46
|
|
|
36
47
|
- O servidor escuta só em `127.0.0.1` (portas 47817 a 47820) e confere o cabeçalho `Host` (barra DNS rebinding).
|
|
@@ -51,11 +62,12 @@ npm install
|
|
|
51
62
|
npm test # núcleo, segurança, CLI
|
|
52
63
|
npm start # gera host-dist/ se faltar e abre o Vibe Render
|
|
53
64
|
npm run benchmark # renderiza scripts/amostra.tsx pelo servidor local, nos 2 presets
|
|
65
|
+
node scripts/paridade-etapa1.mjs --hv <hiveverse-editor> # Edição Simples × editor local (docs/M2-MOTOR-ETAPA1.md)
|
|
54
66
|
npm pack # o pacote como vai para o npm (prepack gera o host-dist)
|
|
55
67
|
```
|
|
56
68
|
|
|
57
69
|
- `core/`: Node puro. Servidor local (`servidor.js`), fila (`jobs.js`), cache por sha256 (`cache.js`),
|
|
58
|
-
pareamento (`pareamento.js`), segurança (`seguranca.js`).
|
|
70
|
+
pareamento (`pareamento.js`), segurança (`seguranca.js`), transcrição (`transcricao.js`), VoiceStudio (`voz.js`).
|
|
59
71
|
- `host/`: projeto Remotion com a composição `VibeHost`, que avalia o código gerado dentro do Chromium.
|
|
60
72
|
Vira `host-dist/` no build.
|
|
61
73
|
- `cli/`: o comando `vibeeditor-render`.
|
|
@@ -67,3 +79,8 @@ npm pack # o pacote como vai para o npm (prepack gera o host-dist)
|
|
|
67
79
|
O código deste pacote é MIT (veja `LICENSE`). Ele usa o [Remotion](https://www.remotion.dev/license), que tem
|
|
68
80
|
licença própria: grátis para pessoas, organizações sem fins lucrativos e empresas de até 3 pessoas; acima
|
|
69
81
|
disso, a empresa precisa de uma licença do Remotion.
|
|
82
|
+
|
|
83
|
+
O tratamento de áudio usa o ffmpeg 6.1.1 estático do projeto [ffmpeg-static](https://github.com/eugeneware/ffmpeg-static)
|
|
84
|
+
(release `b6.1.1`), baixado na primeira vez para `<pasta de dados>/ffmpeg/6.1.1/` com sha256 conferido. Esse binário é
|
|
85
|
+
**GPL-3.0** (a licença fica ao lado, em `ffmpeg.LICENSE`) e roda como programa separado; ele não faz parte deste pacote.
|
|
86
|
+
Para usar outro ffmpeg completo: `VIBE_FFMPEG=<caminho do executável>`.
|
package/cli/vibeeditor-render.js
CHANGED
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
// vibeeditor-render abre (na primeira vez baixa o navegador de render e pede o código)
|
|
7
7
|
// vibeeditor-render --codigo X pareia com o código do site sem perguntar
|
|
8
8
|
// vibeeditor-render --pasta <dir> muda a pasta onde os vídeos são salvos (fica guardada)
|
|
9
|
-
// vibeeditor-render
|
|
9
|
+
// vibeeditor-render --voicestudio <url> endereço do VoiceStudio deste computador (fica guardado)
|
|
10
|
+
// vibeeditor-render desparear desliga este computador da conta
|
|
10
11
|
// vibeeditor-render atalho (re)cria o atalho "Vibe Render" na área de trabalho
|
|
11
12
|
"use strict";
|
|
12
13
|
const fs = require("node:fs");
|
|
@@ -35,6 +36,7 @@ ${negrito("Vibe Render")} ${cinza(`v${VERSAO}`)} · exporta os vídeos do Editor
|
|
|
35
36
|
${azul("npx vibeeditor-render@latest")} abre o Vibe Render
|
|
36
37
|
${azul("npx vibeeditor-render@latest --codigo ABCD-2345")} pareia sem perguntar
|
|
37
38
|
${azul("npx vibeeditor-render@latest --pasta \"C:\\Videos\"")} muda a pasta dos vídeos
|
|
39
|
+
${azul("npx vibeeditor-render@latest --voicestudio http://127.0.0.1:3900")} endereço do VoiceStudio (voz clonada)
|
|
38
40
|
${azul("npx vibeeditor-render@latest desparear")} desliga este computador da sua conta
|
|
39
41
|
${azul("npx vibeeditor-render@latest atalho")} cria o atalho na área de trabalho
|
|
40
42
|
|
|
@@ -44,13 +46,15 @@ Ajuda: ${SITE}
|
|
|
44
46
|
}
|
|
45
47
|
|
|
46
48
|
function lerArgs(argv) {
|
|
47
|
-
const a = {comando: null, codigo: null, pasta: null, semAtalho: false};
|
|
49
|
+
const a = {comando: null, codigo: null, pasta: null, voicestudio: null, semAtalho: false};
|
|
48
50
|
for (let i = 0; i < argv.length; i++) {
|
|
49
51
|
const v = argv[i];
|
|
50
52
|
if (v === "--codigo" || v === "-c") a.codigo = argv[++i] || "";
|
|
51
53
|
else if (v.startsWith("--codigo=")) a.codigo = v.slice(9);
|
|
52
54
|
else if (v === "--pasta") a.pasta = argv[++i] || "";
|
|
53
55
|
else if (v.startsWith("--pasta=")) a.pasta = v.slice(8);
|
|
56
|
+
else if (v === "--voicestudio") a.voicestudio = argv[++i] || "";
|
|
57
|
+
else if (v.startsWith("--voicestudio=")) a.voicestudio = v.slice(14);
|
|
54
58
|
else if (v === "--sem-atalho") a.semAtalho = true;
|
|
55
59
|
else if (v === "--versao" || v === "-v" || v === "--version") a.comando = "versao";
|
|
56
60
|
else if (v === "--ajuda" || v === "-h" || v === "--help") a.comando = "ajuda";
|
|
@@ -229,6 +233,9 @@ async function principal() {
|
|
|
229
233
|
if (args.pasta) {
|
|
230
234
|
config = {...config, dirSaida: path.resolve(args.pasta)}; gravarConfig();
|
|
231
235
|
}
|
|
236
|
+
if (args.voicestudio !== null && !require("../core/voz").urlValida(args.voicestudio)) {
|
|
237
|
+
throw new Error(`--voicestudio precisa ser o endereço do VoiceStudio neste computador, como http://127.0.0.1:3900 (veio "${args.voicestudio}")`);
|
|
238
|
+
}
|
|
232
239
|
|
|
233
240
|
dizer(`\n${negrito(azul("▶ Vibe Render"))} ${cinza(`v${VERSAO}`)} ${cinza("· exporta os vídeos do Editor IA neste computador")}\n`);
|
|
234
241
|
versaoNova().then((v) => v && dizer(`${ambar("!")} Saiu a versão ${v}. Feche e abra de novo com ${azul("npx vibeeditor-render@latest")}.`));
|
|
@@ -252,7 +259,7 @@ async function principal() {
|
|
|
252
259
|
const nucleo = await criarNucleo({
|
|
253
260
|
versao: VERSAO, dirDados: dados, dirSaida: () => config.dirSaida, hostDir, navegador: null, binarios: null,
|
|
254
261
|
armazenamento, nomeAparelho: os.hostname(), licenseKey: config.licenseKey || process.env.REMOTION_LICENSE_KEY || null,
|
|
255
|
-
mostrarArquivo, log,
|
|
262
|
+
mostrarArquivo, log, voiceStudio: args.voicestudio || null,
|
|
256
263
|
});
|
|
257
264
|
|
|
258
265
|
// atalho na área de trabalho, uma vez (quem abriu pelo atalho passa --sem-atalho)
|
|
@@ -266,6 +273,15 @@ async function principal() {
|
|
|
266
273
|
|
|
267
274
|
dizer(`${verde("✓")} Pronto e escutando só neste computador ${cinza(`(127.0.0.1:${nucleo.porta})`)}.`);
|
|
268
275
|
dizer(` Vídeos vão para: ${config.dirSaida}`);
|
|
276
|
+
if (args.voicestudio) dizer(` VoiceStudio: ${require("../core/voz").urlValida(args.voicestudio)}`);
|
|
277
|
+
|
|
278
|
+
// ffmpeg completo do tratamento de áudio: baixa já em segundo plano (a 1ª exportação não espera por ele)
|
|
279
|
+
const ffmpeg = require("../core/ffmpeg");
|
|
280
|
+
if (!ffmpeg.acharFfmpegCompleto({dirDados: dados})) {
|
|
281
|
+
ffmpeg.garantirFfmpeg({dirDados: dados})
|
|
282
|
+
.then((f) => f.origem === "baixado" && dizer(`${verde("✓")} Tratamento de áudio pronto ${cinza(`(ffmpeg ${ffmpeg.VERSAO})`)}.`))
|
|
283
|
+
.catch((e) => dizer(`${ambar("!")} Não consegui baixar o ffmpeg do tratamento de áudio (${e.message}). Tento de novo na exportação.`));
|
|
284
|
+
}
|
|
269
285
|
|
|
270
286
|
// pareamento
|
|
271
287
|
let rl = null;
|
package/core/audio.js
CHANGED
|
@@ -1,20 +1,30 @@
|
|
|
1
|
-
// Tratamento do áudio da fala com ffmpeg, a mesma cadeia do editor local
|
|
2
|
-
// passa-alta → redução de ruído (afftdn, opcional) → loudnorm em 2 passadas (−14 LUFS, linear) →
|
|
3
|
-
// limitador de pico com 4× de reamostragem (≤ −1,5 dBTP).
|
|
1
|
+
// Tratamento do áudio da fala com ffmpeg, a mesma cadeia do editor local.
|
|
4
2
|
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
3
|
+
// tratarFala — a cadeia do hv/montar.py para a linha do tempo editada: corta os trechos mantidos (fade de 8 ms
|
|
4
|
+
// nas emendas), iguala o nível de voz por trecho, passa-alta, (afftdn), limitador de folga e loudnorm
|
|
5
|
+
// em 2 passadas (linear; se o ganho estourasse o pico: ganho fixo + limitador). WAV 48 kHz.
|
|
6
|
+
// limitarPico — o último passo de todo render (hv/render.limitar_pico): pico real ≤ máximo no ARQUIVO FINAL.
|
|
7
|
+
// tratarAudio — cadeia genérica (arquivo inteiro, sem cortes), para áudio avulso.
|
|
8
|
+
//
|
|
9
|
+
// O ffmpeg que vem com o @remotion/renderer é mínimo (sem highpass/afftdn/alimiter/ebur128): o ffmpeg completo vem
|
|
10
|
+
// de core/ffmpeg.js (VIBE_FFMPEG, baixado na pasta de dados, ou o do PATH em desenvolvimento).
|
|
8
11
|
const {spawn} = require("node:child_process");
|
|
9
12
|
const fs = require("node:fs");
|
|
13
|
+
const os = require("node:os");
|
|
10
14
|
const path = require("node:path");
|
|
15
|
+
const crypto = require("node:crypto");
|
|
16
|
+
const {acharFfmpegCompleto, versaoPrincipal} = require("./ffmpeg");
|
|
11
17
|
|
|
12
18
|
const ALVO = {lufs: -14, tp: -1.5, lra: 11};
|
|
19
|
+
const FPS = 30;
|
|
13
20
|
|
|
14
|
-
|
|
21
|
+
/** ffmpeg sem baixar nada: VIBE_FFMPEG, `recursos/ffmpeg-audio`, o baixado na pasta de dados, ou o do PATH */
|
|
22
|
+
function acharFfmpeg({recursos, dirDados, env = process.env} = {}) {
|
|
15
23
|
const exe = process.platform === "win32" ? "ffmpeg.exe" : "ffmpeg";
|
|
16
|
-
const
|
|
17
|
-
|
|
24
|
+
const achado = acharFfmpegCompleto({dirDados, env});
|
|
25
|
+
if (achado) return achado.caminho;
|
|
26
|
+
const r = recursos && path.join(recursos, "ffmpeg-audio", exe);
|
|
27
|
+
if (r && fs.existsSync(r)) return r;
|
|
18
28
|
return exe; // PATH (desenvolvimento)
|
|
19
29
|
}
|
|
20
30
|
|
|
@@ -48,11 +58,11 @@ async function medir(ffmpeg, entrada, opc) {
|
|
|
48
58
|
}
|
|
49
59
|
|
|
50
60
|
/**
|
|
51
|
-
* Trata o áudio de `entrada` (vídeo ou áudio) e grava WAV 48 kHz em `saida`.
|
|
52
|
-
* opc: {ruido: bool (afftdn), recursos, sinal: AbortSignal}
|
|
61
|
+
* Trata o áudio de `entrada` (vídeo ou áudio) inteiro e grava WAV 48 kHz em `saida`.
|
|
62
|
+
* opc: {ruido: bool (afftdn), ffmpeg, recursos, dirDados, sinal: AbortSignal}
|
|
53
63
|
*/
|
|
54
64
|
async function tratarAudio(entrada, saida, opc = {}) {
|
|
55
|
-
const ffmpeg = acharFfmpeg(opc);
|
|
65
|
+
const ffmpeg = opc.ffmpeg || acharFfmpeg(opc);
|
|
56
66
|
const m = await medir(ffmpeg, entrada, opc);
|
|
57
67
|
const loud = `loudnorm=I=${ALVO.lufs}:TP=${ALVO.tp}:LRA=${ALVO.lra}:measured_I=${m.input_i}:measured_TP=${m.input_tp}:measured_LRA=${m.input_lra}:measured_thresh=${m.input_thresh}:offset=${m.target_offset}:linear=true`;
|
|
58
68
|
const limite = Math.pow(10, ALVO.tp / 20).toFixed(4); // −1,5 dB → 0,8414
|
|
@@ -61,13 +71,125 @@ async function tratarAudio(entrada, saida, opc = {}) {
|
|
|
61
71
|
return {medicaoAntes: m, ffmpeg};
|
|
62
72
|
}
|
|
63
73
|
|
|
64
|
-
|
|
74
|
+
// ---------------------------------------------------------------- fala da linha do tempo editada (montar.py)
|
|
75
|
+
|
|
76
|
+
// voz gerada (VoiceStudio): chiado de 1,5–8 kHz que engrossa ao longo da geração; afftdn com rastreio do ruído
|
|
77
|
+
const REDUCAO_VOZ_GERADA = "afftdn=nr=40:nf=-70:tn=1:gs=8";
|
|
78
|
+
|
|
79
|
+
/** número como o Python escreve num f-string sem formato (3.0 → "3.0"; o ffmpeg lê igual a "3") */
|
|
80
|
+
const py = (v) => (Number.isInteger(v) ? `${v}.0` : String(v));
|
|
81
|
+
|
|
82
|
+
/** grafo de áudio do montar._grafo_audio: trechos [[a, b]] já deslocados pelo -ss da leitura */
|
|
83
|
+
function grafoFala(trechos, spec, final, ganhos) {
|
|
84
|
+
const n = trechos.length;
|
|
85
|
+
const partes = n > 1 ? [`[0:a]asplit=${n}` + trechos.map((_, i) => `[s${i}]`).join("")] : [];
|
|
86
|
+
trechos.forEach(([a, b], i) => {
|
|
87
|
+
const src = n > 1 ? `[s${i}]` : "[0:a]";
|
|
88
|
+
const d = b - a;
|
|
89
|
+
const fade = Math.min(0.008, d / 4);
|
|
90
|
+
// ganho do trecho antes do limitador e do loudnorm (eles agem na mistura já nivelada)
|
|
91
|
+
const ganho = ganhos && ganhos[i] ? `,volume=${py(ganhos[i])}dB` : "";
|
|
92
|
+
partes.push(`${src}atrim=${a.toFixed(4)}:${b.toFixed(4)},asetpts=PTS-STARTPTS,`
|
|
93
|
+
+ `afade=t=in:d=${fade.toFixed(3)},afade=t=out:st=${(d - fade).toFixed(4)}:d=${fade.toFixed(3)}${ganho}[a${i}]`);
|
|
94
|
+
});
|
|
95
|
+
const entradas = trechos.map((_, i) => `[a${i}]`).join("");
|
|
96
|
+
let cadeia = `highpass=f=${spec.passa_alta_hz}`;
|
|
97
|
+
if (spec.voz_gerada) cadeia += "," + REDUCAO_VOZ_GERADA;
|
|
98
|
+
else if (spec.reducao_ruido_db) cadeia += `,afftdn=nr=${spec.reducao_ruido_db}:nf=-40`;
|
|
99
|
+
// limitador antes do loudnorm: sem folga de pico o modo linear vira dinâmico
|
|
100
|
+
cadeia += ",alimiter=limit=0.79:level=disabled";
|
|
101
|
+
partes.push(`${entradas}concat=n=${n}:v=0:a=1,${cadeia}${final}`);
|
|
102
|
+
return partes.join(";");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** roda um grafo grande pelo arquivo (o Windows limita a linha de comando) */
|
|
106
|
+
async function rodarGrafo(ffmpeg, versao, leitura, grafo, resto, opc) {
|
|
107
|
+
const arq = path.join(opc.dirTmp || os.tmpdir(), `vibe-grafo-${crypto.randomBytes(5).toString("hex")}.txt`);
|
|
108
|
+
fs.writeFileSync(arq, grafo, "utf8");
|
|
109
|
+
try {
|
|
110
|
+
const flag = versao >= 7 ? ["-/filter_complex", arq] : ["-filter_complex_script", arq];
|
|
111
|
+
return await rodar(ffmpeg, ["-nostats", ...leitura, ...flag, ...resto], opc);
|
|
112
|
+
} finally {
|
|
113
|
+
fs.rmSync(arq, {force: true});
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* A fala da linha do tempo editada, tratada como no montar.py. spec (vem do job, já validado):
|
|
119
|
+
* trechos [[ini, fim]] no tempo do original, ganhos_db [] (um por trecho), lufs_alvo, pico_dbtp (alvo do loudnorm,
|
|
120
|
+
* já com a folga), passa_alta_hz, reducao_ruido_db, voz_gerada.
|
|
121
|
+
* opc: {ffmpeg, versao, sinal, dirTmp}. Grava WAV 48 kHz 16 bits em `saida`; devolve a medição e o modo usado.
|
|
122
|
+
*/
|
|
123
|
+
async function tratarFala(entrada, saida, spec, opc = {}) {
|
|
124
|
+
const ffmpeg = opc.ffmpeg || acharFfmpeg(opc);
|
|
125
|
+
const versao = opc.versao || (await versaoPrincipal(ffmpeg));
|
|
126
|
+
const alvo = spec.lufs_alvo, tp = spec.pico_dbtp;
|
|
127
|
+
// lê só o pedaço do original que interessa (amostra não decodifica o vídeo todo)
|
|
128
|
+
const t0 = spec.trechos[0][0], tN = spec.trechos[spec.trechos.length - 1][1];
|
|
129
|
+
const ofs = Math.max(0, Math.trunc((t0 - 1.0) * FPS) / FPS);
|
|
130
|
+
const leitura = ["-ss", ofs.toFixed(4), "-to", (tN + 0.5).toFixed(4), "-i", entrada];
|
|
131
|
+
const trechos = spec.trechos.map(([a, b]) => [a - ofs, b - ofs]);
|
|
132
|
+
|
|
133
|
+
const g1 = grafoFala(trechos, spec, `,loudnorm=I=${py(alvo)}:TP=${py(tp)}:LRA=11:print_format=json[aout]`, spec.ganhos_db);
|
|
134
|
+
const log = await rodarGrafo(ffmpeg, versao, leitura, g1, ["-map", "[aout]", "-f", "null", "-"], opc);
|
|
135
|
+
const achados = log.match(/\{[^{}]*"input_i"[^{}]*\}/g);
|
|
136
|
+
if (!achados) throw new Error("loudnorm não devolveu a medição");
|
|
137
|
+
const m = JSON.parse(achados[achados.length - 1]);
|
|
138
|
+
|
|
139
|
+
// loudnorm só é linear se o ganho não estourar o pico; senão vira compressor dinâmico (puxa pausas e chiado
|
|
140
|
+
// para cima). Nesse caso: ganho fixo + limitador.
|
|
141
|
+
const ganho = alvo - Number(m.input_i);
|
|
142
|
+
const linear = Number(m.input_tp) + ganho <= tp;
|
|
143
|
+
const ln = linear
|
|
144
|
+
? `,loudnorm=I=${py(alvo)}:TP=${py(tp)}:LRA=11:measured_I=${m.input_i}:measured_TP=${m.input_tp}:measured_LRA=${m.input_lra}`
|
|
145
|
+
+ `:measured_thresh=${m.input_thresh}:offset=${m.target_offset}:linear=true,aresample=48000[aout]`
|
|
146
|
+
: `,volume=${ganho.toFixed(2)}dB,alimiter=limit=${Math.pow(10, tp / 20).toFixed(4)}:attack=1:release=60:level=disabled,aresample=48000[aout]`;
|
|
147
|
+
const g2 = grafoFala(trechos, spec, ln, spec.ganhos_db);
|
|
148
|
+
fs.mkdirSync(path.dirname(saida), {recursive: true});
|
|
149
|
+
await rodarGrafo(ffmpeg, versao, leitura, g2, ["-y", "-v", "error", "-map", "[aout]", "-c:a", "pcm_s16le", saida], opc);
|
|
150
|
+
return {loudnorm_medido: m, normalizacao: linear ? "loudnorm linear" : `ganho ${ganho >= 0 ? "+" : ""}${ganho.toFixed(1)} dB + limitador`, ffmpeg};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ---------------------------------------------------------------- pico real no arquivo final (render.limitar_pico)
|
|
154
|
+
|
|
155
|
+
/** mede loudness integrada, LRA e true peak de um arquivo */
|
|
65
156
|
async function medirLoudness(arquivo, opc = {}) {
|
|
66
|
-
const log = await rodar(acharFfmpeg(opc), ["-i", arquivo, "-vn", "-af", "ebur128=peak=true", "-f", "null", "-"], opc);
|
|
157
|
+
const log = await rodar(opc.ffmpeg || acharFfmpeg(opc), ["-nostats", "-i", arquivo, "-vn", "-af", "ebur128=peak=true", "-f", "null", "-"], opc);
|
|
67
158
|
const resumo = log.slice(log.lastIndexOf("Summary:"));
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
159
|
+
const num = (re) => {
|
|
160
|
+
const v = (resumo.match(re) || [])[1];
|
|
161
|
+
return v === undefined ? NaN : v === "-inf" ? -99 : Number(v);
|
|
162
|
+
};
|
|
163
|
+
return {lufs: num(/I:\s+(-?[\d.]+|-inf) LUFS/), lra: num(/LRA:\s+(-?[\d.]+|-inf) LU/), tp: num(/Peak:\s+(-?[\d.]+|-inf) dBFS/)};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Último passo de todo render: o limitador da montagem age antes das compressões AAC, e cada compressão cria
|
|
168
|
+
* picos novos. Aqui o áudio passa por um limitador com 4× de superamostragem, é recodificado (AAC 256k) e medido
|
|
169
|
+
* de novo — se ainda passar, aperta o limite e repete. O vídeo é copiado. Troca `video` no lugar.
|
|
170
|
+
*/
|
|
171
|
+
async function limitarPico(video, picoMaxDbtp, opc = {}) {
|
|
172
|
+
const ffmpeg = opc.ffmpeg || acharFfmpeg(opc);
|
|
173
|
+
// a medição do arquivo final também vai para a conferência da amostra (LUFS/LRA/pico, como o conferir.py)
|
|
174
|
+
const m0 = await medirLoudness(video, {...opc, ffmpeg});
|
|
175
|
+
const antes = m0.tp;
|
|
176
|
+
if (antes <= picoMaxDbtp - 0.2) return {pico_dbtp: antes, limitado: false, lufs: m0.lufs, lra: m0.lra};
|
|
177
|
+
const tmp = video.replace(/(\.\w+)$/, "_pico$1");
|
|
178
|
+
let limiteDb = picoMaxDbtp - 0.5, m = m0;
|
|
179
|
+
try {
|
|
180
|
+
for (let i = 0; i < 4; i++) {
|
|
181
|
+
await rodar(ffmpeg, ["-y", "-v", "error", "-i", video, "-map", "0", "-c:v", "copy",
|
|
182
|
+
"-af", `aresample=192000,alimiter=limit=${Math.pow(10, limiteDb / 20).toFixed(4)}:attack=1:release=60:level=disabled,aresample=48000`,
|
|
183
|
+
"-c:a", "aac", "-b:a", "256k", "-movflags", "+faststart", tmp], opc);
|
|
184
|
+
m = await medirLoudness(tmp, {...opc, ffmpeg});
|
|
185
|
+
if (m.tp <= picoMaxDbtp) break;
|
|
186
|
+
limiteDb -= (m.tp - picoMaxDbtp) + 0.3;
|
|
187
|
+
}
|
|
188
|
+
fs.renameSync(tmp, video);
|
|
189
|
+
} finally {
|
|
190
|
+
fs.rmSync(tmp, {force: true});
|
|
191
|
+
}
|
|
192
|
+
return {pico_dbtp: m.tp, limitado: true, antes_dbtp: antes, lufs: m.lufs, lra: m.lra};
|
|
71
193
|
}
|
|
72
194
|
|
|
73
|
-
module.exports = {tratarAudio, medirLoudness, acharFfmpeg, ALVO};
|
|
195
|
+
module.exports = {tratarAudio, tratarFala, grafoFala, limitarPico, medirLoudness, acharFfmpeg, ALVO, REDUCAO_VOZ_GERADA};
|
package/core/cache.js
CHANGED
|
@@ -45,6 +45,26 @@ function criarCache(dir, {limiteBytes}) {
|
|
|
45
45
|
}
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
/** põe no cache um arquivo gerado aqui (ex.: o áudio tratado), endereçado pelo sha256 dele; devolve o sha */
|
|
49
|
+
async function adotar(arquivo) {
|
|
50
|
+
const hash = crypto.createHash("sha256");
|
|
51
|
+
await pipeline(fs.createReadStream(arquivo), new Transform({transform(c, _e, cb) {
|
|
52
|
+
hash.update(c); cb();
|
|
53
|
+
}}));
|
|
54
|
+
const sha = hash.digest("hex");
|
|
55
|
+
const destino = caminho(sha);
|
|
56
|
+
if (fs.existsSync(destino)) fs.rmSync(arquivo, {force: true});
|
|
57
|
+
else {
|
|
58
|
+
try {
|
|
59
|
+
fs.renameSync(arquivo, destino);
|
|
60
|
+
} catch {
|
|
61
|
+
fs.copyFileSync(arquivo, destino); fs.rmSync(arquivo, {force: true});
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
tocar(sha);
|
|
65
|
+
return sha;
|
|
66
|
+
}
|
|
67
|
+
|
|
48
68
|
/** marca uso (o limpador tira primeiro o que está há mais tempo sem uso) */
|
|
49
69
|
function tocar(sha) {
|
|
50
70
|
const t = new Date();
|
|
@@ -68,7 +88,7 @@ function criarCache(dir, {limiteBytes}) {
|
|
|
68
88
|
return total;
|
|
69
89
|
}
|
|
70
90
|
|
|
71
|
-
return {dir, caminho, existe, guardar, tocar, limpar};
|
|
91
|
+
return {dir, caminho, existe, guardar, adotar, tocar, limpar};
|
|
72
92
|
}
|
|
73
93
|
|
|
74
94
|
module.exports = {criarCache};
|
package/core/config.js
CHANGED
|
@@ -25,6 +25,11 @@ const PRESETS = {
|
|
|
25
25
|
rascunho: {ladoCurto: 720, crf: 23, x264Preset: "veryfast", jpegQuality: 80},
|
|
26
26
|
// lado curto em 1080, qualidade de entrega
|
|
27
27
|
final: {ladoCurto: 1080, crf: 18, x264Preset: "medium", jpegQuality: 92},
|
|
28
|
+
// 2K e 4K: a composição continua no tamanho do layout (1080 de lado curto) e o Chromium renderiza com escala
|
|
29
|
+
// maior, como o --scale do editor local: texto, legenda e motions saem nítidos e o vídeo do apresentador vem
|
|
30
|
+
// do original (4K do iPhone fica 4K de verdade). Por isso estes dois podem AUMENTAR a escala.
|
|
31
|
+
"2k": {ladoCurto: 1440, crf: 18, x264Preset: "medium", jpegQuality: 92, aumenta: true},
|
|
32
|
+
"4k": {ladoCurto: 2160, crf: 18, x264Preset: "medium", jpegQuality: 92, aumenta: true},
|
|
28
33
|
};
|
|
29
34
|
|
|
30
35
|
const STATUS_INTERVALO = 10 * 60000; // reconferir o pareamento com o servidor
|
package/core/ffmpeg.js
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// ffmpeg completo para o tratamento do áudio (passa-alta, afftdn, alimiter, loudnorm, ebur128). O ffmpeg que vem
|
|
2
|
+
// com o @remotion/renderer é mínimo e não tem esses filtros.
|
|
3
|
+
//
|
|
4
|
+
// Decisão (docs/M2-MOTOR-ETAPA1.md): baixar na primeira vez para a pasta de dados, como o navegador de render —
|
|
5
|
+
// o binário estático do projeto ffmpeg-static (github.com/eugeneware/ffmpeg-static, release b6.1.1), com URL e
|
|
6
|
+
// sha256 fixos aqui. Não vai dentro do pacote npm (o pacote continua pequeno e MIT), não roda script de instalação
|
|
7
|
+
// no `npx` e sobrevive às atualizações (fica em <dados>/ffmpeg/6.1.1/). Esses binários são GPL-3.0 (builds com
|
|
8
|
+
// x264/x265): o Vibe Render só os executa como programa separado, sem ligar código, e a licença vem junto
|
|
9
|
+
// (ffmpeg.LICENSE). Ordem de busca: VIBE_FFMPEG → baixado → (sem download) o do PATH, se tiver os filtros.
|
|
10
|
+
"use strict";
|
|
11
|
+
const fs = require("node:fs");
|
|
12
|
+
const path = require("node:path");
|
|
13
|
+
const crypto = require("node:crypto");
|
|
14
|
+
const zlib = require("node:zlib");
|
|
15
|
+
const {spawn} = require("node:child_process");
|
|
16
|
+
|
|
17
|
+
const VERSAO = "6.1.1";
|
|
18
|
+
const BASE = "https://github.com/eugeneware/ffmpeg-static/releases/download/b6.1.1/";
|
|
19
|
+
const LICENCA_GPL = "8ceb4b9ee5adedde47b31e975c1d90c73ad27b6b165a1dcd80c7c545eb65b903";
|
|
20
|
+
// sha256 do .gz baixado e do executável descompactado (digests publicados pelo GitHub na release b6.1.1)
|
|
21
|
+
const BINARIOS = {
|
|
22
|
+
"win32-x64": {gz: "8883a3dffbd0a16cf4ef95206ea05283f78908dbfb118f73c83f4951dcc06d77", exe: "04e1307997530f9cf2fe35cba2ca7e8875ca91da02f89d6c7243df819c94ad00", bytes: 29581307, licenca: LICENCA_GPL},
|
|
23
|
+
"darwin-x64": {gz: "929b375c1182d956c51f7ac25e0b2b0411fb01f6f407aa15c9758efeb4242106", exe: "ebdddc936f61e14049a2d4b549a412b8a40deeff6540e58a9f2a2da9e6b18894", bytes: 25296431, licenca: "2e1d16c72fd74e12063776371da757322f8b77589386532f4fd8634bde7de1af"},
|
|
24
|
+
"darwin-arm64": {gz: "8923876afa8db5585022d7860ec7e589af192f441c56793971276d450ed3bbfa", exe: "a90e3db6a3fd35f6074b013f948b1aa45b31c6375489d39e572bea3f18336584", bytes: 19246198, licenca: "cb48bf09a11f5fb576cddb0431c8f5ed0a60157a9ec942adffc13907cbe083f2"},
|
|
25
|
+
"linux-x64": {gz: "bfe8a8fc511530457b528c48d77b5737527b504a3797a9bc4866aeca69c2dffa", exe: "e7e7fb30477f717e6f55f9180a70386c62677ef8a4d4d1a5d948f4098aa3eb99", bytes: 29354986, licenca: LICENCA_GPL},
|
|
26
|
+
"linux-arm64": {gz: "754a678672298bc68156adff58aa7385a592c2b30b1d0ae8750c45c915c4bac0", exe: "6bb182d0d75d23028db82e9e4f723ca69b853d055698486e6984ddb2c06fb8ce", bytes: 25568691, licenca: LICENCA_GPL},
|
|
27
|
+
};
|
|
28
|
+
const FILTROS = ["highpass", "afftdn", "alimiter", "loudnorm", "ebur128", "atrim", "afade", "asplit", "concat", "volume", "aresample"];
|
|
29
|
+
|
|
30
|
+
const chave = (plataforma = process.platform, arq = process.arch) => `${plataforma}-${arq}`;
|
|
31
|
+
const nomeExe = (plataforma = process.platform) => (plataforma === "win32" ? "ffmpeg.exe" : "ffmpeg");
|
|
32
|
+
const caminhoBaixado = (dirDados, plataforma = process.platform) => path.join(dirDados, "ffmpeg", VERSAO, nomeExe(plataforma));
|
|
33
|
+
|
|
34
|
+
/** saída de um comando curto, sem travar o servidor (a 1ª execução de um .exe novo pode demorar no antivírus) */
|
|
35
|
+
function saidaDe(exe, args) {
|
|
36
|
+
return new Promise((ok) => {
|
|
37
|
+
let out = "";
|
|
38
|
+
let p;
|
|
39
|
+
try {
|
|
40
|
+
p = spawn(exe, args, {windowsHide: true});
|
|
41
|
+
} catch {
|
|
42
|
+
return ok({status: -1, out: ""});
|
|
43
|
+
}
|
|
44
|
+
p.stdout.on("data", (d) => (out += d));
|
|
45
|
+
p.on("error", () => ok({status: -1, out}));
|
|
46
|
+
p.on("close", (status) => ok({status, out}));
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const filtrosOk = new Map();
|
|
51
|
+
/** o ffmpeg tem os filtros da cadeia de áudio? (resultado guardado por caminho) */
|
|
52
|
+
async function temFiltros(ffmpeg) {
|
|
53
|
+
if (!filtrosOk.has(ffmpeg)) {
|
|
54
|
+
const r = await saidaDe(ffmpeg, ["-hide_banner", "-filters"]);
|
|
55
|
+
filtrosOk.set(ffmpeg, r.status === 0 && FILTROS.every((f) => new RegExp(`\\s${f}\\s`).test(r.out)));
|
|
56
|
+
}
|
|
57
|
+
return filtrosOk.get(ffmpeg);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** versão principal (6, 7, 9…) do ffmpeg: o grafo em arquivo usa "-/filter_complex" a partir da 7 */
|
|
61
|
+
async function versaoPrincipal(ffmpeg) {
|
|
62
|
+
const r = await saidaDe(ffmpeg, ["-hide_banner", "-version"]);
|
|
63
|
+
const m = /ffmpeg version n?(\d+)/.exec(r.out || "");
|
|
64
|
+
return m ? Number(m[1]) : 0;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** sem baixar nada: VIBE_FFMPEG, o já baixado, ou null */
|
|
68
|
+
function acharFfmpegCompleto({dirDados, env = process.env} = {}) {
|
|
69
|
+
if (env.VIBE_FFMPEG && fs.existsSync(env.VIBE_FFMPEG)) return {caminho: env.VIBE_FFMPEG, origem: "VIBE_FFMPEG"};
|
|
70
|
+
if (dirDados && fs.existsSync(caminhoBaixado(dirDados))) return {caminho: caminhoBaixado(dirDados), origem: "baixado"};
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function obter(url, {fetchImpl = fetch, sinal} = {}) {
|
|
75
|
+
const r = await fetchImpl(url, {redirect: "follow", signal: sinal});
|
|
76
|
+
if (!r.ok) throw new Error(`download do ffmpeg falhou (${r.status}) em ${url}`);
|
|
77
|
+
return r;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Baixa o ffmpeg desta plataforma para <dirDados>/ffmpeg/6.1.1/ (uma vez), conferindo o sha256 do .gz e do
|
|
82
|
+
* executável. onProgresso(fração). Devolve o caminho.
|
|
83
|
+
*/
|
|
84
|
+
async function baixarFfmpeg({dirDados, onProgresso, fetchImpl = fetch, sinal} = {}) {
|
|
85
|
+
const b = BINARIOS[chave()];
|
|
86
|
+
if (!b) throw new Error(`não há ffmpeg pronto para ${chave()}: instale um ffmpeg completo e defina VIBE_FFMPEG`);
|
|
87
|
+
const destino = caminhoBaixado(dirDados);
|
|
88
|
+
if (fs.existsSync(destino)) return destino;
|
|
89
|
+
const dir = path.dirname(destino);
|
|
90
|
+
fs.mkdirSync(dir, {recursive: true});
|
|
91
|
+
const tmp = `${destino}.${crypto.randomBytes(4).toString("hex")}.parte`;
|
|
92
|
+
try {
|
|
93
|
+
const r = await obter(`${BASE}ffmpeg-${chave()}.gz`, {fetchImpl, sinal});
|
|
94
|
+
const total = Number(r.headers.get("content-length")) || b.bytes;
|
|
95
|
+
const hGz = crypto.createHash("sha256"), hExe = crypto.createHash("sha256");
|
|
96
|
+
const gunzip = zlib.createGunzip();
|
|
97
|
+
const saida = fs.createWriteStream(tmp, {mode: 0o755});
|
|
98
|
+
const fim = new Promise((ok, falha) => {
|
|
99
|
+
saida.on("finish", ok); saida.on("error", falha); gunzip.on("error", falha);
|
|
100
|
+
});
|
|
101
|
+
gunzip.on("data", (c) => hExe.update(c));
|
|
102
|
+
gunzip.pipe(saida);
|
|
103
|
+
let lidos = 0;
|
|
104
|
+
for await (const pedaco of r.body) {
|
|
105
|
+
lidos += pedaco.length;
|
|
106
|
+
hGz.update(pedaco);
|
|
107
|
+
if (!gunzip.write(pedaco)) await new Promise((ok) => gunzip.once("drain", ok));
|
|
108
|
+
if (onProgresso) onProgresso(Math.min(1, lidos / total));
|
|
109
|
+
}
|
|
110
|
+
gunzip.end();
|
|
111
|
+
await fim;
|
|
112
|
+
if (hGz.digest("hex") !== b.gz || hExe.digest("hex") !== b.exe) throw new Error("o ffmpeg baixado não confere com o sha256 esperado");
|
|
113
|
+
// a licença (GPL-3.0) vai junto do binário
|
|
114
|
+
const l = await obter(`${BASE}${chave()}.LICENSE`, {fetchImpl, sinal});
|
|
115
|
+
const texto = Buffer.from(await l.arrayBuffer());
|
|
116
|
+
if (crypto.createHash("sha256").update(texto).digest("hex") !== b.licenca) throw new Error("a licença do ffmpeg não confere com o sha256 esperado");
|
|
117
|
+
fs.writeFileSync(path.join(dir, "ffmpeg.LICENSE"), texto);
|
|
118
|
+
fs.chmodSync(tmp, 0o755);
|
|
119
|
+
fs.renameSync(tmp, destino);
|
|
120
|
+
return destino;
|
|
121
|
+
} finally {
|
|
122
|
+
fs.rmSync(tmp, {force: true});
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
let emAndamento = null;
|
|
127
|
+
/**
|
|
128
|
+
* O ffmpeg completo, baixando se precisar (um download por vez; quem chega junto espera o mesmo).
|
|
129
|
+
* opc: {dirDados, env, baixar (padrão true), onProgresso, fetchImpl}. Devolve {caminho, origem, versao}.
|
|
130
|
+
*/
|
|
131
|
+
async function garantirFfmpeg(opc = {}) {
|
|
132
|
+
const achado = acharFfmpegCompleto(opc);
|
|
133
|
+
if (achado) return {...achado, versao: await versaoPrincipal(achado.caminho)};
|
|
134
|
+
const doPath = nomeExe();
|
|
135
|
+
if (opc.baixar !== false && opc.dirDados) {
|
|
136
|
+
try {
|
|
137
|
+
if (!emAndamento) emAndamento = baixarFfmpeg(opc).finally(() => { emAndamento = null; });
|
|
138
|
+
const caminho = await emAndamento;
|
|
139
|
+
return {caminho, origem: "baixado", versao: await versaoPrincipal(caminho)};
|
|
140
|
+
} catch (e) {
|
|
141
|
+
if (await temFiltros(doPath)) return {caminho: doPath, origem: "PATH", versao: await versaoPrincipal(doPath)};
|
|
142
|
+
throw e;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if (await temFiltros(doPath)) return {caminho: doPath, origem: "PATH", versao: await versaoPrincipal(doPath)};
|
|
146
|
+
throw new Error("sem ffmpeg completo para tratar o áudio (defina VIBE_FFMPEG ou deixe o Vibe Render baixar)");
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
module.exports = {garantirFfmpeg, baixarFfmpeg, acharFfmpegCompleto, temFiltros, versaoPrincipal, caminhoBaixado, BINARIOS, VERSAO, FILTROS};
|