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.
- package/CHANGELOG.md +172 -0
- package/README.md +109 -5
- package/dist/animated.js +176 -0
- package/dist/audit.js +1 -0
- package/dist/backgrounds.js +42 -7
- package/dist/broken.js +5 -1
- package/dist/browser.js +4 -4
- package/dist/candidates.js +25 -0
- package/dist/check-report.js +56 -6
- package/dist/check.js +26 -5
- package/dist/cli.js +86 -8
- package/dist/egress-proxy.js +2 -1
- package/dist/fetch.js +9 -4
- package/dist/ffmpeg.js +530 -0
- package/dist/gpu.js +12 -0
- package/dist/hints.js +704 -16
- package/dist/image-file.js +154 -34
- package/dist/image-open.js +9 -0
- package/dist/images.js +142 -8
- package/dist/layout.js +7 -7
- package/dist/lcp.js +2 -1
- package/dist/locate-join.js +86 -0
- package/dist/locate-only.js +14 -3
- package/dist/locate.js +6 -2
- package/dist/measure.js +39 -1
- package/dist/media-encode.js +1054 -0
- package/dist/media-file.js +284 -0
- package/dist/media-limits.js +31 -0
- package/dist/media-model.js +251 -0
- package/dist/media-phase.js +675 -0
- package/dist/media-prepare.js +31 -0
- package/dist/px.js +11 -0
- package/dist/recheck.js +12 -4
- package/dist/remote.js +1065 -107
- 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/result-cache.js +56 -3
- package/dist/run.js +32 -8
- package/dist/sizes.js +7 -5
- package/dist/summary.js +69 -3
- package/dist/vector-art.js +294 -0
- package/dist/vector-phase.js +222 -0
- package/dist/video-analysis.js +371 -0
- package/dist/webp-animation.js +283 -0
- package/package.json +25 -4
- package/skills/imgtune/SKILL.md +8 -4
- package/skills/imgtune/reference.md +1 -1
package/dist/locate-join.js
CHANGED
|
@@ -18,8 +18,91 @@ export function isFlagged(record) {
|
|
|
18
18
|
// A CSS background is not an <img>: the dev page's images cannot join it, so it is never located.
|
|
19
19
|
if (record.kind === "css-background")
|
|
20
20
|
return false;
|
|
21
|
+
// A poster is a video's image: no <img> of the dev page is its element, so it is never located (its evidence names the video).
|
|
22
|
+
if (record.poster === true)
|
|
23
|
+
return false;
|
|
21
24
|
return record.fix.bytes.saved > 0 || record.fix.bytes.addedForSharpness > 0;
|
|
22
25
|
}
|
|
26
|
+
/** A video costs bytes per view, so a locate asks for it. Posters are image records and follow isFlagged. */
|
|
27
|
+
export function isFlaggedVideo(record) {
|
|
28
|
+
return record.bytes.perView > 0;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Joins the flagged measured videos to the dev page's videos by URL (each side's URLs are keyed against its own page, see
|
|
32
|
+
* devVideosOf). A pair is made only when the URL is on exactly one measured video and exactly one dev video; a URL on
|
|
33
|
+
* several is not guessed at (the videos count as not joined). Videos have no element order to fall back on, so there is
|
|
34
|
+
* no second rule.
|
|
35
|
+
*/
|
|
36
|
+
export function joinVideos(records, devVideos, flagged) {
|
|
37
|
+
const measured = records.filter(flagged).map((record) => ({ record, key: joinKeyOfUrls([record.src], record.page) }));
|
|
38
|
+
const measuredCount = new Map();
|
|
39
|
+
for (const { key } of measured)
|
|
40
|
+
measuredCount.set(key, (measuredCount.get(key) ?? 0) + 1);
|
|
41
|
+
const devCount = new Map();
|
|
42
|
+
for (const dev of devVideos) {
|
|
43
|
+
const list = devCount.get(dev.pattern);
|
|
44
|
+
if (list)
|
|
45
|
+
list.push(dev);
|
|
46
|
+
else
|
|
47
|
+
devCount.set(dev.pattern, [dev]);
|
|
48
|
+
}
|
|
49
|
+
const pairs = new Map();
|
|
50
|
+
let notJoined = 0;
|
|
51
|
+
for (const { record, key } of measured) {
|
|
52
|
+
const devs = devCount.get(key) ?? [];
|
|
53
|
+
if (key !== "" && measuredCount.get(key) === 1 && devs.length === 1)
|
|
54
|
+
pairs.set(record, devs[0]);
|
|
55
|
+
else
|
|
56
|
+
notJoined++;
|
|
57
|
+
}
|
|
58
|
+
return { pairs, notJoined, devLeft: devVideos.length - pairs.size };
|
|
59
|
+
}
|
|
60
|
+
/** The videos of a dev page's viewport, keyed by URL against that page, with the source the dev build resolved for each. */
|
|
61
|
+
export function devVideosOf(observation, pageUrl, resolved) {
|
|
62
|
+
return (observation.videos ?? []).map((video) => {
|
|
63
|
+
const source = resolved.get(video);
|
|
64
|
+
const dev = { index: video.index, pattern: joinKeyOfUrls([video.currentSrc], pageUrl), currentSrc: video.currentSrc, component: source?.component ?? [] };
|
|
65
|
+
if (source?.file !== undefined) {
|
|
66
|
+
dev.file = source.file;
|
|
67
|
+
dev.line = source.line;
|
|
68
|
+
dev.kind = source.check;
|
|
69
|
+
dev.via = source.via;
|
|
70
|
+
dev.ownerChain = source.ownerChain;
|
|
71
|
+
}
|
|
72
|
+
return dev;
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* The located videos: each paired record's locator takes the dev video's file:line and its source check, and its component.
|
|
77
|
+
* The record's id and bytes do not change: a locate only says where the element is in the source.
|
|
78
|
+
*/
|
|
79
|
+
export function applyLocateVideos(records, join) {
|
|
80
|
+
return records.map((record) => {
|
|
81
|
+
const dev = join.pairs.get(record);
|
|
82
|
+
if (dev === undefined)
|
|
83
|
+
return record;
|
|
84
|
+
const locator = { ...record.locator };
|
|
85
|
+
delete locator.file;
|
|
86
|
+
delete locator.line;
|
|
87
|
+
delete locator.kind;
|
|
88
|
+
delete locator.verified;
|
|
89
|
+
delete locator.via;
|
|
90
|
+
delete locator.ownerChain;
|
|
91
|
+
if (dev.file !== undefined && dev.line !== undefined) {
|
|
92
|
+
locator.file = dev.file;
|
|
93
|
+
locator.line = dev.line;
|
|
94
|
+
if (dev.via !== undefined)
|
|
95
|
+
locator.via = dev.via;
|
|
96
|
+
locator.kind = dev.kind ?? "unchecked";
|
|
97
|
+
locator.verified = locator.kind === "image";
|
|
98
|
+
if (dev.ownerChain?.length)
|
|
99
|
+
locator.ownerChain = dev.ownerChain;
|
|
100
|
+
}
|
|
101
|
+
if (dev.component.length > 0)
|
|
102
|
+
locator.component = dev.component;
|
|
103
|
+
return { ...record, locator };
|
|
104
|
+
});
|
|
105
|
+
}
|
|
23
106
|
/** A URL with the page's own origin taken out, so a built server and a dev server give the same pattern. */
|
|
24
107
|
export function joinUrl(url, pageUrl) {
|
|
25
108
|
let origin;
|
|
@@ -102,6 +185,9 @@ export function joinLocate(records, viewport, devImages, flagged) {
|
|
|
102
185
|
const notMeasured = [];
|
|
103
186
|
let noOrder = 0;
|
|
104
187
|
for (const record of records) {
|
|
188
|
+
// A poster never joins an <img> (see isFlagged): skipped, so it can never take another element's file:line.
|
|
189
|
+
if (record.poster === true)
|
|
190
|
+
continue;
|
|
105
191
|
const entry = record.perViewport.find((p) => p.viewport === viewport);
|
|
106
192
|
if (!entry) {
|
|
107
193
|
if (flagged(record))
|
package/dist/locate-only.js
CHANGED
|
@@ -5,7 +5,7 @@ import { mkdir } from "node:fs/promises";
|
|
|
5
5
|
import { dirname, resolve } from "node:path";
|
|
6
6
|
import { writeFileAtomic } from "./atomic.js";
|
|
7
7
|
import { DEFAULT_VIEWPORTS } from "./config.js";
|
|
8
|
-
import { applyLocate, devImagesOf, isFlagged, joinLocate, joinKeyOfImage, joinKeyOfRecord, readMeasuredScan, reasonCounts } from "./locate-join.js";
|
|
8
|
+
import { applyLocate, applyLocateVideos, devImagesOf, devVideosOf, isFlagged, isFlaggedVideo, joinLocate, joinKeyOfImage, joinKeyOfRecord, joinVideos, readMeasuredScan, reasonCounts } from "./locate-join.js";
|
|
9
9
|
import { observeLight, rootOf } from "./remote.js";
|
|
10
10
|
import { parseAllowHosts, thirdPartyFromFlags } from "./third-party.js";
|
|
11
11
|
import { unreadSourceNote } from "./source-root.js";
|
|
@@ -119,6 +119,7 @@ export async function locateCommand(argv, cwd, log) {
|
|
|
119
119
|
ignoreRobots: args.ignoreRobots,
|
|
120
120
|
wanted: (img, pageUrl) => flaggedPatterns.has(joinKeyOfImage(img, pageUrl)),
|
|
121
121
|
thirdParty: args.thirdParty,
|
|
122
|
+
videos: true,
|
|
122
123
|
});
|
|
123
124
|
if (light.status !== "ok") {
|
|
124
125
|
return { output: `locate: the dev page is ${light.status}${light.error ? `: ${safeText(light.error)}` : ""}\n`, exitCode: 1 };
|
|
@@ -127,6 +128,9 @@ export async function locateCommand(argv, cwd, log) {
|
|
|
127
128
|
const devImages = devImagesOf(observation, args.url, light.resolved);
|
|
128
129
|
const unread = await unreadSourceNote(devImages.flatMap((d) => (d.kind === "unread" && d.file !== undefined ? [d.file] : [])), cwd, root);
|
|
129
130
|
const join = joinLocate(measured.images, viewport, devImages, (record) => flaggedSet.has(record));
|
|
131
|
+
// Videos are joined by URL (no element order): a flagged video (one that costs bytes per view) gets its dev file:line.
|
|
132
|
+
const videoJoin = joinVideos(measured.videos ?? [], devVideosOf(observation, args.url, light.resolvedVideos), isFlaggedVideo);
|
|
133
|
+
const flaggedVideos = (measured.videos ?? []).filter(isFlaggedVideo).length;
|
|
130
134
|
const seconds = (Date.now() - started) / 1000;
|
|
131
135
|
const summary = {
|
|
132
136
|
url: args.url,
|
|
@@ -141,7 +145,12 @@ export async function locateCommand(argv, cwd, log) {
|
|
|
141
145
|
noOrder: join.noOrder,
|
|
142
146
|
reasons: reasonCounts(join),
|
|
143
147
|
};
|
|
144
|
-
const located = {
|
|
148
|
+
const located = {
|
|
149
|
+
...measured,
|
|
150
|
+
locate: summary,
|
|
151
|
+
images: applyLocate(measured.images, join, viewport),
|
|
152
|
+
...(measured.videos !== undefined ? { videos: applyLocateVideos(measured.videos, videoJoin) } : {}),
|
|
153
|
+
};
|
|
145
154
|
const outPath = resolve(cwd, args.out ?? defaultLocateOut(args.from));
|
|
146
155
|
if (outPath === resolve(cwd, args.from))
|
|
147
156
|
throw new Error("--locate-out must not be the measured scan itself");
|
|
@@ -153,12 +162,14 @@ export async function locateCommand(argv, cwd, log) {
|
|
|
153
162
|
];
|
|
154
163
|
for (const [code, count] of Object.entries(summary.reasons))
|
|
155
164
|
lines.push(` not located, ${count}: ${REASON_TEXT[code] ?? code}`);
|
|
165
|
+
if (flaggedVideos > 0)
|
|
166
|
+
lines.push(`flagged videos: ${flaggedVideos}; located: ${videoJoin.pairs.size}; not located: ${videoJoin.notJoined}`);
|
|
156
167
|
if (summary.devLeft > 0)
|
|
157
168
|
lines.push(`dev images of a flagged URL with no measured partner: ${summary.devLeft}`);
|
|
158
169
|
if (summary.noOrder > 0)
|
|
159
170
|
lines.push(`measured elements without an element order: ${summary.noOrder} (the scan is older: scan again)`);
|
|
160
171
|
lines.push(`wrote ${safeText(outPath)}`);
|
|
161
|
-
const exitCode = flagged.length > 0 && summary.joined === 0 ? 1 : 0;
|
|
172
|
+
const exitCode = flagged.length + flaggedVideos > 0 && summary.joined + videoJoin.pairs.size === 0 ? 1 : 0;
|
|
162
173
|
if (exitCode !== 0)
|
|
163
174
|
lines.push("nothing located: is this the dev page of the measured scan, with the same images?");
|
|
164
175
|
else
|
package/dist/locate.js
CHANGED
|
@@ -347,8 +347,12 @@ export const SOURCE_WINDOW = 3;
|
|
|
347
347
|
const OPENING_SEARCH = 30;
|
|
348
348
|
/** Largest project file read for the check. */
|
|
349
349
|
export const MAX_SOURCE_READ = 2 * 1024 * 1024;
|
|
350
|
-
/**
|
|
351
|
-
|
|
350
|
+
/**
|
|
351
|
+
* An opening image tag or a srcset prop. A <video> opening and a <source> child count too: a video's poster is an image
|
|
352
|
+
* record that carries the video's locator, so its owner line is the <video> element (and the sources sit on their own
|
|
353
|
+
* lines). Lowercase only, so a component such as <VideoPlayer> is not taken for the element.
|
|
354
|
+
*/
|
|
355
|
+
const IMAGE_TOKEN = /<(?:img|Image|picture|source|video)(?=[\s/>]|$)|\bsrc[sS]et\s*[={]/;
|
|
352
356
|
/**
|
|
353
357
|
* Whether a project source line (1-based) renders an image: an image token within SOURCE_WINDOW lines of it, an opening
|
|
354
358
|
* image tag above it with no closing tag between, or the definition (`function Name`, `const Name`) of the component
|
package/dist/measure.js
CHANGED
|
@@ -184,14 +184,52 @@ export async function settleWithin(promises, ms) {
|
|
|
184
184
|
await Promise.race([Promise.all(promises), timeout]);
|
|
185
185
|
clearTimeout(timer);
|
|
186
186
|
}
|
|
187
|
-
/**
|
|
187
|
+
/**
|
|
188
|
+
* Marks, on the page, every image and video that is visible at this scroll position: not display:none, not
|
|
189
|
+
* visibility hidden, opacity above 0, a box inside the viewport, and not outside an overflow-hidden ancestor (a carousel slide
|
|
190
|
+
* off its track is not visible). The marks live in window.__imgtuneSeen (a WeakSet of elements) for the page's read (see
|
|
191
|
+
* readView): a copy seen visible at any scroll step of the scan's own pass is shown, whatever its state at the read.
|
|
192
|
+
*/
|
|
193
|
+
export const MARK_VISIBLE_SCRIPT = `(() => {
|
|
194
|
+
const seen = window.__imgtuneSeen || (window.__imgtuneSeen = new WeakSet());
|
|
195
|
+
const vw = window.innerWidth;
|
|
196
|
+
const vh = window.innerHeight;
|
|
197
|
+
for (const el of document.querySelectorAll("img, video")) {
|
|
198
|
+
const cs = getComputedStyle(el);
|
|
199
|
+
if (cs.display === "none" || cs.visibility === "hidden" || cs.visibility === "collapse") continue;
|
|
200
|
+
if (!(parseFloat(cs.opacity) > 0)) continue;
|
|
201
|
+
const r = el.getBoundingClientRect();
|
|
202
|
+
if (r.width <= 0 || r.height <= 0 || r.right <= 0 || r.bottom <= 0 || r.left >= vw || r.top >= vh) continue;
|
|
203
|
+
// The same rules as the hidden check (readView): content-visibility hidden, a closed details (except its summary), a
|
|
204
|
+
// display:none ancestor, and an overflow-hidden ancestor the box lies outside of.
|
|
205
|
+
let clipped = false;
|
|
206
|
+
for (let a = el.parentElement; a && !clipped; a = a.parentElement) {
|
|
207
|
+
const s = getComputedStyle(a);
|
|
208
|
+
if (s.display === "none" || s.getPropertyValue("content-visibility") === "hidden") clipped = true;
|
|
209
|
+
else if (a.tagName === "DETAILS" && !a.hasAttribute("open") && el.closest("summary") === null) clipped = true;
|
|
210
|
+
else if (/hidden|clip/.test(s.overflowX + " " + s.overflowY)) {
|
|
211
|
+
const b = a.getBoundingClientRect();
|
|
212
|
+
if (r.right <= b.left || r.left >= b.right || r.bottom <= b.top || r.top >= b.bottom) clipped = true;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
if (!clipped) seen.add(el);
|
|
216
|
+
}
|
|
217
|
+
})()`;
|
|
218
|
+
/**
|
|
219
|
+
* Scrolls one viewport height at a time until the bottom, so lazy images start loading. After each step the copies visible at
|
|
220
|
+
* that position are marked (see MARK_VISIBLE_SCRIPT): a copy shown at any step of the pass is not a hidden copy.
|
|
221
|
+
*/
|
|
188
222
|
async function scrollThrough(page) {
|
|
223
|
+
await page.evaluate(MARK_VISIBLE_SCRIPT);
|
|
189
224
|
for (let step = 0; step < MAX_SCROLL_STEPS; step++) {
|
|
190
225
|
const atBottom = await page.evaluate(() => {
|
|
191
226
|
window.scrollBy(0, window.innerHeight);
|
|
192
227
|
return window.scrollY + window.innerHeight >= document.documentElement.scrollHeight - 1;
|
|
193
228
|
});
|
|
194
229
|
count("scroll steps");
|
|
230
|
+
// Two frames: the scroll events the step fired have run, so a state a scroll listener sets is the one marked.
|
|
231
|
+
await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))));
|
|
232
|
+
await page.evaluate(MARK_VISIBLE_SCRIPT);
|
|
195
233
|
await new Promise((resolve) => setTimeout(resolve, SCROLL_STEP_WAIT_MS));
|
|
196
234
|
if (atBottom)
|
|
197
235
|
return;
|