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
package/dist/browser.js CHANGED
@@ -14,11 +14,11 @@ export function mapLaunchError(err) {
14
14
  return /Executable doesn['’]t exist/.test(text) ? new ChromiumMissingError() : err;
15
15
  }
16
16
  /**
17
- * Chromium flags for measuring. `--disable-software-rasterizer` stops Chrome from starting SwiftShader. Its worker
18
- * threads get a CPU set of their own from Chrome, which can fall outside the CPUs the CLI was started on.
19
- * Measurements (layout, rendered sizes, transferred bytes, image decoding) do not depend on the rasterizer.
17
+ * Chromium flags for measuring. `--disable-gpu` keeps the GPU process out. The software rasterizer (SwiftShader) is left
18
+ * on: with `--disable-software-rasterizer` WebGL is off, and a page with a 3D viewer never requests its model (a census
19
+ * site made 5 model requests with the default flags and none without). Only WebGL pages pay the extra CPU for it.
20
20
  */
21
- export const CHROMIUM_ARGS = ["--disable-gpu", "--disable-software-rasterizer"];
21
+ export const CHROMIUM_ARGS = ["--disable-gpu"];
22
22
  /** The headless Chromium every measuring command uses. */
23
23
  export async function launchChromium() {
24
24
  try {
@@ -13,6 +13,31 @@ export function rightCandidate(candidates, needed) {
13
13
  const sorted = [...candidates].sort((a, b) => a.width - b.width);
14
14
  return sorted.find((c) => c.width >= needed) ?? sorted[sorted.length - 1];
15
15
  }
16
+ /** The device-pixel step a plain image's variant widths are rounded up to. */
17
+ export const SRCSET_WIDTH_STEP = 10;
18
+ /** A plain image gets at most this many variant widths, one file each. */
19
+ export const SRCSET_MAX_WIDTHS = 3;
20
+ /** The widths of a srcset as printed: "190w, 270w, 380w". */
21
+ export function srcsetWidthLabel(widths) {
22
+ return widths.map((w) => `${w}w`).join(", ");
23
+ }
24
+ /**
25
+ * The widths the variants of a plain image (no srcset today) need: each device-pixel need rounded up to SRCSET_WIDTH_STEP
26
+ * and capped at the file's own width. More than SRCSET_MAX_WIDTHS are merged by joining the closest pair and keeping the
27
+ * larger, so every need is still covered by a file at least as wide. Sorted ascending; empty when no need is known.
28
+ */
29
+ export function srcsetWidthsOf(needs, naturalWidth) {
30
+ const cap = naturalWidth !== undefined && naturalWidth > 0 ? naturalWidth : Infinity;
31
+ const widths = [...new Set(needs.filter((n) => n > 0).map((n) => Math.min(Math.ceil(n / SRCSET_WIDTH_STEP) * SRCSET_WIDTH_STEP, cap)))].sort((a, b) => a - b);
32
+ while (widths.length > SRCSET_MAX_WIDTHS) {
33
+ let at = 0;
34
+ for (let i = 1; i < widths.length - 1; i++)
35
+ if (widths[i + 1] - widths[i] < widths[at + 1] - widths[at])
36
+ at = i;
37
+ widths.splice(at, 1);
38
+ }
39
+ return widths;
40
+ }
16
41
  /** The served file is undersized: its pixels are below 0.9 x need, and a larger candidate exists. */
17
42
  export function isUndersized(servedWidth, needed, largest) {
18
43
  return servedWidth < UNDERSIZED_RATIO * needed && servedWidth < largest;
@@ -1,12 +1,14 @@
1
1
  // The Markdown report of `imgtune check`: a pure builder over the survey results, the deep results, the route grouping and
2
2
  // the deep selection. No browser, no files. The audit mode extends it with source-aware sections; the sections below keep
3
3
  // their order so the two stay comparable. Page-derived text goes through mdText and mdCode (see text.ts).
4
- import { hintsOf } from "./hints.js";
4
+ import { groupSrcsetOf, hiddenPagesLine, hiddenVideoSummaryLine, hintsOf, videoRowOf, videoSavingOf, videoTotalsLine } from "./hints.js";
5
+ import { srcsetWidthLabel } from "./candidates.js";
5
6
  import { lcpLinesOf } from "./lcp.js";
6
7
  import { planQualityCap, qualitiesLine } from "./qualities-cap.js";
7
8
  import { hasFix } from "./images.js";
8
9
  import { APPROX_MARK, siteNumbers } from "./remote.js";
9
- import { errorLine, mdCode, mdText } from "./text.js";
10
+ import { errorLine, mdCode, mdText, safeText } from "./text.js";
11
+ import { resourceTotalsLine } from "./resources/report.js";
10
12
  /** How many fixes, heavy pages, sharpness images and survey route types the report lists in full. */
11
13
  export const REPORT_FIXES = 15;
12
14
  export const REPORT_SHARPNESS = 15;
@@ -66,6 +68,7 @@ export function fixGroupsOf(deep, qualities) {
66
68
  const pages = [...new Set(group.hints.map((h) => h.page))];
67
69
  const saved = group.hints.reduce((sum, h) => sum + h.bytes.saved, 0) / 1024;
68
70
  const sizes = group.hints.reduce((sum, h) => sum + (h.bytes.sizes ?? 0), 0) / 1024;
71
+ const resize = group.hints.reduce((sum, h) => sum + (h.bytes.resize ?? 0), 0) / 1024;
69
72
  const sharp = group.hints.reduce((sum, h) => sum + h.bytes.addedForSharpness, 0) / 1024;
70
73
  const targets = group.hints.map((h) => h.quality?.target).filter((t) => t !== undefined);
71
74
  const quality = group.hints.find((h) => h.quality !== undefined)?.quality;
@@ -82,6 +85,7 @@ export function fixGroupsOf(deep, qualities) {
82
85
  current,
83
86
  proposed,
84
87
  basis: first.sizes.basis,
88
+ srcset: groupSrcsetOf(group),
85
89
  format: best !== undefined && best !== first.format?.current ? best : undefined,
86
90
  ...(quality !== undefined
87
91
  ? {
@@ -99,7 +103,8 @@ export function fixGroupsOf(deep, qualities) {
99
103
  savedKBSum: saved,
100
104
  savedKBPerPage: perPage(saved),
101
105
  sizesKBPerPage: perPage(sizes),
102
- qualityKBPerPage: perPage(saved - sizes),
106
+ resizeKBPerPage: perPage(resize),
107
+ qualityKBPerPage: perPage(saved - sizes - resize),
103
108
  sharpnessKBPerPage: perPage(sharp),
104
109
  });
105
110
  }
@@ -108,7 +113,11 @@ export function fixGroupsOf(deep, qualities) {
108
113
  /** The line of one fix: where, the sizes and quality change, the saving per view and its pages. */
109
114
  function fixLines(fix, rank) {
110
115
  const where = fix.located ? mdCode(fix.where) : `${mdCode(fix.where)} (not located)`;
111
- const sizes = fix.current === fix.proposed ? "sizes unchanged" : `sizes ${mdCode(fix.current)} -> ${mdCode(fix.proposed)}${fix.basis ? ` (${mdText(fix.basis)})` : ""}`;
116
+ const sizes = fix.srcset !== undefined
117
+ ? `no srcset -> serve ${srcsetWidthLabel(fix.srcset.widths)} + srcset/sizes`
118
+ : fix.current === fix.proposed
119
+ ? "sizes unchanged"
120
+ : `sizes ${mdCode(fix.current)} -> ${mdCode(fix.proposed)}${fix.basis ? ` (${mdText(fix.basis)})` : ""}`;
112
121
  const quality = fix.quality === undefined
113
122
  ? "quality -"
114
123
  : fix.quality.target === undefined
@@ -116,7 +125,8 @@ function fixLines(fix, rank) {
116
125
  : fix.quality.void
117
126
  ? `quality ${fix.quality.current} -> ${fix.quality.target} (snapped: void, counted as 0)`
118
127
  : `quality ${fix.quality.current} -> ${fix.quality.target}${fix.quality.snapFrom !== undefined ? ` (snapped from ${fix.quality.snapFrom}${fix.quality.allowed ? `, allowed ${fix.quality.allowed.join(",")}` : ""})` : ""}`;
119
- const saving = `saves ${kb(fix.savedKBPerPage)} KB/view per page (sizes ${kb(fix.sizesKBPerPage)} + quality ${kb(fix.qualityKBPerPage)})`;
128
+ const split = fix.resizeKBPerPage > 0 ? `sizes ${kb(fix.sizesKBPerPage)} + resize ${kb(fix.resizeKBPerPage)} + quality ${kb(fix.qualityKBPerPage)}` : `sizes ${kb(fix.sizesKBPerPage)} + quality ${kb(fix.qualityKBPerPage)}`;
129
+ const saving = `saves ${kb(fix.savedKBPerPage)} KB/view per page (${split})`;
120
130
  const source = fix.pattern !== undefined ? `, src ${mdCode(fix.pattern)}` : "";
121
131
  const where2 = `${fix.pages.length} page(s), ${fix.images} image(s), loader ${mdText(fix.loader)}${source}`;
122
132
  return [
@@ -153,6 +163,13 @@ export function buildCheckReport(input) {
153
163
  const notMeasured = (pages) => pages.length - measured(pages).length - count(pages, "broken") - count(pages, "failed");
154
164
  out(`survey ${input.urls.surveyed} page(s): ${survey.length} ok, ${count(input.survey, "broken")} broken, ${count(input.survey, "failed")} failed, ${notMeasured(input.survey)} not measured · deep ${input.deep.length} page(s)${retried > 0 ? ` (${retried} retried)` : ""}: ${deepOk.length} ok, ${count(input.deep, "failed")} failed, ${notMeasured(input.deep)} not measured`);
155
165
  out(`time: survey ${input.timings.survey.toFixed(1)} s, deep ${input.timings.deep.toFixed(1)} s, total ${(input.timings.survey + input.timings.deep).toFixed(1)} s`, "");
166
+ // The media phase's note is printed once, at the top (the same note is on each page that has media).
167
+ const media = [...input.survey, ...input.deep].find((p) => p.mediaNote !== undefined)?.mediaNote;
168
+ if (media !== undefined)
169
+ out(mdText(media), "");
170
+ const vectorNote = input.deep.find((p) => p.vectorNote !== undefined)?.vectorNote;
171
+ if (vectorNote !== undefined)
172
+ out(mdText(vectorNote), "");
156
173
  if (input.stop !== undefined) {
157
174
  out("## Stopped before the deep phase", "", `${mdText(input.stop.reason)}`, `Fix: ${mdText(input.stop.fix)}`, "");
158
175
  }
@@ -170,6 +187,9 @@ export function buildCheckReport(input) {
170
187
  const exactEst = mean(deepTotals.map((t) => t.exactEstKB));
171
188
  const numbers = siteNumbers(deepOk.map((p) => p.page));
172
189
  out(`- Deep (${deepOk.length} page(s), viewports ${input.options.deepViewports.map(mdText).join(", ")}): mean image ${kb(numbers.meanImageBytes / 1024)} KB/view.`, ` - exact: ${kb(exact)} KB/view (${pct(numbers.exactPct)})`, ` - exact + est.: ${APPROX_MARK.trim()} ${kb(exactEst)} KB/view (${pct(numbers.exactEstPct)})${approx ? " (some estimates are approximate)" : ""}`, ` - sharpness: +${kb(mean(deepTotals.map((t) => t.sharpnessKB)))} KB/view, a separate number: it is never netted against the saving`);
190
+ const hiddenLine = hiddenPagesLine(deepOk);
191
+ if (hiddenLine !== undefined)
192
+ out(` - ${mdText(hiddenLine)}`);
173
193
  // The LCP image is the one the first paint waits for: its line, per page, before the ranked fixes.
174
194
  for (const page of deepOk) {
175
195
  for (const line of lcpLinesOf(page, mdCode))
@@ -192,6 +212,34 @@ export function buildCheckReport(input) {
192
212
  out(`Merged across the deep pages: one file:line when located, else one image pattern with the same proposed sizes. Ranked by the total saving over the pages. Top ${Math.min(REPORT_FIXES, fixes.length)} of ${fixes.length}.`, "");
193
213
  fixes.slice(0, REPORT_FIXES).forEach((fix, i) => out(...fixLines(fix, i + 1), ""));
194
214
  }
215
+ // 3b. Video: the self-hosted <video> files of the deep pages, per view and per play, largest saveable first.
216
+ const videos = deepOk.flatMap((p) => (p.videos ?? []).map((video) => ({ page: p.url, video })));
217
+ if (videos.length > 0) {
218
+ out("## Video", "");
219
+ out(`${videoTotalsLine(videos.map((v) => v.video))} (${videos.length} video(s) on ${deepOk.filter((p) => (p.videos ?? []).length > 0).length} deep page(s))`, "");
220
+ const hiddenVideos = hiddenVideoSummaryLine(videos.map((v) => v.video));
221
+ if (hiddenVideos !== undefined)
222
+ out(`- ${mdText(hiddenVideos)}`);
223
+ const ranked = [...videos].sort((a, b) => videoSavingOf(b.video).view - videoSavingOf(a.video).view || a.page.localeCompare(b.page));
224
+ for (const row of ranked.slice(0, REPORT_FIXES)) {
225
+ out(`- ${mdCode(videoRowOf(row.video, (text) => JSON.stringify(safeText(text))))} on ${mdCode(pathOf(row.page))}`);
226
+ }
227
+ out("");
228
+ }
229
+ // 3c. Resources: the deep pages' non-image findings (compression, favicons), largest saving first (see resources/).
230
+ const resourceRows = deepOk.flatMap((p) => (p.resources ?? []).map((finding) => ({ page: p.url, finding })));
231
+ if (resourceRows.length > 0) {
232
+ out("## Resources", "");
233
+ out(mdText(resourceTotalsLine(resourceRows.map((r) => r.finding)) ?? ""), "");
234
+ const ranked = [...resourceRows].sort((a, b) => b.finding.savedBytes - a.finding.savedBytes || a.page.localeCompare(b.page));
235
+ for (const row of ranked.slice(0, REPORT_FIXES)) {
236
+ const saved = row.finding.savedBytes > 0 ? `, saves ${kb(row.finding.savedBytes / 1024)} KB/view${row.finding.estimate ? " (est.)" : ""}` : "";
237
+ out(`- ${mdText(row.finding.kind)}: ${mdText(row.finding.title)}${saved} on ${mdCode(pathOf(row.page))}`);
238
+ if (row.finding.fix[0] !== undefined)
239
+ out(` - fix: ${mdText(row.finding.fix[0])}`);
240
+ }
241
+ out("");
242
+ }
195
243
  // 4. Heavy but not wasteful.
196
244
  out("## Heavy but not wasteful", "");
197
245
  const heavyCut = percentile(deepTotals.map((t) => t.kb), 0.75);
@@ -280,6 +328,8 @@ export function buildCheckReport(input) {
280
328
  ? `- Route types group URLs by template (locale and device variant stripped), as check does; the route file column names the file(s) each type renders from. Only the heaviest page of each type is deep-scanned; ${input.selection.leftOut.length} type(s) were not (see the table).`
281
329
  : `- Route types group URLs by template (locale and device variant stripped). Only the heaviest page of each type is deep-scanned; ${input.selection.leftOut.length} type(s) were not (see the table).`, input.options.qualities !== undefined
282
330
  ? `- Quality targets are snapped up to the allowed qualities ${input.options.qualities.join(", ")}; a snapped target's quality saving is void and counts as 0.${input.qualitiesNote !== undefined ? ` (${mdText(input.qualitiesNote)})` : ""}`
283
- : "- Quality targets are not snapped (no --qualities given): a target the framework cannot serve is not the saving it looks like.", `- Third-party requests: ${input.options.thirdParty}${input.options.allowHosts ? ` (hosts kept: ${input.options.allowHosts.map(mdText).join(", ")})` : ""}. Blocking can drop images a page loads through its own API on another host.`, `- Pages with images still loading at settle show the images that loaded; the rest are not measured.`, input.options.gpu !== undefined ? `- Quality was scored on the GPU ${input.options.gpu} (with the CPU fallback noted in the page JSON).` : "- Quality was scored on the CPU.", "");
331
+ : "- Quality targets are not snapped (no --qualities given): a target the framework cannot serve is not the saving it looks like.", `- Third-party requests: ${input.options.thirdParty}${input.options.allowHosts ? ` (hosts kept: ${input.options.allowHosts.map(mdText).join(", ")})` : ""}. Blocking can drop images a page loads through its own API on another host.`, `- Pages with images still loading at settle show the images that loaded; the rest are not measured.`, ...(videos.length > 0
332
+ ? [`- Video bytes: per view as the page loads them (autoplay: the whole file). The saveable bytes come from 2 s sample encodes: est.`]
333
+ : []), input.options.gpu !== undefined ? `- Quality was scored on the GPU ${input.options.gpu} (with the CPU fallback noted in the page JSON).` : "- Quality was scored on the CPU.", "");
284
334
  return `${lines.join("\n")}\n`;
285
335
  }
package/dist/check.js CHANGED
@@ -7,11 +7,11 @@ import { mkdir, readFile } from "node:fs/promises";
7
7
  import { join, resolve } from "node:path";
8
8
  import { DEFAULT_VIEWPORTS } from "./config.js";
9
9
  import { writeFileAtomic } from "./atomic.js";
10
- import { parseGpuDevice } from "./gpu.js";
10
+ import { gpuSettingOf, gpuSettingOne } from "./gpu.js";
11
11
  import { parseQualities } from "./hints.js";
12
12
  import { BROKEN_EXIT, surveyStop } from "./broken.js";
13
13
  import { DEFAULT_THIRD_PARTY, parseAllowHosts, thirdPartyFromFlags } from "./third-party.js";
14
- import { MAX_IDLE_TIMEOUT_MS, MAX_PAGE_CONCURRENCY, parseLimit, parseRange, readStorageState, runScan, scanPlan, } from "./remote.js";
14
+ import { MAX_IDLE_TIMEOUT_MS, MAX_PAGE_CONCURRENCY, MAX_RESOURCE_TIME, MAX_SVG_BUDGET, parseLimit, parseRange, readStorageState, runScan, scanPlan, } from "./remote.js";
15
15
  import { restrictRoutes, groupRoutes, parseRouteMap } from "./routes.js";
16
16
  import { sampleByPattern, urlsFromSource } from "./urls.js";
17
17
  import { deepRetries, selectDeepPages } from "./deep.js";
@@ -36,6 +36,10 @@ export function parseCheckFlags(argv) {
36
36
  const arg = argv[i];
37
37
  if (arg === "--no-cache")
38
38
  args.noCache = true;
39
+ else if (arg === "--gpu-encode")
40
+ args.gpuEncode = true;
41
+ else if (arg === "--no-resources")
42
+ args.noResources = true;
39
43
  else if (arg === "--ignore-robots")
40
44
  args.ignoreRobots = true;
41
45
  else if (arg === "--block-third-party")
@@ -44,9 +48,9 @@ export function parseCheckFlags(argv) {
44
48
  else if (arg === "--allow-third-party")
45
49
  continue;
46
50
  else if (arg === "--gpu")
47
- args.gpu ??= { device: 0 };
51
+ args.gpu ??= gpuSettingOne(0);
48
52
  else if (arg === "--gpu-device") {
49
- args.gpu = { device: parseGpuDevice(valueOf(argv, i, arg)) };
53
+ args.gpu = gpuSettingOf(valueOf(argv, i, arg));
50
54
  i++;
51
55
  }
52
56
  else if (arg === "--urls-from" ||
@@ -57,6 +61,8 @@ export function parseCheckFlags(argv) {
57
61
  arg === "--page-concurrency" ||
58
62
  arg === "--qualities" ||
59
63
  arg === "--idle-timeout" ||
64
+ arg === "--svg-budget" ||
65
+ arg === "--resource-time" ||
60
66
  arg === "--storage-state" ||
61
67
  arg === "--allow-host") {
62
68
  const value = valueOf(argv, i, arg);
@@ -77,6 +83,10 @@ export function parseCheckFlags(argv) {
77
83
  args.qualities = parseQualities(value);
78
84
  else if (arg === "--idle-timeout")
79
85
  args.idleTimeoutMs = parseRange(arg, value, 0, MAX_IDLE_TIMEOUT_MS);
86
+ else if (arg === "--svg-budget")
87
+ args.svgBudget = parseRange(arg, value, 0, MAX_SVG_BUDGET);
88
+ else if (arg === "--resource-time")
89
+ args.resourceTime = parseRange(arg, value, 0, MAX_RESOURCE_TIME);
80
90
  else if (arg === "--storage-state")
81
91
  args.storageState = value;
82
92
  else
@@ -182,6 +192,7 @@ export async function scanCheckPages(input) {
182
192
  ignoreRobots: settings.ignoreRobots,
183
193
  aggressive: false,
184
194
  thirdParty: settings.thirdParty,
195
+ resources: settings.noResources !== true,
185
196
  };
186
197
  const base = { urls: [], out: outRoot, aggressive: false, quality: true, thirdParty: settings.thirdParty };
187
198
  const surveyPlan = scanPlan({ ...base, survey: true });
@@ -203,6 +214,9 @@ export async function scanCheckPages(input) {
203
214
  shellWord(surveyDir),
204
215
  ...(settings.thirdParty.block ? ["--block-third-party"] : []),
205
216
  ...(settings.noCache ? ["--no-cache"] : []),
217
+ ...(settings.gpuEncode ? ["--gpu-encode"] : []),
218
+ ...(settings.noResources ? ["--no-resources"] : []),
219
+ ...(settings.resourceTime !== undefined ? ["--resource-time", String(settings.resourceTime)] : []),
206
220
  ].join(" "),
207
221
  });
208
222
  log(`survey: ${urls.length} URL(s), ${routes.types.length} route type(s)`);
@@ -236,13 +250,17 @@ export async function scanCheckPages(input) {
236
250
  if (page.status === "ok")
237
251
  expected.set(page.url, page.counts.images);
238
252
  const flags = [
239
- ...(settings.gpu !== undefined ? ["--gpu-device", String(settings.gpu.device)] : []),
253
+ ...(settings.gpu !== undefined ? ["--gpu-device", settings.gpu.devices.join(",")] : []),
240
254
  ...(settings.thirdParty.block ? ["--block-third-party"] : []),
241
255
  ...(settings.thirdParty.allowHosts.length > 0 ? ["--allow-host", shellWord(settings.thirdParty.allowHosts.join(","))] : []),
242
256
  ...(settings.noCache ? ["--no-cache"] : []),
257
+ ...(settings.gpuEncode ? ["--gpu-encode"] : []),
258
+ ...(settings.noResources ? ["--no-resources"] : []),
259
+ ...(settings.resourceTime !== undefined ? ["--resource-time", String(settings.resourceTime)] : []),
243
260
  ...(settings.ignoreRobots ? ["--ignore-robots"] : []),
244
261
  ...(settings.pageConcurrency !== undefined ? ["--page-concurrency", String(settings.pageConcurrency)] : []),
245
262
  ...(settings.idleTimeoutMs !== undefined ? ["--idle-timeout", String(settings.idleTimeoutMs)] : []),
263
+ ...(settings.svgBudget !== undefined ? ["--svg-budget", String(settings.svgBudget)] : []),
246
264
  ];
247
265
  commands.push({ phase: "deep", command: ["imgtune scan", ...deepUrls.map(shellWord), "--out", shellWord(deepDir), ...flags].join(" ") });
248
266
  log(`deep: ${deepUrls.length} page(s), full scan`);
@@ -254,6 +272,7 @@ export async function scanCheckPages(input) {
254
272
  pageConcurrency: fullPlan.pageConcurrency,
255
273
  idleTimeoutMs: fullPlan.idleTimeoutMs,
256
274
  gpu: settings.gpu,
275
+ svgBudget: settings.svgBudget,
257
276
  expectImages: (url) => expected.get(url),
258
277
  };
259
278
  const deepStart = performance.now();
@@ -342,6 +361,8 @@ export async function finishCheck(input) {
342
361
  pageConcurrency: settings.pageConcurrency,
343
362
  idleTimeoutMs: settings.idleTimeoutMs,
344
363
  noCache: settings.noCache,
364
+ ...(settings.noResources ? { noResources: true } : {}),
365
+ ...(settings.resourceTime !== undefined ? { resourceTime: settings.resourceTime } : {}),
345
366
  ignoreRobots: settings.ignoreRobots,
346
367
  storageState: input.storageState,
347
368
  thirdParty: settings.thirdParty.block ? "blocked" : "allowed",
package/dist/cli.js CHANGED
@@ -21,6 +21,12 @@ const progress = (message) => {
21
21
  const OPTION_HELP = {
22
22
  out: ["--out <dir>", "directory for the JSON files (default: .imgtune/scan)"],
23
23
  noCache: ["--no-cache", "do not read or write the result cache in .imgtune/cache (the numbers are computed again)"],
24
+ noResources: ["--no-resources", "skip the resource phase: no capture of the non-image responses, so none of the resource findings (compression, favicons, fonts, SVG, Lottie, 3D, audio)"],
25
+ resourceTime: ["--resource-time <s>", "time limit of the heavy resource work (3D, fonts, SVG, audio, lottie re-encodes), in seconds, 0 to 3600 (default 60); items over it are reported as not measured"],
26
+ gpuEncode: [
27
+ "--gpu-encode",
28
+ "EXPERIMENTAL: encode the videos and animated images on the GPU (NVENC, CUDA VMAF); off by default (CPU encodes). Bytes are scaled to a CPU encode (÷1.4)",
29
+ ],
24
30
  root: [
25
31
  "--root <dir>",
26
32
  "folder project source files are read from (default: the nearest folder above the cwd that holds the file, else the nearest with a package.json or .git)",
@@ -58,6 +64,18 @@ const OPTION_HELP = {
58
64
  resume: ["--resume", "reuse the complete pages already in --out that were scanned with the same options"],
59
65
  maxPages: ["--max-pages <N>", "stop after N pages (scanned or resumed); run again with --resume to continue"],
60
66
  maxImages: ["--max-images <N>", "stop after the page that brings the images counted to N (checked between pages)"],
67
+ mediaBudget: [
68
+ "--media-budget <N>",
69
+ "sample-encode at most N animated images and videos per run, heaviest first, 0 to 1000 (default 20); the rest keep their cheap hints",
70
+ ],
71
+ svgBudget: [
72
+ "--svg-budget <N>",
73
+ "trace at most N flat images (logos, icons, flat illustrations) to SVG and check them, heaviest first, 0 to 1000 (default 50; 0 traces none)",
74
+ ],
75
+ mediaTime: ["--media-time <s>", "time limit of the media encodes, in seconds, 1 to 3600 (default 600)"],
76
+ videoBar: ["--video-bar <n>", "minimum VMAF of a video or animated encode, 0 to 100 (default 93)"],
77
+ videoStillBar: ["--video-still-bar <n>", "minimum SSIMULACRA2 of the still-frame guard, 0 to 100 (default 65)"],
78
+ maxVideoWidths: ["--max-video-widths <n>", "the most widths each video is served at, 1 to 3 (default 2)"],
61
79
  maxQualities: [
62
80
  "--max-qualities <N>",
63
81
  "the most values next.config's images.qualities may list, 1 to 4 (default 2): next/image qualities are clustered, each mapped up",
@@ -95,7 +113,10 @@ const OPTION_HELP = {
95
113
  "--gpu",
96
114
  "score with the GPU scorer (imgtune-ssimu2-gpu, see the docs); opt-in. Falls back to the CPU with a warning",
97
115
  ],
98
- gpuDevice: ["--gpu-device <n>", "the GPU to score on, by CUDA index (implies --gpu; default 0; see the GPU notes)"],
116
+ gpuDevice: [
117
+ "--gpu-device <n[,n]>",
118
+ "the GPU to score on, by CUDA index (implies --gpu; default 0; see the GPU notes). A comma list (0,1) gives each GPU its own encode workers with --gpu-encode; the first scores",
119
+ ],
99
120
  locateOnly: [
100
121
  "--locate-only",
101
122
  "read the source of the flagged images from a dev page (React owner data) or a built page with source maps (prod-sourcemap); not a measurement (needs --from)",
@@ -134,6 +155,9 @@ const SCAN_HELP = commandHelp("imgtune scan <url> [<url> ...] [options]", [
134
155
  "out",
135
156
  "root",
136
157
  "noCache",
158
+ "noResources",
159
+ "resourceTime",
160
+ "gpuEncode",
137
161
  "aggressive",
138
162
  "viewports",
139
163
  "survey",
@@ -146,6 +170,12 @@ const SCAN_HELP = commandHelp("imgtune scan <url> [<url> ...] [options]", [
146
170
  "resume",
147
171
  "maxPages",
148
172
  "maxImages",
173
+ "mediaBudget",
174
+ "svgBudget",
175
+ "mediaTime",
176
+ "videoBar",
177
+ "videoStillBar",
178
+ "maxVideoWidths",
149
179
  "maxQualities",
150
180
  "pageConcurrency",
151
181
  "idleTimeout",
@@ -187,11 +217,15 @@ Options:
187
217
  --out <dir> the check directory (default ${DEFAULT_CHECK_DIR}/<timestamp>)
188
218
  --qualities <list> the framework's allowed qualities, e.g. 75,85,100: targets are snapped up to them
189
219
  --gpu, --gpu-device <n> score the deep pages with the GPU scorer (see the GPU notes; not the survey)
220
+ --gpu-encode EXPERIMENTAL: encode the deep pages' videos and animated images on the GPU (see scan --help)
190
221
  --page-concurrency <N> pages at once in the deep phase (default 1)
191
222
  --idle-timeout <ms> cap on the network-idle wait in the deep phase (default 10000)
223
+ --svg-budget <N> trace at most N flat images of the deep pages to SVG (default 50; 0 traces none)
192
224
  --block-third-party abort the requests to other sites (off by default; can drop images a page loads from its API)
193
225
  --allow-host <h1,h2> with --block-third-party: keep the requests to these hosts
194
226
  --no-cache do not read or write the result cache
227
+ --no-resources skip the resource phase (compression, favicons, fonts, SVG, Lottie, 3D and audio findings; no capture of the other responses)
228
+ --resource-time <s> time limit of the heavy resource work (3D, fonts, SVG, audio, Lottie re-encodes), in seconds, 0 to 3600 (default 60)
195
229
  --ignore-robots scan pages robots.txt disallows (owned or authorised sites only)
196
230
  --storage-state <file> a logged-in session (holds cookies: never commit it)
197
231
  -h, --help show this help
@@ -222,11 +256,15 @@ Options:
222
256
  --qualities <list> the allowed qualities (default: images.qualities of next.config, else Next's [75])
223
257
  --out <dir> the audit directory (default ${DEFAULT_AUDIT_DIR}/<timestamp>)
224
258
  --gpu, --gpu-device <n> score the deep pages with the GPU scorer (see the GPU notes; not the survey)
259
+ --gpu-encode EXPERIMENTAL: encode the deep pages' videos and animated images on the GPU (see scan --help)
225
260
  --page-concurrency <N> pages at once in the deep phase (default 1)
226
261
  --idle-timeout <ms> cap on the network-idle wait in the deep phase (default 10000)
262
+ --svg-budget <N> trace at most N flat images of the deep pages to SVG (default 50; 0 traces none)
227
263
  --block-third-party abort the requests to other sites (off by default; can drop images a page loads from its API)
228
264
  --allow-host <h1,h2> with --block-third-party: keep the requests to these hosts
229
265
  --no-cache do not read or write the result cache
266
+ --no-resources skip the resource phase (compression, favicons, fonts, SVG, Lottie, 3D and audio findings; no capture of the other responses)
267
+ --resource-time <s> time limit of the heavy resource work (3D, fonts, SVG, audio, Lottie re-encodes), in seconds, 0 to 3600 (default 60)
230
268
  --ignore-robots scan pages robots.txt disallows (owned or authorised sites only)
231
269
  --storage-state <file> a logged-in session (holds cookies: never commit it)
232
270
  -h, --help show this help
@@ -252,9 +290,13 @@ Options:
252
290
  --dev <origin> locate the deep pages at the same paths on this dev server (a running dev server)
253
291
  --qualities <list> the framework's allowed qualities, e.g. 75,85,100 (passed to hints)
254
292
  --gpu, --gpu-device <n> score the deep pages with the GPU scorer (see the GPU notes; not the survey)
293
+ --gpu-encode EXPERIMENTAL: encode the deep pages' videos and animated images on the GPU (see scan --help)
255
294
  --page-concurrency <N> pages at once in the survey (default 4) and in the deep phase (default 1)
256
295
  --idle-timeout <ms> cap on the network-idle wait (default 3000 in the survey, 10000 in the deep phase)
296
+ --svg-budget <N> trace at most N flat images of the deep pages to SVG (default 50; 0 traces none)
257
297
  --no-cache do not read or write the result cache
298
+ --no-resources skip the resource phase (compression, favicons, fonts, SVG, Lottie, 3D and audio findings; no capture of the other responses)
299
+ --resource-time <s> time limit of the heavy resource work (3D, fonts, SVG, audio, Lottie re-encodes), in seconds, 0 to 3600 (default 60)
258
300
  --root <dir> the source folder for --dev (a monorepo sub-folder)
259
301
  --storage-state <file> a logged-in session (holds cookies: never commit it)
260
302
  --ignore-robots scan pages robots.txt disallows (owned or authorised sites only)
@@ -298,17 +340,29 @@ against the file itself: 90 (visually lossless) unless --min-score sets another.
298
340
  --write names a folder for optimised copies (existing names are never overwritten).
299
341
  Evidence is "file-mode". Globs: ** walks folders (node_modules and dot folders are skipped).
300
342
 
343
+ Media: an animated GIF, animated WebP or APNG gets the candidates of animated AVIF, animated WebP and AV1 MP4 at --width
344
+ (the file's own width by default), each the lowest-bytes quality that meets the video bars. A video (MP4, MOV, WebM) is
345
+ described; with --reference <original> a candidate is checked against the original: VMAF and the still-frame guard, one
346
+ verdict line "VMAF <v> (bar <b>) still p10 <s> (bar <sb>): pass|fail", exit 0 on pass and 1 on fail. Media is local files
347
+ only. The encodes need ffmpeg on PATH; without it the sizes are still measured.
348
+
301
349
  Options:
302
- --width <css px> the CSS width the image is shown at (the width is then the user's, not the file's)
350
+ --width <css px> the CSS width the image is shown at (the width is then the user's, not the file's); for media, the width
351
+ compared or searched at (the candidate's own width with --reference)
303
352
  --dpr <list> device pixel ratios for --width (default 1,2)
304
353
  --formats <list> avif, webp, jpeg (default all three)
305
354
  --min-score <n> the SSIMULACRA2 bar, above 0 and at most 100 (default 90)
306
355
  --write <dir> write optimised copies (best format, at the largest needed width) into this folder
307
- --format <text|json> text (default) or JSON
356
+ --reference <file> compare one media candidate with this original (local file): the verdict line and the exit code
357
+ --video-bar <n> the minimum VMAF of a media encode or check, 0 to 100 (default 93)
358
+ --video-still-bar <n> the still-frame guard's bar, 0 to 100 (default 65), lowered for a source whose own best encode scores low
359
+ --format <text|json> text (default) or JSON (--json is the same)
308
360
  --gpu score with the GPU scorer (opt-in; falls back to the CPU with a warning)
309
- --gpu-device <n> the GPU to score on, by CUDA index (implies --gpu; default 0). CUDA numbers the GPUs fastest first
361
+ --gpu-device <n> the GPU to score on, by CUDA index (implies --gpu; default 0). A comma list is accepted: the first GPU
362
+ scores. CUDA numbers the GPUs fastest first
310
363
  unless CUDA_DEVICE_ORDER=PCI_BUS_ID; with CUDA_VISIBLE_DEVICES set, n indexes the devices it lists.
311
364
  The scorer prints the name of the GPU it opened: check it.
365
+ --gpu-encode EXPERIMENTAL: encode the videos and animated images on the GPU (NVENC, CUDA VMAF), off by default
312
366
  --no-cache do not read or write the result cache in .imgtune/cache
313
367
  -h, --help show this help
314
368
  `;
@@ -338,6 +392,8 @@ Options:
338
392
  `;
339
393
  const USAGE = `Usage: imgtune <command> [options]
340
394
 
395
+ imgtune measures page weight on live pages (images, video, hidden downloads, fonts, compression, 3D, audio, Lottie, SVG) and prints the fix for each.
396
+
341
397
  Commands:
342
398
  scan measure live pages by URL, with no source, config or dev server (writes JSON to --out)
343
399
  hints print a batch of the per-image fix hints of a scan file
@@ -355,6 +411,28 @@ Options:
355
411
  --version print the version
356
412
  -h, --help show this help
357
413
  `;
414
+ /** Shell exit codes of a run stopped by a signal: 128 + the signal number. */
415
+ const SIGNAL_EXIT = { SIGINT: 130, SIGTERM: 143 };
416
+ /** The most a first signal waits for the run to clean up (media scratch, ffmpeg children) before the process exits. */
417
+ const CLEANUP_MS = 2000;
418
+ /** Aborted on the first SIGINT or SIGTERM: the media phase and the image command stop, ffmpeg children are killed and their
419
+ * scratch folders removed (the abort is seen in their `finally` blocks). The process then exits within CLEANUP_MS with the
420
+ * signal's exit code, so a Ctrl-C or a supervisor's SIGTERM always ends the run. A second signal exits at once.
421
+ */
422
+ const interrupt = new AbortController();
423
+ let interrupts = 0;
424
+ /** The first signal received, which sets the exit code of an interrupted run. */
425
+ let received;
426
+ for (const signal of ["SIGINT", "SIGTERM"]) {
427
+ process.on(signal, () => {
428
+ interrupts++;
429
+ received ??= signal;
430
+ if (interrupts > 1)
431
+ process.exit(SIGNAL_EXIT[signal]);
432
+ interrupt.abort();
433
+ setTimeout(() => process.exit(SIGNAL_EXIT[signal]), CLEANUP_MS);
434
+ });
435
+ }
358
436
  async function main() {
359
437
  const argv = process.argv.slice(2);
360
438
  if (argv.includes("--version")) {
@@ -415,9 +493,9 @@ async function main() {
415
493
  return;
416
494
  }
417
495
  if (argv[0] === "image") {
418
- const outcome = await imageCommand(argv.slice(1), process.cwd());
496
+ const outcome = await imageCommand(argv.slice(1), process.cwd(), undefined, undefined, interrupt.signal);
419
497
  process.stdout.write(outcome.output);
420
- process.exitCode = outcome.exitCode;
498
+ process.exitCode = received !== undefined ? SIGNAL_EXIT[received] : outcome.exitCode;
421
499
  return;
422
500
  }
423
501
  if (argv[0] === "hints") {
@@ -455,9 +533,9 @@ async function main() {
455
533
  process.exitCode = 2;
456
534
  return;
457
535
  }
458
- const outcome = await scanCommand(argv.slice(1), process.cwd(), progress);
536
+ const outcome = await scanCommand(argv.slice(1), process.cwd(), progress, interrupt.signal);
459
537
  process.stdout.write(outcome.output);
460
- process.exitCode = outcome.exitCode;
538
+ process.exitCode = received !== undefined ? SIGNAL_EXIT[received] : outcome.exitCode;
461
539
  }
462
540
  main().catch((err) => {
463
541
  if (err instanceof ChromiumMissingError) {
@@ -38,8 +38,9 @@ function connectionFor(hostname, policy, onRefused) {
38
38
  const checked = guardedLookup(policy.resolver, policy.allowPrivate, policy.connectAddress);
39
39
  const lookup = ((name, options, callback) => {
40
40
  checked(name, options, (err, ...rest) => {
41
+ // The name a lookup could not resolve: the page may still load it in a browser (see the scan's unresolved hosts).
41
42
  if (err)
42
- onRefused();
43
+ onRefused(name);
43
44
  callback(err, ...rest);
44
45
  });
45
46
  });
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
- current = new URL(location, url).toString();
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 key = JSON.stringify([url, maxBytes, allowPrivate, fetchOpts.sameSiteAs ?? "", fetchOpts.sameOriginAs ?? "", opts.userAgent, IMAGE_ACCEPT]);
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) {