Skip to content
Open
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
13 changes: 12 additions & 1 deletion apps/server/src/imageMime.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vite-plus/test";

import { inferImageExtension, parseBase64DataUrl } from "./imageMime.ts";
import { inferImageExtension, parseBase64DataUrl, pngDimensions } from "./imageMime.ts";

describe("imageMime", () => {
it("parses base64 data URL with mime type", () => {
Expand Down Expand Up @@ -86,4 +86,15 @@ describe("imageMime", () => {
it("does not read inherited keys from mime extension map", () => {
expect(inferImageExtension({ mimeType: "constructor" })).toBe(".bin");
});

it("reads PNG dimensions from the IHDR chunk", () => {
const png = new Uint8Array(24);
new DataView(png.buffer).setUint32(0, 0x89504e47);
new DataView(png.buffer).setUint32(4, 0x0d0a1a0a);
new DataView(png.buffer).setUint32(12, 0x49484452);
new DataView(png.buffer).setUint32(16, 1179);
new DataView(png.buffer).setUint32(20, 2556);
expect(pngDimensions(png)).toEqual({ width: 1179, height: 2556 });
expect(pngDimensions(new Uint8Array([1, 2, 3]))).toEqual({ width: 0, height: 0 });
});
});
12 changes: 12 additions & 0 deletions apps/server/src/imageMime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,3 +134,15 @@ export function inferImageExtension(input: { mimeType: string; fileName?: string

return ".bin";
}

export function pngDimensions(png: Uint8Array): { width: number; height: number } {
if (png.length < 24) return { width: 0, height: 0 };
const view = new DataView(png.buffer, png.byteOffset, png.byteLength);
const isPng =
view.getUint32(0) === 0x89504e47 &&
view.getUint32(4) === 0x0d0a1a0a &&
view.getUint32(12) === 0x49484452;
return isPng
? { width: view.getUint32(16), height: view.getUint32(20) }
: { width: 0, height: 0 };
}
13 changes: 1 addition & 12 deletions apps/server/src/mcp/toolkits/device/handlers.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vite-plus/test";

import { agentDeviceQuickStart, agentDeviceTargetArgs, pngDimensions } from "./handlers.ts";
import { agentDeviceQuickStart, agentDeviceTargetArgs } from "./handlers.ts";

const device = {
hostId: "local",
Expand Down Expand Up @@ -42,15 +42,4 @@ describe("device tool helpers", () => {
expect(text).not.toContain(" agent-device ");
expect(text).not.toContain("is on PATH");
});

it("reads PNG dimensions from the IHDR chunk", () => {
const png = new Uint8Array(24);
new DataView(png.buffer).setUint32(0, 0x89504e47);
new DataView(png.buffer).setUint32(4, 0x0d0a1a0a);
new DataView(png.buffer).setUint32(12, 0x49484452);
new DataView(png.buffer).setUint32(16, 1179);
new DataView(png.buffer).setUint32(20, 2556);
expect(pngDimensions(png)).toEqual({ width: 1179, height: 2556 });
expect(pngDimensions(new Uint8Array([1, 2, 3]))).toEqual({ width: 0, height: 0 });
});
});
13 changes: 1 addition & 12 deletions apps/server/src/mcp/toolkits/device/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import * as Effect from "effect/Effect";
import * as Path from "effect/Path";
import * as HostProcess from "@t3tools/shared/HostProcess";
import * as ServerConfig from "../../../config.ts";
import { pngDimensions } from "../../../imageMime.ts";
import { ensureAgentDeviceShim } from "../../../device/AgentDeviceShim.ts";
import { nodeRuntimeUnavailableMessage } from "@t3tools/shared/nodeRuntime";

Expand Down Expand Up @@ -245,18 +246,6 @@ const handlers = {
),
} satisfies McpToolAccess.Handlers<typeof DeviceToolkit.tools>;

export function pngDimensions(png: Uint8Array): { width: number; height: number } {
if (png.length < 24) return { width: 0, height: 0 };
const view = new DataView(png.buffer, png.byteOffset, png.byteLength);
const isPng =
view.getUint32(0) === 0x89504e47 &&
view.getUint32(4) === 0x0d0a1a0a &&
view.getUint32(12) === 0x49484452;
return isPng
? { width: view.getUint32(16), height: view.getUint32(20) }
: { width: 0, height: 0 };
}

const { device_screenshot, ...standardHandlers } = handlers;

export const layerStandard = McpToolAccess.toLayer(DeviceStandardToolkit, standardHandlers);
Expand Down
49 changes: 3 additions & 46 deletions apps/server/src/preview/ServerBrowser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -248,8 +248,6 @@ interface ViewerState {
readonly pressedKeys: Map<string, { key: string; code: string }>;
readonly pressedButtons: Map<"left" | "middle" | "right", { x: number; y: number }>;
readonly push: (output: ServerBrowserViewerOutput) => void;
readonly pause: () => Promise<void>;
readonly resume: () => Promise<void>;
scrolledAt: number;

requestedSize: { width: number; height: number; order: number } | null;
Expand Down Expand Up @@ -319,7 +317,6 @@ interface ServerTab {

recordingStart: Promise<Recording> | null;
captureLock: Promise<void>;
capturing: number;
}

interface ServerDownload {
Expand Down Expand Up @@ -779,7 +776,6 @@ const make = Effect.gen(function* () {
navigationGeneration: 0,
abortedNavigation: null,
captureLock: Promise.resolve(),
capturing: 0,
};
if (!desktop) {
await page.setViewportSize(fixedViewportSize(tab.setting) ?? UNATTACHED_FILL_VIEWPORT);
Expand Down Expand Up @@ -1449,10 +1445,9 @@ const make = Effect.gen(function* () {
try {
await encoder.evaluate(ServerBrowserPage.RECORDING_ENCODER_SCRIPT);
// 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 @@ -1504,29 +1499,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;
const recording = tab.recording;
try {
await Promise.all([
...[...tab.viewers].map((viewer) => viewer.pause()),
recording?.session.send("Page.stopScreencast").catch(constVoid),
]);
return await capture();
} finally {
tab.capturing -= 1;

await Promise.all([
...[...tab.viewers].map((viewer) => viewer.resume()),
recording && tab.recording === recording
? recording.session.send("Page.startScreencast", RECORDING_SCREENCAST).catch(constVoid)
: undefined,
]);
}
});

const stopRecording = (tab: ServerTab) =>
withCaptureLock(tab, async () => {
const recording = tab.recording;
Expand Down Expand Up @@ -1930,9 +1902,7 @@ const make = Effect.gen(function* () {
return { tabId: tab.tabId, colorScheme };
}
case "snapshot": {
return withScreencastsPaused(tab, () =>
ServerBrowserPage.snapshot({ ...tab, renderScale: RENDER_SCALE }),
);
return withCaptureLock(tab, () => ServerBrowserPage.snapshot(tab));
}
case "click": {
const clickInput = input as PreviewAutomationClickInput;
Expand Down Expand Up @@ -2232,7 +2202,6 @@ const make = Effect.gen(function* () {
const startScreencast = (scale: number) => {
screencastScale = scale;
screencastParams = screencastParams.then(async () => {
if (tab.capturing > 0) return;
await session.send("Page.stopScreencast").catch(constVoid);
await session
.send("Page.startScreencast", {
Expand Down Expand Up @@ -2266,13 +2235,6 @@ const make = Effect.gen(function* () {
for (const item of dropped.value) if (item._tag === "frame") runFork(item.ack);
}
},
pause: () => {
screencastParams = screencastParams.then(() =>
session.send("Page.stopScreencast").then(constVoid, constVoid),
);
return screencastParams;
},
resume: () => startScreencast(screencastScale),
scrolledAt: 0,
requestedSize: null,
};
Expand All @@ -2294,14 +2256,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
65 changes: 63 additions & 2 deletions apps/server/src/preview/ServerBrowserPage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,18 @@ import {
type CDPSession,
type Page,
} from "playwright-core";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vite-plus/test";
import {
afterAll,
afterEach,
beforeAll,
beforeEach,
describe,
expect,
it,
vi,
} from "vite-plus/test";

import { pngDimensions } from "../imageMime.ts";
import { presentAsChrome } from "./ServerBrowserContexts.ts";
import * as ServerBrowserPage from "./ServerBrowserPage.ts";

Expand Down Expand Up @@ -35,7 +45,6 @@ describe("server browser element refs", () => {
ServerBrowserPage.snapshot({
page,
cdp,
renderScale: 1,
consoleEntries: [],
networkEntries: [],
actionTimeline: [],
Expand Down Expand Up @@ -369,3 +378,55 @@ describe("server browser drag", () => {
}
});
});

describe("server browser screenshots", () => {
it("captures at native device pixels and downsamples to 1280 px in the same tab", async () => {
// Raw CDP captures follow the device scale only when it's forced, as the server does.
const browser = await chromium.launch({
headless: true,
args: ["--force-device-scale-factor=2"],
});
try {
const context = await browser.newContext({
viewport: { width: 3840, height: 2160 },
deviceScaleFactor: 2,
});
const page = await context.newPage();
await page.setContent(
`<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'none'; img-src 'none'"><p>sharp</p>`,
);
// The encoder must not depend on anything the page can reach or replace.
await page.evaluate(`globalThis.createImageBitmap = globalThis.OffscreenCanvas = () => {
throw new Error("Do not use the page's encoder globals");
}`);
const cdp = await context.newCDPSession(page);
const send = vi.spyOn(cdp, "send");

const { screenshot } = await ServerBrowserPage.snapshot({
page,
cdp,
consoleEntries: [],
networkEntries: [],
actionTimeline: [],
});

// A scaled capture makes Chromium re-render at that scale, which a hidden desktop
// webview can't do, so it returns a blurry upscaled frame.
const captures = send.mock.calls.filter(([method]) => method === "Page.captureScreenshot");
expect(captures).toHaveLength(1);
expect(captures[0]?.[1]).toMatchObject({ format: "png" });
expect(captures[0]?.[1]).not.toHaveProperty("clip");
const png = Buffer.from(screenshot.data, "base64");
expect(pngDimensions(png)).toEqual({ width: 1280, height: 720 });
expect({ width: screenshot.width, height: screenshot.height }).toEqual(pngDimensions(png));
expect(await page.evaluate("[devicePixelRatio, innerWidth, innerHeight]")).toEqual([
2, 3840, 2160,
]);
expect(await page.locator("body").innerHTML()).toBe("<p>sharp</p>");
expect(browser.contexts()).toEqual([context]);
expect(context.pages()).toEqual([page]);
} finally {
await browser.close();
}
});
});
Loading
Loading