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.
Files changed (63) hide show
  1. package/README.md +19 -2
  2. package/cli/vibeeditor-render.js +19 -3
  3. package/core/audio.js +140 -18
  4. package/core/cache.js +21 -1
  5. package/core/config.js +5 -0
  6. package/core/ffmpeg.js +149 -0
  7. package/core/jobs.js +169 -24
  8. package/core/normalizar.js +454 -0
  9. package/core/nucleo.js +8 -3
  10. package/core/servidor.js +22 -1
  11. package/core/transcricao.js +197 -0
  12. package/core/voz.js +227 -0
  13. package/host-dist/15fb600d42056579.woff2 +0 -0
  14. package/host-dist/1895916a733c4d0e.woff2 +0 -0
  15. package/host-dist/1e96c5356ee905bc.woff2 +0 -0
  16. package/host-dist/2a8028ec716097bf.woff2 +0 -0
  17. package/host-dist/3061f5b6eb250216.woff2 +0 -0
  18. package/host-dist/376dd8dc38524313.woff2 +0 -0
  19. package/host-dist/41be664a74a50674.woff2 +0 -0
  20. package/host-dist/428.bundle.js +1 -1
  21. package/host-dist/46019c506ae1d42c.woff2 +0 -0
  22. package/host-dist/50129cb1dcd58da5.woff2 +0 -0
  23. package/host-dist/5ab57485954531d6.woff2 +0 -0
  24. package/host-dist/606cdd767ee835e0.woff2 +0 -0
  25. package/host-dist/66df0abfcfe8da3e.woff2 +0 -0
  26. package/host-dist/6903de9124a5cab6.woff2 +0 -0
  27. package/host-dist/693cf6c39ab15de9.woff2 +0 -0
  28. package/host-dist/72884976680cf128.woff2 +0 -0
  29. package/host-dist/7777133e901cd5ed.woff2 +0 -0
  30. package/host-dist/78db4f537e4b09d0.woff2 +0 -0
  31. package/host-dist/7a78f1ce0329757f.woff2 +0 -0
  32. package/host-dist/7c3e92e6007b76fd.woff2 +0 -0
  33. package/host-dist/81df42c325a360a6.woff2 +0 -0
  34. package/host-dist/8633e74025a037d3.woff2 +0 -0
  35. package/host-dist/872.bundle.js +1 -1
  36. package/host-dist/8cf68dfb15aca3e4.woff2 +0 -0
  37. package/host-dist/8fb1a96451cec840.woff2 +0 -0
  38. package/host-dist/916d3686010a8de2.woff2 +0 -0
  39. package/host-dist/92d1bbe48d4eec3e.woff2 +0 -0
  40. package/host-dist/93e25f31623ab732.woff2 +0 -0
  41. package/host-dist/95045d685be8648f.woff2 +0 -0
  42. package/host-dist/9545a3d1fceacf5c.woff2 +0 -0
  43. package/host-dist/96be080130d6f32c.woff2 +0 -0
  44. package/host-dist/9a881e2ac07d406b.woff2 +0 -0
  45. package/host-dist/ad20d13d2a924905.woff2 +0 -0
  46. package/host-dist/af2e87f5d61b5665.woff2 +0 -0
  47. package/host-dist/b19b14aaaeb386b9.woff2 +0 -0
  48. package/host-dist/b447814dc676cec1.woff2 +0 -0
  49. package/host-dist/b7b43acee81d05a8.woff2 +0 -0
  50. package/host-dist/bd427f25ac24d036.woff2 +0 -0
  51. package/host-dist/bundle.js +1509 -12
  52. package/host-dist/bundle.js.map +1 -1
  53. package/host-dist/c04551857776278f.woff2 +0 -0
  54. package/host-dist/c050c0dd427d93a6.woff2 +0 -0
  55. package/host-dist/cb2a73f0b29f1031.woff2 +0 -0
  56. package/host-dist/d36a2a2bb416f59e.woff2 +0 -0
  57. package/host-dist/d4df281bd2c98884.woff2 +0 -0
  58. package/host-dist/d869208648ca5469.woff2 +0 -0
  59. package/host-dist/db9d659a1acad1e2.woff2 +0 -0
  60. package/host-dist/ebeb24f1e7287d3a.woff2 +0 -0
  61. package/host-dist/ef6d3f52c547a6e3.woff2 +0 -0
  62. package/host-dist/f4646f71883e1c80.woff2 +0 -0
  63. 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 cria o atalho **Vibe Render** na área de
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>`.
@@ -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 desparear desliga este computador da conta
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 (hv/montar.py + render.limitar_pico):
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
- // O ffmpeg que vem com o @remotion/renderer é mínimo: não tem afftdn, alimiter nem highpass. Por isso este
6
- // módulo usa um ffmpeg próprio: VIBE_FFMPEG, a pasta `recursos` (se quem chama passar) ou o do PATH.
7
- // Para distribuir, usar um build LGPL (ex.: BtbN "win64-lgpl"): os filtros de áudio são LGPL e não precisamos do x264 nele.
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
- function acharFfmpeg({recursos, env = process.env} = {}) {
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 candidatos = [env.VIBE_FFMPEG, recursos && path.join(recursos, "ffmpeg-audio", exe)].filter(Boolean);
17
- for (const c of candidatos) if (fs.existsSync(c)) return c;
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
- /** mede loudness integrada e true peak de um arquivo (para conferir o resultado) */
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 lufs = Number((resumo.match(/I:\s+(-?[\d.]+) LUFS/) || [])[1]);
69
- const tp = Number((resumo.match(/Peak:\s+(-?[\d.]+) dBFS/) || [])[1]);
70
- return {lufs, tp};
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};