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,454 @@
|
|
|
1
|
+
// Vídeo original "fácil de buscar" para a Edição Simples: o original é normalizado UMA vez (por sha256) num proxy
|
|
2
|
+
// H.264 8 bits, 30 fps constantes, quadro-chave a cada 30 quadros, mesma resolução (sem corte nem recorte) e áudio
|
|
3
|
+
// copiado como está. A composição continua cortando e enquadrando; só passa a ler o proxy.
|
|
4
|
+
//
|
|
5
|
+
// Por quê (docs/M2-MOTOR-ETAPA1.md, seção 6, decisão 1): o compositor do Remotion decodifica o original em software,
|
|
6
|
+
// por busca aleatória. Com o HEVC 10 bits 4K do iPhone a amostra de 8 s levava 5–7 min e a V01 de 40 s, 19–26 min;
|
|
7
|
+
// com o arquivo do WhatsApp (1 quadro-chave a cada 10 s) vinha "No frame found at position" com várias abas.
|
|
8
|
+
//
|
|
9
|
+
// Quadros: o proxy sai do filtro fps=30 do ffmpeg, então o quadro k do proxy é o quadro do original que o fps=30
|
|
10
|
+
// escolhe para o tempo k/30 (o mesmo que o montar.py do editor local usa) e começa em 0 como o original. A
|
|
11
|
+
// composição pede `trimBefore = origem = round(t·30)`: no proxy isso é exato (sem o ajuste de ½ quadro que o
|
|
12
|
+
// original a 60 fps precisava na régua, plat/src/motor/plano.js → ajusteDeOrigem).
|
|
13
|
+
//
|
|
14
|
+
// HDR (HLG/PQ do iPhone): o compositor converte para SDR a cada quadro extraído com zscale + tonemap=hable (grafo
|
|
15
|
+
// conferido no binário dele). Aqui o mesmo grafo é amostrado UMA vez numa Hald CLUT (64³ pontos) e aplicado no
|
|
16
|
+
// proxy (haldclut, ~4× mais rápido que o grafo); o proxy sai SDR BT.709 e o compositor pula a conversão. Sem
|
|
17
|
+
// zscale no ffmpeg, o proxy mantém a marcação HLG/PQ em 8 bits e o compositor converte como antes.
|
|
18
|
+
"use strict";
|
|
19
|
+
const fs = require("node:fs");
|
|
20
|
+
const path = require("node:path");
|
|
21
|
+
const crypto = require("node:crypto");
|
|
22
|
+
const {spawn} = require("node:child_process");
|
|
23
|
+
|
|
24
|
+
/** versão da receita do proxy: mudou o que sai daqui, sobe o número (os proxies antigos deixam de valer) */
|
|
25
|
+
const RECEITA = 1;
|
|
26
|
+
const FPS = 30;
|
|
27
|
+
const GOP = 30; // 1 quadro-chave por segundo
|
|
28
|
+
const GOP_MAX_S = 1.05;
|
|
29
|
+
const LADO_CURTO_MAX = 2160; // maior preset (4k) tem 2160 no lado curto
|
|
30
|
+
const HDR = {"arib-std-b67": "HLG", "smpte2084": "PQ"};
|
|
31
|
+
const VERSAO_LUT = 1;
|
|
32
|
+
|
|
33
|
+
// ---------------------------------------------------------------- processo
|
|
34
|
+
|
|
35
|
+
/** roda um programa; devolve {status, stdout, stderr} (stderr: só o fim). opc: {sinal, aoStdout(texto), cwd} */
|
|
36
|
+
function rodar(exe, args, opc = {}) {
|
|
37
|
+
return new Promise((ok, falha) => {
|
|
38
|
+
let p;
|
|
39
|
+
try {
|
|
40
|
+
p = spawn(exe, args, {windowsHide: true, cwd: opc.cwd, signal: opc.sinal});
|
|
41
|
+
} catch (e) {
|
|
42
|
+
return falha(e);
|
|
43
|
+
}
|
|
44
|
+
let stdout = "", stderr = "";
|
|
45
|
+
p.stdout.setEncoding("utf8");
|
|
46
|
+
p.stderr.setEncoding("utf8");
|
|
47
|
+
p.stdout.on("data", (d) => {
|
|
48
|
+
if (opc.aoStdout) opc.aoStdout(d);
|
|
49
|
+
else stdout += d;
|
|
50
|
+
});
|
|
51
|
+
p.stderr.on("data", (d) => {
|
|
52
|
+
stderr = (stderr + d).slice(-256 * 1024);
|
|
53
|
+
});
|
|
54
|
+
p.on("error", (e) => (e && e.name === "AbortError" ? falha(new Error("cancelado")) : falha(e)));
|
|
55
|
+
p.on("close", (status) => ok({status, stdout, stderr}));
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ---------------------------------------------------------------- sondagem
|
|
60
|
+
|
|
61
|
+
const num = (v) => (v == null || v === "" ? NaN : Number(v));
|
|
62
|
+
|
|
63
|
+
/** `ffmpeg -i <arquivo>` (stderr) → {duracaoS, video: {...} | null, temAudio} */
|
|
64
|
+
function lerInfo(texto) {
|
|
65
|
+
const t = String(texto || "");
|
|
66
|
+
const d = /Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)/.exec(t);
|
|
67
|
+
const duracaoS = d ? Number(d[1]) * 3600 + Number(d[2]) * 60 + Number(d[3]) : NaN;
|
|
68
|
+
const linhas = t.split(/\r?\n/);
|
|
69
|
+
let video = null;
|
|
70
|
+
let temAudio = false;
|
|
71
|
+
for (let i = 0; i < linhas.length; i++) {
|
|
72
|
+
const l = linhas[i];
|
|
73
|
+
if (/Stream #\d+:\d+.*: Audio: /.test(l)) temAudio = true;
|
|
74
|
+
const m = /Stream #\d+:\d+.*: Video: (.*)$/.exec(l);
|
|
75
|
+
if (!m || video || /attached pic/.test(l)) continue;
|
|
76
|
+
const resto = m[1];
|
|
77
|
+
const codec = /^([\w-]+)/.exec(resto)[1];
|
|
78
|
+
const perfil = (/^[\w-]+ \(([^)]*)\)/.exec(resto) || [])[1] || "";
|
|
79
|
+
// "hevc (Main 10) (hvc1 / 0x31637668), yuv420p10le(tv, bt2020nc/bt2020/arib-std-b67), 3840x2160, …"
|
|
80
|
+
const partes = resto.split(/,\s*(?![^()]*\))/);
|
|
81
|
+
const pf = /^([\w]+)(?:\(([^)]*)\))?/.exec((partes[1] || "").trim()) || [];
|
|
82
|
+
const pixFmt = pf[1] || "";
|
|
83
|
+
const cores = (pf[2] || "").split(/,\s*/).filter(Boolean);
|
|
84
|
+
let range = "", matriz = "", primarias = "", transferencia = "";
|
|
85
|
+
for (const c of cores) {
|
|
86
|
+
if (c === "tv" || c === "pc") range = c;
|
|
87
|
+
else if (c.includes("/")) [matriz, primarias, transferencia] = c.split("/");
|
|
88
|
+
else if (!/progressive|top|bottom|first|coded/.test(c)) matriz = primarias = transferencia = c;
|
|
89
|
+
}
|
|
90
|
+
const dims = /(?:^|\s|,)(\d{2,5})x(\d{2,5})(?=[\s,[]|$)/.exec(resto);
|
|
91
|
+
const bitsM = /p(\d{2})(?:le|be)$/.exec(pixFmt) || /^p0(\d{2})/.exec(pixFmt);
|
|
92
|
+
let rotacao = 0;
|
|
93
|
+
for (let k = i + 1; k < linhas.length && !/^\s*Stream #/.test(linhas[k]); k++) {
|
|
94
|
+
const r = /displaymatrix: rotation of (-?[\d.]+) degrees/.exec(linhas[k]);
|
|
95
|
+
if (r) rotacao = Number(r[1]);
|
|
96
|
+
}
|
|
97
|
+
video = {codec, perfil, pixFmt, bits: bitsM ? Number(bitsM[1]) : 8, range, matriz, primarias, transferencia,
|
|
98
|
+
largura: dims ? Number(dims[1]) : NaN, altura: dims ? Number(dims[2]) : NaN,
|
|
99
|
+
fps: num((/([\d.]+) fps/.exec(resto) || [])[1]), tbr: num((/([\d.]+)k? tbr/.exec(resto) || [])[1]), rotacao};
|
|
100
|
+
}
|
|
101
|
+
return {duracaoS, video, temAudio};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* pacotes do vídeo sem decodificar (`ffmpeg -i x -map 0:v:0 -c copy -f framecrc -`): cada linha é
|
|
106
|
+
* "stream, dts, pts, duração, bytes, crc[, F=0xNN]" — sem F= (ou com o bit 0x1) é quadro-chave.
|
|
107
|
+
* → {quadros, fps, variavel, gopMaxS, inicioS} (fps pela mediana dos intervalos; variavel = intervalos fora de ±5%)
|
|
108
|
+
*/
|
|
109
|
+
function lerPacotes(texto) {
|
|
110
|
+
let tb = NaN;
|
|
111
|
+
const pts = [], chaves = [];
|
|
112
|
+
for (const l of String(texto || "").split(/\r?\n/)) {
|
|
113
|
+
if (l.startsWith("#tb 0:")) {
|
|
114
|
+
const [a, b] = l.slice(6).trim().split("/").map(Number);
|
|
115
|
+
tb = a / b;
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (!l || l[0] === "#" || !l.startsWith("0,")) continue;
|
|
119
|
+
const c = l.split(",").map((s) => s.trim());
|
|
120
|
+
const p = Number(c[2]);
|
|
121
|
+
if (!Number.isFinite(p)) continue;
|
|
122
|
+
const f = c[6] && c[6].startsWith("F=") ? parseInt(c[6].slice(2), 16) : 1;
|
|
123
|
+
pts.push(p);
|
|
124
|
+
if (f & 1) chaves.push(p);
|
|
125
|
+
}
|
|
126
|
+
if (!Number.isFinite(tb) || pts.length < 2) return null;
|
|
127
|
+
pts.sort((a, b) => a - b);
|
|
128
|
+
chaves.sort((a, b) => a - b);
|
|
129
|
+
const intervalos = [];
|
|
130
|
+
for (let i = 1; i < pts.length; i++) intervalos.push((pts[i] - pts[i - 1]) * tb);
|
|
131
|
+
const ord = [...intervalos].sort((a, b) => a - b);
|
|
132
|
+
const mediana = ord[Math.floor(ord.length / 2)];
|
|
133
|
+
let fora = 0, pior = 0;
|
|
134
|
+
for (const d of intervalos) {
|
|
135
|
+
const desvio = Math.abs(d - mediana) / mediana;
|
|
136
|
+
if (desvio > 0.05) fora++;
|
|
137
|
+
pior = Math.max(pior, desvio);
|
|
138
|
+
}
|
|
139
|
+
let gop = chaves.length ? (pts[pts.length - 1] - chaves[chaves.length - 1]) * tb : (pts[pts.length - 1] - pts[0]) * tb;
|
|
140
|
+
for (let i = 1; i < chaves.length; i++) gop = Math.max(gop, (chaves[i] - chaves[i - 1]) * tb);
|
|
141
|
+
const r3 = (x) => Math.round(x * 1000) / 1000;
|
|
142
|
+
return {quadros: pts.length, fps: r3(1 / mediana), variavel: fora > Math.max(2, pts.length * 0.01) || pior > 0.25,
|
|
143
|
+
gopMaxS: r3(gop), inicioS: r3(pts[0] * tb)};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** precisa normalizar? {precisa, motivos[]} — qualquer motivo basta */
|
|
147
|
+
function decidir(info) {
|
|
148
|
+
const motivos = [];
|
|
149
|
+
const v = info && info.video;
|
|
150
|
+
if (!v) return {precisa: false, motivos: []};
|
|
151
|
+
if (v.codec !== "h264") motivos.push(`codec ${v.codec}`);
|
|
152
|
+
if (v.bits > 8) motivos.push(`${v.bits} bits`);
|
|
153
|
+
else if (!/^(yuv420p|yuvj420p|nv12)$/.test(v.pixFmt)) motivos.push(`formato ${v.pixFmt || "desconhecido"}`);
|
|
154
|
+
if (HDR[v.transferencia]) motivos.push(`HDR ${HDR[v.transferencia]}`);
|
|
155
|
+
const q = info.pacotes;
|
|
156
|
+
if (!q) motivos.push("quadros não conferidos");
|
|
157
|
+
else {
|
|
158
|
+
if (Math.abs(q.fps - FPS) > 0.05) motivos.push(`${q.fps} fps`);
|
|
159
|
+
else if (q.variavel) motivos.push("fps variável");
|
|
160
|
+
if (q.gopMaxS > GOP_MAX_S) motivos.push(`quadro-chave a cada ${q.gopMaxS.toFixed(1)} s`);
|
|
161
|
+
}
|
|
162
|
+
if (Math.min(v.largura, v.altura) > LADO_CURTO_MAX) motivos.push(`resolução ${v.largura}x${v.altura}`);
|
|
163
|
+
return {precisa: motivos.length > 0, motivos};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** sonda o original só com o ffmpeg (o baixado não vem com ffprobe): `-i` + varredura dos pacotes do vídeo */
|
|
167
|
+
async function sondar(ffmpeg, arquivo, {sinal, rodador = rodar} = {}) {
|
|
168
|
+
const r = await rodador(ffmpeg, ["-hide_banner", "-nostdin", "-i", arquivo], {sinal});
|
|
169
|
+
const info = lerInfo(r.stderr);
|
|
170
|
+
if (!info.video) return {...info, pacotes: null};
|
|
171
|
+
const p = await rodador(ffmpeg, ["-hide_banner", "-nostdin", "-v", "error", "-i", arquivo, "-map", "0:v:0", "-c", "copy", "-f", "framecrc", "-"], {sinal});
|
|
172
|
+
return {...info, pacotes: p.status === 0 ? lerPacotes(p.stdout) : null};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ---------------------------------------------------------------- codificador
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* candidatos em ordem de preferência; cada um com os argumentos de qualidade. Qualidade pensada para um arquivo
|
|
179
|
+
* intermediário que ainda vai ser reduzido/recomprimido: visualmente igual ao original, sem estourar o disco.
|
|
180
|
+
*/
|
|
181
|
+
const CODIFICADORES = {
|
|
182
|
+
h264_nvenc: ({}) => ["-c:v", "h264_nvenc", "-preset", "p4", "-rc", "vbr", "-cq", "20", "-b:v", "0", "-bf", "2", "-profile:v", "high", "-no-scenecut", "1"],
|
|
183
|
+
h264_qsv: ({}) => ["-c:v", "h264_qsv", "-preset", "medium", "-global_quality", "20", "-look_ahead", "0", "-bf", "2", "-profile:v", "high"],
|
|
184
|
+
h264_amf: ({}) => ["-c:v", "h264_amf", "-usage", "transcoding", "-quality", "quality", "-rc", "cqp", "-qp_i", "18", "-qp_p", "20", "-qp_b", "22", "-profile:v", "high"],
|
|
185
|
+
// videotoolbox: qualidade constante (-q:v) só no Apple Silicon; taxa por pixel serve nos dois
|
|
186
|
+
h264_videotoolbox: ({largura = 1920, altura = 1080}) => ["-c:v", "h264_videotoolbox", "-b:v", String(Math.round(largura * altura * FPS * 0.2)), "-allow_sw", "0", "-realtime", "0", "-profile:v", "high"],
|
|
187
|
+
libx264: ({}) => ["-c:v", "libx264", "-preset", "veryfast", "-crf", "17", "-sc_threshold", "0", "-profile:v", "high"],
|
|
188
|
+
};
|
|
189
|
+
const ORDEM = {
|
|
190
|
+
win32: ["h264_nvenc", "h264_qsv", "h264_amf", "libx264"],
|
|
191
|
+
darwin: ["h264_videotoolbox", "libx264"],
|
|
192
|
+
linux: ["h264_nvenc", "h264_qsv", "libx264"],
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
/** argumentos do codificador + GOP fixo de 30 quadros (sem quadro-chave extra em troca de cena) */
|
|
196
|
+
function argsCodificador(nome, dims = {}) {
|
|
197
|
+
const gop = nome === "libx264" ? ["-g", String(GOP), "-keyint_min", String(GOP)] : ["-g", String(GOP)];
|
|
198
|
+
return [...CODIFICADORES[nome](dims), ...gop];
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const escolhidos = new Map(); // ffmpeg → Promise<nome>
|
|
202
|
+
/**
|
|
203
|
+
* o melhor codificador H.264 que funciona AQUI: lista do ffmpeg (-encoders) + uma codificação de teste de 6
|
|
204
|
+
* quadros (placa ausente/driver velho falham nela, não no meio do proxy). Guardado por caminho do ffmpeg.
|
|
205
|
+
* VIBE_RENDER_CODIFICADOR fixa um (diagnóstico; "libx264" simula máquina sem placa).
|
|
206
|
+
*/
|
|
207
|
+
function escolherCodificador(ffmpeg, {rodador = rodar, plataforma = process.platform, env = process.env, sinal} = {}) {
|
|
208
|
+
const fixo = env.VIBE_RENDER_CODIFICADOR;
|
|
209
|
+
if (fixo && CODIFICADORES[fixo]) return Promise.resolve(fixo);
|
|
210
|
+
const chave = `${ffmpeg}|${plataforma}`;
|
|
211
|
+
if (!escolhidos.has(chave)) {
|
|
212
|
+
const p = (async () => {
|
|
213
|
+
const lista = await rodador(ffmpeg, ["-hide_banner", "-encoders"], {sinal});
|
|
214
|
+
const tem = (n) => new RegExp(`\\s${n}\\s`).test(lista.stdout || "");
|
|
215
|
+
for (const nome of ORDEM[plataforma] || ["libx264"]) {
|
|
216
|
+
if (!tem(nome)) continue;
|
|
217
|
+
if (nome === "libx264") return nome; // software: sempre funciona se está no build
|
|
218
|
+
const r = await rodador(ffmpeg, ["-hide_banner", "-nostdin", "-v", "error", "-f", "lavfi", "-i", "color=c=gray:s=640x360:r=30:d=0.3",
|
|
219
|
+
"-vf", "format=nv12", ...argsCodificador(nome, {largura: 640, altura: 360}), "-frames:v", "6", "-f", "null", "-"], {sinal});
|
|
220
|
+
if (r.status === 0) return nome;
|
|
221
|
+
}
|
|
222
|
+
if (tem("libx264")) return "libx264";
|
|
223
|
+
throw new Error("o ffmpeg não tem codificador H.264");
|
|
224
|
+
})();
|
|
225
|
+
escolhidos.set(chave, p);
|
|
226
|
+
p.catch(() => escolhidos.delete(chave));
|
|
227
|
+
}
|
|
228
|
+
return escolhidos.get(chave);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// ---------------------------------------------------------------- HDR → SDR
|
|
232
|
+
|
|
233
|
+
/** grafo do compositor do Remotion (rust/tone_map.rs), em RGB cheio e com tudo explícito para o zimg */
|
|
234
|
+
function grafoTonemap(transferencia) {
|
|
235
|
+
return [`zscale=min=gbr:tin=${transferencia}:pin=bt2020:rin=full:m=gbr:t=linear:p=bt2020:r=full:npl=100`, "format=gbrpf32le",
|
|
236
|
+
"zscale=min=gbr:tin=linear:pin=bt2020:rin=full:m=gbr:t=linear:p=bt709:r=full", "tonemap=tonemap=hable:desat=0",
|
|
237
|
+
"zscale=min=gbr:tin=linear:pin=bt709:rin=full:m=gbr:t=bt709:p=bt709:r=full", "format=rgb48be"].join(",");
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const temFiltrosHdr = new Map();
|
|
241
|
+
/** o ffmpeg tem zscale (zimg), tonemap e haldclut? */
|
|
242
|
+
async function suportaLut(ffmpeg, {rodador = rodar} = {}) {
|
|
243
|
+
if (!temFiltrosHdr.has(ffmpeg)) {
|
|
244
|
+
const r = await rodador(ffmpeg, ["-hide_banner", "-filters"]);
|
|
245
|
+
temFiltrosHdr.set(ffmpeg, r.status === 0 && ["zscale", "tonemap", "haldclut", "haldclutsrc"].every((f) => new RegExp(`\\s${f}\\s`).test(r.stdout || "")));
|
|
246
|
+
}
|
|
247
|
+
return temFiltrosHdr.get(ffmpeg);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Hald CLUT (nível 8 = 64³ pontos, PNG 16 bits) de R'G'B' HDR BT.2020 → R'G'B' SDR BT.709, feita com o grafo do
|
|
252
|
+
* compositor. Uma vez por máquina e transferência; fica em <dir>/hdr-<transferência>-v1.png.
|
|
253
|
+
*/
|
|
254
|
+
async function garantirLut(ffmpeg, dir, transferencia, {rodador = rodar, sinal} = {}) {
|
|
255
|
+
const destino = path.join(dir, `hdr-${transferencia}-v${VERSAO_LUT}.png`);
|
|
256
|
+
if (fs.existsSync(destino)) return destino;
|
|
257
|
+
fs.mkdirSync(dir, {recursive: true});
|
|
258
|
+
const tmp = `${destino}.${crypto.randomBytes(4).toString("hex")}.png`;
|
|
259
|
+
try {
|
|
260
|
+
const r = await rodador(ffmpeg, ["-hide_banner", "-nostdin", "-v", "error", "-y", "-f", "lavfi", "-i", "haldclutsrc=level=8,format=gbrp16le",
|
|
261
|
+
"-frames:v", "1", "-vf", grafoTonemap(transferencia), tmp], {sinal});
|
|
262
|
+
if (r.status !== 0 || !fs.existsSync(tmp)) throw new Error(`não deu para montar a tabela HDR→SDR: ${r.stderr.slice(-300)}`);
|
|
263
|
+
fs.renameSync(tmp, destino);
|
|
264
|
+
return destino;
|
|
265
|
+
} finally {
|
|
266
|
+
fs.rmSync(tmp, {force: true});
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// ---------------------------------------------------------------- proxy
|
|
271
|
+
|
|
272
|
+
/** rotação da matriz de exibição → filtro (a mesma regra do autorotate do ffmpeg) */
|
|
273
|
+
function filtroRotacao(rotacao) {
|
|
274
|
+
const theta = ((Math.round(-rotacao) % 360) + 360) % 360;
|
|
275
|
+
if (Math.abs(theta - 90) < 1) return "transpose=clock";
|
|
276
|
+
if (Math.abs(theta - 180) < 1) return "hflip,vflip";
|
|
277
|
+
if (Math.abs(theta - 270) < 1) return "transpose=cclock";
|
|
278
|
+
return null;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* argumentos do ffmpeg para o proxy. plano: {decodificador: "cuda"|"d3d11va"|"videotoolbox"|null, codificador,
|
|
283
|
+
* lut: caminho da Hald CLUT | null}. Sem -ss: o proxy começa no 0 do original (fps=30 → quadro k em k/30).
|
|
284
|
+
*/
|
|
285
|
+
function argsProxy(info, original, destino, plano) {
|
|
286
|
+
const v = info.video;
|
|
287
|
+
const hdr = Boolean(HDR[v.transferencia]);
|
|
288
|
+
const lut = hdr && plano.lut ? plano.lut : null;
|
|
289
|
+
const cuda = plano.decodificador === "cuda";
|
|
290
|
+
const ent = ["-hide_banner", "-nostdin", "-y", "-noautorotate"];
|
|
291
|
+
if (cuda) ent.push("-hwaccel", "cuda", "-hwaccel_output_format", "cuda");
|
|
292
|
+
else if (plano.decodificador) ent.push("-hwaccel", plano.decodificador);
|
|
293
|
+
ent.push("-i", original);
|
|
294
|
+
if (lut) ent.push("-i", lut);
|
|
295
|
+
|
|
296
|
+
// 1) 30 fps constantes; com cuda, o fps roda na placa e só desce metade dos quadros
|
|
297
|
+
const f = [`fps=${FPS}`];
|
|
298
|
+
if (cuda) f.push(lut ? "hwdownload,format=p010le" : "scale_cuda=format=nv12,hwdownload,format=nv12");
|
|
299
|
+
// 2) HDR: tabela do grafo do compositor (RGB 16 bits empacotado: o haldclut do 6.1 erra em formato planar)
|
|
300
|
+
let grafo;
|
|
301
|
+
if (lut) {
|
|
302
|
+
f.push(`scale=in_color_matrix=bt2020:in_range=${v.range === "pc" ? "pc" : "tv"},format=rgb48le[rgb]`);
|
|
303
|
+
grafo = [`[0:v:0]${f.join(",")}`, "[1:v]format=rgb48le[clut]", "[rgb][clut]haldclut,scale=out_color_matrix=bt709:out_range=tv,format=nv12"];
|
|
304
|
+
} else {
|
|
305
|
+
f.push("format=nv12");
|
|
306
|
+
grafo = [`[0:v:0]${f.join(",")}`];
|
|
307
|
+
}
|
|
308
|
+
// 3) lado curto acima de 2160 (8K): reduz para 2160, pares; 4) gira de verdade (o compositor não precisa girar)
|
|
309
|
+
const extra = [];
|
|
310
|
+
const curto = Math.min(v.largura, v.altura);
|
|
311
|
+
if (curto > LADO_CURTO_MAX) {
|
|
312
|
+
const s = LADO_CURTO_MAX / curto;
|
|
313
|
+
const par = (x) => Math.max(2, Math.round((x * s) / 2) * 2);
|
|
314
|
+
extra.push(`scale=${par(v.largura)}:${par(v.altura)}:flags=lanczos`);
|
|
315
|
+
}
|
|
316
|
+
const rot = filtroRotacao(v.rotacao || 0);
|
|
317
|
+
if (rot) extra.push(rot);
|
|
318
|
+
const ultimo = grafo.length - 1;
|
|
319
|
+
grafo[ultimo] = `${grafo[ultimo]}${extra.length ? "," + extra.join(",") : ""}[v]`;
|
|
320
|
+
|
|
321
|
+
const girado = rot && rot.startsWith("transpose");
|
|
322
|
+
const dims = {largura: girado ? v.altura : v.largura, altura: girado ? v.largura : v.altura};
|
|
323
|
+
const cor = lut ? ["-color_primaries", "bt709", "-color_trc", "bt709", "-colorspace", "bt709"] : [
|
|
324
|
+
...(v.primarias && v.primarias !== "unknown" ? ["-color_primaries", v.primarias] : []),
|
|
325
|
+
...(v.transferencia && v.transferencia !== "unknown" ? ["-color_trc", v.transferencia] : []),
|
|
326
|
+
...(v.matriz && v.matriz !== "unknown" ? ["-colorspace", v.matriz] : [])];
|
|
327
|
+
return [...ent, "-filter_complex", grafo.join(";"), "-map", "[v]", "-map", "0:a:0?",
|
|
328
|
+
...argsCodificador(plano.codificador, dims), ...cor, "-color_range", "tv",
|
|
329
|
+
"-c:a", "copy", "-map_metadata", "-1", "-map_chapters", "-1", "-dn", "-sn",
|
|
330
|
+
"-progress", "pipe:1", "-nostats", "-f", "mp4", destino];
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** decodificadores na placa a tentar antes do software, por plataforma */
|
|
334
|
+
const DECODIFICADORES = {win32: ["cuda", "d3d11va"], linux: ["cuda"], darwin: ["videotoolbox"]};
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Faz o proxy (com progresso e cancelamento). Escolhe o plano mais rápido que funciona NESTE arquivo: um teste de
|
|
338
|
+
* meio segundo com a mesma linha de comando (placa sem HEVC 10 bits, driver velho etc. falham nele); se o proxy
|
|
339
|
+
* inteiro falhar mesmo assim, refaz em software + libx264. Devolve {plano, segundos, tentativas}.
|
|
340
|
+
*/
|
|
341
|
+
async function fazerProxy({ffmpeg, original, destino, info, dirLut, sinal, onProgresso, rodador = rodar, plataforma = process.platform, env = process.env}) {
|
|
342
|
+
const codificador = await escolherCodificador(ffmpeg, {rodador, plataforma, env, sinal});
|
|
343
|
+
const hdr = HDR[info.video.transferencia] ? info.video.transferencia : null;
|
|
344
|
+
let lut = null;
|
|
345
|
+
if (hdr && env.VIBE_RENDER_HDR !== "marcar" && await suportaLut(ffmpeg, {rodador})) {
|
|
346
|
+
lut = await garantirLut(ffmpeg, dirLut, hdr, {rodador, sinal}).catch(() => null);
|
|
347
|
+
}
|
|
348
|
+
const hw = await rodador(ffmpeg, ["-hide_banner", "-hwaccels"], {sinal});
|
|
349
|
+
const temHw = (n) => new RegExp(`^\\s*${n}\\s*$`, "m").test(hw.stdout || "");
|
|
350
|
+
const planos = [];
|
|
351
|
+
// a placa só ajuda a decodificar o que é pesado (HEVC/AV1/VP9, 10 bits, 1080p ou mais); num H.264 pequeno a
|
|
352
|
+
// ida e volta de cada quadro custa mais que decodificar em software (WhatsApp 478x850: 43 s × 7 s)
|
|
353
|
+
const v = info.video;
|
|
354
|
+
const pesado = v.codec !== "h264" || v.bits > 8 || v.largura * v.altura >= 1920 * 1080;
|
|
355
|
+
if (pesado) for (const d of DECODIFICADORES[plataforma] || []) if (temHw(d)) planos.push({decodificador: d, codificador, lut});
|
|
356
|
+
planos.push({decodificador: null, codificador, lut});
|
|
357
|
+
if (codificador !== "libx264") planos.push({decodificador: null, codificador: "libx264", lut});
|
|
358
|
+
|
|
359
|
+
const tmpTeste = `${destino}.teste.mp4`;
|
|
360
|
+
const t0 = Date.now();
|
|
361
|
+
let ultimoErro = null, tentativas = 0;
|
|
362
|
+
for (let i = 0; i < planos.length; i++) {
|
|
363
|
+
const plano = planos[i];
|
|
364
|
+
if (i < planos.length - 1) { // o último é o seguro: vai direto
|
|
365
|
+
const a = argsProxy(info, original, tmpTeste, plano);
|
|
366
|
+
const teste = await rodador(ffmpeg, [...a.slice(0, a.indexOf("-i")), "-t", "0.5", ...a.slice(a.indexOf("-i"))], {sinal});
|
|
367
|
+
fs.rmSync(tmpTeste, {force: true});
|
|
368
|
+
if (sinal && sinal.aborted) throw new Error("cancelado");
|
|
369
|
+
if (teste.status !== 0) {
|
|
370
|
+
ultimoErro = teste.stderr.slice(-600); continue;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
tentativas++;
|
|
374
|
+
let resto = "";
|
|
375
|
+
const r = await rodador(ffmpeg, argsProxy(info, original, destino, plano), {sinal, aoStdout: (d) => {
|
|
376
|
+
resto += d;
|
|
377
|
+
const linhas = resto.split(/\r?\n/);
|
|
378
|
+
resto = linhas.pop();
|
|
379
|
+
for (const l of linhas) {
|
|
380
|
+
const m = /^out_time_(?:us|ms)=(\d+)/.exec(l);
|
|
381
|
+
if (m && onProgresso && info.duracaoS > 0) onProgresso(Math.min(0.999, Number(m[1]) / 1e6 / info.duracaoS));
|
|
382
|
+
}
|
|
383
|
+
}});
|
|
384
|
+
if (sinal && sinal.aborted) throw new Error("cancelado");
|
|
385
|
+
if (r.status === 0 && fs.existsSync(destino) && fs.statSync(destino).size > 0) {
|
|
386
|
+
return {plano: {decodificador: plano.decodificador || "software", codificador: plano.codificador, hdr: hdr ? (lut ? "tabela" : "marcado") : null},
|
|
387
|
+
segundos: Math.round((Date.now() - t0) / 100) / 10, tentativas};
|
|
388
|
+
}
|
|
389
|
+
ultimoErro = r.stderr.slice(-600);
|
|
390
|
+
fs.rmSync(destino, {force: true});
|
|
391
|
+
}
|
|
392
|
+
throw new Error(`não deu para preparar o vídeo original: ${ultimoErro || "erro do ffmpeg"}`);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// ---------------------------------------------------------------- cache
|
|
396
|
+
|
|
397
|
+
/** registro sha do original + receita → {precisa, proxy (sha), motivos, plano, segundos} em <cache>/proxies/ */
|
|
398
|
+
const arquivoRegistro = (dirCache, sha, receita = RECEITA) => path.join(dirCache, "proxies", `${sha}.r${receita}.json`);
|
|
399
|
+
|
|
400
|
+
function lerRegistro(dirCache, sha) {
|
|
401
|
+
try {
|
|
402
|
+
const r = JSON.parse(fs.readFileSync(arquivoRegistro(dirCache, sha), "utf8"));
|
|
403
|
+
return r && r.receita === RECEITA ? r : null;
|
|
404
|
+
} catch {
|
|
405
|
+
return null;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function gravarRegistro(dirCache, sha, dados) {
|
|
410
|
+
const destino = arquivoRegistro(dirCache, sha);
|
|
411
|
+
fs.mkdirSync(path.dirname(destino), {recursive: true});
|
|
412
|
+
const tmp = `${destino}.${crypto.randomBytes(4).toString("hex")}.parte`;
|
|
413
|
+
fs.writeFileSync(tmp, JSON.stringify({...dados, receita: RECEITA, original: sha}));
|
|
414
|
+
fs.renameSync(tmp, destino);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* O vídeo que o render deve ler para o original `sha`: o proxy (feito agora ou antes) ou o próprio original.
|
|
419
|
+
* opc: {cache, ffmpeg, dirTmp, sinal, onProgresso(fração), aoSondar(), rodador, plataforma, env}
|
|
420
|
+
* → {sha (o que renderizar), proxy: bool, novo: bool, motivos, plano, segundos}
|
|
421
|
+
*/
|
|
422
|
+
async function garantirProxy(sha, opc) {
|
|
423
|
+
const {cache} = opc;
|
|
424
|
+
const reg = lerRegistro(cache.dir, sha);
|
|
425
|
+
if (reg && !reg.precisa) return {sha, proxy: false, novo: false, motivos: []};
|
|
426
|
+
if (reg && reg.proxy && cache.existe(reg.proxy)) {
|
|
427
|
+
cache.tocar(reg.proxy);
|
|
428
|
+
return {sha: reg.proxy, proxy: true, novo: false, motivos: reg.motivos, plano: reg.plano, segundos: reg.segundos};
|
|
429
|
+
}
|
|
430
|
+
if (opc.aoSondar) opc.aoSondar();
|
|
431
|
+
const original = cache.caminho(sha);
|
|
432
|
+
const info = await sondar(opc.ffmpeg, original, {sinal: opc.sinal, rodador: opc.rodador});
|
|
433
|
+
const {precisa, motivos} = decidir(info);
|
|
434
|
+
if (!precisa) {
|
|
435
|
+
gravarRegistro(cache.dir, sha, {precisa: false, motivos: []});
|
|
436
|
+
return {sha, proxy: false, novo: false, motivos: []};
|
|
437
|
+
}
|
|
438
|
+
fs.mkdirSync(opc.dirTmp, {recursive: true});
|
|
439
|
+
const destino = path.join(opc.dirTmp, `proxy-${sha.slice(0, 16)}-${crypto.randomBytes(4).toString("hex")}.mp4`);
|
|
440
|
+
try {
|
|
441
|
+
const feito = await fazerProxy({ffmpeg: opc.ffmpeg, original, destino, info, dirLut: path.join(cache.dir, "proxies"), sinal: opc.sinal,
|
|
442
|
+
onProgresso: opc.onProgresso, rodador: opc.rodador, plataforma: opc.plataforma, env: opc.env});
|
|
443
|
+
const proxy = await cache.adotar(destino);
|
|
444
|
+
gravarRegistro(cache.dir, sha, {precisa: true, proxy, motivos, plano: feito.plano, segundos: feito.segundos,
|
|
445
|
+
video: {codec: info.video.codec, bits: info.video.bits, largura: info.video.largura, altura: info.video.altura, rotacao: info.video.rotacao,
|
|
446
|
+
transferencia: info.video.transferencia, ...(info.pacotes ? {fps: info.pacotes.fps, gopMaxS: info.pacotes.gopMaxS} : {})}});
|
|
447
|
+
return {sha: proxy, proxy: true, novo: true, motivos, plano: feito.plano, segundos: feito.segundos};
|
|
448
|
+
} finally {
|
|
449
|
+
fs.rmSync(destino, {force: true});
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
module.exports = {garantirProxy, sondar, lerInfo, lerPacotes, decidir, escolherCodificador, argsCodificador, argsProxy, fazerProxy,
|
|
454
|
+
garantirLut, grafoTonemap, suportaLut, filtroRotacao, lerRegistro, arquivoRegistro, rodar, RECEITA, FPS, GOP, CODIFICADORES, ORDEM};
|
package/core/nucleo.js
CHANGED
|
@@ -5,11 +5,13 @@ const {criarCache} = require("./cache");
|
|
|
5
5
|
const {criarJobs} = require("./jobs");
|
|
6
6
|
const {criarPareamento} = require("./pareamento");
|
|
7
7
|
const {criarServidor} = require("./servidor");
|
|
8
|
+
const {criarTranscricoes} = require("./transcricao");
|
|
9
|
+
const {criarVoz} = require("./voz");
|
|
8
10
|
const {LIMITES, origensPermitidas, STATUS_INTERVALO} = require("./config");
|
|
9
11
|
|
|
10
12
|
/**
|
|
11
13
|
* opc: {versao, dirDados, dirSaida (string ou função), hostDir, navegador, binarios, armazenamento, nomeAparelho, licenseKey,
|
|
12
|
-
* mostrarArquivo, log, env, portas}
|
|
14
|
+
* mostrarArquivo, log, env, portas, voiceStudio (endereço do --voicestudio; fica guardado)}
|
|
13
15
|
*/
|
|
14
16
|
async function criarNucleo(opc) {
|
|
15
17
|
const env = opc.env || process.env;
|
|
@@ -18,10 +20,12 @@ async function criarNucleo(opc) {
|
|
|
18
20
|
let servidor = null;
|
|
19
21
|
const jobs = criarJobs({
|
|
20
22
|
cache, hostDir: opc.hostDir, navegador: opc.navegador, binarios: opc.binarios, dirSaida: opc.dirSaida,
|
|
21
|
-
dirTmp: path.join(opc.dirDados, "tmp"), licenseKey: opc.licenseKey,
|
|
23
|
+
dirTmp: path.join(opc.dirDados, "tmp"), dirDados: opc.dirDados, licenseKey: opc.licenseKey, audio: opc.audio,
|
|
22
24
|
urlAtivo: (jobId, sha, token) => `http://127.0.0.1:${servidor.porta()}/v1/ativos/${jobId}/${sha}?t=${encodeURIComponent(token)}`,
|
|
23
25
|
});
|
|
24
|
-
|
|
26
|
+
const transcricoes = criarTranscricoes({cache, dirDados: opc.dirDados, dirTmp: path.join(opc.dirDados, "tmp"), whisper: opc.whisper});
|
|
27
|
+
const voz = criarVoz({dirDados: opc.dirDados, env, urlInicial: opc.voiceStudio, log: opc.log});
|
|
28
|
+
servidor = criarServidor({versao: opc.versao, origens: origensPermitidas(env), cache, jobs, transcricoes, voz, pareamento, mostrarArquivo: opc.mostrarArquivo, log: opc.log});
|
|
25
29
|
const porta = await servidor.iniciar(opc.portas);
|
|
26
30
|
pareamento.conferir();
|
|
27
31
|
const relogio = setInterval(() => pareamento.conferir(), STATUS_INTERVALO);
|
|
@@ -31,6 +35,7 @@ async function criarNucleo(opc) {
|
|
|
31
35
|
async parar() {
|
|
32
36
|
clearInterval(relogio);
|
|
33
37
|
for (const j of jobs.ativos()) jobs.cancelar(j.id);
|
|
38
|
+
transcricoes.cancelarTodas();
|
|
34
39
|
await servidor.parar();
|
|
35
40
|
},
|
|
36
41
|
};
|
package/core/servidor.js
CHANGED
|
@@ -115,7 +115,8 @@ function criarServidor(opc) {
|
|
|
115
115
|
if (u.pathname === "/v1/ola" && req.method === "GET") {
|
|
116
116
|
const p = opc.pareamento.visao();
|
|
117
117
|
return json(200, {app: "vibe-render", versao: opc.versao, pareado: p.pareado, dispositivoId: p.dispositivoId, email: p.email,
|
|
118
|
-
plataforma: process.platform, porta, acessoPlataforma: p.plataforma
|
|
118
|
+
plataforma: process.platform, porta, acessoPlataforma: p.plataforma,
|
|
119
|
+
recursos: [...(opc.transcricoes ? ["transcricao"] : []), ...(opc.voz ? ["voz"] : [])]}, cors);
|
|
119
120
|
}
|
|
120
121
|
|
|
121
122
|
// daqui para baixo: sessão válida deste aparelho
|
|
@@ -140,6 +141,26 @@ function criarServidor(opc) {
|
|
|
140
141
|
const corpo = await lerJson(req, LIMITES.jsonBytes);
|
|
141
142
|
return json(201, opc.jobs.criar(corpo), cors);
|
|
142
143
|
}
|
|
144
|
+
// transcrição (whisper.cpp) para quem não tem placa de vídeo no navegador: core/transcricao.js
|
|
145
|
+
if (u.pathname === "/v1/transcricoes" && req.method === "POST" && opc.transcricoes) {
|
|
146
|
+
if (!opc.pareamento.visao().plataforma) return json(403, {erro: "Sua conta está sem a Plataforma."}, cors);
|
|
147
|
+
return json(201, opc.transcricoes.criar(await lerJson(req, 64 * 1024)), cors);
|
|
148
|
+
}
|
|
149
|
+
if (partes[0] === "v1" && partes[1] === "transcricoes" && partes[2] && opc.transcricoes) {
|
|
150
|
+
if (partes.length === 3 && req.method === "GET") {
|
|
151
|
+
const t = opc.transcricoes.obter(partes[2]);
|
|
152
|
+
return t ? json(200, t, cors) : json(404, {erro: "transcrição não encontrada"}, cors);
|
|
153
|
+
}
|
|
154
|
+
if (partes[3] === "cancelar" && req.method === "POST") {
|
|
155
|
+
const t = opc.transcricoes.cancelar(partes[2]);
|
|
156
|
+
return t ? json(200, t, cors) : json(404, {erro: "transcrição não encontrada"}, cors);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
// VoiceStudio (voz clonada) pelo app local dele: core/voz.js
|
|
160
|
+
if (partes[0] === "v1" && partes[1] === "voz" && opc.voz) {
|
|
161
|
+
if (!opc.pareamento.visao().plataforma) return json(403, {erro: "Sua conta está sem a Plataforma."}, cors);
|
|
162
|
+
if (await opc.voz.rota(req, res, partes.slice(2), {json, cors})) return;
|
|
163
|
+
}
|
|
143
164
|
if (partes[0] === "v1" && partes[1] === "jobs" && partes[2]) {
|
|
144
165
|
const id = partes[2];
|
|
145
166
|
const j = opc.jobs.obter(id);
|