imgtune 0.2.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.
- package/CHANGELOG.md +98 -0
- package/README.md +69 -6
- package/dist/audit.js +1 -0
- package/dist/backgrounds.js +31 -7
- package/dist/broken.js +5 -1
- package/dist/browser.js +4 -4
- package/dist/candidates.js +25 -0
- package/dist/check-report.js +37 -4
- package/dist/check.js +18 -1
- package/dist/cli.js +20 -0
- package/dist/egress-proxy.js +2 -1
- package/dist/ffmpeg.js +35 -0
- package/dist/hints.js +294 -18
- package/dist/image-open.js +9 -0
- package/dist/images.js +116 -7
- package/dist/layout.js +7 -7
- package/dist/lcp.js +2 -1
- package/dist/measure.js +39 -1
- package/dist/media-encode.js +5 -1
- package/dist/media-model.js +8 -4
- package/dist/media-phase.js +25 -2
- package/dist/px.js +11 -0
- package/dist/recheck.js +9 -1
- package/dist/remote.js +616 -63
- package/dist/replay.js +20 -0
- package/dist/resources/audio.js +248 -0
- package/dist/resources/brotli.js +22 -0
- package/dist/resources/cache.js +59 -0
- package/dist/resources/capture.js +225 -0
- package/dist/resources/compression.js +122 -0
- package/dist/resources/favicon.js +130 -0
- package/dist/resources/font.js +440 -0
- package/dist/resources/index.js +8 -0
- package/dist/resources/lottie.js +491 -0
- package/dist/resources/model.js +478 -0
- package/dist/resources/phase.js +171 -0
- package/dist/resources/pool.js +47 -0
- package/dist/resources/report.js +57 -0
- package/dist/resources/sfnt.js +305 -0
- package/dist/resources/svg.js +178 -0
- package/dist/resources/types.js +1 -0
- package/dist/resources/zip.js +67 -0
- package/dist/run.js +24 -4
- package/dist/sizes.js +7 -5
- package/dist/summary.js +47 -1
- package/dist/vector-art.js +294 -0
- package/dist/vector-phase.js +222 -0
- package/dist/video-analysis.js +119 -21
- package/package.json +25 -4
- package/skills/imgtune/SKILL.md +8 -4
- package/skills/imgtune/reference.md +1 -1
package/dist/run.js
CHANGED
|
@@ -10,7 +10,7 @@ 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";
|
|
@@ -45,6 +45,8 @@ export function parseRunArgs(argv) {
|
|
|
45
45
|
args.noCache = true;
|
|
46
46
|
else if (arg === "--gpu-encode")
|
|
47
47
|
args.gpuEncode = true;
|
|
48
|
+
else if (arg === "--no-resources")
|
|
49
|
+
args.noResources = true;
|
|
48
50
|
else if (arg === "--ignore-robots")
|
|
49
51
|
args.ignoreRobots = true;
|
|
50
52
|
else if (arg === "--block-third-party")
|
|
@@ -65,6 +67,8 @@ export function parseRunArgs(argv) {
|
|
|
65
67
|
arg === "--deep" ||
|
|
66
68
|
arg === "--page-concurrency" ||
|
|
67
69
|
arg === "--idle-timeout" ||
|
|
70
|
+
arg === "--svg-budget" ||
|
|
71
|
+
arg === "--resource-time" ||
|
|
68
72
|
arg === "--dev" ||
|
|
69
73
|
arg === "--qualities" ||
|
|
70
74
|
arg === "--root" ||
|
|
@@ -85,6 +89,10 @@ export function parseRunArgs(argv) {
|
|
|
85
89
|
args.pageConcurrency = parseRange(arg, value, 1, MAX_PAGE_CONCURRENCY);
|
|
86
90
|
else if (arg === "--idle-timeout")
|
|
87
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);
|
|
88
96
|
else if (arg === "--dev")
|
|
89
97
|
args.dev = new URL(parseHttpUrl(value, "--dev")).origin;
|
|
90
98
|
else if (arg === "--qualities")
|
|
@@ -171,6 +179,7 @@ export async function runCommand(argv, cwd, log) {
|
|
|
171
179
|
ignoreRobots: args.ignoreRobots,
|
|
172
180
|
aggressive: false,
|
|
173
181
|
thirdParty: args.thirdParty,
|
|
182
|
+
resources: args.noResources !== true,
|
|
174
183
|
};
|
|
175
184
|
const base = { urls: [], out: outRoot, aggressive: false, quality: true, thirdParty: DEFAULT_THIRD_PARTY };
|
|
176
185
|
const surveyPlan = scanPlan({ ...base, survey: true, pageConcurrency: args.pageConcurrency, idleTimeoutMs: args.idleTimeoutMs });
|
|
@@ -245,6 +254,7 @@ export async function runCommand(argv, cwd, log) {
|
|
|
245
254
|
pageConcurrency: fullPlan.pageConcurrency,
|
|
246
255
|
idleTimeoutMs: fullPlan.idleTimeoutMs,
|
|
247
256
|
gpu: args.gpu,
|
|
257
|
+
svgBudget: args.svgBudget,
|
|
248
258
|
expectImages: (url) => expected.get(url),
|
|
249
259
|
});
|
|
250
260
|
phases.push(phaseOf("deep", deepStart, deep));
|
|
@@ -342,6 +352,10 @@ function runFlags(args) {
|
|
|
342
352
|
common.push("--no-cache");
|
|
343
353
|
if (args.gpuEncode)
|
|
344
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));
|
|
345
359
|
if (args.pageConcurrency !== undefined)
|
|
346
360
|
common.push("--page-concurrency", String(args.pageConcurrency));
|
|
347
361
|
if (args.idleTimeoutMs !== undefined)
|
|
@@ -353,6 +367,8 @@ function runFlags(args) {
|
|
|
353
367
|
const deep = [...common];
|
|
354
368
|
if (args.gpu !== undefined)
|
|
355
369
|
deep.push("--gpu-device", args.gpu.devices.join(","));
|
|
370
|
+
if (args.svgBudget !== undefined)
|
|
371
|
+
deep.push("--svg-budget", String(args.svgBudget));
|
|
356
372
|
return { survey: common, deep };
|
|
357
373
|
}
|
|
358
374
|
function recordBroken(broken, phase, page) {
|
|
@@ -383,7 +399,7 @@ async function exists(path) {
|
|
|
383
399
|
return false;
|
|
384
400
|
}
|
|
385
401
|
}
|
|
386
|
-
/** 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). */
|
|
387
403
|
export async function filesOf(outRoot) {
|
|
388
404
|
const files = [{ path: "urls.txt", kind: "urls" }];
|
|
389
405
|
const folders = [
|
|
@@ -394,8 +410,10 @@ export async function filesOf(outRoot) {
|
|
|
394
410
|
for (const [folder, kind] of folders) {
|
|
395
411
|
if (!(await exists(join(outRoot, folder))))
|
|
396
412
|
continue;
|
|
397
|
-
|
|
398
|
-
|
|
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 });
|
|
399
417
|
}
|
|
400
418
|
if (await exists(join(outRoot, "hints.json")))
|
|
401
419
|
files.push({ path: "hints.json", kind: "hints" });
|
|
@@ -422,6 +440,8 @@ export async function writeManifest(outRoot, cwd, run) {
|
|
|
422
440
|
pageConcurrency: args.pageConcurrency,
|
|
423
441
|
idleTimeoutMs: args.idleTimeoutMs,
|
|
424
442
|
noCache: args.noCache,
|
|
443
|
+
...(args.noResources ? { noResources: true } : {}),
|
|
444
|
+
...(args.resourceTime !== undefined ? { resourceTime: args.resourceTime } : {}),
|
|
425
445
|
root: args.root,
|
|
426
446
|
ignoreRobots: args.ignoreRobots,
|
|
427
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 `${
|
|
38
|
+
return `${wholePx(maxWidth)}px`;
|
|
38
39
|
if (Math.abs(b) < 1)
|
|
39
40
|
return `${Math.round(a * 100)}vw`;
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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 `${
|
|
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, videoTotalsLine } 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) {
|
|
@@ -61,6 +62,9 @@ export function renderSummary(input) {
|
|
|
61
62
|
const media = [...(input.survey ?? []), ...input.deep].find((p) => p.mediaNote !== undefined)?.mediaNote;
|
|
62
63
|
if (media !== undefined)
|
|
63
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));
|
|
64
68
|
lines.push("");
|
|
65
69
|
if (input.stop !== undefined) {
|
|
66
70
|
lines.push(`STOPPED before the deep phase: ${safeText(input.stop.reason)}`, ` fix: ${safeText(input.stop.fix)}`, "");
|
|
@@ -71,6 +75,9 @@ export function renderSummary(input) {
|
|
|
71
75
|
lines.push(safeText(input.surveyLabel), "");
|
|
72
76
|
if (input.deep.length > 0)
|
|
73
77
|
lines.push(...deepBlock(input.deep), "");
|
|
78
|
+
const vector = vectorLine(input.deep);
|
|
79
|
+
if (vector !== undefined)
|
|
80
|
+
lines.push(vector, "");
|
|
74
81
|
const withVideo = input.deep.filter((p) => p.status === "ok" && (p.videos ?? []).length > 0);
|
|
75
82
|
if (withVideo.length > 0)
|
|
76
83
|
lines.push(...videoBlock(withVideo), "");
|
|
@@ -131,6 +138,29 @@ function deepBlock(deep) {
|
|
|
131
138
|
lines.push(` ${APPROX_NOTE}`);
|
|
132
139
|
return lines;
|
|
133
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
|
+
}
|
|
134
164
|
const exactOf = (page) => {
|
|
135
165
|
const n = page.page;
|
|
136
166
|
return (n.meanImageBytes * n.exactPct) / 100;
|
|
@@ -142,8 +172,24 @@ function videoBlock(pages) {
|
|
|
142
172
|
for (const page of pages.slice(0, DEEP_ROWS)) {
|
|
143
173
|
lines.push(` ${trim(labelOf(page.url), LABEL_WIDTH).padEnd(LABEL_WIDTH)} ${videoTotalsLine(page.videos ?? []) ?? ""}`);
|
|
144
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
|
+
}
|
|
145
189
|
return lines;
|
|
146
190
|
}
|
|
191
|
+
/** The loaded-but-not-shown video rows the summary lists per run. */
|
|
192
|
+
const HIDDEN_VIDEO_ROWS = 5;
|
|
147
193
|
function callSiteBlock(sites) {
|
|
148
194
|
if (sites.length === 0)
|
|
149
195
|
return ["call sites by saving per view: none (no deep page has a fix hint)"];
|
|
@@ -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;
|