Skip to content

Commit a5364fb

Browse files
feat(capture): re-add the full-page plate a scroll shot needs, at 1x
`product-launch-video` tells a scroll shot to animate a viewport over a full-page capture. No such file existed: capture emits 15 viewport-sized scroll-position tiles, and a plate is not substitutable by tiles — a viewport travelling down one continuous image is the whole point. An earlier `full-page.png` was dropped in 62b5517 because 1/8 agents read it and the contact sheet covered the same ground. That measured it as a *comprehension* artifact, on an eval where nothing was building scroll shots. The scroll shot is a different consumer, so this brings the plate back — but not as it was, because two things have to hold for it to be worth having: - **Taken last.** After the scroll traversal, so lazy images have loaded and scroll-triggered reveals have fired. A plate shot on arrival is full of blank bands, which is a good reason for an agent to look once and never again. - **Sticky chrome neutralised.** `fullPage` bakes a fixed header in at one position, freezing a nav across the middle of the plate. The viewport tiles keep sticky on purpose (natural browsing state); the plate cannot. Positions are recorded and restored in a `finally`, so the extraction passes that run afterwards see an unmodified DOM. **1x, deliberately.** 2x is what you'd want to push in without softening text, but doubling a long marketing page passes Chrome's 16384px screenshot cap precisely on the pages that most want a scroll shot (linear.app: 10962 CSS px → 21924 at 2x). At 1x a 1920-wide plate is pixel-exact for a 1920x1080 viewport. A frame that needs headroom captures its own region at 2x instead. Pages over the cap get no plate rather than a silently clipped one, and the caller falls back to the tiles. Validation: `vitest run src/capture` — 90 pass (5 new) · oxlint/oxfmt clean · `tsc --noEmit` clean
1 parent 8964d5d commit a5364fb

2 files changed

Lines changed: 148 additions & 1 deletion

File tree

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { describe, expect, it, vi } from "vitest";
2+
import { existsSync, mkdtempSync, readFileSync } from "node:fs";
3+
import { tmpdir } from "node:os";
4+
import { join } from "node:path";
5+
import type { Page } from "puppeteer-core";
6+
import { captureFullPagePlate, MAX_PLATE_HEIGHT_PX } from "./screenshotCapture.js";
7+
8+
// The mocks declare their parameters so `mock.calls[i][0]` is a real slot — a zero-arg
9+
// vi.fn() types its call tuple as [] and indexing it is a compile error.
10+
function fakePage(overrides: Record<string, unknown> = {}) {
11+
const evaluate = vi.fn(async (_script?: unknown) => undefined);
12+
const screenshot = vi.fn(async (_opts?: unknown) => Buffer.from("PNG-BYTES"));
13+
return { page: { evaluate, screenshot, ...overrides } as unknown as Page, evaluate, screenshot };
14+
}
15+
16+
describe("captureFullPagePlate — the scroll shot's plate", () => {
17+
it("writes one full-page png and returns its relative path", async () => {
18+
const dir = mkdtempSync(join(tmpdir(), "hf-plate-"));
19+
const { page, screenshot } = fakePage();
20+
21+
const out = await captureFullPagePlate(page, dir, 10962);
22+
23+
expect(out).toBe("screenshots/full-page.png");
24+
expect(screenshot).toHaveBeenCalledWith({ type: "png", fullPage: true });
25+
expect(readFileSync(join(dir, "full-page.png"), "utf8")).toBe("PNG-BYTES");
26+
});
27+
28+
it("stays 1x: it never touches the viewport's deviceScaleFactor", async () => {
29+
const dir = mkdtempSync(join(tmpdir(), "hf-plate-"));
30+
const setViewport = vi.fn(async () => undefined);
31+
const { page } = fakePage({ setViewport });
32+
33+
await captureFullPagePlate(page, dir, 4000);
34+
35+
// A 2x plate would exceed the cap on exactly the long pages that want a scroll shot.
36+
expect(setViewport).not.toHaveBeenCalled();
37+
});
38+
39+
it("skips a page taller than Chrome can capture, instead of writing a clipped plate", async () => {
40+
const dir = mkdtempSync(join(tmpdir(), "hf-plate-"));
41+
const { page, screenshot } = fakePage();
42+
43+
const out = await captureFullPagePlate(page, dir, MAX_PLATE_HEIGHT_PX + 1);
44+
45+
expect(out).toBeNull();
46+
expect(screenshot).not.toHaveBeenCalled();
47+
expect(existsSync(join(dir, "full-page.png"))).toBe(false);
48+
});
49+
50+
it("neutralises sticky/fixed chrome for the shot and restores it afterwards", async () => {
51+
const dir = mkdtempSync(join(tmpdir(), "hf-plate-"));
52+
const { page, evaluate, screenshot } = fakePage();
53+
54+
await captureFullPagePlate(page, dir, 8000);
55+
56+
const scripts = evaluate.mock.calls.map((c) => String(c[0]));
57+
expect(scripts).toHaveLength(2);
58+
// Neutralise first — a fixed header would otherwise bake in mid-plate.
59+
expect(scripts[0]).toContain("'fixed'");
60+
expect(scripts[0]).toContain("'sticky'");
61+
expect(scripts[0]).toContain("data-hf-plate-position");
62+
// Then hand the page back unchanged: the caller keeps reading the DOM after this.
63+
expect(scripts[1]).toContain("removeAttribute");
64+
expect(scripts[1]).toContain("data-hf-plate-position");
65+
expect(evaluate.mock.invocationCallOrder[0]).toBeLessThan(
66+
screenshot.mock.invocationCallOrder[0]!,
67+
);
68+
expect(screenshot.mock.invocationCallOrder[0]).toBeLessThan(
69+
evaluate.mock.invocationCallOrder[1]!,
70+
);
71+
});
72+
73+
it("restores the page even when the screenshot throws", async () => {
74+
const dir = mkdtempSync(join(tmpdir(), "hf-plate-"));
75+
const screenshot = vi.fn(async (_opts?: unknown) => {
76+
throw new Error("capture failed");
77+
});
78+
const { page, evaluate } = fakePage({ screenshot });
79+
80+
await expect(captureFullPagePlate(page, dir, 8000)).rejects.toThrow("capture failed");
81+
// A page left with every sticky element forced static would corrupt the extraction
82+
// passes that run after this one.
83+
expect(String(evaluate.mock.calls.at(-1)?.[0])).toContain("removeAttribute");
84+
});
85+
});

packages/cli/src/capture/screenshotCapture.ts

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,62 @@ import { join } from "node:path";
2121
* elements — screenshots show the page in its natural browsing state with
2222
* scroll-triggered animations fired.
2323
*/
24+
/**
25+
* Chrome caps a screenshot at 16384px per side (Skia's max texture dimension); past that the
26+
* capture comes back clipped or fails outright. Long marketing pages do reach this.
27+
*/
28+
export const MAX_PLATE_HEIGHT_PX = 16384;
29+
30+
/**
31+
* One tall image of the whole document — the plate a scroll shot slides its viewport over.
32+
*
33+
* This is deliberately 1x. A 2x plate is what you'd want for pushing in without softening
34+
* text, but doubling a long marketing page blows past `MAX_PLATE_HEIGHT_PX` exactly on the
35+
* pages that most want a scroll shot; a frame that needs 2x should capture its own region
36+
* instead. At 1x a 1920-wide plate is pixel-exact for a 1920x1080 viewport travelling down it.
37+
*
38+
* Two things have to be true for the plate to be usable, and both are why the earlier
39+
* `full-page.png` was worth removing rather than keeping as-is:
40+
* · It must be taken AFTER the scroll traversal, so lazy images have loaded and
41+
* scroll-triggered reveals have fired. A plate shot on arrival is full of blank bands.
42+
* · Sticky/fixed chrome has to be neutralised first. `fullPage` bakes a fixed header in at
43+
* one position, so a nav ends up frozen across the middle of the plate. The viewport
44+
* shots keep sticky on purpose (natural browsing state); the plate cannot.
45+
*
46+
* Returns the relative path, or null when the page is too tall to capture in one piece.
47+
*/
48+
export async function captureFullPagePlate(
49+
page: Page,
50+
screenshotsDir: string,
51+
scrollHeight: number,
52+
): Promise<string | null> {
53+
if (scrollHeight > MAX_PLATE_HEIGHT_PX) return null;
54+
55+
// Record the inline value before overwriting so the page is handed back unchanged — the
56+
// caller keeps using it (asset extraction, DOM reads) after this returns.
57+
await page.evaluate(
58+
`document.querySelectorAll('*').forEach((el) => {
59+
const p = getComputedStyle(el).position;
60+
if (p === 'fixed' || p === 'sticky') {
61+
el.setAttribute('data-hf-plate-position', el.style.position || '');
62+
el.style.position = 'static';
63+
}
64+
})`,
65+
);
66+
try {
67+
const buffer = await page.screenshot({ type: "png", fullPage: true });
68+
writeFileSync(join(screenshotsDir, "full-page.png"), buffer);
69+
return "screenshots/full-page.png";
70+
} finally {
71+
await page.evaluate(
72+
`document.querySelectorAll('[data-hf-plate-position]').forEach((el) => {
73+
el.style.position = el.getAttribute('data-hf-plate-position');
74+
el.removeAttribute('data-hf-plate-position');
75+
})`,
76+
);
77+
}
78+
}
79+
2480
export async function captureScrollScreenshots(page: Page, outputDir: string): Promise<string[]> {
2581
const screenshotsDir = join(outputDir, "screenshots");
2682
mkdirSync(screenshotsDir, { recursive: true });
@@ -151,7 +207,13 @@ export async function captureScrollScreenshots(page: Page, outputDir: string): P
151207
await page.evaluate(`window.scrollTo(0, 0)`);
152208
await new Promise((r) => setTimeout(r, 200));
153209

154-
// full-page.png removed — 1/8 agents read it, contact sheet covers the same content
210+
// The scroll plate, last: everything above has loaded the page and fired its reveals, which
211+
// is the only state a full-page shot is worth taking in. (An earlier full-page.png was
212+
// dropped because 1/8 agents read it and the contact sheet covered the same ground — that
213+
// was about it as a *comprehension* artifact. The scroll shot is a different consumer: it
214+
// needs one continuous plate, which no set of viewport tiles can substitute for.)
215+
const plate = await captureFullPagePlate(page, screenshotsDir, scrollHeight);
216+
if (plate) filePaths.push(plate);
155217
} catch {
156218
/* scroll screenshots are non-critical */
157219
}

0 commit comments

Comments
 (0)