Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/server/src/preview/ServerBrowser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,8 @@ function makeSession(frame?: ReturnType<typeof makeFrame>) {
// A snapshot sizes its screenshot from the PNG header; viewers get JPEG frames as-is.
return { data: (input as { format: string }).format === "png" ? PNG_HEADER : "ZnJhbWU=" };
}
// Snapshots shrink their screenshot in the page; the header stands in for the result.
if (method === "Runtime.callFunctionOn") return { result: { value: PNG_HEADER } };
if (method === "Page.getFrameTree") return { frameTree: { frame: { id: "main" } } };
// Snapshots read password fields from their own isolated world in each frame.
if (method === "Page.createIsolatedWorld") {
Expand Down
11 changes: 2 additions & 9 deletions apps/server/src/preview/ServerBrowser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1479,10 +1479,9 @@ const make = Effect.gen(function* () {
try {
await encoder.evaluate(ServerBrowserPage.recordingEncoderScript(recordingFrameRate));
// Seed idle pages before returning so an immediate stop has a frame to encode.
const firstFrame = await ServerBrowserPage.captureViewport(tab.page, tab.cdp, {
const firstFrame = await ServerBrowserPage.captureViewport(tab.cdp, {
format: "jpeg",
quality: RECORDING_SCREENCAST.quality,
scale: 1,
});
await encoder.evaluate(
([data, width]) =>
Expand Down Expand Up @@ -1534,7 +1533,6 @@ const make = Effect.gen(function* () {
return started;
};

// Scaled captures repaint every screencast; pause them to avoid leaking that frame.
const withScreencastsPaused = <A>(tab: ServerTab, capture: () => Promise<A>): Promise<A> =>
withCaptureLock(tab, async () => {
tab.capturing += 1;
Expand Down Expand Up @@ -2347,14 +2345,9 @@ const make = Effect.gen(function* () {
}
broadcastControl(tab);
pushFileChooser(tab);
// Full scale: a scaled capture would flash in every other viewer.
const pushStill = async () => {
const data = await withCaptureLock(tab, () =>
ServerBrowserPage.captureViewport(tab.page, session, {
format: "jpeg",
quality,
scale: 1,
}),
ServerBrowserPage.captureViewport(session, { format: "jpeg", quality }),
).catch(() => null);
if (data) viewer.push({ _tag: "frame", data: Buffer.from(data, "base64"), ack: Effect.void });
};
Expand Down
103 changes: 103 additions & 0 deletions apps/server/src/preview/ServerBrowserPage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,109 @@ describe("server browser element refs", () => {
});
});

describe("server browser snapshot screenshot", () => {
let browser: Browser;
let context: BrowserContext;

beforeAll(async () => {
// ServerBrowserContexts launches with these, and the capture scale depends on them.
browser = await chromium.launch({
headless: true,
args: ["--disable-gpu", "--force-device-scale-factor=2"],
});
});
afterAll(async () => {
await browser?.close();
});
beforeEach(async () => {
context = await browser.newContext({
viewport: { width: 1280, height: 800 },
deviceScaleFactor: 2,
});
});
afterEach(async () => {
await context.close();
});

// Blue on the left half and red on the right, so a mirrored left half shows up on the right.
const halves = (overlay: string) => `
<style>
body { margin: 0; background: white; }
.half { position: absolute; top: 0; width: 640px; height: 800px; }
.overlay { position: fixed; inset: 0; ${overlay} }
</style>
<div class="half" style="left: 0; background: #2563eb"></div>
<div class="half" style="left: 640px; background: #ef4444"></div>
<div class="overlay"></div>`;

const screenshotOf = async (html: string) => {
const page = await context.newPage();
await page.setContent(html);
const { screenshot } = await ServerBrowserPage.snapshot({
page,
cdp: await context.newCDPSession(page),
renderScale: 2,
consoleEntries: [],
networkEntries: [],
actionTimeline: [],
});
// The server has no DOM types, so the decoder runs as page source.
const points = [
[0.15, 0.75],
[0.85, 0.25],
[0.85, 0.75],
];
const pixels = (await page.evaluate(`(async () => {
const bytes = Uint8Array.from(atob(${JSON.stringify(screenshot.data)}), (char) => char.charCodeAt(0));
const bitmap = await createImageBitmap(new Blob([bytes], { type: "image/png" }));
const canvas = document.createElement("canvas");
canvas.width = bitmap.width;
canvas.height = bitmap.height;
const context = canvas.getContext("2d");
context.drawImage(bitmap, 0, 0);
return ${JSON.stringify(points)}.map(([x, y]) =>
Array.from(context.getImageData(x * bitmap.width, y * bitmap.height, 1, 1).data),
);
})()`)) as Array<[number, number, number, number]>;
return { screenshot, pixels };
};
const colorOf = ([red, green, blue]: readonly [number, number, number, number]) =>
blue - red > 100 ? "blue" : red - blue > 100 ? "red" : `rgb(${red}, ${green}, ${blue})`;

it("captures a blurred backdrop-filter page as rendered, not mirrored", async () => {
const { screenshot, pixels } = await screenshotOf(halves("backdrop-filter: blur(4px);"));

expect({
size: [screenshot.width, screenshot.height],
colors: pixels.map(colorOf),
}).toEqual({ size: [1280, 800], colors: ["blue", "red", "red"] });
});

it("captures a plain page at the shrunk size with its content in place", async () => {
const { screenshot, pixels } = await screenshotOf(halves(""));

expect({
size: [screenshot.width, screenshot.height],
colors: pixels.map(colorOf),
}).toEqual({ size: [1280, 800], colors: ["blue", "red", "red"] });
});

it("shrinks the screenshot of an XML document, whose createElement makes no canvas", async () => {
const page = await context.newPage();
await page.goto("data:text/xml,<a/>");
const { screenshot } = await ServerBrowserPage.snapshot({
page,
cdp: await context.newCDPSession(page),
renderScale: 2,
consoleEntries: [],
networkEntries: [],
actionTimeline: [],
});

expect([screenshot.width, screenshot.height]).toEqual([1280, 800]);
});
});

describe("server browser drag", () => {
it("fails the drag, not the server, when it times out while the cursor is still moving", async () => {
const timedOut = new Error("locator.dragTo: Timeout 30000ms exceeded.");
Expand Down
86 changes: 47 additions & 39 deletions apps/server/src/preview/ServerBrowserPage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -428,8 +428,6 @@ const pngSize = (base64: string) => {
return { width: header.readUInt32BE(16), height: header.readUInt32BE(20) };
};

type Viewport = { readonly width: number; readonly height: number };

/**
* A page's display ratio and CSS viewport, read in an isolated world since page script can
* reassign `devicePixelRatio`, `innerWidth`, and `innerHeight` in its own, and its page zoom.
Expand All @@ -456,55 +454,64 @@ const readPageMetrics = async (cdp: CDPSession) => {
readonly width: number;
readonly height: number;
};
return {
ratio,
width,
height,
zoom,
offset: { x: cssVisualViewport.pageX * zoom, y: cssVisualViewport.pageY * zoom },
};
return { ratio, width, height, zoom };
}
}
throw new Error(
"The page's zoom kept changing while the snapshot read its size. Try the snapshot again.",
);
};

// Scaled captures repaint live screencasts, so callers pause them. Clips use document offsets.
// Never pass a clip scale: Chromium mirrors the top-left of the page into a `backdrop-filter`
// in any capture it shrinks.
export const captureViewport = async (
page: Page,
cdp: CDPSession,
options: {
readonly format: "png" | "jpeg";
readonly quality?: number;
readonly scale: number;
readonly viewport?: Viewport;
readonly offset?: { readonly x: number; readonly y: number } | undefined;
},
options: { readonly format: "png" | "jpeg"; readonly quality?: number },
) => {
let clip;
if (options.scale < 1) {
const viewport = options.viewport ?? page.viewportSize() ?? { width: 1280, height: 800 };
let offset = options.offset;
if (!offset) {
const { cssVisualViewport } = await cdp.send("Page.getLayoutMetrics");
const zoom = cssVisualViewport.zoom ?? 1;
offset = { x: cssVisualViewport.pageX * zoom, y: cssVisualViewport.pageY * zoom };
}
clip = {
...offset,
...viewport,
scale: options.scale,
};
}
const { data } = await cdp.send("Page.captureScreenshot", {
format: options.format,
...(options.quality === undefined ? {} : { quality: options.quality }),
...(clip ? { clip } : {}),
});
return data;
};

/** Runs in the page's own isolated world, as the server has no image library. */
const SHRINK_PNG = `async function (png, width, height) {
const bytes = Uint8Array.from(atob(png), (char) => char.charCodeAt(0));
const bitmap = await createImageBitmap(new Blob([bytes], { type: "image/png" }));
// Namespaced, as an XML document's createElement makes a plain element with no getContext.
const canvas = document.createElementNS("http://www.w3.org/1999/xhtml", "canvas");
canvas.width = width;
canvas.height = height;
const context = canvas.getContext("2d");
context.imageSmoothingQuality = "high";
context.drawImage(bitmap, 0, 0, width, height);
bitmap.close();
return canvas.toDataURL("image/png").slice("data:image/png;base64,".length);
}`;

const shrinkPng = async (cdp: CDPSession, png: string, scale: number) => {
const size = pngSize(png);
const { frameTree } = await cdp.send("Page.getFrameTree");
const { executionContextId } = await cdp.send("Page.createIsolatedWorld", {
frameId: frameTree.frame.id,
worldName: "t3-preview-screenshot",
});
const { result, exceptionDetails } = await cdp.send("Runtime.callFunctionOn", {
functionDeclaration: SHRINK_PNG,
executionContextId,
arguments: [
{ value: png },
{ value: Math.round(size.width * scale) },
{ value: Math.round(size.height * scale) },
],
awaitPromise: true,
returnByValue: true,
});
if (exceptionDetails) throw new Error("The snapshot could not shrink its screenshot.");
return result.value as string;
};

type SnapshotInput = {
readonly page: Page;
readonly cdp: CDPSession;
Expand All @@ -515,17 +522,16 @@ type SnapshotInput = {
readonly actionTimeline: PreviewAutomationSnapshot["actionTimeline"];
};

/** The scale a page renders at and its viewport in device-independent pixels, a clip's units. */
/** The scale a page renders at and its viewport in device-independent pixels. */
const renderingOf = async (input: SnapshotInput) => {
if (input.renderScale !== "display") {
const viewport = input.page.viewportSize() ?? { width: 1280, height: 800 };
return { renderScale: input.renderScale, viewport, offset: undefined };
return { renderScale: input.renderScale, viewport };
}
// The display ratio a desktop page reports includes its page zoom, and its sizes are CSS pixels.
const page = await readPageMetrics(input.cdp);
return {
renderScale: page.ratio / page.zoom,
offset: page.offset,
viewport: {
width: Math.round(page.width * page.zoom),
height: Math.round(page.height * page.zoom),
Expand All @@ -537,7 +543,7 @@ const capture = async (
input: SnapshotInput,
deadline: number,
): Promise<PreviewAutomationSnapshot> => {
const { renderScale, viewport, offset } = await renderingOf(input);
const { renderScale, viewport } = await renderingOf(input);
// A late metrics reply must not begin a capture after viewers have resumed.
if (Date.now() >= deadline) throw tooBusy();
const scale = Math.min(1, MAX_SCREENSHOT_WIDTH / (viewport.width * renderScale));
Expand All @@ -555,7 +561,9 @@ const capture = async (
"url" | "title" | "loading" | "visibleText" | "interactiveElements"
>
>,
captureViewport(input.page, input.cdp, { format: "png", scale, viewport, offset }),
captureViewport(input.cdp, { format: "png" }).then((png) =>
scale < 1 ? shrinkPng(input.cdp, png, scale) : png,
),
]);
void pageAndScreenshot.catch(constVoid);
// Leaves time for the second read; a frame that never loads holds the tree until then.
Expand Down
Loading