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
@@ -15,13 +15,22 @@ export const CACHE_DIR = join(".imgtune", "cache");
15
15
  /** The most the cache may hold; the oldest entries go first. */
16
16
  export const CACHE_MAX_BYTES = 500 * 1024 * 1024;
17
17
  /** Bumped when what an entry means changes, so the old entries are never read again. */
18
- export const CACHE_VERSION = 2;
18
+ export const CACHE_VERSION = 3;
19
19
  const ENTRY_NAME = /^[0-9a-f]{64}\.json$/;
20
20
  /** An entry is a key and a number: anything larger is not one of ours. */
21
21
  const MAX_ENTRY_BYTES = 1024;
22
22
  /** Keys are short and built here; a longer one is refused rather than hashed or written. */
23
23
  const MAX_KEY_LENGTH = 4096;
24
+ /**
25
+ * A key longer than this is stored by its hash, `sha256:<hex>`, in the entry's `k` field (its file name is the hash already).
26
+ * Signed and CDN video URLs are often longer than an entry can hold, and their encodes must still be cached.
27
+ */
28
+ const LONG_KEY = 512;
24
29
  const sha256 = (data) => createHash("sha256").update(data).digest("hex");
30
+ /** The `k` an entry of `key` carries: the key itself, or its hash when the key is long (see LONG_KEY). */
31
+ function keyTagOf(key) {
32
+ return key.length > LONG_KEY ? `sha256:${sha256(key)}` : key;
33
+ }
25
34
  /** The scope every key of one reference and scorer carries: its original's content hash, width, scorer, encoder and tool. */
26
35
  export function cacheScope(original, width, scorerId, encoderId) {
27
36
  return [sha256(original), `w${width}`, scorerId, encoderId, `imgtune ${IMGTUNE_VERSION}`, `cache ${CACHE_VERSION}`].join("|");
@@ -60,7 +69,7 @@ export class ResultCache {
60
69
  const parsed = JSON.parse(await readFile(file, "utf8"));
61
70
  if (parsed === null || typeof parsed !== "object")
62
71
  return undefined;
63
- if (parsed.k !== key || typeof parsed.v !== "number" || !Number.isFinite(parsed.v))
72
+ if (parsed.k !== keyTagOf(key) || typeof parsed.v !== "number" || !Number.isFinite(parsed.v))
64
73
  return undefined;
65
74
  return parsed.v;
66
75
  }
@@ -77,12 +86,56 @@ export class ResultCache {
77
86
  await mkdir(this.dir, { recursive: true });
78
87
  this.dirReady = true;
79
88
  }
80
- await writeFileAtomic(this.fileOf(key), JSON.stringify({ k: key, v: value }), { root: this.dir });
89
+ await writeFileAtomic(this.fileOf(key), JSON.stringify({ k: keyTagOf(key), v: value }), { root: this.dir });
81
90
  }
82
91
  catch {
83
92
  // Ignored: the number is recomputed the next time.
84
93
  }
85
94
  }
95
+ /**
96
+ * The JSON value stored under `key` (see putJson), or undefined: absent, corrupt, of another key, a number entry, or
97
+ * a value that is null. The value is untrusted data: callers check its shape before use.
98
+ */
99
+ async getJson(key) {
100
+ if (key.length > MAX_KEY_LENGTH)
101
+ return undefined;
102
+ const file = this.fileOf(key);
103
+ try {
104
+ const info = await stat(file);
105
+ if (!info.isFile() || info.size > MAX_ENTRY_BYTES)
106
+ return undefined;
107
+ const parsed = JSON.parse(await readFile(file, "utf8"));
108
+ if (parsed === null || typeof parsed !== "object")
109
+ return undefined;
110
+ if (parsed.k !== keyTagOf(key) || parsed.j === undefined || parsed.j === null)
111
+ return undefined;
112
+ return parsed.j;
113
+ }
114
+ catch {
115
+ return undefined;
116
+ }
117
+ }
118
+ /**
119
+ * Stores a JSON value under `key`, for small results such as a sample encode's outcome. A value that does not fit an
120
+ * entry (MAX_ENTRY_BYTES) or a key that is too long is not stored, and a failed write is ignored, as for put.
121
+ */
122
+ async putJson(key, value) {
123
+ if (key.length > MAX_KEY_LENGTH || value === undefined)
124
+ return;
125
+ const text = JSON.stringify({ k: keyTagOf(key), j: value });
126
+ if (text === undefined || Buffer.byteLength(text, "utf8") > MAX_ENTRY_BYTES)
127
+ return;
128
+ try {
129
+ if (!this.dirReady) {
130
+ await mkdir(this.dir, { recursive: true });
131
+ this.dirReady = true;
132
+ }
133
+ await writeFileAtomic(this.fileOf(key), text, { root: this.dir });
134
+ }
135
+ catch {
136
+ // Ignored: the value is computed again the next time.
137
+ }
138
+ }
86
139
  /** Removes the oldest entries until the folder holds at most 90% of the limit. Only files named like entries are touched. */
87
140
  async trim() {
88
141
  return trimCache(this.dir, this.maxBytes);
package/dist/run.js CHANGED
@@ -5,12 +5,12 @@ import { mkdir, readdir, readFile, stat } from "node:fs/promises";
5
5
  import { join, resolve } from "node:path";
6
6
  import { DEFAULT_VIEWPORTS } from "./config.js";
7
7
  import { writeFileAtomic } from "./atomic.js";
8
- import { parseGpuDevice } from "./gpu.js";
8
+ import { gpuSettingOf, gpuSettingOne } from "./gpu.js";
9
9
  import { hintsCommand, parseQualities } from "./hints.js";
10
10
  import { locateCommand } from "./locate-only.js";
11
11
  import { surveyStop, BROKEN_EXIT } from "./broken.js";
12
12
  import { DEFAULT_THIRD_PARTY, parseAllowHosts, thirdPartyFromFlags } from "./third-party.js";
13
- import { MAX_IDLE_TIMEOUT_MS, MAX_PAGE_CONCURRENCY, pageFileName, parseLimit, parseRange, readStorageState, rootOf, runScan, scanPlan, } from "./remote.js";
13
+ import { MAX_IDLE_TIMEOUT_MS, MAX_PAGE_CONCURRENCY, MAX_RESOURCE_TIME, MAX_SVG_BUDGET, pageFileName, parseLimit, parseRange, readStorageState, rootOf, runScan, scanPlan, } from "./remote.js";
14
14
  import { sampleByPattern, urlsFromSource } from "./urls.js";
15
15
  import { groupRoutes, parseRouteMap } from "./routes.js";
16
16
  import { selectDeepPages } from "./deep.js";
@@ -43,6 +43,10 @@ export function parseRunArgs(argv) {
43
43
  const arg = argv[i];
44
44
  if (arg === "--no-cache")
45
45
  args.noCache = true;
46
+ else if (arg === "--gpu-encode")
47
+ args.gpuEncode = true;
48
+ else if (arg === "--no-resources")
49
+ args.noResources = true;
46
50
  else if (arg === "--ignore-robots")
47
51
  args.ignoreRobots = true;
48
52
  else if (arg === "--block-third-party")
@@ -51,11 +55,11 @@ export function parseRunArgs(argv) {
51
55
  else if (arg === "--allow-third-party")
52
56
  continue;
53
57
  else if (arg === "--gpu")
54
- args.gpu ??= { device: 0 };
58
+ args.gpu ??= gpuSettingOne(0);
55
59
  else if (arg === "--gpu-device") {
56
60
  const value = argValue(argv, i, arg);
57
61
  i++;
58
- args.gpu = { device: parseGpuDevice(value) };
62
+ args.gpu = gpuSettingOf(value);
59
63
  }
60
64
  else if (arg === "--out" ||
61
65
  arg === "--urls-from" ||
@@ -63,6 +67,8 @@ export function parseRunArgs(argv) {
63
67
  arg === "--deep" ||
64
68
  arg === "--page-concurrency" ||
65
69
  arg === "--idle-timeout" ||
70
+ arg === "--svg-budget" ||
71
+ arg === "--resource-time" ||
66
72
  arg === "--dev" ||
67
73
  arg === "--qualities" ||
68
74
  arg === "--root" ||
@@ -83,6 +89,10 @@ export function parseRunArgs(argv) {
83
89
  args.pageConcurrency = parseRange(arg, value, 1, MAX_PAGE_CONCURRENCY);
84
90
  else if (arg === "--idle-timeout")
85
91
  args.idleTimeoutMs = parseRange(arg, value, 0, MAX_IDLE_TIMEOUT_MS);
92
+ else if (arg === "--svg-budget")
93
+ args.svgBudget = parseRange(arg, value, 0, MAX_SVG_BUDGET);
94
+ else if (arg === "--resource-time")
95
+ args.resourceTime = parseRange(arg, value, 0, MAX_RESOURCE_TIME);
86
96
  else if (arg === "--dev")
87
97
  args.dev = new URL(parseHttpUrl(value, "--dev")).origin;
88
98
  else if (arg === "--qualities")
@@ -169,6 +179,7 @@ export async function runCommand(argv, cwd, log) {
169
179
  ignoreRobots: args.ignoreRobots,
170
180
  aggressive: false,
171
181
  thirdParty: args.thirdParty,
182
+ resources: args.noResources !== true,
172
183
  };
173
184
  const base = { urls: [], out: outRoot, aggressive: false, quality: true, thirdParty: DEFAULT_THIRD_PARTY };
174
185
  const surveyPlan = scanPlan({ ...base, survey: true, pageConcurrency: args.pageConcurrency, idleTimeoutMs: args.idleTimeoutMs });
@@ -243,6 +254,7 @@ export async function runCommand(argv, cwd, log) {
243
254
  pageConcurrency: fullPlan.pageConcurrency,
244
255
  idleTimeoutMs: fullPlan.idleTimeoutMs,
245
256
  gpu: args.gpu,
257
+ svgBudget: args.svgBudget,
246
258
  expectImages: (url) => expected.get(url),
247
259
  });
248
260
  phases.push(phaseOf("deep", deepStart, deep));
@@ -338,6 +350,12 @@ function runFlags(args) {
338
350
  common.push("--ignore-robots");
339
351
  if (args.noCache)
340
352
  common.push("--no-cache");
353
+ if (args.gpuEncode)
354
+ common.push("--gpu-encode");
355
+ if (args.noResources)
356
+ common.push("--no-resources");
357
+ if (args.resourceTime !== undefined)
358
+ common.push("--resource-time", String(args.resourceTime));
341
359
  if (args.pageConcurrency !== undefined)
342
360
  common.push("--page-concurrency", String(args.pageConcurrency));
343
361
  if (args.idleTimeoutMs !== undefined)
@@ -348,7 +366,9 @@ function runFlags(args) {
348
366
  common.push("--allow-host", shellWord(args.thirdParty.allowHosts.join(",")));
349
367
  const deep = [...common];
350
368
  if (args.gpu !== undefined)
351
- deep.push("--gpu-device", String(args.gpu.device));
369
+ deep.push("--gpu-device", args.gpu.devices.join(","));
370
+ if (args.svgBudget !== undefined)
371
+ deep.push("--svg-budget", String(args.svgBudget));
352
372
  return { survey: common, deep };
353
373
  }
354
374
  function recordBroken(broken, phase, page) {
@@ -379,7 +399,7 @@ async function exists(path) {
379
399
  return false;
380
400
  }
381
401
  }
382
- /** Every file of the run directory, relative to it, by the folder it is in. */
402
+ /** Every file of the run directory, relative to it, by the folder it is in (folders inside them are not listed). */
383
403
  export async function filesOf(outRoot) {
384
404
  const files = [{ path: "urls.txt", kind: "urls" }];
385
405
  const folders = [
@@ -390,8 +410,10 @@ export async function filesOf(outRoot) {
390
410
  for (const [folder, kind] of folders) {
391
411
  if (!(await exists(join(outRoot, folder))))
392
412
  continue;
393
- for (const name of (await readdir(join(outRoot, folder))).sort())
394
- files.push({ path: `${folder}/${name}`, kind });
413
+ // Files only: a folder in it (the traced SVGs of the vector check, under svg/) is not a page file.
414
+ const entries = await readdir(join(outRoot, folder), { withFileTypes: true });
415
+ for (const entry of entries.filter((e) => e.isFile()).sort((a, b) => a.name.localeCompare(b.name)))
416
+ files.push({ path: `${folder}/${entry.name}`, kind });
395
417
  }
396
418
  if (await exists(join(outRoot, "hints.json")))
397
419
  files.push({ path: "hints.json", kind: "hints" });
@@ -418,6 +440,8 @@ export async function writeManifest(outRoot, cwd, run) {
418
440
  pageConcurrency: args.pageConcurrency,
419
441
  idleTimeoutMs: args.idleTimeoutMs,
420
442
  noCache: args.noCache,
443
+ ...(args.noResources ? { noResources: true } : {}),
444
+ ...(args.resourceTime !== undefined ? { resourceTime: args.resourceTime } : {}),
421
445
  root: args.root,
422
446
  ignoreRobots: args.ignoreRobots,
423
447
  storageState: args.storageState !== undefined,
package/dist/sizes.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { offsetPx, wholePx } from "./px.js";
1
2
  /** Mobile/desktop split used in the emitted media query. */
2
3
  export const MOBILE_MAX_WIDTH = 768;
3
4
  /** Max residual (px) for a mobile/desktop group to count as a linear fit. */
@@ -34,15 +35,16 @@ function expressionFor(points) {
34
35
  const maxResidual = Math.max(...points.map((p) => Math.abs(p.renderedWidth - (a * p.viewportWidth + b))));
35
36
  if (maxResidual <= FIT_TOLERANCE_PX) {
36
37
  if (a * 100 < 1)
37
- return `${Math.round(maxWidth)}px`;
38
+ return `${wholePx(maxWidth)}px`;
38
39
  if (Math.abs(b) < 1)
39
40
  return `${Math.round(a * 100)}vw`;
40
- return b < 0
41
- ? `calc(${Math.round(a * 100)}vw - ${Math.round(-b)}px)`
42
- : `calc(${Math.round(a * 100)}vw + ${Math.round(b)}px)`;
41
+ const offset = offsetPx(b);
42
+ return offset < 0
43
+ ? `calc(${Math.round(a * 100)}vw - ${-offset}px)`
44
+ : `calc(${Math.round(a * 100)}vw + ${offset}px)`;
43
45
  }
44
46
  }
45
- return `${Math.ceil(maxWidth)}px`;
47
+ return `${wholePx(maxWidth)}px`;
46
48
  }
47
49
  /**
48
50
  * Derives the mobile and desktop width expressions from measured rendered widths across viewports.
package/dist/summary.js CHANGED
@@ -2,7 +2,7 @@
2
2
  // MAX_SUMMARY_LINES lines, so an agent decides the next step from it without opening the JSON.
3
3
  import { readdir, readFile, stat } from "node:fs/promises";
4
4
  import { isAbsolute, join, relative, resolve } from "node:path";
5
- import { hintsCommand } from "./hints.js";
5
+ import { hiddenVideoSummaryLine, hintsCommand, videoTotalsLine } from "./hints.js";
6
6
  import { safeText, shellWord } from "./text.js";
7
7
  import { APPROX_MARK, APPROX_NOTE, pageFileName, siteNumbers, surveyLabelOf } from "./remote.js";
8
8
  import { candidatesOf, patternRanking } from "./worst.js";
@@ -46,6 +46,7 @@ export function labelOf(url) {
46
46
  }
47
47
  }
48
48
  const kbText = (kb) => kb.toFixed(1);
49
+ const kb = (bytes) => `${(bytes / 1024).toFixed(0)} KB`;
49
50
  const pctText = (value) => `${value.toFixed(1)}%`;
50
51
  /** The lines of the summary, capped at MAX_SUMMARY_LINES (the row limits keep it under the cap; the cap is the backstop). */
51
52
  export function renderSummary(input) {
@@ -57,6 +58,13 @@ export function renderSummary(input) {
57
58
  const parts = input.timings.map((t) => `${safeText(t.name)} ${t.seconds.toFixed(0)} s`).join(", ");
58
59
  lines.push(`time: ${total.toFixed(0)} s total (${parts})`);
59
60
  }
61
+ // The media phase's note is printed once, at the top (the same note is on each page that has media).
62
+ const media = [...(input.survey ?? []), ...input.deep].find((p) => p.mediaNote !== undefined)?.mediaNote;
63
+ if (media !== undefined)
64
+ lines.push(safeText(media));
65
+ const vectorNote = input.deep.find((p) => p.vectorNote !== undefined)?.vectorNote;
66
+ if (vectorNote !== undefined)
67
+ lines.push(safeText(vectorNote));
60
68
  lines.push("");
61
69
  if (input.stop !== undefined) {
62
70
  lines.push(`STOPPED before the deep phase: ${safeText(input.stop.reason)}`, ` fix: ${safeText(input.stop.fix)}`, "");
@@ -67,6 +75,12 @@ export function renderSummary(input) {
67
75
  lines.push(safeText(input.surveyLabel), "");
68
76
  if (input.deep.length > 0)
69
77
  lines.push(...deepBlock(input.deep), "");
78
+ const vector = vectorLine(input.deep);
79
+ if (vector !== undefined)
80
+ lines.push(vector, "");
81
+ const withVideo = input.deep.filter((p) => p.status === "ok" && (p.videos ?? []).length > 0);
82
+ if (withVideo.length > 0)
83
+ lines.push(...videoBlock(withVideo), "");
70
84
  if (input.callSites !== undefined)
71
85
  lines.push(...callSiteBlock(input.callSites), "");
72
86
  if (input.warnings.length > 0)
@@ -124,10 +138,58 @@ function deepBlock(deep) {
124
138
  lines.push(` ${APPROX_NOTE}`);
125
139
  return lines;
126
140
  }
141
+ /**
142
+ * The vector check over the pages: the flat images, the ones traced to an SVG that passes, and the SVG's saving per view
143
+ * (the bytes the passing SVGs save over their served files). Undefined when no image is flat.
144
+ */
145
+ export function vectorLine(pages) {
146
+ let flat = 0;
147
+ let passed = 0;
148
+ let savedPerView = 0;
149
+ for (const page of pages) {
150
+ for (const image of page.images ?? []) {
151
+ if (image.fix.vector === undefined)
152
+ continue;
153
+ flat++;
154
+ if (image.fix.vector.passed === true) {
155
+ passed++;
156
+ savedPerView += image.fix.vector.savedPerView ?? 0;
157
+ }
158
+ }
159
+ }
160
+ if (flat === 0)
161
+ return undefined;
162
+ return `vector art: ${flat} image(s) look like vector art, ${passed} traced to a smaller SVG, ${kbText(savedPerView / 1024)} KB/view`;
163
+ }
127
164
  const exactOf = (page) => {
128
165
  const n = page.page;
129
166
  return (n.meanImageBytes * n.exactPct) / 100;
130
167
  };
168
+ /** The deep pages with a `<video>`: one line each, the page's video totals (per view, per play, saveable est.). */
169
+ function videoBlock(pages) {
170
+ const shown = Math.min(DEEP_ROWS, pages.length);
171
+ const lines = [`videos: ${pages.length} deep page(s) with <video> (${shown} of ${pages.length} shown)`];
172
+ for (const page of pages.slice(0, DEEP_ROWS)) {
173
+ lines.push(` ${trim(labelOf(page.url), LABEL_WIDTH).padEnd(LABEL_WIDTH)} ${videoTotalsLine(page.videos ?? []) ?? ""}`);
174
+ }
175
+ // The loaded-but-not-shown group: the videos the browser fetched that no viewport shows in full, largest first.
176
+ const hidden = pages.flatMap((page) => (page.videos ?? []).filter((video) => video.hidden !== undefined).map((video) => ({ page, video })));
177
+ const hiddenLine = hiddenVideoSummaryLine(hidden.map((h) => h.video));
178
+ if (hiddenLine !== undefined) {
179
+ lines.push(` ${hiddenLine}`);
180
+ const rows = [...hidden].sort((a, b) => (b.video.hidden?.perViewBytes ?? 0) - (a.video.hidden?.perViewBytes ?? 0)).slice(0, HIDDEN_VIDEO_ROWS);
181
+ for (const row of rows) {
182
+ const hiddenFix = row.video.hidden;
183
+ const where = row.video.locator.file !== undefined ? `${row.video.locator.file}:${row.video.locator.line ?? 0}` : row.video.locator.selector;
184
+ lines.push(` ${JSON.stringify(safeText(where))} hidden at ${hiddenFix.viewports.length} of ${hiddenFix.of} ${kb(hiddenFix.perViewBytes)}/view ${hiddenFix.action}`);
185
+ }
186
+ if (hidden.length > rows.length)
187
+ lines.push(` (+${hidden.length - rows.length} more: see imgtune hints)`);
188
+ }
189
+ return lines;
190
+ }
191
+ /** The loaded-but-not-shown video rows the summary lists per run. */
192
+ const HIDDEN_VIDEO_ROWS = 5;
131
193
  function callSiteBlock(sites) {
132
194
  if (sites.length === 0)
133
195
  return ["call sites by saving per view: none (no deep page has a fix hint)"];
@@ -159,8 +221,12 @@ export async function readPages(dir) {
159
221
  return [];
160
222
  }
161
223
  const pages = [];
162
- for (const name of names)
163
- pages.push(await readPageFile(join(dir, name)));
224
+ for (const name of names) {
225
+ const page = await readPageFile(join(dir, name));
226
+ // A page without the completeness marker is an interrupted run's: its media results are not in yet (see remote.ts).
227
+ if (page.complete === true)
228
+ pages.push(page);
229
+ }
164
230
  return pages;
165
231
  }
166
232
  async function readPageFile(file) {
@@ -0,0 +1,294 @@
1
+ // Vector art: a flat raster (a logo, an icon, a flat illustration) that could be an SVG. The cheap check reads the pixels
2
+ // of a still image at 256x256 and calls it flat when a few colours cover it. The proof traces it with vtracer
3
+ // (@neplex/vectorizer, an optional dependency), renders the SVG back at the served size and scores it against the served
4
+ // image at half size: an antialiased raster is not ground truth, and the traced SVG is sharper at the edges. Nothing here
5
+ // throws on a bad image or a missing vectorizer: the caller gets undefined and says why.
6
+ import { createRequire } from "node:module";
7
+ import { Worker } from "node:worker_threads";
8
+ import { brotliCompressSync, constants } from "node:zlib";
9
+ import sharp from "sharp";
10
+ import { openImage } from "./image-open.js";
11
+ import { score } from "./metric.js";
12
+ /** The vectorizer version the package pins (optionalDependencies). It is part of the cache key of a trace. */
13
+ export const VECTORIZER_VERSION = "0.1.0";
14
+ /** The fidelity bar: the half-size SSIMULACRA2 of the rendered SVG against the served image. */
15
+ export const VECTOR_BAR = 70;
16
+ /** The SVG must be at least this much smaller than the served bytes for the hint to be given. */
17
+ export const VECTOR_MIN_SAVING = 0.3;
18
+ /** The side of the square the flatness is read at (nearest kernel). */
19
+ export const FLAT_SIZE = 256;
20
+ /** A flat image: the 16 most common colours cover at least this share of its pixels... */
21
+ export const FLAT_TOP16_MIN = 0.97;
22
+ /** ...and it has at most this many colours (after 5-bit quantisation of each channel). */
23
+ export const FLAT_MAX_COLORS = 300;
24
+ /**
25
+ * ...and at most this share of its neighbouring pixel pairs differ by a small step (1 to 8 levels in some channel). Soft
26
+ * shading and photo-like gradients are made of small steps; flat art has none, or hard edges.
27
+ */
28
+ export const FLAT_SMOOTH_MAX = 0.12;
29
+ /** A larger image is not traced at all (decoding and tracing it cost seconds, and the screens it comes from are not art). */
30
+ export const VECTOR_MAX_MEGAPIXELS = 5;
31
+ /** The trace input: at most this many pixels, and this long side. */
32
+ export const TRACE_MAX_PIXELS = 1_000_000;
33
+ export const TRACE_MAX_SIDE = 1024;
34
+ /** Smaller files are not worth a vector check: their bytes are too few to matter. */
35
+ export const VECTOR_MIN_BYTES = 2048;
36
+ /** A flat image this big or bigger in bytes gets the "looks like vector art" note when no SVG was traced. */
37
+ export const VECTOR_LOOKS_MIN_BYTES = 10 * 1024;
38
+ /** The time allowed for one image's trace and fidelity check, in ms. The trace cannot be stopped: a late result is dropped. */
39
+ export const VECTOR_CAP_MS = 2000;
40
+ /** The most pixels the fidelity check scores: the half-size pair is scaled down to this (both images alike). */
41
+ export const SCORE_MAX_PIXELS = 1_000_000;
42
+ /** A pixel counts as a bad pixel when its RGB L1 difference from the served image is above this. */
43
+ const BAD_PIXEL_L1 = 96;
44
+ /** The raster formats the vector check reads: a still image in one of them. */
45
+ const STILL_FORMATS = new Set(["png", "webp", "jpeg", "avif", "gif"]);
46
+ /**
47
+ * The flatness of a served file the vector check can read: a still raster of at least VECTOR_MIN_BYTES. Undefined otherwise
48
+ * (an animated file, a format it does not read, or a file too small to matter). Never throws.
49
+ */
50
+ export async function stillFlatness(response, served, animated) {
51
+ if (animated || response.buffer === undefined || response.bytes < VECTOR_MIN_BYTES)
52
+ return undefined;
53
+ if (served.format === undefined || !STILL_FORMATS.has(served.format))
54
+ return undefined;
55
+ return flatnessOf(response.buffer);
56
+ }
57
+ const WHITE = "#ffffff";
58
+ const flatMemo = new WeakMap();
59
+ /**
60
+ * How flat an image is, read at FLAT_SIZE with the nearest kernel. Undefined when the bytes do not decode. Memoised per
61
+ * buffer, so one response is read once.
62
+ */
63
+ export function flatnessOf(buffer) {
64
+ let pending = flatMemo.get(buffer);
65
+ if (pending === undefined) {
66
+ pending = computeFlatness(buffer);
67
+ flatMemo.set(buffer, pending);
68
+ }
69
+ return pending;
70
+ }
71
+ async function computeFlatness(buffer) {
72
+ try {
73
+ const { data, info } = await openImage(buffer)
74
+ .rotate()
75
+ .resize(FLAT_SIZE, FLAT_SIZE, { fit: "inside", kernel: "nearest", withoutEnlargement: true })
76
+ .ensureAlpha()
77
+ .raw()
78
+ .toBuffer({ resolveWithObject: true });
79
+ const pixels = info.width * info.height;
80
+ if (pixels === 0)
81
+ return undefined;
82
+ const counts = new Map();
83
+ let binary = 0;
84
+ for (let i = 0; i < data.length; i += 4) {
85
+ const key = ((data[i] >> 3) << 15) | ((data[i + 1] >> 3) << 10) | ((data[i + 2] >> 3) << 5) | (data[i + 3] >> 3);
86
+ counts.set(key, (counts.get(key) ?? 0) + 1);
87
+ if (data[i + 3] === 0 || data[i + 3] === 255)
88
+ binary++;
89
+ }
90
+ const top16 = [...counts.values()].sort((a, b) => b - a).slice(0, 16).reduce((sum, n) => sum + n, 0);
91
+ const top16Share = top16 / pixels;
92
+ const uniqueColors = counts.size;
93
+ const smoothShare = smallStepShare(data, info.width, info.height);
94
+ const size = await displayedSize(buffer);
95
+ const megapixels = size === undefined ? 0 : round3((size.width * size.height) / 1e6);
96
+ return {
97
+ uniqueColors,
98
+ top16Share: round3(top16Share),
99
+ alphaBinaryShare: round3(binary / pixels),
100
+ smoothShare: round3(smoothShare),
101
+ megapixels,
102
+ flat: top16Share >= FLAT_TOP16_MIN && uniqueColors <= FLAT_MAX_COLORS && smoothShare <= FLAT_SMOOTH_MAX && megapixels <= VECTOR_MAX_MEGAPIXELS,
103
+ };
104
+ }
105
+ catch {
106
+ return undefined;
107
+ }
108
+ }
109
+ /** The share of horizontal and vertical neighbour pairs whose largest channel difference is a small step (1 to 8). */
110
+ function smallStepShare(data, width, height) {
111
+ let small = 0;
112
+ let pairs = 0;
113
+ const diff = (i, j) => Math.max(Math.abs(data[i] - data[j]), Math.abs(data[i + 1] - data[j + 1]), Math.abs(data[i + 2] - data[j + 2]));
114
+ for (let y = 0; y < height; y++) {
115
+ for (let x = 0; x < width; x++) {
116
+ const i = (y * width + x) * 4;
117
+ if (x + 1 < width) {
118
+ const d = diff(i, i + 4);
119
+ pairs++;
120
+ if (d >= 1 && d <= 8)
121
+ small++;
122
+ }
123
+ if (y + 1 < height) {
124
+ const d = diff(i, i + width * 4);
125
+ pairs++;
126
+ if (d >= 1 && d <= 8)
127
+ small++;
128
+ }
129
+ }
130
+ }
131
+ return pairs === 0 ? 0 : small / pairs;
132
+ }
133
+ /** The size of an image as it is displayed (orientation applied), from its header. Undefined when it cannot be read. */
134
+ export async function displayedSize(buffer) {
135
+ try {
136
+ const meta = await openImage(buffer).metadata();
137
+ if (!meta.width || !meta.height)
138
+ return undefined;
139
+ const turned = (meta.orientation ?? 1) >= 5;
140
+ return turned ? { width: meta.height, height: meta.width } : { width: meta.width, height: meta.height };
141
+ }
142
+ catch {
143
+ return undefined;
144
+ }
145
+ }
146
+ let vectorizer;
147
+ /** The vectorizer module, loaded once. Undefined when the optional dependency is not installed or will not load. Never throws. */
148
+ export function loadVectorizer() {
149
+ vectorizer ??= import("@neplex/vectorizer").then((mod) => (typeof mod.vectorize === "function" && mod.Preset !== undefined ? mod : undefined), () => undefined);
150
+ return vectorizer;
151
+ }
152
+ /**
153
+ * The SVG traced from an image, with the Poster preset, after the trace input is downscaled (traceInput) and the cleanup.
154
+ * Undefined when the vectorizer is missing or the trace fails. Never throws.
155
+ */
156
+ export async function traceSvg(buffer, signal) {
157
+ if (signal?.aborted || (await loadVectorizer()) === undefined)
158
+ return undefined;
159
+ try {
160
+ const svg = await traceInWorker(await traceInput(buffer), signal);
161
+ return svg === undefined ? undefined : cleanSvg(svg);
162
+ }
163
+ catch {
164
+ return undefined;
165
+ }
166
+ }
167
+ /**
168
+ * The vectorizer runs in a worker thread, so an abort really stops it: the native trace does not honour an AbortSignal once it
169
+ * has started, and only ending the thread stops the CPU. A worker is started per trace (a few per run, under the budget).
170
+ * Undefined when the trace fails, the worker dies, or the signal aborts (the worker is then terminated).
171
+ */
172
+ const TRACE_WORKER = `
173
+ const { parentPort, workerData } = require("node:worker_threads");
174
+ const vectorizer = require(workerData.entry);
175
+ vectorizer.vectorize(Buffer.from(workerData.png), vectorizer.Preset.Poster).then(
176
+ (svg) => parentPort.postMessage({ svg }),
177
+ () => parentPort.postMessage({ error: true }),
178
+ );
179
+ `;
180
+ function traceInWorker(png, signal) {
181
+ return new Promise((resolve) => {
182
+ const entry = createRequire(import.meta.url).resolve("@neplex/vectorizer");
183
+ const worker = new Worker(TRACE_WORKER, { eval: true, workerData: { entry, png } });
184
+ let settled = false;
185
+ const finish = (value) => {
186
+ if (settled)
187
+ return;
188
+ settled = true;
189
+ signal?.removeEventListener("abort", onAbort);
190
+ void worker.terminate();
191
+ resolve(value);
192
+ };
193
+ const onAbort = () => finish(undefined);
194
+ signal?.addEventListener("abort", onAbort, { once: true });
195
+ worker.on("message", (message) => finish(typeof message.svg === "string" ? message.svg : undefined));
196
+ worker.on("error", () => finish(undefined));
197
+ worker.on("exit", () => finish(undefined));
198
+ if (signal?.aborted)
199
+ onAbort();
200
+ });
201
+ }
202
+ /**
203
+ * The image as a PNG for the trace, rotated as displayed and downscaled to at most TRACE_MAX_PIXELS with a long side of at most
204
+ * TRACE_MAX_SIDE: a bigger input costs seconds of tracing for no gain in a vector.
205
+ */
206
+ async function traceInput(buffer) {
207
+ const size = await displayedSize(buffer);
208
+ if (size === undefined)
209
+ throw new Error("not an image");
210
+ const { width, height } = size;
211
+ const scale = Math.min(1, Math.sqrt(TRACE_MAX_PIXELS / (width * height)), TRACE_MAX_SIDE / Math.max(width, height));
212
+ let img = openImage(buffer).rotate();
213
+ if (scale < 1)
214
+ img = img.resize(Math.max(1, Math.round(width * scale)), Math.max(1, Math.round(height * scale)), { kernel: "lanczos3" });
215
+ return img.png().toBuffer();
216
+ }
217
+ /**
218
+ * Light cleanup of a traced SVG: whitespace between tags goes, and the numbers of path data are rounded to one decimal
219
+ * (a 1/10 px change at most). The fidelity is scored on the cleaned SVG, so the rounding is measured, not assumed.
220
+ */
221
+ export function cleanSvg(svg) {
222
+ return svg
223
+ .replace(/>\s+</g, "><")
224
+ .replace(/\sd="([^"]*)"/g, (_match, d) => ` d="${d.replace(/-?\d+\.\d+/g, (n) => (Math.round(Number(n) * 10) / 10).toFixed(1))}"`)
225
+ .trim();
226
+ }
227
+ /** The brotli (quality 11) size of an SVG: the bytes it costs on the wire, the same at every DPR. */
228
+ export function svgBytes(svg) {
229
+ return brotliCompressSync(Buffer.from(svg, "utf8"), { params: { [constants.BROTLI_PARAM_QUALITY]: 11 } }).length;
230
+ }
231
+ /**
232
+ * The fidelity of an SVG against the served image it traces. The SVG is rendered back at the served size; the score is
233
+ * SSIMULACRA2 at half size (both images halved with lanczos3, so the raster's antialiasing is not the ground truth; capped
234
+ * at SCORE_MAX_PIXELS for a very large image), and badPixelShare is the share of pixels at that scoring size whose RGB L1
235
+ * difference is over BAD_PIXEL_L1. Both are on white.
236
+ */
237
+ export async function svgFidelity(served, svg) {
238
+ const full = await decodeOnWhite(openImage(served).rotate());
239
+ // Scored at half size, and at most SCORE_MAX_PIXELS: a large logo costs seconds at full size, and the scale is the same
240
+ // for both images, so the score is the same kind of number.
241
+ const scale = Math.min(0.5, Math.sqrt(SCORE_MAX_PIXELS / (full.width * full.height)));
242
+ const width = Math.max(1, Math.round(full.width * scale));
243
+ const height = Math.max(1, Math.round(full.height * scale));
244
+ const ref = await resizeTo(full, width, height);
245
+ const test = await drawAt(svg, width, height);
246
+ // ssimulacra2 needs at least 8 px a side: a smaller image is scored at the size it has.
247
+ const value = width >= 8 && height >= 8 ? await score("ssimulacra2", ref, test) : await score("ssimulacra2", full, await drawAt(svg, full.width, full.height));
248
+ return { score: value, badPixelShare: badPixelShareOf(ref, test) };
249
+ }
250
+ /**
251
+ * The SVG drawn at width x height, the same way the served image is brought to that size (lanczos3). When the trace is
252
+ * smaller than the target (a large image, traced from a downscaled input), it is drawn at the target as vectors: resizing a
253
+ * small raster up would blur its edges.
254
+ */
255
+ async function drawAt(svg, width, height) {
256
+ const root = /<svg[^>]*?\swidth="([\d.]+)"\s+height="([\d.]+)"/.exec(svg);
257
+ const traced = root === null ? { width, height } : { width: Number(root[1]), height: Number(root[2]) };
258
+ const drawn = traced.width >= width && traced.height >= height ? traced : { width, height };
259
+ const vector = svgSized(svg, root, drawn.width, drawn.height, traced);
260
+ const raster = await decodeOnWhite(sharp(Buffer.from(vector)));
261
+ return drawn.width === width && drawn.height === height ? raster : resizeTo(raster, width, height);
262
+ }
263
+ /** The SVG with its own size set to width x height, and a viewBox of its traced size (vectors scale without loss). */
264
+ function svgSized(svg, root, width, height, traced) {
265
+ if (root === null)
266
+ return svg;
267
+ return svg.replace(root[0], root[0].replace(`width="${root[1]}" height="${root[2]}"`, `width="${width}" height="${height}" viewBox="0 0 ${traced.width} ${traced.height}"`));
268
+ }
269
+ async function decodeOnWhite(img) {
270
+ const { data, info } = await img.flatten({ background: WHITE }).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
271
+ return { data: new Uint8ClampedArray(data.buffer, data.byteOffset, data.byteLength), width: info.width, height: info.height };
272
+ }
273
+ async function resizeTo(img, width, height) {
274
+ const { data, info } = await sharp(Buffer.from(img.data.buffer, img.data.byteOffset, img.data.byteLength), {
275
+ raw: { width: img.width, height: img.height, channels: 4 },
276
+ })
277
+ .resize(width, height, { kernel: "lanczos3", fit: "fill" })
278
+ .raw()
279
+ .toBuffer({ resolveWithObject: true });
280
+ return { data: new Uint8ClampedArray(data.buffer, data.byteOffset, data.byteLength), width: info.width, height: info.height };
281
+ }
282
+ function badPixelShareOf(ref, test) {
283
+ const pixels = ref.width * ref.height;
284
+ if (pixels === 0)
285
+ return 0;
286
+ let bad = 0;
287
+ for (let i = 0; i < ref.data.length; i += 4) {
288
+ const d = Math.abs(ref.data[i] - test.data[i]) + Math.abs(ref.data[i + 1] - test.data[i + 1]) + Math.abs(ref.data[i + 2] - test.data[i + 2]);
289
+ if (d > BAD_PIXEL_L1)
290
+ bad++;
291
+ }
292
+ return round3(bad / pixels);
293
+ }
294
+ const round3 = (value) => Math.round(value * 1000) / 1000;