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.
@@ -0,0 +1,652 @@
1
+ // The media phase of a scan: after every page is measured, the animated images and `<video>` files of all pages are
2
+ // ranked by their bytes, and the heaviest ones within the budget get their sample encodes (media-encode.ts). Each asset
3
+ // is encoded once per run (its key), an outcome is kept in the result cache, and every ffmpeg step is bounded by the
4
+ // asset's deadline. Nothing here throws: a failure becomes the asset's `notEncoded` reason, so a scan always finishes.
5
+ // Without ffmpeg the phase still runs its cheap part: every asset is marked "ffmpeg not found" with one note.
6
+ import { createHash } from "node:crypto";
7
+ import { mkdir, rm, writeFile } from "node:fs/promises";
8
+ import { join, resolve } from "node:path";
9
+ import { mapLimit } from "./concurrency.js";
10
+ import { animationOf } from "./animated.js";
11
+ import { FFMPEG_JOBS, onGpuDevice, probeFfmpeg, probeMedia, withGpu } from "./ffmpeg.js";
12
+ import { prepareMedia } from "./media-prepare.js";
13
+ import { cutSamples, hasRange, isAbortError, MAX_INPUT_PIXELS_FFMPEG, searchQuality, } from "./media-encode.js";
14
+ import { attributeLevers, rankForBudget, snapWidth, withFileLength } from "./media-model.js";
15
+ import { makeScorer } from "./metric.js";
16
+ import { isThirdPartyBlocked } from "./third-party.js";
17
+ import { CACHE_VERSION, contentHash } from "./result-cache.js";
18
+ import { IMGTUNE_VERSION } from "./version.js";
19
+ /** The note printed once when ffmpeg is missing: what installing it adds. */
20
+ export const MEDIA_NOTE = "install ffmpeg for verified video and GIF hints";
21
+ /** Longest a single asset may take, in ms; the phase's own time limit caps it further. */
22
+ const ASSET_CAP_MS = 60_000;
23
+ /** Time allowed for the Range probe of a video URL, in ms. */
24
+ const RANGE_TIMEOUT_MS = 15_000;
25
+ /** Time allowed for ffprobe of one file, in ms. */
26
+ const PROBE_TIMEOUT_MS = 30_000;
27
+ /** A video the server cannot seek in is not downloaded above this size: the encode would read all of it. */
28
+ const NO_RANGE_MAX_BYTES = 50_000_000;
29
+ /** Time allowed for the length probe (a Range request) of one video URL, in ms. */
30
+ export const LENGTH_TIMEOUT_MS = 10_000;
31
+ /** Video URLs probed at once for their length. */
32
+ export const LENGTH_CONCURRENCY = 8;
33
+ /** The notEncoded reason of an asset the third-party policy blocks on its page. */
34
+ export const THIRD_PARTY_BLOCKED = "third-party blocked";
35
+ /** Longest error text kept in a notEncoded reason. */
36
+ const MAX_ERROR_CHARS = 200;
37
+ /** The identity of an asset blocked on one page: the same file may be allowed on another page. */
38
+ export function blockedKeyOf(page, key) {
39
+ return `${page}\n${key}`;
40
+ }
41
+ /**
42
+ * Splits the assets by the third-party policy, each checked from the page it was found on, as the browser route guard checks
43
+ * a request. A blocked asset is never fetched, read or proxied: its blockedKeyOf goes into `blocked`. A video is a media
44
+ * request and an animated image an image request, so the policy's kept types decide.
45
+ */
46
+ export function partitionByPolicy(assets, policy) {
47
+ const blocked = new Set();
48
+ const allowed = [];
49
+ for (const asset of assets) {
50
+ const facts = { pageUrl: asset.page, requestUrl: asset.url, resourceType: asset.kind === "video" ? "media" : "image", subFrame: false };
51
+ if (isThirdPartyBlocked(policy, facts))
52
+ blocked.add(blockedKeyOf(asset.page, asset.key));
53
+ else
54
+ allowed.push(asset);
55
+ }
56
+ return { allowed, blocked };
57
+ }
58
+ /**
59
+ * The key of one asset: its kind, its context, its widths and its URL. Two assets with one key are one encode. The
60
+ * validators of a video (its ETag and length) refine the result-cache key later, not this one.
61
+ */
62
+ export function mediaKeyOf(kind, context, url, widths) {
63
+ return `${kind}|${context}|${widths.join(",")}|${url}`;
64
+ }
65
+ /** The asset of a `<video>` record: its widths are the kept widths of the scan. */
66
+ export function videoAssetOf(record) {
67
+ const widths = record.widths?.widths ?? [];
68
+ return {
69
+ key: mediaKeyOf("video", "video", record.src, widths),
70
+ url: record.src,
71
+ page: record.page,
72
+ kind: "video",
73
+ context: "video",
74
+ widths,
75
+ perView: record.bytes.perView,
76
+ perPlay: record.bytes.perPlay,
77
+ perViewBasis: record.bytes.perViewBasis,
78
+ };
79
+ }
80
+ /** The redirect-hop check of a video's page: the third-party policy as the browser's route guard applies it. */
81
+ export function hopCheckOf(page, policy) {
82
+ if (policy === undefined)
83
+ return undefined;
84
+ return (hop) => !isThirdPartyBlocked(policy, { pageUrl: page, requestUrl: hop, resourceType: "media", subFrame: false });
85
+ }
86
+ /**
87
+ * The full file length of each URL, from its Range probe (hasRange: a HEAD and a `bytes=0-0` GET through the proxy). Runs
88
+ * for every unique URL, LENGTH_CONCURRENCY at a time, each with LENGTH_TIMEOUT_MS. A URL is probed from the page it was
89
+ * first found on, so its redirect hops are checked against that page's policy. A URL with no length is left out. Never
90
+ * throws: a failed probe is just no length.
91
+ */
92
+ export async function probeLengths(targets, proxy, options = {}) {
93
+ const lengths = new Map();
94
+ const firstPage = new Map();
95
+ for (const target of targets)
96
+ if (!firstPage.has(target.url))
97
+ firstPage.set(target.url, target.page);
98
+ const probe = options.probe ?? hasRange;
99
+ await mapLimit([...firstPage], LENGTH_CONCURRENCY, async ([url, page]) => {
100
+ try {
101
+ const info = await probe(url, LENGTH_TIMEOUT_MS, proxy, hopCheckOf(page, options.thirdParty));
102
+ if (info.length !== undefined && info.length > 0)
103
+ lengths.set(url, info.length);
104
+ }
105
+ catch {
106
+ // No length: the record keeps its observed bytes.
107
+ }
108
+ });
109
+ return lengths;
110
+ }
111
+ /**
112
+ * The asset with its per-view and per-play bytes refined by the file length (see withFileLength), so the budget ranks a
113
+ * click-to-play video by its full file. An asset without a length is returned as it is.
114
+ */
115
+ export function refineAssetLength(asset, length) {
116
+ if (length === undefined || asset.kind !== "video")
117
+ return asset;
118
+ const bytes = withFileLength({ perView: asset.perView, perPlay: asset.perPlay, perViewBasis: asset.perViewBasis ?? "observed", browserDependent: false }, length);
119
+ return { ...asset, perView: bytes.perView, perPlay: bytes.perPlay };
120
+ }
121
+ /** The device width an animated image needs: the largest any viewport needs (0 when none is known). */
122
+ export function animatedNeedOf(record) {
123
+ const needs = record.perViewport.map((p) => p.neededPx).filter((n) => n > 0);
124
+ return needs.length === 0 ? 0 : Math.max(...needs);
125
+ }
126
+ /**
127
+ * The asset of an animated image record, or undefined when the record has no URL or no needed width. The one width is the
128
+ * largest device width any viewport needs, snapped to a bucket, never above the file's own width.
129
+ */
130
+ export function animatedAssetOf(record, fix) {
131
+ const url = record.current.src;
132
+ if (url === undefined)
133
+ return undefined;
134
+ const need = animatedNeedOf(record);
135
+ if (need === 0)
136
+ return undefined;
137
+ const widths = [snapWidth(need, record.current.naturalWidth ?? need)];
138
+ return {
139
+ key: mediaKeyOf("animated", fix.context, url, widths),
140
+ url,
141
+ page: record.page,
142
+ kind: "animated",
143
+ context: fix.context,
144
+ widths,
145
+ perView: fix.perViewBytes,
146
+ perPlay: fix.perViewBytes,
147
+ };
148
+ }
149
+ /** The assets of one measured page: its videos and its animated images. */
150
+ export function mediaAssetsOf(result) {
151
+ const assets = (result.videos ?? []).map(videoAssetOf);
152
+ for (const image of result.images ?? []) {
153
+ if (image.fix.animated === undefined)
154
+ continue;
155
+ const asset = animatedAssetOf(image, image.fix.animated);
156
+ if (asset !== undefined)
157
+ assets.push(asset);
158
+ }
159
+ return assets;
160
+ }
161
+ /** Results that mark every asset as not encoded for one reason (a survey, or a scan with quality off). */
162
+ export function skippedMedia(assets, reason) {
163
+ const byKey = new Map();
164
+ for (const asset of assets) {
165
+ if (!byKey.has(asset.key))
166
+ byKey.set(asset.key, { candidates: [], notEncoded: reason });
167
+ }
168
+ return { byKey };
169
+ }
170
+ /**
171
+ * The jobs of a video: AV1 at each width, and the H.264 fallback at each width unless the served file already is H.264 at
172
+ * that width. The widest (the target, whose AV1 is the main hint) goes first, so it is not the job the asset's time limit
173
+ * cuts off.
174
+ */
175
+ function videoJobs(widths, probe) {
176
+ const jobs = [];
177
+ for (const width of [...widths].sort((a, b) => b - a)) {
178
+ jobs.push({ codec: "av1-mp4", width });
179
+ if (!(probe.videoCodec === "h264" && width >= probe.width))
180
+ jobs.push({ codec: "h264-mp4", width });
181
+ }
182
+ return jobs;
183
+ }
184
+ /**
185
+ * The jobs of an animated image: animated AVIF and WebP at each width, and AV1 too for an `<img>`, so the over-threshold
186
+ * rule (a video instead of the image) can be judged.
187
+ */
188
+ function animatedJobs(widths, context) {
189
+ const jobs = [];
190
+ for (const width of [...widths].sort((a, b) => b - a)) {
191
+ jobs.push({ codec: "animated-avif", width }, { codec: "animated-webp", width });
192
+ if (context === "img")
193
+ jobs.push({ codec: "av1-mp4", width });
194
+ }
195
+ return jobs;
196
+ }
197
+ /** Assets one GPU works on at once: each worker holds one asset, and a GPU's own pool keeps its processes to GPU_MAX_PROCS. */
198
+ export const GPU_WORKERS_PER_DEVICE = 2;
199
+ /** A short, file-name-safe digest of a key, for the per-asset folder. */
200
+ function digest(text) {
201
+ return createHash("sha256").update(text).digest("hex").slice(0, 16);
202
+ }
203
+ /** The error text of an unexpected failure, one line and at most MAX_ERROR_CHARS long. */
204
+ function errorText(err) {
205
+ const message = err instanceof Error ? err.message : String(err);
206
+ return message.replace(/\s+/g, " ").slice(0, MAX_ERROR_CHARS);
207
+ }
208
+ /** True when a cached value has the shape the phase wrote. The cache is untrusted, so every field is checked. */
209
+ function isCachedOutcome(value) {
210
+ if (typeof value !== "object" || value === null)
211
+ return false;
212
+ const v = value;
213
+ if (v.ok === false)
214
+ return v.reason === "no-passing-quality";
215
+ if (v.ok !== true || typeof v.best !== "object" || v.best === null)
216
+ return false;
217
+ const b = v.best;
218
+ const numbers = ["width", "quality", "sampleBytes", "sampleSeconds", "bytes", "vmaf", "still"];
219
+ return (numbers.every((k) => typeof b[k] === "number" && Number.isFinite(b[k])) &&
220
+ (b.codec === "av1-mp4" || b.codec === "h264-mp4" || b.codec === "animated-avif" || b.codec === "animated-webp"));
221
+ }
222
+ /**
223
+ * The result-cache key of one job of an asset: its identity, codec and width, the bars, the scorer, the encoder (NVENC on a
224
+ * GPU, or the CPU) and the versions.
225
+ */
226
+ function cacheKeyOf(identity, job, opts) {
227
+ const encoder = opts.gpu !== undefined ? `gpu${opts.gpu}` : "cpu";
228
+ return `media|${identity}|${job.codec}|w${job.width}|v${opts.vmafBar}|s${opts.stillBar}|scorer ${opts.scorerId ?? "cpu"}|encoder ${encoder}|imgtune ${IMGTUNE_VERSION}|cache ${CACHE_VERSION}`;
229
+ }
230
+ /**
231
+ * The entry of an asset whose every job is already in the result cache, read without the file: no probe and no frame. Undefined
232
+ * when one job is not cached, and then the caller reads the file and runJobs does the rest.
233
+ */
234
+ async function cachedEntry(jobs, identity, opts) {
235
+ if (opts.cache === undefined || jobs.length === 0)
236
+ return undefined;
237
+ const candidates = [];
238
+ const failed = [];
239
+ for (const job of jobs) {
240
+ const cached = await opts.cache.getJson(cacheKeyOf(identity, job, opts));
241
+ if (!isCachedOutcome(cached))
242
+ return undefined;
243
+ if (cached.ok)
244
+ candidates.push(cached.best);
245
+ else
246
+ failed.push({ codec: job.codec, width: job.width, reason: "no-passing-quality" });
247
+ }
248
+ if (candidates.length > 0)
249
+ return { candidates, failed };
250
+ return { candidates, notEncoded: failed[0]?.reason, failed };
251
+ }
252
+ /**
253
+ * Runs the jobs of one asset in order, each through the cache first. The samples are cut once, on the first job that
254
+ * misses the cache. Returns the candidates that passed; when none did, the first reason.
255
+ */
256
+ async function runJobs(jobs, identity, probe, samplesOf, dir, deadline, env, readError) {
257
+ const { opts } = env;
258
+ const candidates = [];
259
+ const reasons = [];
260
+ const failed = [];
261
+ let samples;
262
+ for (const job of jobs) {
263
+ const cacheKey = cacheKeyOf(identity, job, opts);
264
+ const cached = await opts.cache?.getJson(cacheKey);
265
+ let outcome;
266
+ if (isCachedOutcome(cached)) {
267
+ outcome = cached.ok ? { ok: true, best: cached.best, tried: 0 } : { ok: false, reason: "no-passing-quality" };
268
+ }
269
+ else {
270
+ if (opts.signal?.aborted)
271
+ return { probe, candidates, notEncoded: "aborted" };
272
+ if (Date.now() >= deadline)
273
+ return { probe, candidates, notEncoded: "time-limit" };
274
+ samples ??= samplesOf();
275
+ let cut;
276
+ try {
277
+ cut = await samples;
278
+ }
279
+ catch {
280
+ return { probe, candidates, notEncoded: Date.now() >= deadline ? "time-limit" : readError };
281
+ }
282
+ outcome = await env.search(cut, probe, job.codec, job.width, {
283
+ vmafBar: opts.vmafBar,
284
+ stillBar: opts.stillBar,
285
+ deadline,
286
+ caps: env.caps,
287
+ gpu: opts.gpu,
288
+ score: env.score,
289
+ workDir: join(dir, `${job.codec}-${job.width}`),
290
+ signal: opts.signal,
291
+ });
292
+ if (outcome.ok)
293
+ await opts.cache?.putJson(cacheKey, { ok: true, best: outcome.best });
294
+ else if (outcome.reason === "no-passing-quality")
295
+ await opts.cache?.putJson(cacheKey, { ok: false, reason: "no-passing-quality" });
296
+ }
297
+ if (outcome.ok)
298
+ candidates.push(outcome.best);
299
+ else {
300
+ reasons.push(outcome.reason);
301
+ failed.push({ codec: job.codec, width: job.width, reason: outcome.reason });
302
+ }
303
+ }
304
+ if (candidates.length > 0)
305
+ return { probe, candidates, failed };
306
+ if (jobs.length === 0)
307
+ return { probe, candidates, notEncoded: "no width to encode" };
308
+ return { probe, candidates, notEncoded: reasons[0], failed };
309
+ }
310
+ /**
311
+ * The sample encodes of one file: its probe, then the jobs `jobsOf` gives for it. `input` is an absolute path or a URL
312
+ * (read through `proxy`). A file that cannot be probed is a read error.
313
+ */
314
+ async function encodeFile(input, identity, jobsOf, env, deadline, dir, proxy, readError, known) {
315
+ // A file is probed here; a WebP source always comes with its probe (see prepareMedia).
316
+ const probe = known ?? (await env.probe(input, PROBE_TIMEOUT_MS, { proxy }));
317
+ if (probe === undefined)
318
+ return { candidates: [], notEncoded: readError };
319
+ // A frame over the pixel limit is refused before any sample is cut or encoded (see MAX_INPUT_PIXELS_FFMPEG).
320
+ if (probe.width * probe.height > MAX_INPUT_PIXELS_FFMPEG)
321
+ return { probe, candidates: [], notEncoded: "too-large" };
322
+ return runJobs(jobsOf(probe), identity, probe, () => env.cut(input, probe, dir, deadline, proxy, env.opts.signal), dir, deadline, env, readError);
323
+ }
324
+ /** The notEncoded reason of a failure while preparing a file: too large to score, aborted, or the read error. */
325
+ function prepareReasonOf(err, readError) {
326
+ if (isAbortError(err))
327
+ return "aborted";
328
+ if (err instanceof Error && err.message.startsWith("too large to score"))
329
+ return "too-large";
330
+ return readError;
331
+ }
332
+ /** Writes a file's bytes into the asset's folder and returns the local path. */
333
+ async function writeSource(dir, buffer) {
334
+ await mkdir(dir, { recursive: true });
335
+ const file = join(dir, "source");
336
+ await writeFile(file, buffer);
337
+ return file;
338
+ }
339
+ /**
340
+ * A `<video>`: its Range support and length, then its sample encodes. A server with Range support is sampled through the
341
+ * proxy by seeking. A server without it cannot be sought (ffmpeg then cuts a truncated sample and still exits 0), so a
342
+ * file of up to NO_RANGE_MAX_BYTES is read whole, once, and cut from the local copy; a larger one is not read at all.
343
+ */
344
+ async function encodeVideo(asset, env, deadline) {
345
+ const proxy = env.opts.proxy;
346
+ const range = await env.hasRange(asset.url, RANGE_TIMEOUT_MS, proxy, hopCheckOf(asset.page, env.opts.thirdParty));
347
+ if (range.blocked)
348
+ return { candidates: [], notEncoded: THIRD_PARTY_BLOCKED };
349
+ const length = range.length;
350
+ // ffmpeg reads the URL after its redirects, so it does not follow a chain the policy has already checked. The residual
351
+ // risk (a later, different redirect that ffmpeg follows by itself) is accepted: the egress proxy still blocks private
352
+ // addresses at connect time, and a third-party hop is never reached by the probe above.
353
+ const input = range.url ?? asset.url;
354
+ if (!range.range && (length ?? 0) > NO_RANGE_MAX_BYTES) {
355
+ return { candidates: [], notEncoded: "server has no Range support" };
356
+ }
357
+ const dir = join(resolve(env.opts.workDir), digest(asset.key));
358
+ const jobsOf = (probe) => videoJobs(asset.widths, probe);
359
+ if (!range.range) {
360
+ // The fallback fetch follows redirects itself, so each hop gets the same policy check as the Range probe.
361
+ let refused = false;
362
+ const allowHop = hopCheckOf(asset.page, env.opts.thirdParty);
363
+ const hop = allowHop === undefined
364
+ ? undefined
365
+ : { page: asset.page, allowHop: (next) => { const ok = allowHop(next); if (!ok)
366
+ refused = true; return ok; } };
367
+ const buffer = env.fetch ? await env.fetch(asset.url, NO_RANGE_MAX_BYTES, hop) : undefined;
368
+ if (refused)
369
+ return { candidates: [], notEncoded: THIRD_PARTY_BLOCKED };
370
+ if (buffer === undefined)
371
+ return { candidates: [], notEncoded: "could not read the video" };
372
+ const file = await writeSource(dir, buffer);
373
+ return encodeFile(file, `${asset.url}|${contentHash(buffer)}`, jobsOf, env, deadline, dir, undefined, "could not read the video");
374
+ }
375
+ const identity = videoIdentityOf(asset.url, { etag: range.etag, lastModified: range.lastModified, length });
376
+ return encodeFile(input, identity, jobsOf, env, deadline, dir, proxy, "could not read the video");
377
+ }
378
+ /**
379
+ * The identity of a video's cached results: its URL, its validator (the ETag, else the Last-Modified date) and its length from
380
+ * the Range probe. A changed file at the same URL changes the validator or the length, so it misses the cache. The asset key
381
+ * (mediaKeyOf) cannot carry them: they are known only after the probe, which runs after the key is made.
382
+ */
383
+ export function videoIdentityOf(url, range) {
384
+ return `${url}|${range.etag ?? range.lastModified ?? ""}|${range.length ?? ""}`;
385
+ }
386
+ /** An animated image: its bytes, written to the work folder so ffmpeg reads a local file, then its sample encodes. */
387
+ async function encodeAnimated(asset, env, deadline) {
388
+ const readError = "could not read the image";
389
+ const buffer = asset.buffer ?? (env.fetch ? await env.fetch(asset.url) : undefined);
390
+ if (buffer === undefined)
391
+ return { candidates: [], notEncoded: readError };
392
+ const dir = join(resolve(env.opts.workDir), digest(asset.key));
393
+ const jobsOf = () => animatedJobs(asset.widths, asset.context);
394
+ const identity = `${asset.url}|${contentHash(buffer)}`;
395
+ if (animationOf(buffer)?.format === "webp") {
396
+ // The result cache is asked before the file is read: an asset whose every job is cached needs neither its probe nor its frames.
397
+ const cachedAll = await cachedEntry(jobsOf(), identity, env.opts);
398
+ if (cachedAll !== undefined)
399
+ return cachedAll;
400
+ // ffmpeg cannot read an animated WebP: it is decoded once into an FFV1 file (the samples are cut from that), and the
401
+ // probe of that file is known. Refused before any decode when it is too large to score.
402
+ let prepared;
403
+ try {
404
+ prepared = await prepareMedia(join(dir, "source.webp"), buffer, join(dir, "prepared"), env.opts.framePixelLimit, env.opts.signal);
405
+ }
406
+ catch (err) {
407
+ return { candidates: [], notEncoded: prepareReasonOf(err, readError) };
408
+ }
409
+ return encodeFile(prepared.source, identity, jobsOf, env, deadline, dir, undefined, readError, prepared.probe);
410
+ }
411
+ const file = await writeSource(dir, buffer);
412
+ return encodeFile(file, identity, jobsOf, env, deadline, dir, undefined, readError);
413
+ }
414
+ /** One asset's entry. A passed deadline is a time limit, checked before any work. */
415
+ async function encodeOne(asset, env, deadline) {
416
+ if (env.opts.signal?.aborted)
417
+ return { candidates: [], notEncoded: "aborted" };
418
+ if (Date.now() >= deadline)
419
+ return { candidates: [], notEncoded: "time-limit" };
420
+ try {
421
+ return asset.kind === "video" ? await encodeVideo(asset, env, deadline) : await encodeAnimated(asset, env, deadline);
422
+ }
423
+ catch (err) {
424
+ if (isAbortError(err))
425
+ return { candidates: [], notEncoded: "aborted" };
426
+ return { candidates: [], notEncoded: `media error: ${errorText(err)}` };
427
+ }
428
+ }
429
+ /**
430
+ * Runs the media phase for the assets of a scan. Within the budget, each unique asset is probed, cut into samples and
431
+ * searched per codec and width; the rest are marked "over --media-budget". `--survey` encodes nothing. Without ffmpeg
432
+ * every asset is marked "ffmpeg not found" and the note is set. Never throws. The work folder is removed at the end.
433
+ */
434
+ export async function runMediaPhase(assets, opts, deps = {}) {
435
+ const byKey = new Map();
436
+ if (assets.length === 0)
437
+ return { byKey };
438
+ const started = Date.now();
439
+ const phaseEnd = started + Math.max(0, opts.timeS) * 1000;
440
+ try {
441
+ const probed = deps.caps ?? (await probeFfmpeg());
442
+ const unique = [...new Map(assets.map((a) => [a.key, a])).values()];
443
+ const ranked = rankForBudget(unique, opts.budget);
444
+ const running = !opts.survey && probed.ffmpeg;
445
+ opts.log(`media: ${assets.length} assets, ${running ? ranked.encode.length : 0} to encode (budget ${opts.budget})`);
446
+ if (opts.survey) {
447
+ for (const asset of unique)
448
+ byKey.set(asset.key, { candidates: [], notEncoded: "survey" });
449
+ return finish(byKey, started, opts, undefined, unique);
450
+ }
451
+ if (!probed.ffmpeg) {
452
+ for (const asset of unique)
453
+ byKey.set(asset.key, { candidates: [], notEncoded: "ffmpeg not found" });
454
+ opts.log(`media: ffmpeg not found; ${MEDIA_NOTE}`);
455
+ return finish(byKey, started, opts, MEDIA_NOTE, unique);
456
+ }
457
+ for (const asset of ranked.skipped)
458
+ byKey.set(asset.key, { candidates: [], notEncoded: "over --media-budget" });
459
+ const score = opts.score ?? makeScorer("ssimulacra2");
460
+ const envOf = (device, caps) => ({
461
+ opts: { ...opts, gpu: device },
462
+ caps,
463
+ score,
464
+ search: deps.search ?? searchQuality,
465
+ cut: deps.cut ?? cutSamples,
466
+ probe: deps.probe ?? probeMedia,
467
+ hasRange: deps.hasRange ?? hasRange,
468
+ fetch: deps.fetch,
469
+ });
470
+ // The workers: FFMPEG_JOBS on the CPU, and GPU_WORKERS_PER_DEVICE for each GPU that proves a use. A GPU worker runs
471
+ // its assets' processes in that GPU's pool, with NVENC and libvmaf_cuda where the device has them.
472
+ const cpuCaps = { ...probed, av1Nvenc: false, h264Nvenc: false, vmafCuda: false };
473
+ const workers = Array.from({ length: FFMPEG_JOBS }, () => ({ env: envOf(undefined, cpuCaps) }));
474
+ const gpuNotes = [];
475
+ for (const device of opts.gpus ?? []) {
476
+ const caps = await (deps.gpu ?? withGpu)(probed, device);
477
+ if (!caps.av1Nvenc && !caps.h264Nvenc && !caps.vmafCuda) {
478
+ gpuNotes.push(`GPU ${device} has no working encoder or VMAF, not used`);
479
+ continue;
480
+ }
481
+ for (let i = 0; i < GPU_WORKERS_PER_DEVICE; i++)
482
+ workers.push({ device, env: envOf(device, caps) });
483
+ const parts = [caps.av1Nvenc && "AV1 NVENC", caps.h264Nvenc && "H.264 NVENC", caps.vmafCuda && "VMAF on the GPU"].filter(Boolean);
484
+ gpuNotes.push(`GPU ${device}: ${parts.join(", ") || "no GPU encoder"}`);
485
+ }
486
+ opts.log(`media: ${workers.filter((w) => w.device === undefined).length} CPU workers${gpuNotes.length > 0 ? `; ${gpuNotes.join("; ")}` : ""}`);
487
+ await mkdir(opts.workDir, { recursive: true });
488
+ // One FIFO queue in rank order: whichever worker is free takes the next asset.
489
+ const queue = [...ranked.encode];
490
+ const drain = async (worker) => {
491
+ for (let asset = queue.shift(); asset !== undefined; asset = queue.shift()) {
492
+ const deadline = Math.min(Date.now() + ASSET_CAP_MS, phaseEnd);
493
+ const work = () => encodeOne(asset, worker.env, deadline);
494
+ byKey.set(asset.key, worker.device === undefined ? await work() : await onGpuDevice(worker.device, work));
495
+ }
496
+ };
497
+ try {
498
+ await Promise.all(workers.map(drain));
499
+ }
500
+ finally {
501
+ await rm(opts.workDir, { recursive: true, force: true });
502
+ }
503
+ return finish(byKey, started, opts, undefined, unique);
504
+ }
505
+ catch (err) {
506
+ // Unexpected: every asset gets the reason, and the scan goes on.
507
+ const reason = `media error: ${errorText(err)}`;
508
+ for (const asset of assets)
509
+ if (!byKey.has(asset.key))
510
+ byKey.set(asset.key, { candidates: [], notEncoded: reason });
511
+ return { byKey };
512
+ }
513
+ }
514
+ /**
515
+ * True when the entry has a candidate at the asset's target width, the largest of its widths: the width a video's estimate
516
+ * is taken at (attachVideo) and an animated image's single width. An encode at a smaller width alone is not an estimate.
517
+ */
518
+ export function hasTargetEstimate(entry, asset) {
519
+ const target = Math.max(0, ...asset.widths);
520
+ return entry.candidates.some((c) => c.width === target);
521
+ }
522
+ /** The phase's closing log line, and its results. An asset counts as encoded only with an estimate at its target width. */
523
+ function finish(byKey, started, opts, note, assets) {
524
+ const byAsset = new Map(assets.map((a) => [a.key, a]));
525
+ let encoded = 0;
526
+ let notEncoded = 0;
527
+ for (const [key, entry] of byKey) {
528
+ const asset = byAsset.get(key);
529
+ if (asset !== undefined && hasTargetEstimate(entry, asset))
530
+ encoded++;
531
+ else
532
+ notEncoded++;
533
+ }
534
+ opts.log(`media: done in ${((Date.now() - started) / 1000).toFixed(1)} s, ${encoded} encoded, ${notEncoded} not encoded`);
535
+ return note === undefined ? { byKey } : { byKey, note };
536
+ }
537
+ /**
538
+ * The steps of the lever chain for a video, from its full bytes: audio, then resolution, then the re-encode (see
539
+ * attributeLevers). The search returns only the best passing quality, so the quality stage is zero by design and the codec
540
+ * stage carries the whole re-encode (codec and quality together). Task 8 labels that step "re-encode". The resolution stage
541
+ * is the no-audio bytes scaled by the pixel ratio (an estimate), clamped into [final, no-audio]; the steps then sum to
542
+ * start − final exactly. An encode larger than the no-audio bytes has no lever (a negative saving), so no steps are given.
543
+ */
544
+ function stepsOf(record, estimate, target, probe) {
545
+ const start = record.bytes.perPlay;
546
+ const audio = record.levers.audio?.bytes ?? 0;
547
+ const noAudio = start - audio;
548
+ const final = estimate.bytes;
549
+ const source = probe?.width ?? target;
550
+ if (!(source > 0) || final > noAudio)
551
+ return undefined;
552
+ const scaled = Math.round(noAudio * (target / source) ** 2);
553
+ const resized = Math.min(noAudio, Math.max(final, scaled));
554
+ return attributeLevers({ start, noAudio, resized, recoded: final, final });
555
+ }
556
+ /** The lowest-bytes candidate, or undefined when there is none. The first of equal bytes wins. */
557
+ function smallest(candidates) {
558
+ let best;
559
+ for (const c of candidates)
560
+ if (best === undefined || c.bytes < best.bytes)
561
+ best = c;
562
+ return best;
563
+ }
564
+ /** The audio bytes of a muted video that has an audio track: its audio bitrate over its length. */
565
+ function audioBytesOf(record, probe) {
566
+ const bitrate = probe?.audio?.bitrate;
567
+ if (bitrate === undefined || !record.attributes?.muted || !(probe.durationS > 0))
568
+ return undefined;
569
+ return Math.round((bitrate / 8) * probe.durationS);
570
+ }
571
+ /** The bytes of a video refined by the server's length, when the record did not know the full size. */
572
+ export function refineBytes(bytes, length) {
573
+ if (bytes.fullBytesKnown || length === undefined)
574
+ return bytes;
575
+ return { ...withFileLength(bytes, length), fullBytesKnown: true };
576
+ }
577
+ /** One video record with its entry: the probe, the audio lever, the estimate and its steps, or the reason it has none. */
578
+ function attachVideo(record, entry, fallback) {
579
+ if (entry === undefined)
580
+ return fallback === undefined ? record : { ...record, notEncoded: fallback };
581
+ const levers = { ...record.levers };
582
+ const audio = audioBytesOf(record, entry.probe);
583
+ if (audio !== undefined)
584
+ levers.audio = { bytes: audio };
585
+ // Frame rate is informational, never in the claim (CLAUDE.md): a faster source costs more bytes, so a lower rate is a lever.
586
+ if (entry.probe !== undefined && entry.probe.fps > 30) {
587
+ levers.info = [...levers.info, `${entry.probe.fps} fps: a lower frame rate would save bytes too (not measured)`];
588
+ }
589
+ const out = {
590
+ ...record,
591
+ levers,
592
+ ...(entry.probe !== undefined ? { probe: entry.probe } : {}),
593
+ };
594
+ const target = Math.max(0, ...(record.widths?.widths ?? []));
595
+ const atTarget = entry.candidates.filter((c) => c.width === target);
596
+ // AV1 is the main hint; the H.264 fallback is the estimate only when no AV1 encode passed at the target width.
597
+ const estimate = smallest(atTarget.filter((c) => c.codec === "av1-mp4")) ?? smallest(atTarget);
598
+ if (estimate === undefined) {
599
+ // The target's own failure says why (a time limit, or no quality that passed); the generic reason is the last resort.
600
+ const atTargetFailure = entry.failed?.find((f) => f.width === target)?.reason;
601
+ out.notEncoded = entry.notEncoded ?? atTargetFailure ?? "no passing quality";
602
+ return out;
603
+ }
604
+ out.estimate = estimate;
605
+ const steps = stepsOf(out, estimate, target, entry.probe);
606
+ if (steps !== undefined)
607
+ out.steps = steps;
608
+ return out;
609
+ }
610
+ /**
611
+ * Puts the media results on one analysed page, before it is written: each video gets its probe, its audio lever, its
612
+ * estimate and steps (or its reason), and each animated image gets its candidates (or its reason). A page whose asset has
613
+ * no entry gets the fallback reason of the scan (survey or quality off), and otherwise keeps the cheap levers it has.
614
+ * Pages resumed from disk never pass through here.
615
+ */
616
+ export function attachMediaResults(result, results, args) {
617
+ const fallback = args.survey ? "survey" : args.quality ? undefined : "quality off";
618
+ const isBlocked = (page, key) => results.blocked?.has(blockedKeyOf(page, key)) === true;
619
+ const videos = result.videos?.map((record) => {
620
+ const key = videoAssetOf(record).key;
621
+ if (isBlocked(record.page, key))
622
+ return { ...record, notEncoded: THIRD_PARTY_BLOCKED };
623
+ const sized = { ...record, bytes: refineBytes(record.bytes, results.lengths?.get(record.src)) };
624
+ return attachVideo(sized, results.byKey.get(key), fallback);
625
+ });
626
+ const images = result.images?.map((image) => {
627
+ const animated = image.fix.animated;
628
+ if (animated === undefined)
629
+ return image;
630
+ const asset = animatedAssetOf(image, animated);
631
+ if (asset !== undefined && isBlocked(image.page, asset.key)) {
632
+ return { ...image, fix: { ...image.fix, animated: { ...animated, notEncoded: THIRD_PARTY_BLOCKED } } };
633
+ }
634
+ const entry = asset === undefined ? undefined : results.byKey.get(asset.key);
635
+ if (entry === undefined) {
636
+ return fallback === undefined ? image : { ...image, fix: { ...image.fix, animated: { ...animated, notEncoded: fallback } } };
637
+ }
638
+ // The need and the file's width travel with the estimate, so a hint can say when the width was capped at the file's own.
639
+ const sizing = { need: animatedNeedOf(image), ...(image.current.naturalWidth !== undefined ? { sourceWidth: image.current.naturalWidth } : {}) };
640
+ const next = entry.candidates.length > 0
641
+ ? { ...animated, estimate: entry.candidates, ...sizing }
642
+ : { ...animated, notEncoded: entry.notEncoded ?? "no passing quality", ...sizing };
643
+ return { ...image, fix: { ...image.fix, animated: next } };
644
+ });
645
+ const hasAssets = mediaAssetsOf(result).length > 0;
646
+ return {
647
+ ...result,
648
+ ...(videos !== undefined ? { videos } : {}),
649
+ ...(images !== undefined ? { images } : {}),
650
+ ...(results.note !== undefined && hasAssets ? { mediaNote: results.note } : {}),
651
+ };
652
+ }