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
@@ -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))
@@ -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 = { ...measured, locate: summary, images: applyLocate(measured.images, join, viewport) };
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
- /** An opening image tag or a srcset prop. */
351
- const IMAGE_TOKEN = /<(?:img|Image|picture|source)(?=[\s/>]|$)|\bsrc[sS]et\s*[={]/;
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
- /** Scrolls one viewport height at a time until the bottom, so lazy images start loading. */
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;