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
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
// Transcrição no computador da pessoa, para quem não tem placa de vídeo que o navegador use (o Whisper do site
|
|
2
|
+
// precisa de WebGPU; no processador do navegador leva ~6× a duração do vídeo). Aqui roda o whisper.cpp nativo,
|
|
3
|
+
// com todos os núcleos: na 1ª vez baixa o programa e o modelo large-v3-turbo (~1,6 GB) para a pasta de dados.
|
|
4
|
+
//
|
|
5
|
+
// O site manda o áudio já em WAV mono 16 kHz (PUT /v1/arquivos/<sha>) e recebe as palavras com tempo
|
|
6
|
+
// [{ini, fim, txt, prob}]; o alinhamento ao envelope e as substituições continuam no site, como no navegador.
|
|
7
|
+
const os = require("node:os");
|
|
8
|
+
const path = require("node:path");
|
|
9
|
+
const fs = require("node:fs");
|
|
10
|
+
const crypto = require("node:crypto");
|
|
11
|
+
const {spawn} = require("node:child_process");
|
|
12
|
+
|
|
13
|
+
const VERSAO_WHISPER = "1.7.6"; // a 1.5.5 (a do Remotion) não tem --dtw para o turbo; Mac/Linux compilam esta
|
|
14
|
+
const MODELO = "large-v3-turbo";
|
|
15
|
+
const TEXTO_PROMPT = (glossario) => (glossario.length ? `Aula em português do Brasil. Termos: ${glossario.join(", ")}.` : "");
|
|
16
|
+
|
|
17
|
+
/** junta os itens do whisper.cpp (--max-len 1 --split-on-word: ~1 palavra por item) em palavras do transcricao.json */
|
|
18
|
+
function palavrasDoWhisper(json) {
|
|
19
|
+
const palavras = [];
|
|
20
|
+
for (const item of (json && json.transcription) || []) {
|
|
21
|
+
const bruto = String(item.text || "");
|
|
22
|
+
const txt = bruto.trim();
|
|
23
|
+
if (!txt || /^\[.*\]$/.test(txt)) continue; // [_BEG_], [Música]…
|
|
24
|
+
const toks = (item.tokens || []).filter((t) => !/^\[_/.test(String(t.text || "")));
|
|
25
|
+
const prob = toks.length ? toks.reduce((s, t) => s + (Number(t.p) || 0), 0) / toks.length : 1;
|
|
26
|
+
const ini = (item.offsets && item.offsets.from) / 1000, fim = (item.offsets && item.offsets.to) / 1000;
|
|
27
|
+
// pontuação solta ("," "." "?") vai para a palavra anterior, como no faster-whisper
|
|
28
|
+
if (palavras.length && (/^[^\p{L}\p{N}]+$/u.test(txt) || !/^\s/.test(bruto))) {
|
|
29
|
+
const ant = palavras[palavras.length - 1];
|
|
30
|
+
ant.txt += txt;
|
|
31
|
+
ant.fim = Math.max(ant.fim, fim);
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
palavras.push({ini: Math.round(ini * 1000) / 1000, fim: Math.round(fim * 1000) / 1000, txt, prob: Math.round(prob * 1000) / 1000});
|
|
35
|
+
}
|
|
36
|
+
return palavras;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function validarPedido(d, cache) {
|
|
40
|
+
const erro = (m, status = 400) => Object.assign(new Error(m), {status});
|
|
41
|
+
if (!d || typeof d !== "object") throw erro("corpo inválido");
|
|
42
|
+
if (typeof d.arquivo !== "string" || !/^[a-f0-9]{64}$/.test(d.arquivo)) throw erro("arquivo precisa ser o sha256 do WAV");
|
|
43
|
+
if (!cache.existe(d.arquivo)) throw erro("envie o WAV antes (PUT /v1/arquivos/<sha>)", 409);
|
|
44
|
+
const g = d.glossario == null ? [] : d.glossario;
|
|
45
|
+
if (!Array.isArray(g) || g.length > 200 || !g.every((x) => typeof x === "string" && x.length <= 80)) throw erro("glossario: até 200 termos de até 80 letras");
|
|
46
|
+
const idioma = d.idioma == null ? "pt" : d.idioma;
|
|
47
|
+
if (!/^[a-z]{2}$/.test(idioma)) throw erro("idioma: código de 2 letras");
|
|
48
|
+
return {arquivo: d.arquivo, glossario: g.map((x) => x.replace(/[\r\n"]/g, " ").trim()).filter(Boolean), idioma};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** baixa um arquivo seguindo redirecionamentos (o GitHub manda para o CDN) */
|
|
52
|
+
async function baixar(url, destino, signal) {
|
|
53
|
+
const r = await fetch(url, {redirect: "follow", signal});
|
|
54
|
+
if (!r.ok) throw new Error(`download falhou (${r.status}): ${url}`);
|
|
55
|
+
fs.writeFileSync(destino, Buffer.from(await r.arrayBuffer()));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function rodar(bin, args, {cwd, signal, aoLinha} = {}) {
|
|
59
|
+
return new Promise((ok, falha) => {
|
|
60
|
+
const p = spawn(bin, args, {cwd, signal, windowsHide: true});
|
|
61
|
+
let fim = "";
|
|
62
|
+
const ler = (d) => {
|
|
63
|
+
const t = d.toString("utf8");
|
|
64
|
+
if (aoLinha) t.split(/\r?\n/).forEach(aoLinha);
|
|
65
|
+
fim = (fim + t).slice(-4000);
|
|
66
|
+
};
|
|
67
|
+
p.stdout.on("data", ler);
|
|
68
|
+
p.stderr.on("data", ler);
|
|
69
|
+
p.on("error", falha);
|
|
70
|
+
p.on("close", (code) => (code === 0 ? ok() : falha(new Error(`${path.basename(bin)} saiu com código ${code}: ${fim.trim().split("\n").slice(-3).join(" | ")}`))));
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** o whisper.cpp de verdade (injetável nos testes): binário em whisper/bin-<versão>/, modelo em whisper/ */
|
|
75
|
+
function whisperCpp({dirDados}) {
|
|
76
|
+
const W = require("@remotion/install-whisper-cpp");
|
|
77
|
+
const pasta = path.join(dirDados, "whisper");
|
|
78
|
+
const pastaBin = path.join(pasta, `bin-${VERSAO_WHISPER}`);
|
|
79
|
+
const exe = () => (process.platform === "win32" ? path.join(pastaBin, "whisper-cli.exe") : W.getWhisperExecutablePath(pastaBin, VERSAO_WHISPER));
|
|
80
|
+
const modelo = path.join(pasta, `ggml-${MODELO}.bin`);
|
|
81
|
+
|
|
82
|
+
// Windows: o binário pronto do GitHub (CPU, com AVX2), extraído com o tar que vem no Windows 10+
|
|
83
|
+
async function instalarNoWindows(signal) {
|
|
84
|
+
const zip = path.join(pasta, "whisper-bin-x64.zip");
|
|
85
|
+
const tmp = path.join(pasta, `extraindo-${process.pid}`);
|
|
86
|
+
await baixar(`https://github.com/ggml-org/whisper.cpp/releases/download/v${VERSAO_WHISPER}/whisper-bin-x64.zip`, zip, signal);
|
|
87
|
+
fs.rmSync(tmp, {recursive: true, force: true});
|
|
88
|
+
fs.mkdirSync(tmp, {recursive: true});
|
|
89
|
+
// o tar.exe do Windows pelo caminho completo: um "tar" do Git no PATH entende "C:" como computador remoto
|
|
90
|
+
await rodar(path.join(process.env.SystemRoot || "C:\\Windows", "System32", "tar.exe"), ["-xf", zip, "-C", tmp], {signal});
|
|
91
|
+
const release = fs.existsSync(path.join(tmp, "Release")) ? path.join(tmp, "Release") : tmp;
|
|
92
|
+
fs.rmSync(pastaBin, {recursive: true, force: true});
|
|
93
|
+
fs.renameSync(release, pastaBin);
|
|
94
|
+
fs.rmSync(tmp, {recursive: true, force: true});
|
|
95
|
+
fs.rmSync(zip, {force: true});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
async garantir({onProgresso, signal}) {
|
|
100
|
+
fs.mkdirSync(pasta, {recursive: true});
|
|
101
|
+
if (!fs.existsSync(exe())) {
|
|
102
|
+
if (process.platform === "win32") await instalarNoWindows(signal);
|
|
103
|
+
else await W.installWhisperCpp({version: VERSAO_WHISPER, to: pastaBin, printOutput: false, signal});
|
|
104
|
+
}
|
|
105
|
+
if (!fs.existsSync(modelo)) {
|
|
106
|
+
await W.downloadWhisperModel({model: MODELO, folder: pasta, printOutput: false, signal,
|
|
107
|
+
onProgress: (baixado, total) => onProgresso && total && onProgresso(baixado / total)});
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
async transcrever(wav, {glossario, idioma, onProgresso, signal}) {
|
|
111
|
+
const prompt = TEXTO_PROMPT(glossario);
|
|
112
|
+
const threads = String(Math.max(2, Math.min(16, Math.floor((os.cpus().length || 4) * 0.75))));
|
|
113
|
+
const saida = wav.replace(/\.wav$/i, "");
|
|
114
|
+
// -ml 1 -sow: um item por palavra; --dtw: tempo de cada palavra pelo alinhamento das cabeças de atenção
|
|
115
|
+
const args = ["-m", modelo, "-f", wav, "-l", idioma, "-t", threads, "-ojf", "-of", saida, "-ml", "1", "-sow", "-pp",
|
|
116
|
+
"--dtw", MODELO.replace(/-/g, "."), ...(prompt ? ["--prompt", prompt] : [])];
|
|
117
|
+
await rodar(exe(), args, {cwd: pasta, signal, aoLinha: (l) => {
|
|
118
|
+
const m = /progress\s*=\s*(\d+)%/.exec(l);
|
|
119
|
+
if (m && onProgresso) onProgresso(Number(m[1]) / 100);
|
|
120
|
+
}});
|
|
121
|
+
try {
|
|
122
|
+
return JSON.parse(fs.readFileSync(`${saida}.json`, "utf8"));
|
|
123
|
+
} finally {
|
|
124
|
+
fs.rmSync(`${saida}.json`, {force: true});
|
|
125
|
+
}
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Fila de transcrições (uma por vez: o whisper.cpp já usa os núcleos). opc: {cache, dirDados, dirTmp, whisper?}
|
|
132
|
+
* O WAV vem do cache pelo sha; o whisper.cpp exige extensão .wav, então vai uma cópia para dirTmp.
|
|
133
|
+
*/
|
|
134
|
+
function criarTranscricoes(opc) {
|
|
135
|
+
const W = opc.whisper || whisperCpp({dirDados: opc.dirDados});
|
|
136
|
+
const itens = new Map();
|
|
137
|
+
const fila = [];
|
|
138
|
+
let rodando = null;
|
|
139
|
+
|
|
140
|
+
const visao = (t) => ({id: t.id, status: t.status, progresso: t.progresso, etapa: t.etapa, erro: t.erro, modelo: `whisper.cpp ${VERSAO_WHISPER} · ${MODELO}`,
|
|
141
|
+
tempoMs: t.tempoMs, ...(t.status === "pronto" ? {palavras: t.palavras} : {})});
|
|
142
|
+
const mudar = (t, patch) => Object.assign(t, patch);
|
|
143
|
+
|
|
144
|
+
async function proximo() {
|
|
145
|
+
if (rodando || !fila.length) return;
|
|
146
|
+
const t = fila.shift();
|
|
147
|
+
if (t.status === "cancelado") return proximo();
|
|
148
|
+
rodando = t;
|
|
149
|
+
const ab = new AbortController();
|
|
150
|
+
t.abortar = () => ab.abort();
|
|
151
|
+
const inicio = Date.now();
|
|
152
|
+
const wav = path.join(opc.dirTmp, `${t.id}.wav`);
|
|
153
|
+
try {
|
|
154
|
+
mudar(t, {status: "preparando", etapa: "Preparando o Whisper (na 1ª vez baixa ~1,6 GB)"});
|
|
155
|
+
await W.garantir({signal: ab.signal, onProgresso: (p) => mudar(t, {etapa: `Baixando o modelo de transcrição (uma vez só) ${Math.round(p * 100)}%`})});
|
|
156
|
+
fs.mkdirSync(opc.dirTmp, {recursive: true});
|
|
157
|
+
fs.copyFileSync(opc.cache.caminho(t.pedido.arquivo), wav);
|
|
158
|
+
mudar(t, {status: "transcrevendo", etapa: "Transcrevendo", progresso: 0});
|
|
159
|
+
const json = await W.transcrever(wav, {...t.pedido, signal: ab.signal, onProgresso: (p) => mudar(t, {progresso: Math.max(0, Math.min(1, p))})});
|
|
160
|
+
mudar(t, {status: "pronto", etapa: "Pronto", progresso: 1, palavras: palavrasDoWhisper(json), tempoMs: Date.now() - inicio});
|
|
161
|
+
} catch (e) {
|
|
162
|
+
if (t.status !== "cancelado") mudar(t, {status: "erro", erro: String((e && e.message) || e).slice(0, 500), etapa: "Erro"});
|
|
163
|
+
} finally {
|
|
164
|
+
fs.rmSync(wav, {force: true});
|
|
165
|
+
rodando = null;
|
|
166
|
+
proximo();
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
criar(corpo) {
|
|
172
|
+
const pedido = validarPedido(corpo, opc.cache);
|
|
173
|
+
const t = {id: crypto.randomUUID(), pedido, status: "fila", progresso: 0, etapa: "Na fila", erro: null};
|
|
174
|
+
itens.set(t.id, t);
|
|
175
|
+
// guarda só as 20 mais novas
|
|
176
|
+
for (const id of [...itens.keys()].slice(0, Math.max(0, itens.size - 20))) if (itens.get(id) !== rodando) itens.delete(id);
|
|
177
|
+
fila.push(t);
|
|
178
|
+
proximo();
|
|
179
|
+
return visao(t);
|
|
180
|
+
},
|
|
181
|
+
obter: (id) => (itens.has(id) ? visao(itens.get(id)) : null),
|
|
182
|
+
cancelar(id) {
|
|
183
|
+
const t = itens.get(id);
|
|
184
|
+
if (!t) return null;
|
|
185
|
+
if (["fila", "preparando", "transcrevendo"].includes(t.status)) {
|
|
186
|
+
mudar(t, {status: "cancelado", etapa: "Cancelado"});
|
|
187
|
+
if (t.abortar) t.abortar();
|
|
188
|
+
}
|
|
189
|
+
return visao(t);
|
|
190
|
+
},
|
|
191
|
+
cancelarTodas() {
|
|
192
|
+
for (const id of itens.keys()) this.cancelar(id);
|
|
193
|
+
},
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
module.exports = {criarTranscricoes, palavrasDoWhisper, validarPedido, whisperCpp, VERSAO_WHISPER, MODELO};
|
package/core/voz.js
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
// VoiceStudio (voz clonada → narração) pelo Vibe Render. O VoiceStudio é um app open source que roda no
|
|
2
|
+
// computador da pessoa (github.com/debpalash/VoiceStudio, API local em http://127.0.0.1:3900). O site é https
|
|
3
|
+
// e não consegue falar direto com ele (CORS e o bloqueio de rede local do Chrome), então estas rotas, já com a
|
|
4
|
+
// sessão do site conferida pelo servidor, repassam os pedidos para a API dele:
|
|
5
|
+
// GET /v1/voz/config endereço em uso {url, origem, fixo}
|
|
6
|
+
// POST /v1/voz/config {url} troca o endereço (só 127.0.0.1/localhost), guardado em voicestudio.json
|
|
7
|
+
// GET /v1/voz/status /health + /profiles + /personalities numa resposta só (online ou não)
|
|
8
|
+
// POST /v1/voz/perfis multipart → POST /profiles (clonar: name, kind, ref_audio, ref_text, language)
|
|
9
|
+
// POST /v1/voz/perfis/<id>/excluir → DELETE /profiles/<id> (a voz que já não existe conta como excluída)
|
|
10
|
+
// POST /v1/voz/gerar multipart → POST /generate (text, profile_id ou ref_audio… → WAV)
|
|
11
|
+
// Nada de IA aqui: gerar voz é determinístico. O formato das respostas do VoiceStudio muda entre versões, então
|
|
12
|
+
// o status vai cru e quem interpreta é o site.
|
|
13
|
+
const fs = require("node:fs");
|
|
14
|
+
const path = require("node:path");
|
|
15
|
+
|
|
16
|
+
const URL_PADRAO = "http://127.0.0.1:3900";
|
|
17
|
+
const HOSTS_LOCAIS = new Set(["127.0.0.1", "localhost", "[::1]"]);
|
|
18
|
+
const LIMITE_CORPO = 64 * 1024 * 1024; // referência de voz ou texto + referência avulsa; um WAV de 19 s tem ~1 MB
|
|
19
|
+
const LIMITE_RESPOSTA = 512 * 1024 * 1024; // o WAV de um bloco
|
|
20
|
+
const ARQUIVO = "voicestudio.json";
|
|
21
|
+
|
|
22
|
+
/** "http://127.0.0.1:3900/" → "http://127.0.0.1:3900"; null se não for um endereço local simples */
|
|
23
|
+
function urlValida(bruta) {
|
|
24
|
+
let u;
|
|
25
|
+
try {
|
|
26
|
+
u = new URL(String(bruta || "").trim());
|
|
27
|
+
} catch {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
if (!["http:", "https:"].includes(u.protocol)) return null;
|
|
31
|
+
if (!HOSTS_LOCAIS.has(u.hostname.toLowerCase())) return null;
|
|
32
|
+
if (u.username || u.password || u.search || u.hash || (u.pathname && u.pathname !== "/")) return null;
|
|
33
|
+
return `${u.protocol}//${u.host}`.toLowerCase();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const erro = (status, mensagem, extra = {}) => Object.assign(new Error(mensagem), {status, ...extra});
|
|
37
|
+
|
|
38
|
+
/** lê o corpo inteiro até `max` bytes (413 acima disso) */
|
|
39
|
+
function lerCorpo(req, max) {
|
|
40
|
+
return new Promise((ok, falha) => {
|
|
41
|
+
let n = 0;
|
|
42
|
+
const partes = [];
|
|
43
|
+
req.on("data", (c) => {
|
|
44
|
+
n += c.length;
|
|
45
|
+
if (n > max) {
|
|
46
|
+
falha(erro(413, "corpo grande demais")); req.destroy();
|
|
47
|
+
} else partes.push(c);
|
|
48
|
+
});
|
|
49
|
+
req.on("end", () => ok(Buffer.concat(partes)));
|
|
50
|
+
req.on("error", falha);
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** detalhe do erro do VoiceStudio (FastAPI manda {detail}) */
|
|
55
|
+
async function detalhe(r) {
|
|
56
|
+
try {
|
|
57
|
+
const t = await r.text();
|
|
58
|
+
try {
|
|
59
|
+
const j = JSON.parse(t);
|
|
60
|
+
const d = j && (j.detail !== undefined ? j.detail : j.erro || j.error || j);
|
|
61
|
+
return typeof d === "string" ? d : JSON.stringify(d);
|
|
62
|
+
} catch {
|
|
63
|
+
return t;
|
|
64
|
+
}
|
|
65
|
+
} catch {
|
|
66
|
+
return "";
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* opc: {dirDados, env, urlInicial (do --voicestudio: grava como a do site), fetch (injetável nos testes), log}
|
|
72
|
+
*/
|
|
73
|
+
function criarVoz(opc) {
|
|
74
|
+
const env = opc.env || process.env;
|
|
75
|
+
const buscar = opc.fetch || fetch;
|
|
76
|
+
const log = opc.log || (() => {});
|
|
77
|
+
const arq = path.join(opc.dirDados, ARQUIVO);
|
|
78
|
+
|
|
79
|
+
const salva = () => {
|
|
80
|
+
try {
|
|
81
|
+
return urlValida(JSON.parse(fs.readFileSync(arq, "utf8")).url);
|
|
82
|
+
} catch {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
/** a variável de ambiente manda (fixo); senão o endereço guardado; senão o padrão do VoiceStudio */
|
|
87
|
+
function config() {
|
|
88
|
+
const daEnv = urlValida(env.VIBE_RENDER_VOICESTUDIO);
|
|
89
|
+
if (daEnv) return {url: daEnv, origem: "env", fixo: true};
|
|
90
|
+
const s = salva();
|
|
91
|
+
return s ? {url: s, origem: "salvo", fixo: false} : {url: URL_PADRAO, origem: "padrao", fixo: false};
|
|
92
|
+
}
|
|
93
|
+
function definir(bruta) {
|
|
94
|
+
const url = urlValida(bruta);
|
|
95
|
+
if (!url) throw erro(400, "Endereço inválido: use o do VoiceStudio neste computador, como http://127.0.0.1:3900");
|
|
96
|
+
fs.mkdirSync(opc.dirDados, {recursive: true});
|
|
97
|
+
fs.writeFileSync(arq, JSON.stringify({url}, null, 2));
|
|
98
|
+
return config();
|
|
99
|
+
}
|
|
100
|
+
if (opc.urlInicial) definir(opc.urlInicial);
|
|
101
|
+
|
|
102
|
+
/** um pedido ao VoiceStudio; erro com status 503 (fechado), 504 (demorou) ou o status dele */
|
|
103
|
+
async function chamar(caminho, {metodo = "GET", corpo, tipo, timeout = 8000, sinal} = {}) {
|
|
104
|
+
const {url} = config();
|
|
105
|
+
const ctl = new AbortController();
|
|
106
|
+
const t = timeout ? setTimeout(() => ctl.abort(), timeout) : null;
|
|
107
|
+
const parar = () => ctl.abort();
|
|
108
|
+
if (sinal) sinal.addEventListener("abort", parar, {once: true});
|
|
109
|
+
try {
|
|
110
|
+
const r = await buscar(url + caminho, {method: metodo, body: corpo, headers: tipo ? {"Content-Type": tipo} : {}, signal: ctl.signal, redirect: "manual"});
|
|
111
|
+
if (!r.ok) {
|
|
112
|
+
const d = await detalhe(r);
|
|
113
|
+
throw erro(r.status, `VoiceStudio respondeu ${r.status}${d ? `: ${String(d).slice(0, 300)}` : ""}`, {doVoiceStudio: true});
|
|
114
|
+
}
|
|
115
|
+
return r;
|
|
116
|
+
} catch (e) {
|
|
117
|
+
if (e.doVoiceStudio) throw e;
|
|
118
|
+
if (sinal && sinal.aborted) throw erro(499, "parado");
|
|
119
|
+
if (e.name === "AbortError") throw erro(504, "o VoiceStudio demorou demais para responder");
|
|
120
|
+
throw erro(503, `não consegui falar com o VoiceStudio em ${url} (ele está aberto?)`, {offline: true});
|
|
121
|
+
} finally {
|
|
122
|
+
if (t) clearTimeout(t);
|
|
123
|
+
if (sinal) sinal.removeEventListener("abort", parar);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
const jsonDe = async (r, padrao) => {
|
|
127
|
+
try {
|
|
128
|
+
return await r.json();
|
|
129
|
+
} catch {
|
|
130
|
+
return padrao;
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
async function status() {
|
|
135
|
+
const {url} = config();
|
|
136
|
+
try {
|
|
137
|
+
const saude = await jsonDe(await chamar("/health", {timeout: 2500}), {});
|
|
138
|
+
const perfis = await jsonDe(await chamar("/profiles", {timeout: 5000}), []);
|
|
139
|
+
let estilos = [];
|
|
140
|
+
try {
|
|
141
|
+
estilos = await jsonDe(await chamar("/personalities", {timeout: 3000}), []);
|
|
142
|
+
} catch { /* versão sem estilos prontos */ }
|
|
143
|
+
return {online: true, url, saude, perfis, estilos, erro: null};
|
|
144
|
+
} catch (e) {
|
|
145
|
+
return {online: false, url, saude: null, perfis: [], estilos: [], erro: e.message};
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** tipo multipart obrigatório nas rotas que repassam formulário */
|
|
150
|
+
function multipart(req) {
|
|
151
|
+
const t = String(req.headers["content-type"] || "");
|
|
152
|
+
if (!/^multipart\/form-data;\s*boundary=/i.test(t) || t.length > 300) throw erro(415, "envie multipart/form-data");
|
|
153
|
+
return t;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** a resposta do VoiceStudio até o site; aborta o pedido a ele se o site desistir (botão Parar) */
|
|
157
|
+
async function repassar(req, res, cors, caminho, {timeout}) {
|
|
158
|
+
const tipo = multipart(req);
|
|
159
|
+
const corpo = await lerCorpo(req, LIMITE_CORPO);
|
|
160
|
+
const ctl = new AbortController();
|
|
161
|
+
res.on("close", () => {
|
|
162
|
+
if (!res.writableFinished) ctl.abort();
|
|
163
|
+
});
|
|
164
|
+
const r = await chamar(caminho, {metodo: "POST", corpo, tipo, timeout, sinal: ctl.signal});
|
|
165
|
+
const tam = Number(r.headers.get("content-length") || 0);
|
|
166
|
+
if (tam > LIMITE_RESPOSTA) throw erro(502, "a resposta do VoiceStudio é grande demais");
|
|
167
|
+
const bytes = Buffer.from(await r.arrayBuffer());
|
|
168
|
+
if (bytes.length > LIMITE_RESPOSTA) throw erro(502, "a resposta do VoiceStudio é grande demais");
|
|
169
|
+
res.writeHead(200, {...cors, "Content-Type": r.headers.get("content-type") || "application/octet-stream", "Content-Length": bytes.length, "Cache-Control": "no-store"});
|
|
170
|
+
res.end(bytes);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Rotas /v1/voz/... (o servidor já conferiu Host, Origin e a sessão). `resto` = partes depois de /v1/voz.
|
|
175
|
+
* Devolve true se tratou o pedido.
|
|
176
|
+
*/
|
|
177
|
+
async function rota(req, res, resto, {json, cors}) {
|
|
178
|
+
const [a, b, c] = resto;
|
|
179
|
+
try {
|
|
180
|
+
if (a === "config" && !b && req.method === "GET") return json(200, config(), cors), true;
|
|
181
|
+
if (a === "config" && !b && req.method === "POST") {
|
|
182
|
+
let d;
|
|
183
|
+
try {
|
|
184
|
+
d = JSON.parse((await lerCorpo(req, 4096)).toString("utf8") || "{}");
|
|
185
|
+
} catch (e) {
|
|
186
|
+
throw e.status ? e : erro(400, "JSON inválido");
|
|
187
|
+
}
|
|
188
|
+
const atual = definir(d && d.url);
|
|
189
|
+
log("voz", `endereço do VoiceStudio: ${atual.url}`);
|
|
190
|
+
return json(200, atual, cors), true;
|
|
191
|
+
}
|
|
192
|
+
if (a === "status" && !b && req.method === "GET") return json(200, await status(), cors), true;
|
|
193
|
+
if (a === "perfis" && !b && req.method === "POST") {
|
|
194
|
+
await repassar(req, res, cors, "/profiles", {timeout: 180000});
|
|
195
|
+
return true;
|
|
196
|
+
}
|
|
197
|
+
if (a === "perfis" && b && c === "excluir" && resto.length === 3 && req.method === "POST") {
|
|
198
|
+
if (!/^[\w.:-]{1,128}$/.test(b)) throw erro(400, "voz inválida");
|
|
199
|
+
try {
|
|
200
|
+
await chamar(`/profiles/${encodeURIComponent(b)}`, {metodo: "DELETE", timeout: 20000});
|
|
201
|
+
} catch (e) {
|
|
202
|
+
if (e.status !== 404) throw e;
|
|
203
|
+
return json(200, {ok: true, jaNaoExistia: true}, cors), true;
|
|
204
|
+
}
|
|
205
|
+
return json(200, {ok: true}, cors), true;
|
|
206
|
+
}
|
|
207
|
+
if (a === "gerar" && !b && req.method === "POST") {
|
|
208
|
+
// sem prazo: um bloco grande no processador leva minutos; o Parar do site corta
|
|
209
|
+
await repassar(req, res, cors, "/generate", {timeout: 0});
|
|
210
|
+
return true;
|
|
211
|
+
}
|
|
212
|
+
return false;
|
|
213
|
+
} catch (e) {
|
|
214
|
+
if (res.headersSent) return res.destroy(), true;
|
|
215
|
+
if (e.status === 499) return res.destroy(), true;
|
|
216
|
+
// 401/403 do VoiceStudio não podem parecer sessão inválida do Vibe Render (o site renovaria a sessão)
|
|
217
|
+
const st = e.status === 401 || e.status === 403 ? 502 : e.status || 500;
|
|
218
|
+
json(st, {erro: e.status ? e.message : "erro interno", ...(e.offline ? {offline: true} : {}), ...(e.doVoiceStudio ? {voiceStudio: e.status} : {})}, cors);
|
|
219
|
+
if (!e.status) log("erro", e.message);
|
|
220
|
+
return true;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return {config, definir, status, chamar, rota};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
module.exports = {criarVoz, urlValida, URL_PADRAO, LIMITE_CORPO};
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/host-dist/428.bundle.js
CHANGED
|
@@ -25941,7 +25941,7 @@ var registrationPromise3 = null;
|
|
|
25941
25941
|
var doRegister3 = async () => {
|
|
25942
25942
|
const nativeSupport = await (0,encode/* canEncodeAudio */.Tc)("mp3");
|
|
25943
25943
|
if (!nativeSupport) {
|
|
25944
|
-
const { registerMp3Encoder } = await __webpack_require__.e(/* import() */ 872).then(__webpack_require__.bind(__webpack_require__,
|
|
25944
|
+
const { registerMp3Encoder } = await __webpack_require__.e(/* import() */ 872).then(__webpack_require__.bind(__webpack_require__, 2491));
|
|
25945
25945
|
registerMp3Encoder();
|
|
25946
25946
|
}
|
|
25947
25947
|
};
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/host-dist/872.bundle.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
(self["webpackChunkvibeeditor_render"] = self["webpackChunkvibeeditor_render"] || []).push([[872],{
|
|
3
3
|
|
|
4
|
-
/***/
|
|
4
|
+
/***/ 2491
|
|
5
5
|
(__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) {
|
|
6
6
|
|
|
7
7
|
/* harmony export */ __webpack_require__.d(__webpack_exports__, {
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|