imgtune 0.1.0 → 0.2.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/CHANGELOG.md +74 -0
- package/README.md +41 -0
- package/dist/animated.js +176 -0
- package/dist/backgrounds.js +13 -2
- package/dist/check-report.js +20 -3
- package/dist/check.js +8 -4
- package/dist/cli.js +66 -8
- package/dist/fetch.js +9 -4
- package/dist/ffmpeg.js +495 -0
- package/dist/gpu.js +12 -0
- package/dist/hints.js +418 -6
- package/dist/image-file.js +154 -34
- package/dist/images.js +27 -2
- package/dist/locate-join.js +86 -0
- package/dist/locate-only.js +14 -3
- package/dist/locate.js +6 -2
- package/dist/media-encode.js +1050 -0
- package/dist/media-file.js +284 -0
- package/dist/media-limits.js +31 -0
- package/dist/media-model.js +247 -0
- package/dist/media-phase.js +652 -0
- package/dist/media-prepare.js +31 -0
- package/dist/recheck.js +3 -3
- package/dist/remote.js +474 -69
- package/dist/result-cache.js +56 -3
- package/dist/run.js +8 -4
- package/dist/summary.js +23 -3
- package/dist/video-analysis.js +273 -0
- package/dist/webp-animation.js +283 -0
- package/package.json +1 -1
package/dist/fetch.js
CHANGED
|
@@ -75,7 +75,7 @@ export function makeFetcher(opts) {
|
|
|
75
75
|
}
|
|
76
76
|
};
|
|
77
77
|
/** Each hop is checked at connect time (guardedLookup); redirects are followed here, not by the client. */
|
|
78
|
-
const fetchChecked = async (start, maxBytes, allowPrivate, sameSiteAs, sameOriginAs) => {
|
|
78
|
+
const fetchChecked = async (start, maxBytes, allowPrivate, sameSiteAs, sameOriginAs, allowHop) => {
|
|
79
79
|
const controller = new AbortController();
|
|
80
80
|
const timer = setTimeout(() => controller.abort(), EXTRA_FETCH_TIMEOUT_MS);
|
|
81
81
|
try {
|
|
@@ -99,7 +99,11 @@ export function makeFetcher(opts) {
|
|
|
99
99
|
res.resume();
|
|
100
100
|
if (hop >= MAX_REDIRECTS)
|
|
101
101
|
return undefined;
|
|
102
|
-
|
|
102
|
+
const next = new URL(location, url).toString();
|
|
103
|
+
// A hop the policy refuses is not requested: the fetch ends here, as a failed one does.
|
|
104
|
+
if (allowHop !== undefined && !allowHop(next))
|
|
105
|
+
return undefined;
|
|
106
|
+
current = next;
|
|
103
107
|
continue;
|
|
104
108
|
}
|
|
105
109
|
if (status < 200 || status >= 300) {
|
|
@@ -137,14 +141,15 @@ export function makeFetcher(opts) {
|
|
|
137
141
|
const maxBytes = fetchOpts.maxBytes ?? MAX_IMAGE_BYTES;
|
|
138
142
|
const allowPrivate = fetchOpts.allowPrivate ?? false;
|
|
139
143
|
// The request headers are part of the key: a response can depend on them (the user agent and Accept are fixed per run).
|
|
140
|
-
const
|
|
144
|
+
const hopKey = fetchOpts.allowHop === undefined ? "" : `hop:${fetchOpts.allowHopKey ?? ""}`;
|
|
145
|
+
const key = JSON.stringify([url, maxBytes, allowPrivate, fetchOpts.sameSiteAs ?? "", fetchOpts.sameOriginAs ?? "", opts.userAgent, IMAGE_ACCEPT, hopKey]);
|
|
141
146
|
const known = memo.get(key);
|
|
142
147
|
if (known) {
|
|
143
148
|
count("fetch memo hits");
|
|
144
149
|
return known;
|
|
145
150
|
}
|
|
146
151
|
count("fetch requests");
|
|
147
|
-
const fetched = measure("fetch: network", () => withSlot(() => fetchChecked(url, maxBytes, allowPrivate, fetchOpts.sameSiteAs, fetchOpts.sameOriginAs))).catch(() => undefined);
|
|
152
|
+
const fetched = measure("fetch: network", () => withSlot(() => fetchChecked(url, maxBytes, allowPrivate, fetchOpts.sameSiteAs, fetchOpts.sameOriginAs, fetchOpts.allowHop))).catch(() => undefined);
|
|
148
153
|
const pending = fetched.then((answer) => {
|
|
149
154
|
// Settled: a failure or a no-store body is not kept for the next page (its callers above already have it).
|
|
150
155
|
if (answer === undefined || answer.noStore) {
|
package/dist/ffmpeg.js
ADDED
|
@@ -0,0 +1,495 @@
|
|
|
1
|
+
// The system ffmpeg and ffprobe, used through their command lines. imgtune never bundles ffmpeg: a probe reports what
|
|
2
|
+
// the installed build can do, and every call here is bounded by a timeout and can be aborted, so one heavy file cannot
|
|
3
|
+
// hold a scan.
|
|
4
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
5
|
+
import { spawn } from "node:child_process";
|
|
6
|
+
import { isAbsolute } from "node:path";
|
|
7
|
+
/** Parallel ffmpeg jobs an encode run starts at once: a small number, because each job already uses several threads. */
|
|
8
|
+
export const FFMPEG_JOBS = 4;
|
|
9
|
+
/** Threads each ffmpeg job uses. FFMPEG_JOBS × FFMPEG_THREADS stays within the CPUs the encodes are given. */
|
|
10
|
+
export const FFMPEG_THREADS = 2;
|
|
11
|
+
/** Stderr kept from a run, in bytes. The tail is kept, because ffmpeg prints the error last. */
|
|
12
|
+
const STDERR_CAP = 64 * 1024;
|
|
13
|
+
/** Stdout a run may produce before it is stopped, in bytes: enough for a short raw-video sample, not for a runaway. */
|
|
14
|
+
export const DEFAULT_MAX_STDOUT_BYTES = 32 * 1024 * 1024;
|
|
15
|
+
/** Thrown when an ffmpeg run passes its timeout; the process is killed before this rejects. */
|
|
16
|
+
export class FfmpegTimeout extends Error {
|
|
17
|
+
timeoutMs;
|
|
18
|
+
constructor(timeoutMs) {
|
|
19
|
+
super(`ffmpeg did not finish within ${timeoutMs} ms`);
|
|
20
|
+
this.name = "FfmpegTimeout";
|
|
21
|
+
this.timeoutMs = timeoutMs;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/** Thrown when a run writes more stdout than its cap; the process is killed before this rejects. */
|
|
25
|
+
export class FfmpegOutputTooLarge extends Error {
|
|
26
|
+
maxStdoutBytes;
|
|
27
|
+
constructor(maxStdoutBytes) {
|
|
28
|
+
super(`ffmpeg wrote more than ${maxStdoutBytes} bytes to stdout`);
|
|
29
|
+
this.name = "FfmpegOutputTooLarge";
|
|
30
|
+
this.maxStdoutBytes = maxStdoutBytes;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* The most ffmpeg and ffprobe processes the CPU pool runs at once. 4 jobs × FFMPEG_THREADS (2) = 8 threads, which is the CPU
|
|
35
|
+
* budget the encodes are given. Every CPU run waits for a slot, FIFO, across the whole process.
|
|
36
|
+
*/
|
|
37
|
+
export const FFMPEG_MAX_PROCS = 4;
|
|
38
|
+
/**
|
|
39
|
+
* The most ffmpeg processes one GPU runs at once (its own pool, apart from the CPU pool). Two, so the card's NVENC sessions
|
|
40
|
+
* and its CUDA context are not oversubscribed by one worker pair.
|
|
41
|
+
*/
|
|
42
|
+
export const GPU_MAX_PROCS = 2;
|
|
43
|
+
/** The GPU a run is bound to, inherited by every async step started inside onGpuDevice. */
|
|
44
|
+
const gpuContext = new AsyncLocalStorage();
|
|
45
|
+
/**
|
|
46
|
+
* Runs `run` with every ffmpeg and ffprobe process it starts bound to one GPU: they take that GPU's pool (GPU_MAX_PROCS) and
|
|
47
|
+
* run with CUDA_DEVICE_ORDER=PCI_BUS_ID and CUDA_VISIBLE_DEVICES=device, unless a call names its own environment. Runs outside
|
|
48
|
+
* it use the CPU pool (FFMPEG_MAX_PROCS).
|
|
49
|
+
*/
|
|
50
|
+
export function onGpuDevice(device, run) {
|
|
51
|
+
return gpuContext.run({ device }, run);
|
|
52
|
+
}
|
|
53
|
+
/** A slot pool: at most `limit` processes hold a slot at once, the rest wait in arrival order. */
|
|
54
|
+
class SlotPool {
|
|
55
|
+
limit;
|
|
56
|
+
inUse = 0;
|
|
57
|
+
queue = [];
|
|
58
|
+
constructor(limit) {
|
|
59
|
+
this.limit = limit;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
const cpuPool = new SlotPool(FFMPEG_MAX_PROCS);
|
|
63
|
+
const gpuPools = new Map();
|
|
64
|
+
function gpuPool(device) {
|
|
65
|
+
let pool = gpuPools.get(device);
|
|
66
|
+
if (pool === undefined) {
|
|
67
|
+
pool = new SlotPool(GPU_MAX_PROCS);
|
|
68
|
+
gpuPools.set(device, pool);
|
|
69
|
+
}
|
|
70
|
+
return pool;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Starts `run` now when a slot of the pool is free, else queues it. Returns a function that removes the run from the queue if
|
|
74
|
+
* it is still waiting (used when its timeout or abort fires first). A run that starts holds its slot until releaseSlot.
|
|
75
|
+
*/
|
|
76
|
+
function requestSlot(pool, run) {
|
|
77
|
+
if (pool.inUse < pool.limit) {
|
|
78
|
+
pool.inUse++;
|
|
79
|
+
run();
|
|
80
|
+
return () => { };
|
|
81
|
+
}
|
|
82
|
+
pool.queue.push(run);
|
|
83
|
+
return () => {
|
|
84
|
+
const index = pool.queue.indexOf(run);
|
|
85
|
+
if (index >= 0)
|
|
86
|
+
pool.queue.splice(index, 1);
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
/** Gives a slot back. The next queued run of the pool takes it at once, so the count never exceeds the pool's limit. */
|
|
90
|
+
function releaseSlot(pool) {
|
|
91
|
+
pool.inUse--;
|
|
92
|
+
const next = pool.queue.shift();
|
|
93
|
+
if (next) {
|
|
94
|
+
pool.inUse++;
|
|
95
|
+
next();
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Runs a binary to completion, once it holds a slot (see FFMPEG_MAX_PROCS). Resolves with the exit code for any exit (a
|
|
100
|
+
* non-zero code is the caller's to judge); rejects on a timeout (FfmpegTimeout), on an abort, on stdout past its cap
|
|
101
|
+
* (FfmpegOutputTooLarge), or when the binary cannot be started.
|
|
102
|
+
*
|
|
103
|
+
* The timeout starts when the call is made, so it covers the wait for a slot: a run still queued when its timeout fires
|
|
104
|
+
* is removed from the queue and never starts. The per-asset deadline in media-encode is computed at call time too, so it
|
|
105
|
+
* covers the queueing as well. The process is killed with SIGKILL on a timeout, an abort or an oversized stdout, and its
|
|
106
|
+
* slot is released then, so a stuck or runaway encode gives its CPU back at once.
|
|
107
|
+
*/
|
|
108
|
+
function runBinary(bin, args, opts) {
|
|
109
|
+
const maxStdoutBytes = opts.maxStdoutBytes ?? DEFAULT_MAX_STDOUT_BYTES;
|
|
110
|
+
// A run started inside onGpuDevice belongs to that GPU: its pool, and its CUDA environment unless the call names one.
|
|
111
|
+
const device = gpuContext.getStore()?.device;
|
|
112
|
+
const pool = device === undefined ? cpuPool : gpuPool(device);
|
|
113
|
+
const env = opts.env ?? (device === undefined ? undefined : nvencEnv(device));
|
|
114
|
+
return new Promise((resolve, reject) => {
|
|
115
|
+
if (opts.signal?.aborted) {
|
|
116
|
+
reject(abortError());
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
let child;
|
|
120
|
+
let holdsSlot = false;
|
|
121
|
+
let cancelQueued = () => { };
|
|
122
|
+
const stdout = [];
|
|
123
|
+
let stdoutBytes = 0;
|
|
124
|
+
// Stderr is kept as bytes and decoded once at close, so a multi-byte character is never split by a chunk edge.
|
|
125
|
+
let stderr = [];
|
|
126
|
+
let stderrBytes = 0;
|
|
127
|
+
let settled = false;
|
|
128
|
+
let timer;
|
|
129
|
+
const finish = (settle) => {
|
|
130
|
+
if (settled)
|
|
131
|
+
return;
|
|
132
|
+
settled = true;
|
|
133
|
+
clearTimeout(timer);
|
|
134
|
+
opts.signal?.removeEventListener("abort", onAbort);
|
|
135
|
+
if (holdsSlot) {
|
|
136
|
+
holdsSlot = false;
|
|
137
|
+
releaseSlot(pool);
|
|
138
|
+
}
|
|
139
|
+
else {
|
|
140
|
+
cancelQueued();
|
|
141
|
+
}
|
|
142
|
+
settle();
|
|
143
|
+
};
|
|
144
|
+
const kill = () => {
|
|
145
|
+
child?.kill("SIGKILL");
|
|
146
|
+
};
|
|
147
|
+
const onAbort = () => {
|
|
148
|
+
kill();
|
|
149
|
+
finish(() => reject(abortError()));
|
|
150
|
+
};
|
|
151
|
+
timer = setTimeout(() => {
|
|
152
|
+
kill();
|
|
153
|
+
finish(() => reject(new FfmpegTimeout(opts.timeoutMs)));
|
|
154
|
+
}, opts.timeoutMs);
|
|
155
|
+
opts.signal?.addEventListener("abort", onAbort, { once: true });
|
|
156
|
+
const start = () => {
|
|
157
|
+
holdsSlot = true;
|
|
158
|
+
try {
|
|
159
|
+
child = spawn(bin, args, { stdio: [opts.stdin ? "pipe" : "ignore", "pipe", "pipe"], env });
|
|
160
|
+
}
|
|
161
|
+
catch (err) {
|
|
162
|
+
finish(() => reject(err instanceof Error ? err : new Error(String(err))));
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
if (opts.stdin && child.stdin)
|
|
166
|
+
feedStdin(child.stdin, opts.stdin(), () => settled);
|
|
167
|
+
child.stdout.on("data", (chunk) => {
|
|
168
|
+
if (settled)
|
|
169
|
+
return;
|
|
170
|
+
stdoutBytes += chunk.length;
|
|
171
|
+
if (stdoutBytes > maxStdoutBytes) {
|
|
172
|
+
kill();
|
|
173
|
+
finish(() => reject(new FfmpegOutputTooLarge(maxStdoutBytes)));
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
stdout.push(chunk);
|
|
177
|
+
});
|
|
178
|
+
child.stderr.on("data", (chunk) => {
|
|
179
|
+
stderr.push(chunk);
|
|
180
|
+
stderrBytes += chunk.length;
|
|
181
|
+
if (stderrBytes > STDERR_CAP) {
|
|
182
|
+
// Compact to the tail, so memory stays bounded even for a chatty run.
|
|
183
|
+
const tail = Buffer.concat(stderr).subarray(-STDERR_CAP);
|
|
184
|
+
stderr = [tail];
|
|
185
|
+
stderrBytes = tail.length;
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
child.on("error", (err) => finish(() => reject(err)));
|
|
189
|
+
child.on("close", (code) => {
|
|
190
|
+
finish(() => resolve({ code: code ?? -1, stdout: Buffer.concat(stdout), stderr: decodeStderr(Buffer.concat(stderr)) }));
|
|
191
|
+
});
|
|
192
|
+
};
|
|
193
|
+
cancelQueued = requestSlot(pool, start);
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Decodes the kept tail of stderr. The tail may start inside a multi-byte character, so leading continuation bytes
|
|
198
|
+
* (10xxxxxx) are dropped first. Valid UTF-8 never starts with one, so this is a no-op when nothing was cut.
|
|
199
|
+
*/
|
|
200
|
+
function decodeStderr(bytes) {
|
|
201
|
+
let start = 0;
|
|
202
|
+
while (start < bytes.length && (bytes[start] & 0xc0) === 0x80)
|
|
203
|
+
start++;
|
|
204
|
+
return bytes.subarray(start).toString("utf8");
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Writes the chunks to a process's stdin, honouring backpressure, then ends it. Stops when the run has settled (a killed
|
|
208
|
+
* process closes its stdin, and a write to it then fails: the error is the run's outcome, not this one).
|
|
209
|
+
*/
|
|
210
|
+
async function feedStdin(stdin, chunks, settled) {
|
|
211
|
+
stdin.on("error", () => undefined);
|
|
212
|
+
try {
|
|
213
|
+
for await (const chunk of chunks) {
|
|
214
|
+
if (settled() || stdin.destroyed)
|
|
215
|
+
break;
|
|
216
|
+
if (!stdin.write(chunk)) {
|
|
217
|
+
await new Promise((resolve) => {
|
|
218
|
+
const done = () => {
|
|
219
|
+
stdin.off("drain", done);
|
|
220
|
+
stdin.off("close", done);
|
|
221
|
+
resolve();
|
|
222
|
+
};
|
|
223
|
+
stdin.once("drain", done);
|
|
224
|
+
stdin.once("close", done);
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
catch {
|
|
230
|
+
// The producer failed: the process sees its input end early and the run reports that.
|
|
231
|
+
}
|
|
232
|
+
finally {
|
|
233
|
+
if (!stdin.destroyed)
|
|
234
|
+
stdin.end();
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
function abortError() {
|
|
238
|
+
const err = new Error("ffmpeg run aborted");
|
|
239
|
+
err.name = "AbortError";
|
|
240
|
+
return err;
|
|
241
|
+
}
|
|
242
|
+
/** The ffmpeg binary: IMGTUNE_FFMPEG when set, else `ffmpeg` on PATH. */
|
|
243
|
+
function ffmpegBin() {
|
|
244
|
+
return process.env.IMGTUNE_FFMPEG || "ffmpeg";
|
|
245
|
+
}
|
|
246
|
+
/** The ffprobe binary: IMGTUNE_FFPROBE when set, else `ffprobe` on PATH. */
|
|
247
|
+
function ffprobeBin() {
|
|
248
|
+
return process.env.IMGTUNE_FFPROBE || "ffprobe";
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Runs ffmpeg with `-hide_banner -nostdin` in front of the given arguments. Resolves with the exit code and captured
|
|
252
|
+
* output; rejects with FfmpegTimeout past the timeout, on abort, or when the binary cannot be started.
|
|
253
|
+
*/
|
|
254
|
+
export function runFfmpeg(args, opts) {
|
|
255
|
+
return runBinary(ffmpegBin(), ["-hide_banner", "-nostdin", ...args], opts);
|
|
256
|
+
}
|
|
257
|
+
let probing;
|
|
258
|
+
/**
|
|
259
|
+
* Which capabilities the installed ffmpeg has. Memoised per process, so a scan probes once. Never throws: a missing or
|
|
260
|
+
* broken binary reports false for everything it would have provided.
|
|
261
|
+
*/
|
|
262
|
+
export function probeFfmpeg() {
|
|
263
|
+
probing ??= computeCaps();
|
|
264
|
+
return probing;
|
|
265
|
+
}
|
|
266
|
+
/** Forgets the memoised probe, so the next probeFfmpeg reads the environment again. For tests only. */
|
|
267
|
+
export function resetFfmpegProbe() {
|
|
268
|
+
probing = undefined;
|
|
269
|
+
}
|
|
270
|
+
const PROBE_TIMEOUT_MS = 15_000;
|
|
271
|
+
async function computeCaps() {
|
|
272
|
+
try {
|
|
273
|
+
const [encoders, filters, muxers, ffprobeOk] = await Promise.all([
|
|
274
|
+
listOutput(() => runFfmpeg(["-encoders"], { timeoutMs: PROBE_TIMEOUT_MS })),
|
|
275
|
+
listOutput(() => runFfmpeg(["-filters"], { timeoutMs: PROBE_TIMEOUT_MS })),
|
|
276
|
+
listOutput(() => runFfmpeg(["-muxers"], { timeoutMs: PROBE_TIMEOUT_MS })),
|
|
277
|
+
runBinary(ffprobeBin(), ["-version"], { timeoutMs: PROBE_TIMEOUT_MS }).then((r) => r.code === 0, () => false),
|
|
278
|
+
]);
|
|
279
|
+
return capsOf({ encoders, filters, muxers, ffprobe: ffprobeOk });
|
|
280
|
+
}
|
|
281
|
+
catch {
|
|
282
|
+
return capsOf({});
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* The capabilities a set of ffmpeg listings gives. A listing that is undefined (the command failed) reports its
|
|
287
|
+
* capabilities as missing; `ffmpeg` is true only when the encoder listing could be read. The NVENC flags are only the
|
|
288
|
+
* listing's: the device test (withGpu) is what makes them usable.
|
|
289
|
+
*/
|
|
290
|
+
export function capsOf(lists) {
|
|
291
|
+
const encoderNames = lists.encoders ?? new Set();
|
|
292
|
+
const av1 = encoderNames.has("libsvtav1")
|
|
293
|
+
? "libsvtav1"
|
|
294
|
+
: encoderNames.has("libaom-av1")
|
|
295
|
+
? "libaom-av1"
|
|
296
|
+
: undefined;
|
|
297
|
+
return {
|
|
298
|
+
ffmpeg: lists.encoders !== undefined,
|
|
299
|
+
ffprobe: lists.ffprobe ?? false,
|
|
300
|
+
vmaf: lists.filters?.has("libvmaf") ?? false,
|
|
301
|
+
av1,
|
|
302
|
+
x264: encoderNames.has("libx264"),
|
|
303
|
+
webpAnim: encoderNames.has("libwebp_anim"),
|
|
304
|
+
avifMuxer: lists.muxers?.has("avif") ?? false,
|
|
305
|
+
av1Nvenc: encoderNames.has("av1_nvenc"),
|
|
306
|
+
h264Nvenc: encoderNames.has("h264_nvenc"),
|
|
307
|
+
vmafCuda: lists.filters?.has("libvmaf_cuda") ?? false,
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* The AV1 level NVENC is pinned to: 5.1 (seq_level_idx 13). NVENC's default ("auto", 24) writes seq_level_idx 23 at high
|
|
312
|
+
* quality, which libaom rejects ("not yet defined"), so the stream cannot be scored. 5.1 is far above any sample's bitrate.
|
|
313
|
+
*/
|
|
314
|
+
export const AV1_NVENC_LEVEL = 13;
|
|
315
|
+
/**
|
|
316
|
+
* The NVENC options of one encode: preset p4, the high-quality tune, constant quality `cq` in VBR, on GPU index 0. An AV1
|
|
317
|
+
* encode also pins its level (AV1_NVENC_LEVEL); H.264's -level is a different scale, so it is not set.
|
|
318
|
+
*/
|
|
319
|
+
export function nvencOptions(cq, av1 = false) {
|
|
320
|
+
return [
|
|
321
|
+
...(av1 ? ["-level", String(AV1_NVENC_LEVEL)] : []),
|
|
322
|
+
"-preset", "p4", "-tune", "hq", "-rc", "vbr", "-cq", String(cq), "-b:v", "0", "-gpu", "0",
|
|
323
|
+
];
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* The environment of an NVENC run on one GPU: CUDA numbers the devices by PCI bus, and only the chosen device is visible,
|
|
327
|
+
* so `-gpu 0` is that device. This overrides any CUDA_VISIBLE_DEVICES of this process (the encode has no other use for it).
|
|
328
|
+
*/
|
|
329
|
+
export function nvencEnv(device, base = process.env) {
|
|
330
|
+
return { ...base, CUDA_DEVICE_ORDER: "PCI_BUS_ID", CUDA_VISIBLE_DEVICES: String(device) };
|
|
331
|
+
}
|
|
332
|
+
/** The 256x144 test clip of the NVENC probe: 0.2 s, the smallest size NVENC takes. */
|
|
333
|
+
const NVENC_TEST_INPUT = ["-f", "lavfi", "-i", "testsrc2=s=256x144:d=0.2"];
|
|
334
|
+
const NVENC_TEST_TIMEOUT_MS = 30_000;
|
|
335
|
+
const gpuTests = new Map();
|
|
336
|
+
/**
|
|
337
|
+
* Proves the GPU encoders and VMAF on one device: a tiny encode per NVENC encoder the build lists, and a tiny libvmaf_cuda run
|
|
338
|
+
* when the build lists it. Memoised per device, so a run tests each card once. A failed test means that encoder or filter
|
|
339
|
+
* cannot run on that device.
|
|
340
|
+
*/
|
|
341
|
+
export function testGpu(device, caps) {
|
|
342
|
+
let pending = gpuTests.get(device);
|
|
343
|
+
if (pending === undefined) {
|
|
344
|
+
pending = Promise.all([
|
|
345
|
+
caps.av1Nvenc ? encodesOn("av1_nvenc", device) : Promise.resolve(false),
|
|
346
|
+
caps.h264Nvenc ? encodesOn("h264_nvenc", device) : Promise.resolve(false),
|
|
347
|
+
caps.vmafCuda ? vmafCudaOn(device) : Promise.resolve(false),
|
|
348
|
+
]).then(([av1, h264, vmafCuda]) => ({ av1, h264, vmafCuda }));
|
|
349
|
+
gpuTests.set(device, pending);
|
|
350
|
+
}
|
|
351
|
+
return pending;
|
|
352
|
+
}
|
|
353
|
+
/** Whether libvmaf_cuda scores two frames on `device` (exit code 0). Never throws. */
|
|
354
|
+
async function vmafCudaOn(device) {
|
|
355
|
+
try {
|
|
356
|
+
const result = await runFfmpeg([
|
|
357
|
+
"-f", "lavfi", "-i", "testsrc2=s=256x144:d=0.2",
|
|
358
|
+
"-f", "lavfi", "-i", "testsrc2=s=256x144:d=0.2",
|
|
359
|
+
"-filter_complex", "[0:v]format=yuv420p,hwupload_cuda[a];[1:v]format=yuv420p,hwupload_cuda[b];[a][b]libvmaf_cuda",
|
|
360
|
+
"-f", "null", "-",
|
|
361
|
+
], { timeoutMs: NVENC_TEST_TIMEOUT_MS, env: nvencEnv(device) });
|
|
362
|
+
return result.code === 0;
|
|
363
|
+
}
|
|
364
|
+
catch {
|
|
365
|
+
return false;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
/** Whether `encoder` makes a frame on `device` (exit code 0 of a short encode to the null muxer). Never throws. */
|
|
369
|
+
async function encodesOn(encoder, device) {
|
|
370
|
+
try {
|
|
371
|
+
const result = await runFfmpeg([...NVENC_TEST_INPUT, "-c:v", encoder, ...nvencOptions(30, encoder === "av1_nvenc"), "-f", "null", "-"], { timeoutMs: NVENC_TEST_TIMEOUT_MS, env: nvencEnv(device) });
|
|
372
|
+
return result.code === 0;
|
|
373
|
+
}
|
|
374
|
+
catch {
|
|
375
|
+
return false;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
/**
|
|
379
|
+
* The capabilities with the GPU encoders and VMAF that passed their tests on `device`. Without a device the caps come back
|
|
380
|
+
* with the NVENC and CUDA flags false.
|
|
381
|
+
*/
|
|
382
|
+
export async function withGpu(caps, device) {
|
|
383
|
+
if (device === undefined || (!caps.av1Nvenc && !caps.h264Nvenc && !caps.vmafCuda)) {
|
|
384
|
+
return { ...caps, av1Nvenc: false, h264Nvenc: false, vmafCuda: false };
|
|
385
|
+
}
|
|
386
|
+
const tested = await testGpu(device, caps);
|
|
387
|
+
return { ...caps, av1Nvenc: tested.av1, h264Nvenc: tested.h264, vmafCuda: tested.vmafCuda };
|
|
388
|
+
}
|
|
389
|
+
/**
|
|
390
|
+
* Runs a listing command (`-encoders`, `-filters`, `-muxers`) and returns the set of names it lists, or undefined when
|
|
391
|
+
* the command fails. Each listing row is `flags name description`, so the name is the second token; legend and header
|
|
392
|
+
* lines contribute only tokens that no capability is looked up by.
|
|
393
|
+
*/
|
|
394
|
+
async function listOutput(run) {
|
|
395
|
+
const result = await run().catch(() => undefined);
|
|
396
|
+
if (!result || result.code !== 0)
|
|
397
|
+
return undefined;
|
|
398
|
+
return parseListing(result.stdout.toString("utf8"));
|
|
399
|
+
}
|
|
400
|
+
/** Names from an ffmpeg listing (`-encoders`, `-filters`, `-muxers`): the second token of each row. */
|
|
401
|
+
export function parseListing(text) {
|
|
402
|
+
const names = new Set();
|
|
403
|
+
for (const line of text.split("\n")) {
|
|
404
|
+
const tokens = line.trim().split(/\s+/);
|
|
405
|
+
if (tokens.length >= 2)
|
|
406
|
+
names.add(tokens[1]);
|
|
407
|
+
}
|
|
408
|
+
return names;
|
|
409
|
+
}
|
|
410
|
+
/**
|
|
411
|
+
* The input options for one ffmpeg or ffprobe input, to go before its `-i`. An http(s) URL comes from a scanned page,
|
|
412
|
+
* so it may read only the network protocols, and goes through `proxy` when one is set. A local file may read only
|
|
413
|
+
* files and pipes. Any other input is refused: a relative path, or a scheme such as file: or concat:, could read
|
|
414
|
+
* something the scan did not mean to open. A URL with no proxy is refused with its own message.
|
|
415
|
+
*/
|
|
416
|
+
export function inputOptions(input, proxy) {
|
|
417
|
+
if (/^https?:\/\//i.test(input)) {
|
|
418
|
+
// A URL comes from a scanned page, so it only ever goes through the egress proxy, which checks each destination.
|
|
419
|
+
if (proxy === undefined)
|
|
420
|
+
throw new Error("URL inputs require the egress proxy");
|
|
421
|
+
// httpproxy is ffmpeg's CONNECT tunnel to `proxy`: without it every https URL fails ("not on whitelist").
|
|
422
|
+
return ["-protocol_whitelist", "http,https,tcp,tls,crypto,httpproxy", "-http_proxy", proxy];
|
|
423
|
+
}
|
|
424
|
+
if (isAbsolute(input))
|
|
425
|
+
return ["-protocol_whitelist", "file,pipe"];
|
|
426
|
+
throw new Error(`media input must be an absolute path or an http(s) URL: ${input}`);
|
|
427
|
+
}
|
|
428
|
+
/**
|
|
429
|
+
* Reads the size, length, frame rate and codecs of a video or animated file with ffprobe. `input` is an absolute file
|
|
430
|
+
* path or an http(s) URL (see inputOptions); `opts.proxy` is the HTTP proxy a URL goes through. Resolves undefined when
|
|
431
|
+
* ffprobe fails, times out, finds no video stream, or the input is refused. A URL with no proxy rejects.
|
|
432
|
+
*/
|
|
433
|
+
export async function probeMedia(input, timeoutMs, opts = {}) {
|
|
434
|
+
if (/^https?:\/\//i.test(input) && opts.proxy === undefined)
|
|
435
|
+
throw new Error("URL inputs require the egress proxy");
|
|
436
|
+
try {
|
|
437
|
+
const result = await runBinary(ffprobeBin(), ["-v", "error", "-print_format", "json", "-show_streams", "-show_format", ...inputOptions(input, opts.proxy), "-i", input], { timeoutMs });
|
|
438
|
+
if (result.code !== 0)
|
|
439
|
+
return undefined;
|
|
440
|
+
return parseProbeJson(JSON.parse(result.stdout.toString("utf8")));
|
|
441
|
+
}
|
|
442
|
+
catch {
|
|
443
|
+
return undefined;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
/** Turns ffprobe's JSON into a MediaProbe, or undefined when there is no usable video stream. */
|
|
447
|
+
export function parseProbeJson(json) {
|
|
448
|
+
const streams = json.streams ?? [];
|
|
449
|
+
// A cover image attached to an audio file is a video stream too, so skip it.
|
|
450
|
+
const video = streams.find((s) => s.codec_type === "video" && s.disposition?.attached_pic !== 1);
|
|
451
|
+
if (!video || !video.width || !video.height || !video.codec_name)
|
|
452
|
+
return undefined;
|
|
453
|
+
const audio = streams.find((s) => s.codec_type === "audio");
|
|
454
|
+
const formatDuration = toNumber(json.format?.duration);
|
|
455
|
+
const durationS = formatDuration ?? toNumber(video.duration) ?? 0;
|
|
456
|
+
const bitrate = toNumber(json.format?.bit_rate);
|
|
457
|
+
const media = {
|
|
458
|
+
durationS,
|
|
459
|
+
width: video.width,
|
|
460
|
+
height: video.height,
|
|
461
|
+
fps: parseRate(video.avg_frame_rate) ?? parseRate(video.r_frame_rate) ?? 0,
|
|
462
|
+
videoCodec: video.codec_name,
|
|
463
|
+
};
|
|
464
|
+
if (audio?.codec_name) {
|
|
465
|
+
const audioBitrate = toNumber(audio.bit_rate);
|
|
466
|
+
media.audio = audioBitrate === undefined
|
|
467
|
+
? { codec: audio.codec_name }
|
|
468
|
+
: { codec: audio.codec_name, bitrate: audioBitrate };
|
|
469
|
+
}
|
|
470
|
+
if (bitrate !== undefined)
|
|
471
|
+
media.bitrate = bitrate;
|
|
472
|
+
return media;
|
|
473
|
+
}
|
|
474
|
+
/** A finite, non-negative number from an ffprobe string field, or undefined. */
|
|
475
|
+
function toNumber(value) {
|
|
476
|
+
if (value === undefined)
|
|
477
|
+
return undefined;
|
|
478
|
+
const n = Number(value);
|
|
479
|
+
return Number.isFinite(n) && n >= 0 ? n : undefined;
|
|
480
|
+
}
|
|
481
|
+
/**
|
|
482
|
+
* Frames per second from an ffprobe rate such as "30/1" or "30000/1001". Undefined for "0/0" or anything not a rate,
|
|
483
|
+
* so the caller can fall back to the next rate field.
|
|
484
|
+
*/
|
|
485
|
+
export function parseRate(rate) {
|
|
486
|
+
if (!rate)
|
|
487
|
+
return undefined;
|
|
488
|
+
const [num, den] = rate.split("/");
|
|
489
|
+
const n = Number(num);
|
|
490
|
+
const d = den === undefined ? 1 : Number(den);
|
|
491
|
+
if (!Number.isFinite(n) || !Number.isFinite(d) || d <= 0 || n < 0)
|
|
492
|
+
return undefined;
|
|
493
|
+
const fps = n / d;
|
|
494
|
+
return fps > 0 ? fps : undefined;
|
|
495
|
+
}
|
package/dist/gpu.js
CHANGED
|
@@ -37,6 +37,18 @@ export function parseGpuDevice(text) {
|
|
|
37
37
|
throw new Error(`--gpu-device needs a GPU index (a whole number, as nvidia-smi lists it), not ${JSON.stringify(text.slice(0, 40))}`);
|
|
38
38
|
return Number(text);
|
|
39
39
|
}
|
|
40
|
+
/** One GPU, as `--gpu` and the scorer's default use it. */
|
|
41
|
+
export function gpuSettingOne(device) {
|
|
42
|
+
return { device, devices: [device] };
|
|
43
|
+
}
|
|
44
|
+
/** A comma list of GPU indexes, as `--gpu-device` takes it: `0` or `0,1`. Each is validated, and a repeat is an error. */
|
|
45
|
+
export function gpuSettingOf(text) {
|
|
46
|
+
const parts = text.split(",");
|
|
47
|
+
const devices = parts.map((part) => parseGpuDevice(part.trim()));
|
|
48
|
+
if (new Set(devices).size !== devices.length)
|
|
49
|
+
throw new Error(`--gpu-device lists a GPU twice: ${JSON.stringify(text.slice(0, 40))}`);
|
|
50
|
+
return { device: devices[0], devices };
|
|
51
|
+
}
|
|
40
52
|
/** Text that came from a program or a file: printable, one line, short. */
|
|
41
53
|
function plain(text) {
|
|
42
54
|
return text.replace(/[\u0000-\u001f\u007f]/g, " ").trim().slice(0, 80);
|