imgtune 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/CHANGELOG.md +172 -0
  2. package/README.md +109 -5
  3. package/dist/animated.js +176 -0
  4. package/dist/audit.js +1 -0
  5. package/dist/backgrounds.js +42 -7
  6. package/dist/broken.js +5 -1
  7. package/dist/browser.js +4 -4
  8. package/dist/candidates.js +25 -0
  9. package/dist/check-report.js +56 -6
  10. package/dist/check.js +26 -5
  11. package/dist/cli.js +86 -8
  12. package/dist/egress-proxy.js +2 -1
  13. package/dist/fetch.js +9 -4
  14. package/dist/ffmpeg.js +530 -0
  15. package/dist/gpu.js +12 -0
  16. package/dist/hints.js +704 -16
  17. package/dist/image-file.js +154 -34
  18. package/dist/image-open.js +9 -0
  19. package/dist/images.js +142 -8
  20. package/dist/layout.js +7 -7
  21. package/dist/lcp.js +2 -1
  22. package/dist/locate-join.js +86 -0
  23. package/dist/locate-only.js +14 -3
  24. package/dist/locate.js +6 -2
  25. package/dist/measure.js +39 -1
  26. package/dist/media-encode.js +1054 -0
  27. package/dist/media-file.js +284 -0
  28. package/dist/media-limits.js +31 -0
  29. package/dist/media-model.js +251 -0
  30. package/dist/media-phase.js +675 -0
  31. package/dist/media-prepare.js +31 -0
  32. package/dist/px.js +11 -0
  33. package/dist/recheck.js +12 -4
  34. package/dist/remote.js +1065 -107
  35. package/dist/replay.js +20 -0
  36. package/dist/resources/audio.js +248 -0
  37. package/dist/resources/brotli.js +22 -0
  38. package/dist/resources/cache.js +59 -0
  39. package/dist/resources/capture.js +225 -0
  40. package/dist/resources/compression.js +122 -0
  41. package/dist/resources/favicon.js +130 -0
  42. package/dist/resources/font.js +440 -0
  43. package/dist/resources/index.js +8 -0
  44. package/dist/resources/lottie.js +491 -0
  45. package/dist/resources/model.js +478 -0
  46. package/dist/resources/phase.js +171 -0
  47. package/dist/resources/pool.js +47 -0
  48. package/dist/resources/report.js +57 -0
  49. package/dist/resources/sfnt.js +305 -0
  50. package/dist/resources/svg.js +178 -0
  51. package/dist/resources/types.js +1 -0
  52. package/dist/resources/zip.js +67 -0
  53. package/dist/result-cache.js +56 -3
  54. package/dist/run.js +32 -8
  55. package/dist/sizes.js +7 -5
  56. package/dist/summary.js +69 -3
  57. package/dist/vector-art.js +294 -0
  58. package/dist/vector-phase.js +222 -0
  59. package/dist/video-analysis.js +371 -0
  60. package/dist/webp-animation.js +283 -0
  61. package/package.json +25 -4
  62. package/skills/imgtune/SKILL.md +8 -4
  63. package/skills/imgtune/reference.md +1 -1
@@ -0,0 +1,1054 @@
1
+ // Sample encodes for animated media and video. Each asset is cut into 2-second lossless clips, every clip is encoded at
2
+ // candidate qualities, and the search keeps the lowest-bytes quality that passes the bar: VMAF on the clip, plus an
3
+ // SSIMULACRA2 guard on its stillest frames (a blurred word in a screen recording shows in a still frame more than in
4
+ // VMAF). The bytes of a passing encode are extrapolated to the full duration, so every figure here is an estimate.
5
+ //
6
+ // Performance comes first: the encodes run on the CPU with FFMPEG_THREADS threads per job, and every ffmpeg call is
7
+ // bounded by the asset's deadline, so one heavy file cannot hold a scan.
8
+ import { mkdir, readFile, rm, stat } from "node:fs/promises";
9
+ import { request as httpRequest } from "node:http";
10
+ import { request as httpsRequest } from "node:https";
11
+ import { isIP } from "node:net";
12
+ import { join } from "node:path";
13
+ import { connect as tlsConnect } from "node:tls";
14
+ import sharp from "sharp";
15
+ import { FFMPEG_THREADS, FfmpegTimeout, inputOptions, nvencEnv, nvencOptions, runFfmpeg, } from "./ffmpeg.js";
16
+ import { extrapolate, sampleOffsets } from "./media-model.js";
17
+ import { isTooLarge, MAX_INPUT_PIXELS_FFMPEG, PixelsTooLarge, WindowTooLarge, MAX_WINDOW_FRAMES } from "./media-limits.js";
18
+ import { WebpAnimation, cfrIndices } from "./webp-animation.js";
19
+ export { cfrIndices, MAX_INPUT_PIXELS_FFMPEG, PixelsTooLarge, WindowTooLarge };
20
+ /** Length of each sample clip, in seconds. */
21
+ const SAMPLE_SECONDS = 2;
22
+ /** Frame rate assumed when the probe does not say. */
23
+ const DEFAULT_FPS = 30;
24
+ /**
25
+ * Stillest frames per sample that the SSIMULACRA2 guard scores. Two, not three: each scored frame at 1280 px costs about
26
+ * half a second, and the guard is the largest cost of a search (scoring was 52% of a 1280 px search's time; see the
27
+ * performance round in the report). With 2 per sample the guard p10 is taken over 6 frames, still the minimum.
28
+ */
29
+ const GUARD_FRAMES = 2;
30
+ /**
31
+ * libvmaf scores every n-th frame. 2 halves VMAF's frame work; the pooled mean moves by about 0.15 on the test clip.
32
+ */
33
+ const VMAF_SUBSAMPLE = 2;
34
+ /**
35
+ * Gaussian sigma of the still-frame guard's prefilter for GIF sources. A dithered GIF has per-pixel dither patterns that
36
+ * an encoder smooths, and SSIMULACRA2 scores that as loss even when VMAF passes (anim.gif: VMAF 93 at CRF 40, guard
37
+ * about 19). Blurring the reference and the candidate guard frames alike compares the picture, not the dither. VMAF is
38
+ * not blurred.
39
+ */
40
+ export const GIF_GUARD_SIGMA = 1;
41
+ /**
42
+ * How far below the source's own best-quality guard score the still bar may sit. The guard is relative: an encode
43
+ * cannot score above what its codec achieves at its best quality, so a bar above (ceiling − this) can never pass a
44
+ * source whose ceiling is low. Same rule as imgtune's image bar (max(70, served − delta)), taken the other way round.
45
+ */
46
+ export const STILL_GUARD_DELTA = 10;
47
+ /**
48
+ * The lowest still bar a source can get. Below this a guard score says nothing: a bar at or under 0 would pass any encode.
49
+ */
50
+ export const STILL_GUARD_FLOOR = 20;
51
+ /**
52
+ * The still bar for one source: the requested bar, or the source's ceiling minus STILL_GUARD_DELTA when that is lower,
53
+ * but never below STILL_GUARD_FLOOR. A ceiling of 5 gives STILL_GUARD_FLOOR, not −5.
54
+ */
55
+ export function effectiveStillBar(requested, ceiling) {
56
+ return Math.max(STILL_GUARD_FLOOR, Math.min(requested, ceiling - STILL_GUARD_DELTA));
57
+ }
58
+ /**
59
+ * Bytes of an NVENC encode are scaled by this to a CPU encode's bytes, at equal quality (an earlier spike measured NVENC at
60
+ * about 1.4 times the bytes). The reported `bytes` are measuredBytes / NVENC_BYTE_FACTOR; VMAF and the still guard are measured
61
+ * on the NVENC output itself, on the assumption that a CPU encode at 1/NVENC_BYTE_FACTOR of the bytes reaches the same quality.
62
+ */
63
+ export const NVENC_BYTE_FACTOR = 1.4;
64
+ /** The narrowest width NVENC encodes (it has a minimum frame size of about 136 px): narrower encodes use the CPU. */
65
+ export const NVENC_MIN_WIDTH = 144;
66
+ /** The bytes a candidate reports as a CPU encode would give them: an NVENC encode's bytes ÷ NVENC_BYTE_FACTOR. */
67
+ export function cpuEquivalentBytes(measuredBytes, nvenc) {
68
+ return nvenc ? measuredBytes / NVENC_BYTE_FACTOR : measuredBytes;
69
+ }
70
+ /** Longest single ffmpeg run, in ms; the asset deadline caps it further. */
71
+ const RUN_CAP_MS = 60_000;
72
+ const MAX_REDIRECTS = 3;
73
+ const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
74
+ const USER_AGENT = "imgtune";
75
+ /** Quality ranges and step counts from the design. AVIF shares the AV1 range. */
76
+ const SPECS = {
77
+ "av1-mp4": { lo: 20, hi: 55, steps: 5, ext: "mp4", fewerBytesAt: "high", start: 40 },
78
+ "animated-avif": { lo: 20, hi: 55, steps: 5, ext: "mp4", fewerBytesAt: "high", start: 40 },
79
+ "h264-mp4": { lo: 18, hi: 35, steps: 4, ext: "mp4", fewerBytesAt: "high" },
80
+ "animated-webp": { lo: 40, hi: 90, steps: 4, ext: "webp", fewerBytesAt: "low" },
81
+ };
82
+ /** True for an abort (the run's signal fired): the search stops and reports "aborted". */
83
+ export function isAbortError(err) {
84
+ return err instanceof Error && err.name === "AbortError";
85
+ }
86
+ /** The deadline passed before a step could start. */
87
+ class DeadlineHit extends Error {
88
+ constructor() {
89
+ super("deadline passed");
90
+ this.name = "DeadlineHit";
91
+ }
92
+ }
93
+ /** A step failed in a way that names the outcome: the encoder or the VMAF filter does not work. */
94
+ class StageError extends Error {
95
+ reason;
96
+ constructor(reason) {
97
+ super(reason);
98
+ this.name = "StageError";
99
+ this.reason = reason;
100
+ }
101
+ }
102
+ /**
103
+ * An NVENC encode that failed (the encoder refused the frame size, the session limit was hit, or the device is gone). It is
104
+ * not an error of the search: the candidate is encoded again on the CPU (see withNvencFallback).
105
+ */
106
+ export class NvencFailure extends Error {
107
+ constructor(detail) {
108
+ super(`nvenc failed: ${detail}`);
109
+ this.name = "NvencFailure";
110
+ }
111
+ }
112
+ /** Whether a candidate of this codec and width is encoded with NVENC on `gpu`: the codec has an NVENC encoder, the width is at least NVENC_MIN_WIDTH. */
113
+ export function usesNvenc(codec, width, caps, gpu) {
114
+ if (gpu === undefined || width < NVENC_MIN_WIDTH)
115
+ return false;
116
+ switch (codec) {
117
+ case "av1-mp4":
118
+ case "animated-avif":
119
+ return caps.av1Nvenc;
120
+ case "h264-mp4":
121
+ return caps.h264Nvenc;
122
+ case "animated-webp":
123
+ return false;
124
+ }
125
+ }
126
+ /**
127
+ * Runs `run` with NVENC when `want` is set, and again on the CPU when NVENC fails. Returns the value and whether NVENC made it.
128
+ * A whole candidate falls back together, so one candidate never mixes encoders across its samples.
129
+ */
130
+ export async function withNvencFallback(want, run) {
131
+ if (!want)
132
+ return { value: await run(false), nvenc: false };
133
+ try {
134
+ return { value: await run(true), nvenc: true };
135
+ }
136
+ catch (err) {
137
+ if (!(err instanceof NvencFailure))
138
+ throw err;
139
+ return { value: await run(false), nvenc: false };
140
+ }
141
+ }
142
+ /** True for a timeout or a passed deadline: the search then ends with what it has. */
143
+ function isDeadline(err) {
144
+ return err instanceof DeadlineHit || err instanceof FfmpegTimeout;
145
+ }
146
+ /** Milliseconds one ffmpeg run may take: what is left of the deadline, capped. Throws once the deadline has passed. */
147
+ function runBudget(deadline) {
148
+ const left = deadline - Date.now();
149
+ if (left <= 0)
150
+ throw new DeadlineHit();
151
+ return Math.min(left, RUN_CAP_MS);
152
+ }
153
+ /**
154
+ * One ffmpeg run, bounded by the deadline and stopped by the signal. `stdin` feeds the input from a pipe (see RunOptions).
155
+ * The caller judges the exit code.
156
+ */
157
+ function ffrun(args, deadline, signal, stdin, env) {
158
+ return runFfmpeg(["-loglevel", "error", ...args], { timeoutMs: runBudget(deadline), signal, stdin, env });
159
+ }
160
+ /**
161
+ * The results of parallel work, or the first failure once every piece has settled. Promise.all would reject at the
162
+ * first failure while the siblings still run, and a caller that then removes the shared directory would race them.
163
+ * Waiting for all of them first means nothing is still writing when the failure reaches the caller.
164
+ */
165
+ async function settleAll(work) {
166
+ const settled = await Promise.allSettled(work);
167
+ for (const result of settled) {
168
+ if (result.status === "rejected")
169
+ throw result.reason;
170
+ }
171
+ return settled.map((result) => result.value);
172
+ }
173
+ /** Last non-empty line of ffmpeg's stderr: the error, since ffmpeg prints it last. */
174
+ function lastLine(stderr) {
175
+ const lines = stderr.trim().split("\n");
176
+ return lines[lines.length - 1] ?? "";
177
+ }
178
+ /**
179
+ * Escapes a value that goes inside a filter option (libvmaf's log_path, the metadata file), so its separators are read
180
+ * as text.
181
+ */
182
+ function escapeFilterValue(value) {
183
+ return value.replace(/[\\:',;[\]]/g, "\\$&");
184
+ }
185
+ /** Runs `work`; a failure that is not a deadline becomes a StageError with the reason for this stage. */
186
+ async function stage(reason, work) {
187
+ try {
188
+ return await work();
189
+ }
190
+ catch (err) {
191
+ if (isDeadline(err) || isAbortError(err) || err instanceof StageError || err instanceof NvencFailure || isTooLarge(err))
192
+ throw err;
193
+ throw new StageError(reason);
194
+ }
195
+ }
196
+ /**
197
+ * Cuts the 2-second samples of a video or animated file, as lossless FFV1 in Matroska at the offsets sampleOffsets
198
+ * gives. The input may be an absolute path or an http(s) URL, read with Range requests; a URL goes through `proxy`
199
+ * when one is set. The frame rate is forced to the probed rate, so the reference and the candidates share one
200
+ * timeline. Rejects when the input is refused, or when any cut fails (a 404 for example).
201
+ */
202
+ export async function cutSamples(input, probe, workDir, deadline, proxy, signal) {
203
+ if (typeof input !== "string")
204
+ return cutWebpSamples(input, probe.durationS, workDir, deadline, signal);
205
+ // Refuses a bad input before any file is written.
206
+ const options = inputOptions(input, proxy);
207
+ await mkdir(workDir, { recursive: true });
208
+ const rate = probe.fps > 0 ? ["-r", String(probe.fps), "-fps_mode", "cfr"] : [];
209
+ return settleAll(sampleOffsets(probe.durationS).map(async (offset, i) => {
210
+ const out = join(workDir, `sample-${i}.mkv`);
211
+ const result = await ffrun([
212
+ ...options,
213
+ "-ss",
214
+ String(offset),
215
+ "-i",
216
+ input,
217
+ "-t",
218
+ String(SAMPLE_SECONDS),
219
+ "-map",
220
+ "0:v:0",
221
+ "-an",
222
+ ...rate,
223
+ "-c:v",
224
+ "ffv1",
225
+ "-threads",
226
+ String(FFMPEG_THREADS),
227
+ "-y",
228
+ out,
229
+ ], deadline, signal);
230
+ if (result.code !== 0)
231
+ throw new Error(`ffmpeg could not cut sample ${i}: ${lastLine(result.stderr)}`);
232
+ return out;
233
+ }));
234
+ }
235
+ /** A redirect hop the third-party policy refuses: the request is not made. */
236
+ class HopBlocked extends Error {
237
+ hop;
238
+ constructor(hop) {
239
+ super("third-party redirect blocked");
240
+ this.hop = hop;
241
+ this.name = "HopBlocked";
242
+ }
243
+ }
244
+ /**
245
+ * Whether the server serves byte ranges, with the length and validators, from a HEAD and a `Range: bytes=0-0` GET.
246
+ * The GET is the decisive check: a server may not advertise ranges on HEAD, and ffmpeg needs the real thing to seek.
247
+ * Any request failure reads as no range. Only the headers of each answer are read, never a body. Every request goes
248
+ * through `proxy` (the egress proxy, which checks each destination): an http target gets the absolute URL, and an https
249
+ * target gets a CONNECT tunnel. Redirects are followed, at most 3. Rejects when no proxy is given.
250
+ */
251
+ export async function hasRange(url, timeoutMs, proxy, allowHop) {
252
+ // A URL comes from a scanned page, so it only ever goes through the egress proxy.
253
+ if (proxy === undefined)
254
+ throw new Error("URL inputs require the egress proxy");
255
+ let target;
256
+ try {
257
+ target = new URL(url);
258
+ }
259
+ catch {
260
+ return { range: false };
261
+ }
262
+ const controller = new AbortController();
263
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
264
+ let blocked = false;
265
+ const refused = (err) => {
266
+ if (err instanceof HopBlocked)
267
+ blocked = true;
268
+ return undefined;
269
+ };
270
+ try {
271
+ const head = await exchange("HEAD", target, {}, proxy, controller.signal, 0, allowHop).catch(refused);
272
+ const get = await exchange("GET", target, { Range: "bytes=0-0" }, proxy, controller.signal, 0, allowHop).catch(refused);
273
+ // A file behind a refused hop is not read at all: its length and range are not taken from the allowed part of the chain.
274
+ if (blocked)
275
+ return { range: false, blocked: true };
276
+ const info = { range: get?.status === 206 };
277
+ const answered = get?.url ?? head?.url;
278
+ if (answered !== undefined)
279
+ info.url = answered;
280
+ const length = get?.status === 206
281
+ ? totalOf(headerText(get.headers["content-range"]))
282
+ : head && head.status >= 200 && head.status < 300
283
+ ? numberOf(headerText(head.headers["content-length"]))
284
+ : undefined;
285
+ if (length !== undefined)
286
+ info.length = length;
287
+ const etag = headerText(head?.headers.etag) ?? headerText(get?.headers.etag);
288
+ if (etag !== undefined)
289
+ info.etag = etag;
290
+ const lastModified = headerText(head?.headers["last-modified"]) ?? headerText(get?.headers["last-modified"]);
291
+ if (lastModified !== undefined)
292
+ info.lastModified = lastModified;
293
+ return info;
294
+ }
295
+ finally {
296
+ clearTimeout(timer);
297
+ }
298
+ }
299
+ /** One request, following redirects (at most MAX_REDIRECTS). */
300
+ async function exchange(method, url, headers, proxy, signal, hops = 0, allowHop) {
301
+ if (url.protocol !== "http:" && url.protocol !== "https:")
302
+ throw new Error("not an http(s) URL");
303
+ const reply = await send(method, url, headers, proxy, signal);
304
+ const location = headerText(reply.headers.location);
305
+ if (REDIRECT_STATUSES.has(reply.status) && location !== undefined) {
306
+ if (hops >= MAX_REDIRECTS)
307
+ throw new Error("too many redirects");
308
+ const next = new URL(location, url);
309
+ // The policy is checked on every hop, as the browser's route guard checks each request. A refused hop is not requested.
310
+ if (allowHop !== undefined && !allowHop(next.href))
311
+ throw new HopBlocked(next.href);
312
+ return exchange(method, next, headers, proxy, signal, hops + 1, allowHop);
313
+ }
314
+ return { ...reply, url: url.href };
315
+ }
316
+ /** One request to one URL, through the egress proxy. The response body is discarded after the headers. */
317
+ function send(method, url, headers, proxy, signal) {
318
+ return new Promise((resolve, reject) => {
319
+ const finish = (res) => {
320
+ resolve({ status: res.statusCode ?? 0, headers: res.headers });
321
+ res.destroy();
322
+ };
323
+ const fail = (err) => reject(err);
324
+ const secure = url.protocol === "https:";
325
+ const host = url.hostname.replace(/^\[|\]$/g, "");
326
+ const port = Number(url.port) || (secure ? 443 : 80);
327
+ const common = { method, headers: { ...headers, "User-Agent": USER_AGENT, Accept: "*/*" }, signal, agent: false };
328
+ const via = new URL(proxy);
329
+ if (!secure) {
330
+ const req = httpRequest({ host: via.hostname, port: via.port, path: url.href, ...common, headers: { ...common.headers, Host: url.host } }, finish);
331
+ req.on("error", fail);
332
+ req.end();
333
+ return;
334
+ }
335
+ // An https target: a CONNECT tunnel through the proxy, then TLS inside it.
336
+ const authority = `${host}:${port}`;
337
+ const connect = httpRequest({
338
+ host: via.hostname,
339
+ port: via.port,
340
+ method: "CONNECT",
341
+ path: authority,
342
+ headers: { Host: authority },
343
+ agent: false,
344
+ signal,
345
+ });
346
+ connect.on("error", fail);
347
+ connect.on("connect", (res, socket) => {
348
+ if (res.statusCode !== 200) {
349
+ socket.destroy();
350
+ fail(new Error(`proxy refused the tunnel: ${res.statusCode}`));
351
+ return;
352
+ }
353
+ const tunnel = tlsConnect({ socket, ...(isIP(host) === 0 ? { servername: host } : {}) });
354
+ tunnel.on("error", fail);
355
+ const req = httpsRequest({ host, port, path: url.pathname + url.search, ...common, createConnection: () => tunnel }, finish);
356
+ req.on("error", fail);
357
+ req.end();
358
+ });
359
+ connect.end();
360
+ });
361
+ }
362
+ function headerText(value) {
363
+ return Array.isArray(value) ? value[0] : value;
364
+ }
365
+ function numberOf(text) {
366
+ if (text === undefined)
367
+ return undefined;
368
+ const n = Number(text);
369
+ return Number.isFinite(n) && n >= 0 ? n : undefined;
370
+ }
371
+ /** The total length from a Content-Range such as "bytes 0-0/12345". */
372
+ function totalOf(contentRange) {
373
+ const match = /\/(\d+)$/.exec(contentRange ?? "");
374
+ return match ? Number(match[1]) : undefined;
375
+ }
376
+ // ---------------------------------------------------------------------------------------------------------------------
377
+ // Frame selection and reading
378
+ // ---------------------------------------------------------------------------------------------------------------------
379
+ /**
380
+ * The indices of the `count` stillest frames, from the metadata that signalstats prints (`frame:N` then the YDIF
381
+ * difference to the previous frame). Frame 0 has no previous frame, so its 0 is not a real difference and it is not
382
+ * ranked, unless it is the only frame. Returns them in frame order. With no readable difference it returns [0].
383
+ */
384
+ export function stillestFrames(metadata, count) {
385
+ const diffs = new Map();
386
+ let frame = -1;
387
+ for (const line of metadata.split("\n")) {
388
+ const index = /^frame:(\d+)/.exec(line);
389
+ if (index) {
390
+ frame = Number(index[1]);
391
+ continue;
392
+ }
393
+ const ydif = /^lavfi\.signalstats\.YDIF=(\S+)/.exec(line);
394
+ if (ydif && frame >= 0 && Number.isFinite(Number(ydif[1])))
395
+ diffs.set(frame, Number(ydif[1]));
396
+ }
397
+ const ranked = [...diffs]
398
+ .filter(([n]) => n > 0 || diffs.size === 1)
399
+ .sort((a, b) => a[1] - b[1] || a[0] - b[0]);
400
+ if (ranked.length === 0)
401
+ return [0];
402
+ return ranked
403
+ .slice(0, Math.max(1, Math.floor(count)))
404
+ .map(([n]) => n)
405
+ .sort((a, b) => a - b);
406
+ }
407
+ /** The 10th percentile by the lower nearest rank: for up to 10 values, the minimum. 0 for no values. */
408
+ export function tenthPercentile(values) {
409
+ if (values.length === 0)
410
+ return 0;
411
+ const sorted = [...values].sort((a, b) => a - b);
412
+ return sorted[Math.floor(0.1 * (sorted.length - 1))];
413
+ }
414
+ /**
415
+ * The image at exactly `width` x `height`: the same pixels when it already is that size, else resized (lanczos). The
416
+ * scorer needs equal dimensions, and a candidate can differ by a pixel from the reference after its own scaling.
417
+ */
418
+ async function fitTo(img, width, height) {
419
+ if (img.width === width && img.height === height)
420
+ return img;
421
+ const bytes = Buffer.from(img.data.buffer, img.data.byteOffset, img.data.byteLength);
422
+ const { data, info } = await sharp(bytes, { raw: { width: img.width, height: img.height, channels: 4 } })
423
+ .resize(width, height, { fit: "fill", kernel: "lanczos3" })
424
+ .raw()
425
+ .toBuffer({ resolveWithObject: true });
426
+ return { data: new Uint8ClampedArray(data.buffer, data.byteOffset, data.byteLength), width: info.width, height: info.height };
427
+ }
428
+ /**
429
+ * The frames of a YUV4MPEG2 stream (ffmpeg's yuv4mpegpipe, 4:2:0). The size comes from the header, and each frame's planes
430
+ * are read as they are: no colour metadata is applied, so the planes are the decoded values.
431
+ */
432
+ export function yuv4mpegFrames(bytes) {
433
+ const headerEnd = bytes.indexOf(0x0a);
434
+ if (headerEnd < 0 || bytes.toString("latin1", 0, 9) !== "YUV4MPEG2")
435
+ throw new Error("ffmpeg did not write a YUV4MPEG2 stream");
436
+ const tokens = bytes.toString("latin1", 0, headerEnd).split(" ");
437
+ const width = Number(tokens.find((t) => t.startsWith("W"))?.slice(1));
438
+ const height = Number(tokens.find((t) => t.startsWith("H"))?.slice(1));
439
+ if (!(width > 0 && height > 0))
440
+ throw new Error("the YUV4MPEG2 header has no frame size");
441
+ const chromaSize = ((width + 1) >> 1) * ((height + 1) >> 1);
442
+ const lumaSize = width * height;
443
+ const frames = [];
444
+ let pos = headerEnd + 1;
445
+ while (pos < bytes.length) {
446
+ const lineEnd = bytes.indexOf(0x0a, pos);
447
+ if (lineEnd < 0 || bytes.toString("latin1", pos, pos + 5) !== "FRAME")
448
+ throw new Error("a YUV4MPEG2 frame header is malformed");
449
+ const start = lineEnd + 1;
450
+ if (start + lumaSize + 2 * chromaSize > bytes.length)
451
+ throw new Error("a YUV4MPEG2 frame is truncated");
452
+ frames.push({
453
+ width,
454
+ height,
455
+ y: bytes.subarray(start, start + lumaSize),
456
+ u: bytes.subarray(start + lumaSize, start + lumaSize + chromaSize),
457
+ v: bytes.subarray(start + lumaSize + chromaSize, start + lumaSize + 2 * chromaSize),
458
+ });
459
+ pos = start + lumaSize + 2 * chromaSize;
460
+ }
461
+ return frames;
462
+ }
463
+ /**
464
+ * BT.601 limited-range 4:2:0 YUV to 8-bit RGBA, opaque. Each chroma sample covers its 2 × 2 luma block (nearest). The
465
+ * constants are written out, so a reference and a candidate with the same planes always give the same pixels, whatever
466
+ * colour metadata either file carries.
467
+ */
468
+ export function yuvToRgba(frame) {
469
+ const { width, height, y, u, v } = frame;
470
+ const cw = (width + 1) >> 1;
471
+ const out = new Uint8ClampedArray(width * height * 4);
472
+ for (let row = 0; row < height; row++) {
473
+ const chromaRow = (row >> 1) * cw;
474
+ for (let col = 0; col < width; col++) {
475
+ const luma = 1.164383 * (y[row * width + col] - 16);
476
+ const c = chromaRow + (col >> 1);
477
+ const cb = u[c] - 128;
478
+ const cr = v[c] - 128;
479
+ const o = (row * width + col) * 4;
480
+ out[o] = luma + 1.596027 * cr;
481
+ out[o + 1] = luma - 0.391762 * cb - 0.812968 * cr;
482
+ out[o + 2] = luma + 2.017232 * cb;
483
+ out[o + 3] = 255;
484
+ }
485
+ }
486
+ return { data: out, width, height };
487
+ }
488
+ /** The indices of the stillest frames of one sample, from signalstats' YDIF (see stillestFrames). */
489
+ async function stillestOf(sample, metadataFile, deadline, signal) {
490
+ const result = await ffrun([
491
+ ...inputOptions(sample),
492
+ "-i",
493
+ sample,
494
+ "-vf",
495
+ `format=yuv420p,signalstats,metadata=mode=print:file=${escapeFilterValue(metadataFile)}`,
496
+ "-f",
497
+ "null",
498
+ "-",
499
+ ], deadline, signal);
500
+ if (result.code !== 0)
501
+ throw new Error(`ffmpeg could not measure frame differences: ${lastLine(result.stderr)}`);
502
+ return stillestFrames(await readFile(metadataFile, "utf8"), GUARD_FRAMES);
503
+ }
504
+ /**
505
+ * The frames at `indices` (ascending) of a file, after `filters`, as RGBA in index order. A select filter picks exactly
506
+ * the frames asked for, and ffmpeg writes them as raw 4:2:0 planes (YUV4MPEG2) on stdout. The conversion to RGB is
507
+ * yuvToRgba, not ffmpeg's. The suspected fault (measured symptom: an AV1 CRF 26 candidate of a 720x1280 WebP scored VMAF
508
+ * 95.3 but guard 21.3) is that ffmpeg's RGB conversion reads colour metadata (matrix, range, chroma location) that the
509
+ * sample and the encoded candidate carry differently. VMAF reads luma only, so it cannot see that. Converting the planes
510
+ * here, with one fixed formula, makes the reference and every candidate take the same conversion. Not yet run.
511
+ */
512
+ async function framesAt(input, filters, indices, deadline, signal) {
513
+ if (indices.length === 0)
514
+ return [];
515
+ const pick = `format=yuv420p,select='${indices.map((n) => `eq(n,${n})`).join("+")}'`;
516
+ const result = await ffrun([...inputOptions(input), "-i", input, "-vf", [...filters, pick].join(","), "-fps_mode", "passthrough", "-f", "yuv4mpegpipe", "-"], deadline, signal);
517
+ if (result.code !== 0)
518
+ throw new Error(`ffmpeg could not read frames: ${lastLine(result.stderr)}`);
519
+ const planes = yuv4mpegFrames(result.stdout);
520
+ if (planes.length !== indices.length)
521
+ throw new Error(`ffmpeg gave ${planes.length} of the ${indices.length} frames asked for`);
522
+ return planes.map(yuvToRgba);
523
+ }
524
+ // ---------------------------------------------------------------------------------------------------------------------
525
+ // Encoding and scoring
526
+ // ---------------------------------------------------------------------------------------------------------------------
527
+ /** The AV1 encoder arguments: SVT-AV1 when built in, libaom otherwise. `crf` is the quality. */
528
+ function av1Args(caps, crf) {
529
+ return caps.av1 === "libaom-av1"
530
+ ? ["-c:v", "libaom-av1", "-crf", crf, "-b:v", "0", "-cpu-used", "8", "-row-mt", "1"]
531
+ : ["-c:v", "libsvtav1", "-preset", "10", "-crf", crf, "-svtav1-params", "lp=2"];
532
+ }
533
+ /** The video encoder arguments of a codec at quality `q`: NVENC (cq) when `nvenc`, else the CPU encoder (CRF). */
534
+ function videoEncoderArgs(codec, q, caps, nvenc) {
535
+ if (nvenc)
536
+ return ["-c:v", codec === "h264-mp4" ? "h264_nvenc" : "av1_nvenc", ...nvencOptions(q, codec !== "h264-mp4")];
537
+ if (codec === "h264-mp4")
538
+ return ["-c:v", "libx264", "-preset", "veryfast", "-crf", String(q)];
539
+ return av1Args(caps, String(q));
540
+ }
541
+ /**
542
+ * The encoder and container arguments for one codec at quality `q`. Every output drops audio. `nvenc` selects the NVENC
543
+ * encoder of the codec (the same options for AV1 and H.264, with `q` as the constant quality).
544
+ *
545
+ * animated-avif is measured as the same AV1 bitstream in an MP4 container, not as an AVIF file. The bitstream is the
546
+ * same (same encoder, preset, CRF and pixel format). The AVIF container differs by about 150 bytes per 2-second clip.
547
+ * ffmpeg cannot read an animated AVIF back for scoring, so the MP4 twin is also what gets scored. The bytes are an
548
+ * estimate within that container difference.
549
+ */
550
+ export function encoderArgs(codec, q, caps, nvenc = false) {
551
+ switch (codec) {
552
+ case "av1-mp4":
553
+ return [...videoEncoderArgs("av1-mp4", q, caps, nvenc), "-pix_fmt", "yuv420p", "-an", "-movflags", "+faststart", "-f", "mp4"];
554
+ case "animated-avif":
555
+ return [...videoEncoderArgs("animated-avif", q, caps, nvenc), "-pix_fmt", "yuv420p", "-an", "-f", "mp4"];
556
+ case "h264-mp4":
557
+ return [...videoEncoderArgs("h264-mp4", q, caps, nvenc), "-an", "-pix_fmt", "yuv420p", "-movflags", "+faststart", "-f", "mp4"];
558
+ case "animated-webp":
559
+ return ["-c:v", "libwebp_anim", "-q:v", String(q), "-loop", "0", "-an", "-f", "webp"];
560
+ }
561
+ }
562
+ /**
563
+ * Encodes one sample at `width` (lanczos scale) and quality `q`. With `nvenc` the encode runs on the GPU `gpu` (its
564
+ * environment, see nvencEnv); an NVENC failure is an NvencFailure, which the caller answers with the CPU encode. A CPU
565
+ * failure is the encoder's: StageError no-encoder.
566
+ */
567
+ async function encodeClip(sample, codec, width, q, out, caps, deadline, signal, nvenc = false, gpu) {
568
+ const run = () => stage("no-encoder", async () => {
569
+ const result = await ffrun([
570
+ ...inputOptions(sample),
571
+ "-i",
572
+ sample,
573
+ "-vf",
574
+ `scale=${width}:-2:flags=lanczos`,
575
+ "-threads",
576
+ String(FFMPEG_THREADS),
577
+ ...encoderArgs(codec, q, caps, nvenc),
578
+ "-y",
579
+ out,
580
+ ], deadline, signal, undefined, nvenc && gpu !== undefined ? nvencEnv(gpu) : undefined);
581
+ if (result.code !== 0) {
582
+ if (nvenc)
583
+ throw new NvencFailure(lastLine(result.stderr));
584
+ throw new Error(lastLine(result.stderr));
585
+ }
586
+ });
587
+ await run();
588
+ }
589
+ /** A candidate that ffmpeg reads from its file. */
590
+ function fileCandidate(out, deadline, signal) {
591
+ return {
592
+ input: [...inputOptions(out), "-i", out],
593
+ frames: (indices) => framesAt(out, [], indices, deadline, signal),
594
+ };
595
+ }
596
+ /** The tick phase of a candidate's WebP timeline: the middle of each tick's interval (see webpCandidate). */
597
+ const TICK_CENTRE = 0.5;
598
+ /**
599
+ * An animated WebP candidate, read frame by frame: its frames are composed in order (WebpAnimation), so the candidate's
600
+ * timeline is streamed to VMAF and its guard frames are decoded on demand. Two readers of the same file: one feeds ffmpeg, the
601
+ * other gives the guard frames, so each is read in order.
602
+ */
603
+ async function webpCandidate(out, fps, seconds) {
604
+ const bytes = await readFile(out);
605
+ // A still candidate (an encoder writes one when its frames are all alike) holds for the sample's length.
606
+ // The candidate has one frame per sample frame, so tick k is read at the middle of its interval: the frame starts of an
607
+ // encoded WebP are rounded to whole milliseconds, and a tick at its exact start can fall in the frame before (off by one).
608
+ const feed = new WebpAnimation(bytes, fps, undefined, seconds * 1000, TICK_CENTRE);
609
+ const guard = new WebpAnimation(bytes, fps, undefined, seconds * 1000, TICK_CENTRE);
610
+ return {
611
+ input: ["-f", "rawvideo", "-pix_fmt", "rgba", "-video_size", `${feed.width}x${feed.height}`, "-framerate", String(fps), "-i", "pipe:0"],
612
+ stdin: () => feed.ticks(0, feed.tickPages.length),
613
+ frames: async (indices) => {
614
+ const frames = [];
615
+ for (const n of indices)
616
+ frames.push(await guard.page(guard.tickPages[Math.min(n, guard.tickPages.length - 1)]));
617
+ return frames;
618
+ },
619
+ };
620
+ }
621
+ /**
622
+ * The 2-second sample windows of an animated WebP source, cut as lossless FFV1 like cutSamples' files: each window's frames
623
+ * are read from the animation and streamed to ffmpeg, so only the pages inside a window are decoded, and the whole animation
624
+ * is never materialised. Windows are cut in order, so the reader moves forward (see WebpAnimation.page).
625
+ */
626
+ async function cutWebpSamples(animation, durationS, workDir, deadline, signal) {
627
+ await mkdir(workDir, { recursive: true });
628
+ const fps = animation.fps;
629
+ const total = animation.tickPages.length;
630
+ const samples = [];
631
+ for (const [i, offset] of sampleOffsets(durationS).entries()) {
632
+ const from = Math.min(total, Math.round(offset * fps));
633
+ const to = Math.min(total, from + SAMPLE_SECONDS * fps);
634
+ if (to - from > MAX_WINDOW_FRAMES)
635
+ throw new WindowTooLarge(to - from);
636
+ const out = join(workDir, `sample-${i}.mkv`);
637
+ const result = await ffrun(["-f", "rawvideo", "-pix_fmt", "rgba", "-video_size", `${animation.width}x${animation.height}`, "-framerate", String(fps), "-i", "pipe:0", "-an", "-c:v", "ffv1", "-pix_fmt", "yuv420p", "-threads", String(FFMPEG_THREADS), "-y", out], deadline, signal, () => animation.ticks(from, to));
638
+ if (result.code !== 0)
639
+ throw new Error(`ffmpeg could not write sample ${i} of this animated WebP: ${lastLine(result.stderr)}`);
640
+ samples.push(out);
641
+ }
642
+ return samples;
643
+ }
644
+ /**
645
+ * Pooled VMAF mean of `distorted` against `reference`, both scaled to `width` as the design sets. Both sides are
646
+ * re-timed by frame index at `fps`: libvmaf pairs frames by timestamp, and the lossless sample and the encodes carry
647
+ * different timebases, so their timestamps would otherwise pair the wrong frames. The longer side is trimmed to the
648
+ * shorter (shortest=1), since an encode can drop or add a frame.
649
+ */
650
+ export async function vmafOf(distorted, reference, width, fps, log, deadline, signal, stdin, cuda) {
651
+ return stage("no-vmaf", async () => {
652
+ const retime = `settb=AVTB,setpts=N/${fps}/TB`;
653
+ const logPath = escapeFilterValue(log);
654
+ // The frames are re-timed by index before any upload, so the GPU and the CPU pair the same frames. On a GPU the frames
655
+ // go to libvmaf_cuda in CUDA memory (format, then hwupload_cuda); the device is the run's (CUDA_VISIBLE_DEVICES).
656
+ const graph = cuda === undefined
657
+ ? `[0:v]scale=${width}:-2:flags=bicubic,${retime}[d];` +
658
+ `[1:v]scale=${width}:-2:flags=bicubic,${retime}[r];` +
659
+ `[d][r]libvmaf=n_threads=${FFMPEG_THREADS}:shortest=1:n_subsample=${VMAF_SUBSAMPLE}:log_fmt=json:log_path=${logPath}`
660
+ : `[0:v]scale=${width}:-2:flags=bicubic,${retime},format=yuv420p,hwupload_cuda[d];` +
661
+ `[1:v]scale=${width}:-2:flags=bicubic,${retime},format=yuv420p,hwupload_cuda[r];` +
662
+ `[d][r]libvmaf_cuda=shortest=1:n_subsample=${VMAF_SUBSAMPLE}:log_fmt=json:log_path=${logPath}`;
663
+ const result = await ffrun([...distorted, ...inputOptions(reference), "-i", reference, "-lavfi", graph, "-f", "null", "-"], deadline, signal, stdin, cuda === undefined ? undefined : nvencEnv(cuda));
664
+ if (result.code !== 0)
665
+ throw new Error(lastLine(result.stderr));
666
+ const json = JSON.parse(await readFile(log, "utf8"));
667
+ const mean = json.pooled_metrics?.vmaf?.mean;
668
+ if (typeof mean !== "number")
669
+ throw new Error("no pooled VMAF mean");
670
+ return mean;
671
+ });
672
+ }
673
+ /**
674
+ * The GPU that scores a candidate's VMAF, or undefined for the CPU libvmaf. A GPU search uses libvmaf_cuda when its device
675
+ * has it; an animated WebP candidate is CPU work (no GPU decoder for it), so it always uses the CPU.
676
+ */
677
+ function cudaFor(ctx, codec) {
678
+ if (codec === "animated-webp" || ctx.opts.gpu === undefined || !ctx.opts.caps.vmafCuda)
679
+ return undefined;
680
+ return ctx.opts.gpu;
681
+ }
682
+ /**
683
+ * evaluate, memoised per search: a quality already encoded is returned from the cache. The deadline is checked before a
684
+ * new encode, so the ceiling encode and the bisection both stop at the deadline. `strict` is for the ceiling (see evaluate).
685
+ */
686
+ async function evaluateCached(ctx, q, strict = false) {
687
+ const known = ctx.cache.get(q);
688
+ if (known)
689
+ return known;
690
+ if (Date.now() >= ctx.opts.deadline)
691
+ throw new DeadlineHit();
692
+ const result = await evaluate(ctx, q, strict);
693
+ ctx.cache.set(q, result);
694
+ return result;
695
+ }
696
+ /**
697
+ * Encodes one sample at quality `q` and reads what the search needs from it: the bytes, the VMAF, and the frames at the
698
+ * guard's indices. These are child processes (or a short decode), so the samples of a candidate run in parallel (an
699
+ * animated WebP's samples run one at a time, see readAll). A frame that cannot be read (a candidate with fewer frames than
700
+ * the reference, for example) gives null, which scores 0 for every guard frame of that sample: the candidate fails closed.
701
+ */
702
+ async function readSample(ctx, q, dir, i, nvenc) {
703
+ const { codec, width, opts } = ctx;
704
+ const out = join(dir, `s${i}.${SPECS[codec].ext}`);
705
+ await encodeClip(ctx.samples[i], codec, width, q, out, opts.caps, opts.deadline, opts.signal, nvenc, opts.gpu);
706
+ const bytes = (await stat(out)).size;
707
+ const cand = codec === "animated-webp"
708
+ ? await stage("no-encoder", () => webpCandidate(out, ctx.fps, ctx.seconds[i]))
709
+ : fileCandidate(out, opts.deadline, opts.signal);
710
+ const vmaf = await vmafOf(cand.input, ctx.samples[i], width, ctx.fps, join(dir, `vmaf-${i}.json`), opts.deadline, opts.signal, cand.stdin, cudaFor(ctx, codec)).catch((err) => {
711
+ // An NVENC stream that the scorer cannot read is an NVENC failure: the candidate is encoded again on the CPU.
712
+ if (nvenc && err instanceof StageError && err.reason === "no-vmaf")
713
+ throw new NvencFailure("VMAF could not read the NVENC output");
714
+ throw err;
715
+ });
716
+ let frames;
717
+ try {
718
+ frames = await cand.frames(ctx.guards[i].indices);
719
+ }
720
+ catch (err) {
721
+ if (isDeadline(err) || isAbortError(err))
722
+ throw err;
723
+ frames = null;
724
+ }
725
+ return { bytes, vmaf, frames };
726
+ }
727
+ /**
728
+ * The samples of one quality, read. An animated WebP's samples are read one after another: each decodes its own timeline,
729
+ * so running them together would hold several decoded timelines at once. Other codecs run their samples in parallel.
730
+ */
731
+ async function readAll(ctx, q, dir, nvenc) {
732
+ if (ctx.codec !== "animated-webp")
733
+ return settleAll(ctx.samples.map((_, i) => readSample(ctx, q, dir, i, nvenc)));
734
+ const reads = [];
735
+ for (let i = 0; i < ctx.samples.length; i++)
736
+ reads.push(await readSample(ctx, q, dir, i, nvenc));
737
+ return reads;
738
+ }
739
+ /**
740
+ * The guard scores of one sample, all of them (no early exit). A frame that cannot be read scores 0 (fails closed), or,
741
+ * when `strict` (a ceiling, whose bar the source is judged by), is score-failed: a ceiling of 0 would lower the bar of
742
+ * every candidate of the source.
743
+ */
744
+ export async function guardScores(ctx, i, cand, strict = false) {
745
+ const ref = ctx.guards[i];
746
+ let frames;
747
+ try {
748
+ frames = await cand.frames(ref.indices);
749
+ }
750
+ catch (err) {
751
+ if (isDeadline(err) || isAbortError(err))
752
+ throw err;
753
+ if (strict)
754
+ throw new StageError("score-failed");
755
+ return ref.indices.map(() => 0);
756
+ }
757
+ const out = [];
758
+ for (let k = 0; k < frames.length; k++)
759
+ out.push(await scoreFrame(ctx, ref.frames[k], frames[k]));
760
+ return out;
761
+ }
762
+ /**
763
+ * The SSIMULACRA2 score of one guard frame: the candidate fitted to the reference's size, prefiltered when the source is
764
+ * a GIF, then scored. A scorer that throws is a broken scorer, not a failing candidate, so it surfaces as score-failed.
765
+ * A deadline ends the search either way.
766
+ */
767
+ async function scoreFrame(ctx, want, got) {
768
+ if (Date.now() >= ctx.opts.deadline)
769
+ throw new DeadlineHit();
770
+ try {
771
+ let frame = await fitTo(got, want.width, want.height);
772
+ if (ctx.blur !== undefined)
773
+ frame = await blurFrame(frame, ctx.blur);
774
+ return await ctx.opts.score(want, frame);
775
+ }
776
+ catch (err) {
777
+ if (isDeadline(err))
778
+ throw err;
779
+ throw new StageError("score-failed");
780
+ }
781
+ }
782
+ function mean(values) {
783
+ return values.reduce((acc, v) => acc + v, 0) / values.length;
784
+ }
785
+ /**
786
+ * Encodes every sample at quality `q`, scores it, and returns the result (pass or fail). Removes its files after.
787
+ *
788
+ * Two phases. First the samples are read (encode, VMAF, guard frames). Then the guard is scored in order, and a candidate
789
+ * stops at its first score below ctx.failBelow: the p10 of up to 10 frames is their minimum, so one frame below the bar
790
+ * already decides the result. The reported `still` is then the minimum so far, which is still below the bar.
791
+ *
792
+ * `strict` (the ceiling): an unreadable guard frame is score-failed, never a 0 score (see guardScores).
793
+ */
794
+ async function evaluate(ctx, q, strict = false) {
795
+ const { codec, width, opts } = ctx;
796
+ const dir = join(opts.workDir, `cand-${codec}-${width}-${q}`);
797
+ await mkdir(dir, { recursive: true });
798
+ try {
799
+ const { value: reads, nvenc } = await withNvencFallback(usesNvenc(codec, width, opts.caps, opts.gpu), (n) => readAll(ctx, q, dir, n));
800
+ const scores = [];
801
+ let failed = false;
802
+ for (let i = 0; i < reads.length && !failed; i++) {
803
+ const ref = ctx.guards[i];
804
+ const frames = reads[i].frames;
805
+ if (frames === null && strict)
806
+ throw new StageError("score-failed");
807
+ for (let k = 0; k < ref.indices.length && !failed; k++) {
808
+ const value = frames === null ? 0 : await scoreFrame(ctx, ref.frames[k], frames[k]);
809
+ scores.push(value);
810
+ if (ctx.failBelow !== undefined && value < ctx.failBelow)
811
+ failed = true;
812
+ }
813
+ }
814
+ const sampleBytes = reads.reduce((acc, r) => acc + r.bytes, 0);
815
+ const sampleSeconds = ctx.seconds.reduce((acc, s) => acc + s, 0);
816
+ const measuredBytes = extrapolate(sampleBytes, sampleSeconds, ctx.probe.durationS);
817
+ return {
818
+ codec,
819
+ width,
820
+ quality: q,
821
+ sampleBytes,
822
+ sampleSeconds,
823
+ bytes: cpuEquivalentBytes(measuredBytes, nvenc),
824
+ measuredBytes,
825
+ encoder: nvenc ? "nvenc" : "cpu",
826
+ estimate: true,
827
+ audio: false,
828
+ vmaf: mean(reads.map((r) => r.vmaf)),
829
+ still: tenthPercentile(scores),
830
+ ...(codec === "av1-mp4" || codec === "animated-avif" ? { av1Encoder: ctx.opts.caps.av1 } : {}),
831
+ };
832
+ }
833
+ finally {
834
+ await rm(dir, { recursive: true, force: true });
835
+ }
836
+ }
837
+ /**
838
+ * A gaussian blur of one frame (sharp's gaussian, sigma as given). Used as the guard prefilter; the same call is made on
839
+ * the reference and on the candidate, so both sides are compared through the same filter.
840
+ */
841
+ export async function blurFrame(img, sigma) {
842
+ const bytes = Buffer.from(img.data.buffer, img.data.byteOffset, img.data.byteLength);
843
+ const { data, info } = await sharp(bytes, { raw: { width: img.width, height: img.height, channels: 4 } })
844
+ .blur(sigma)
845
+ .raw()
846
+ .toBuffer({ resolveWithObject: true });
847
+ return { data: new Uint8ClampedArray(data.buffer, data.byteOffset, data.byteLength), width: info.width, height: info.height };
848
+ }
849
+ /**
850
+ * The reference's stillest frames of one sample, at `width`: the frame indices, and the reference pixels there (blurred
851
+ * by `blurSigma` when given). The candidate is later read at the same indices (see guardScores). `metadataFile` and
852
+ * `dir` are scratch paths.
853
+ */
854
+ export async function guardOf(sample, width, metadataFile, _dir, deadline, blurSigma, signal) {
855
+ // `_dir` is kept so the callers keep their signature: the frames now come over stdout, so no scratch files are written.
856
+ const indices = await stillestOf(sample, metadataFile, deadline, signal);
857
+ const raw = await framesAt(sample, [`scale=${width}:-2:flags=lanczos`], indices, deadline, signal);
858
+ const frames = blurSigma === undefined ? raw : await Promise.all(raw.map((f) => blurFrame(f, blurSigma)));
859
+ return { indices, frames };
860
+ }
861
+ /** The reference's stillest frames per sample, at the candidate width. Run once per search. */
862
+ async function prepareGuards(ctx) {
863
+ const guards = [];
864
+ for (let i = 0; i < ctx.samples.length; i++) {
865
+ guards.push(await guardOf(ctx.samples[i], ctx.width, join(ctx.opts.workDir, `ydif-${i}.txt`), join(ctx.opts.workDir, `ref-${ctx.width}-${i}`), ctx.opts.deadline, ctx.blur, ctx.opts.signal));
866
+ }
867
+ return guards;
868
+ }
869
+ /** Whether ffmpeg has the encoder a codec needs. */
870
+ function hasEncoder(caps, codec) {
871
+ switch (codec) {
872
+ case "av1-mp4":
873
+ case "animated-avif":
874
+ return caps.av1 !== undefined;
875
+ case "h264-mp4":
876
+ return caps.x264;
877
+ case "animated-webp":
878
+ return caps.webpAnim;
879
+ }
880
+ }
881
+ /** Seconds of each sample: 2 s, shorter where the file ends first. */
882
+ function clipSeconds(durationS, count) {
883
+ const offsets = sampleOffsets(durationS);
884
+ return Array.from({ length: count }, (_, i) => Math.max(0, Math.min(SAMPLE_SECONDS, durationS - (offsets[i] ?? 0))));
885
+ }
886
+ /**
887
+ * Finds the lowest-bytes quality that passes the bar: VMAF of at least `vmafBar` and a still-frame guard of at least
888
+ * `stillBar`. The quality is bisected over the codec's range, in at most the codec's steps. Quality changes bytes
889
+ * monotonically, so the bisection keeps the passing side and narrows toward the cheapest passing quality.
890
+ *
891
+ * The samples are local files (cutSamples). Each step encodes every sample at the width, scores VMAF and the guard,
892
+ * then removes its files. The deadline is checked before every encode and run: when it passes, the best passing
893
+ * quality so far is returned, or time-limit when none passed. Encoder and VMAF failures are reported as no-encoder and
894
+ * no-vmaf, and a failure to prepare the reference is probe-failed.
895
+ *
896
+ * The bisection has 1-CRF resolution: it stops after the codec's steps, so the result can sit one CRF (or one WebP q
897
+ * step) below the true cheapest passing quality. Accepted for performance (see the performance round in the report).
898
+ */
899
+ export async function searchQuality(samples, probe, codec, width, opts) {
900
+ const { caps } = opts;
901
+ if (opts.signal?.aborted)
902
+ return { ok: false, reason: "aborted" };
903
+ if (!caps.ffmpeg)
904
+ return { ok: false, reason: "no-ffmpeg" };
905
+ if (!caps.vmaf)
906
+ return { ok: false, reason: "no-vmaf" };
907
+ if (!usesNvenc(codec, width, caps, opts.gpu) && !hasEncoder(caps, codec))
908
+ return { ok: false, reason: "no-encoder" };
909
+ // An oversized frame is refused before any decode or encode (ffmpeg would allocate frames of several GB).
910
+ if (probe.width * probe.height > MAX_INPUT_PIXELS_FFMPEG)
911
+ return { ok: false, reason: "too-large" };
912
+ // Without a length the bytes cannot be extrapolated, so there is nothing to report.
913
+ if (samples.length === 0 || !(probe.durationS > 0))
914
+ return { ok: false, reason: "probe-failed" };
915
+ if (Date.now() >= opts.deadline)
916
+ return { ok: false, reason: "time-limit" };
917
+ const spec = SPECS[codec];
918
+ const ctx = {
919
+ samples,
920
+ probe,
921
+ codec,
922
+ width,
923
+ fps: probe.fps > 0 ? probe.fps : DEFAULT_FPS,
924
+ seconds: clipSeconds(probe.durationS, samples.length),
925
+ opts,
926
+ guards: [],
927
+ blur: probe.videoCodec === "gif" ? GIF_GUARD_SIGMA : undefined,
928
+ cache: new Map(),
929
+ };
930
+ let best;
931
+ try {
932
+ await mkdir(opts.workDir, { recursive: true });
933
+ ctx.guards = await prepareGuards(ctx);
934
+ // The ceiling: the codec's best-quality encode. Its guard score sets the still bar for this source. A ceiling that
935
+ // misses the VMAF bar means no quality can pass, so the search ends there.
936
+ const bound = codec === "animated-webp" ? spec.hi : spec.lo;
937
+ const ceiling = await evaluateCached(ctx, bound, true);
938
+ if (ceiling.vmaf < opts.vmafBar)
939
+ return { ok: false, reason: "no-passing-quality" };
940
+ const stillBar = effectiveStillBar(opts.stillBar, ceiling.still);
941
+ ctx.failBelow = stillBar;
942
+ let lo = spec.lo;
943
+ let hi = spec.hi;
944
+ for (let step = 0; step < spec.steps && lo <= hi; step++) {
945
+ const q = step === 0 && spec.start !== undefined ? spec.start : Math.floor((lo + hi) / 2);
946
+ const result = await evaluateCached(ctx, q);
947
+ const pass = result.vmaf >= opts.vmafBar && result.still >= stillBar;
948
+ if (pass && (best === undefined || result.bytes < best.bytes))
949
+ best = { ...result, stillBar, vmafBar: opts.vmafBar };
950
+ // The cheaper side is the one to search next: up for a CRF, down for a WebP q.
951
+ if (spec.fewerBytesAt === "high") {
952
+ if (pass)
953
+ lo = q + 1;
954
+ else
955
+ hi = q - 1;
956
+ }
957
+ else if (pass) {
958
+ hi = q - 1;
959
+ }
960
+ else {
961
+ lo = q + 1;
962
+ }
963
+ }
964
+ }
965
+ catch (err) {
966
+ // An aborted run stops at once, and its partial result is not kept: the caller reports it as interrupted.
967
+ if (opts.signal?.aborted || isAbortError(err))
968
+ return { ok: false, reason: "aborted" };
969
+ // A timeline over the cap, or a frame over the pixel limit, is refused as a whole: no quality of this codec can be scored.
970
+ if (isTooLarge(err))
971
+ return { ok: false, reason: "too-large" };
972
+ // A broken scorer is reported even when an earlier quality passed: the passes before it were not checked by it.
973
+ if (err instanceof StageError && err.reason === "score-failed")
974
+ return { ok: false, reason: "score-failed" };
975
+ if (best) {
976
+ // A search the deadline ended keeps its best passing quality, labelled as cut short (never silently a finished search).
977
+ if (isDeadline(err))
978
+ return { ok: true, best: { ...best, timeLimited: true }, tried: ctx.cache.size };
979
+ return { ok: true, best, tried: ctx.cache.size };
980
+ }
981
+ if (err instanceof StageError)
982
+ return { ok: false, reason: err.reason };
983
+ return { ok: false, reason: isDeadline(err) ? "time-limit" : "probe-failed" };
984
+ }
985
+ return best ? { ok: true, best, tried: ctx.cache.size } : { ok: false, reason: "no-passing-quality" };
986
+ }
987
+ /**
988
+ * Scores one encoded candidate against its reference, for `imgtune image --reference`. The reference is cut into the same
989
+ * 2-second samples the search uses, at the offsets of its probe; the candidate is cut at those same offsets, so both sides
990
+ * cover the same moments. Both are scaled to `width` and scored as searchQuality scores an encode (the VMAF mean, the
991
+ * 10th percentile of the still guard, the same prefilter for a GIF reference). Nothing is re-encoded except the
992
+ * reference's ceiling: one encode of each sample at the best quality of the candidate's own codec (`codec`: AV1 CRF 20, H.264
993
+ * CRF 18, WebP q 90), so a candidate is judged against the bar its codec can reach. AV1 stands in when that codec has no
994
+ * encoder. Throws when a file cannot be cut or scored, and when the ceiling's own frames cannot be read (score-failed).
995
+ */
996
+ export async function compareToReference(candidate, reference, probe, width, opts, codec = "av1-mp4") {
997
+ const referenceSamples = await cutSamples(reference, probe, join(opts.workDir, "reference"), opts.deadline, undefined, opts.signal);
998
+ const candidateSamples = await cutSamples(candidate, probe, join(opts.workDir, "candidate"), opts.deadline, undefined, opts.signal);
999
+ const scored = await scoreSamples(candidateSamples, referenceSamples, probe, width, opts, { webp: false, strict: false });
1000
+ const encodable = (c) => usesNvenc(c, width, opts.caps, opts.gpu) || hasEncoder(opts.caps, c);
1001
+ const ceilingCodec = encodable(codec) ? codec : encodable("av1-mp4") ? "av1-mp4" : undefined;
1002
+ let ceiling;
1003
+ if (ceilingCodec !== undefined) {
1004
+ const dir = join(opts.workDir, "ceiling");
1005
+ await mkdir(dir, { recursive: true });
1006
+ const spec = SPECS[ceilingCodec];
1007
+ const bound = ceilingCodec === "animated-webp" ? spec.hi : spec.lo;
1008
+ // The ceiling is encoded as the search encodes (NVENC when it is used for this codec and width), so the bar is judged by it.
1009
+ const { value: encoded } = await withNvencFallback(usesNvenc(ceilingCodec, width, opts.caps, opts.gpu), async (nvenc) => {
1010
+ const files = [];
1011
+ for (let i = 0; i < referenceSamples.length; i++) {
1012
+ const out = join(dir, `s${i}.${spec.ext}`);
1013
+ await encodeClip(referenceSamples[i], ceilingCodec, width, bound, out, opts.caps, opts.deadline, opts.signal, nvenc, opts.gpu);
1014
+ files.push(out);
1015
+ }
1016
+ return files;
1017
+ });
1018
+ ceiling = (await scoreSamples(encoded, referenceSamples, probe, width, opts, { webp: ceilingCodec === "animated-webp", strict: true })).still;
1019
+ }
1020
+ return { vmaf: scored.vmaf, still: scored.still, ceiling, ceilingCodec, samples: referenceSamples.length };
1021
+ }
1022
+ /**
1023
+ * The pooled VMAF mean and the still-guard 10th percentile of candidate samples against reference samples (the same
1024
+ * cut offsets, one file per sample). The loop of an encode's score, without the encode: the search and the comparison
1025
+ * both score this way. `kind.webp`: the candidates are animated WebP files (decoded, not read by ffmpeg); `kind.strict`:
1026
+ * an unreadable guard frame is score-failed (see guardScores).
1027
+ */
1028
+ async function scoreSamples(candidates, samples, probe, width, opts, kind) {
1029
+ // The codec field only names the candidate kind for the cache of a search; scoring never reads it.
1030
+ const ctx = {
1031
+ samples,
1032
+ probe,
1033
+ codec: kind.webp ? "animated-webp" : "av1-mp4",
1034
+ width,
1035
+ fps: probe.fps > 0 ? probe.fps : DEFAULT_FPS,
1036
+ seconds: clipSeconds(probe.durationS, samples.length),
1037
+ opts,
1038
+ guards: [],
1039
+ blur: probe.videoCodec === "gif" ? GIF_GUARD_SIGMA : undefined,
1040
+ cache: new Map(),
1041
+ };
1042
+ await mkdir(opts.workDir, { recursive: true });
1043
+ ctx.guards = await prepareGuards(ctx);
1044
+ const vmafs = [];
1045
+ const scores = [];
1046
+ for (let i = 0; i < samples.length; i++) {
1047
+ const cand = kind.webp
1048
+ ? await webpCandidate(candidates[i], ctx.fps, ctx.seconds[i])
1049
+ : fileCandidate(candidates[i], opts.deadline, opts.signal);
1050
+ vmafs.push(await vmafOf(cand.input, samples[i], width, ctx.fps, join(opts.workDir, `vmaf-${i}.json`), opts.deadline, opts.signal, cand.stdin, cudaFor(ctx, kind.webp ? "animated-webp" : "av1-mp4")));
1051
+ scores.push(...(await guardScores(ctx, i, cand, kind.strict)));
1052
+ }
1053
+ return { vmaf: mean(vmafs), still: tenthPercentile(scores) };
1054
+ }