Skip to content

Commit f150607

Browse files
authored
Merge pull request #54 from happyprime/feat/downscale-oversized-captures
Downscale captures too large for the browser to render
2 parents 411681d + 8d7e6d7 commit f150607

12 files changed

Lines changed: 573 additions & 11 deletions

File tree

CLAUDE.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@ The package is a thin CLI over a set of focused modules:
3434
`context.route()` and fulfills repeats locally instead of hitting the origin.
3535
- `src/compare.mjs` — pixelmatch comparison + HTML diffing. Exports
3636
`compare(config, options)`; opens the generated report via `file://`.
37+
- `src/downscale.mjs` — area-filter image downscaling. `compare` uses it to
38+
produce browser-safe display copies of captures that exceed the ~32,767px
39+
decode limit, while pixelmatch still runs against the full-res originals.
3740
- `src/report.mjs` — Assembles the report data and writes the single-page
3841
report. `buildHtmlDiff` turns two HTML snapshots into changed-line counts and
3942
unified-diff hunk JSON; `generateReport` groups the per-slug results into one
@@ -53,6 +56,7 @@ config works across developers who each run against their own local domain.
5356
All artifacts are written under the configured output directory (`.reglance` by
5457
default), which gets a generated `.gitignore` so nothing is committed to the host
5558
project. Subdirectories: `captures/`, `controls/`, `compares/` (diff PNGs only),
59+
`display/` (downscaled copies of any capture too large for a browser to render),
5660
`reports/`, `assets/`, and `image-cache/` when the image cache is enabled.
5761

5862
## Templates

README.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,7 @@ guards against silently baselining bad data:
156156
captures/ Latest screenshots + HTML snapshots
157157
controls/ Baseline screenshots + HTML (+ manifest.json)
158158
compares/ Diff images
159+
display/ Downscaled copies of any capture too large for the browser to render
159160
reports/ The report — open reports/index.html
160161
assets/ Report stylesheet + script
161162
image-cache/ Cached image responses (only with imageCache enabled)
@@ -167,6 +168,18 @@ and renders three views client-side from the URL hash — a triage overview
167168
view (swipe, side-by-side, onion skin, diff overlay, blink), and a unified HTML
168169
diff. It opens straight from disk with no network access.
169170

171+
### Oversized captures
172+
173+
Browsers refuse to decode an image taller or wider than 32,767px and show it as
174+
a broken/corrupt image. Full-page mobile captures cross this easily — a narrow
175+
viewport stacks content into a very tall page, and a `deviceScaleFactor` of 2
176+
doubles the pixel height again. `capture` warns when it writes a screenshot past
177+
the limit, and `compare` writes a downscaled copy to `display/`, points the
178+
report at it, and flags the view with a "Downscaled" notice. Pixel diffs are
179+
still computed against the full-resolution originals; only what the browser
180+
paints is shrunk. To capture such a page at full resolution instead, lower that
181+
viewport's `deviceScaleFactor`.
182+
170183
## Development
171184

172185
```bash

src/capture.mjs

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,38 @@ import path from 'node:path';
33
import { chromium } from 'playwright';
44
import { filterTargets, DEFAULT_TIMEOUTS } from './config.mjs';
55
import { openImageCache } from './image-cache.mjs';
6+
import { MAX_DISPLAY_DIMENSION } from './downscale.mjs';
7+
8+
/**
9+
* Read a PNG's pixel dimensions from its IHDR header without decoding it.
10+
*
11+
* A full-page mobile screenshot can be tens of millions of pixels; the width
12+
* and height live at fixed offsets in the first chunk, so a 24-byte read is
13+
* enough and avoids inflating the whole image just to measure it.
14+
*
15+
* @param {string} file The PNG path.
16+
* @returns {{ width: number, height: number }|null} The dimensions, or null if
17+
* the header can't be read.
18+
*/
19+
export function pngDimensions(file) {
20+
let fd;
21+
try {
22+
fd = fs.openSync(file, 'r');
23+
const header = Buffer.alloc(24);
24+
fs.readSync(fd, header, 0, 24, 0);
25+
// Bytes 0-7 are the PNG signature; the IHDR width/height follow at 16/20.
26+
return {
27+
width: header.readUInt32BE(16),
28+
height: header.readUInt32BE(20),
29+
};
30+
} catch {
31+
return null;
32+
} finally {
33+
if (fd !== undefined) {
34+
fs.closeSync(fd);
35+
}
36+
}
37+
}
638

739
/**
840
* Whether a host is a local development host, for which self-signed/invalid
@@ -373,6 +405,25 @@ async function captureTarget(browser, target, viewports, dirs, options) {
373405
const imagePath = path.join(dirs.captures, `${slug}.png`);
374406
await page.screenshot({ path: imagePath, fullPage: true });
375407

408+
// Browsers refuse to decode an image past ~32,767px on a side and
409+
// show it as broken; tall mobile captures (narrow page, doubled by
410+
// a 2x deviceScaleFactor) cross this. The file is still valid and
411+
// compare downscales a display copy, but flag it here so the cause
412+
// is visible at capture time, not just in the report.
413+
const shot = pngDimensions(imagePath);
414+
if (
415+
shot &&
416+
(shot.width > MAX_DISPLAY_DIMENSION ||
417+
shot.height > MAX_DISPLAY_DIMENSION)
418+
) {
419+
console.warn(
420+
` ⚠️ ${slug}: ${shot.width}×${shot.height}px exceeds the browser ` +
421+
`image limit (${MAX_DISPLAY_DIMENSION}px) — the report will downscale ` +
422+
"it for display. Lower this viewport's deviceScaleFactor to capture " +
423+
'it at full size.'
424+
);
425+
}
426+
376427
const htmlPath = path.join(dirs.capturesHtml, `${slug}.html`);
377428
fs.writeFileSync(htmlPath, await page.content());
378429

src/compare.mjs

Lines changed: 54 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import open from 'open';
1010
import { filterTargets } from './config.mjs';
1111
import { readManifest, detectStaleControls } from './manifest.mjs';
1212
import { copyAssets, buildHtmlDiff, generateReport } from './report.mjs';
13+
import { downscaleToDisplay } from './downscale.mjs';
1314

1415
// Skip the inline HTML line-diff above this snapshot size; line-diffing a
1516
// multi-MB (often minified) document is slow and produces no useful result.
@@ -77,21 +78,21 @@ export function compareSlug(config, target, viewport) {
7778
return null;
7879
}
7980

80-
let img1 = PNG.sync.read(fs.readFileSync(controlImage));
81-
let img2 = PNG.sync.read(fs.readFileSync(captureImage));
81+
const control = PNG.sync.read(fs.readFileSync(controlImage));
82+
const capture = PNG.sync.read(fs.readFileSync(captureImage));
8283

8384
// Pad both onto a common canvas so width changes (a real layout
8485
// regression) surface as a large diff instead of dropping the slug from
8586
// the report. Height was already handled this way; width is too now.
86-
const maxWidth = Math.max(img1.width, img2.width);
87-
const maxHeight = Math.max(img1.height, img2.height);
88-
img1 = padImage(img1, maxWidth, maxHeight);
89-
img2 = padImage(img2, maxWidth, maxHeight);
87+
const maxWidth = Math.max(control.width, capture.width);
88+
const maxHeight = Math.max(control.height, capture.height);
89+
const padded1 = padImage(control, maxWidth, maxHeight);
90+
const padded2 = padImage(capture, maxWidth, maxHeight);
9091

9192
const diff = new PNG({ width: maxWidth, height: maxHeight });
9293
const numDiffPixels = pixelmatch(
93-
img1.data,
94-
img2.data,
94+
padded1.data,
95+
padded2.data,
9596
diff.data,
9697
maxWidth,
9798
maxHeight,
@@ -101,6 +102,44 @@ export function compareSlug(config, target, viewport) {
101102
const diffImage = path.join(dirs.compares, `${slug}-diff.png`);
102103
writeArtifact(diffImage, PNG.sync.write(diff));
103104

105+
// pixelmatch runs at full resolution above; the report, however, is viewed
106+
// in a browser that can't decode an image past ~32,767px on a side. Hand
107+
// the report a downscaled copy of any oversized image (and remember the
108+
// original size so it can say so), leaving the full-res artifact in place
109+
// as the source of truth and the baseline pixelmatch compares against.
110+
const displayOf = (source, original, name) => {
111+
const result = downscaleToDisplay(source);
112+
if (!result.downscaled) {
113+
return { image: original, scaled: null };
114+
}
115+
const displayImage = path.join(dirs.display, `${slug}-${name}.png`);
116+
writeArtifact(displayImage, PNG.sync.write(result.png));
117+
return {
118+
image: displayImage,
119+
scaled: {
120+
from: [result.originalWidth, result.originalHeight],
121+
to: [result.width, result.height],
122+
},
123+
};
124+
};
125+
126+
// control/capture display at their own native size in the report; the diff
127+
// is shown at the padded common size.
128+
const controlDisp = displayOf(control, controlImage, 'control');
129+
const captureDisp = displayOf(capture, captureImage, 'capture');
130+
const diffDisp = displayOf(diff, diffImage, 'diff');
131+
132+
const scaled = {};
133+
if (controlDisp.scaled) {
134+
scaled.control = controlDisp.scaled;
135+
}
136+
if (captureDisp.scaled) {
137+
scaled.capture = captureDisp.scaled;
138+
}
139+
if (diffDisp.scaled) {
140+
scaled.diff = diffDisp.scaled;
141+
}
142+
104143
const totalPixels = maxWidth * maxHeight;
105144
const diffPercentage = (numDiffPixels / totalPixels) * 100;
106145

@@ -143,6 +182,12 @@ export function compareSlug(config, target, viewport) {
143182
controlImage,
144183
captureImage,
145184
diffImage,
185+
// Browser-safe paths the report links to (the original when it already
186+
// fits, a downscaled copy when it didn't).
187+
controlDisplay: controlDisp.image,
188+
captureDisplay: captureDisp.image,
189+
diffDisplay: diffDisp.image,
190+
scaled: Object.keys(scaled).length ? scaled : null,
146191
diffPercentage,
147192
htmlAdd: htmlResult.add,
148193
htmlDel: htmlResult.del,
@@ -337,6 +382,7 @@ export async function compare(config, options = {}) {
337382
const startedAt = Date.now();
338383

339384
fs.mkdirSync(dirs.compares, { recursive: true });
385+
fs.mkdirSync(dirs.display, { recursive: true });
340386
fs.mkdirSync(dirs.reports, { recursive: true });
341387
copyAssets(config);
342388

src/config.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -390,6 +390,7 @@ export function loadConfig({ configPath = 'reglance.json', domain } = {}) {
390390
controls: path.join(outputDir, 'controls'),
391391
controlsHtml: path.join(outputDir, 'controls', 'html'),
392392
compares: path.join(outputDir, 'compares'),
393+
display: path.join(outputDir, 'display'),
393394
reports: path.join(outputDir, 'reports'),
394395
assets: path.join(outputDir, 'assets'),
395396
imageCache: path.join(outputDir, 'image-cache'),

0 commit comments

Comments
 (0)