server/browser-page-size.ts
Rama main · réplica de lectura del repositorio original.
// The size of a bot's page on the agent-browser engine: the standard page while nobody watches, and the shape of the
// watching person's panel, at a desktop's width, while someone does.
import { fillPage } from "./browser/viewers/viewport.ts";
export type PageSize = { width: number; height: number };
/** Page size in CSS pixels. agent-browser sizes the Chrome window, and headless full Chrome (Chrome for Testing) takes its
* emulated UI out of it (page 1280×577, screencast 1280×720); `set viewport` is agent-browser's only page-size control. */
export const BROWSER_VIEWPORT: PageSize = { width: 1280, height: 720 };
export const viewportArgs = (size: PageSize): string[] => ["set", "viewport", String(size.width), String(size.height)];
/** A screenshot of a page larger than the standard one comes as JPEG unless the bot asked for a format: a tall PNG of a
* busy page can pass the 5 MB that Claude's API (2026) takes for one image. */
const ENLARGED_PICTURE = { format: "jpeg", quality: 80 } as const;
/** agent-browser's live stream sends frames at the page's own size (0.37.0 ignores a size cap), and a frame of a page of
* noise this large is about 2.8 MB of base64: a larger panel gets this many pixels in its shape, drawn larger. */
export const MAX_PAGE_PIXELS = 1280 * 2560;
/** The page that fills `panel` within MAX_PAGE_PIXELS, still at least a desktop wide. */
export function streamedPage(panel: PageSize): PageSize {
const page = fillPage(panel);
const pixels = page.width * page.height;
if (pixels <= MAX_PAGE_PIXELS) return page;
const scale = Math.sqrt(MAX_PAGE_PIXELS / pixels);
return { width: Math.floor(page.width * scale), height: Math.floor(page.height * scale) };
}
const key = (size: PageSize) => `${size.width}x${size.height}`;
export interface PageSizeHost {
/** Sizes the session's running page; resolves once the engine answered or gave up. */
apply(session: string, size: PageSize): Promise<unknown>;
/** Runs `work` in the session's browser lane, after the bot's call running now; null when that call outlasted the wait. */
lane(session: string, work: () => Promise<void>): Promise<void | null>;
/** Someone watches the session's browser, and it is not closing. */
watched(session: string): boolean;
}
/**
* Each session's page size: the one its watchers' panels want, and the one its browser was last given. A change waits for
* the bot's call running in the browser's lane, so no action sees the page change under it; while nobody watches, the
* bot's next call applies it.
*/
export class PageSizes {
readonly #host: PageSizeHost;
readonly #sessions = new Map<string, { wanted: PageSize; applied: string | null; applying: Promise<void> | null }>();
constructor(host: PageSizeHost) {
this.#host = host;
}
#entry(session: string) {
let entry = this.#sessions.get(session);
if (!entry) this.#sessions.set(session, entry = { wanted: BROWSER_VIEWPORT, applied: null, applying: null });
return entry;
}
wanted(session: string): PageSize {
return this.#sessions.get(session)?.wanted ?? BROWSER_VIEWPORT;
}
/** The browser may be a new one, sized as Chrome chose: the next call sizes it again. */
forget(session: string): void {
const entry = this.#sessions.get(session);
if (!entry) return;
if (entry.wanted === BROWSER_VIEWPORT && !entry.applying) this.#sessions.delete(session);
else entry.applied = null;
}
/**
* The page a watching panel of `panel` CSS pixels needs, or the standard one (null). `fresh` sizes it even when this
* server sized it so before, for a browser a view just started.
*/
resize(session: string, panel: PageSize | null, fresh = false): Promise<void> {
const entry = this.#entry(session);
entry.wanted = panel ? streamedPage(panel) : BROWSER_VIEWPORT;
if (fresh) entry.applied = null;
if (!entry.applying && this.#behind(session, entry)) entry.applying = this.#follow(session, entry);
return entry.applying ?? Promise.resolve();
}
#behind(session: string, entry: { wanted: PageSize; applied: string | null }): boolean {
return this.#host.watched(session) && entry.applied !== key(entry.wanted);
}
/** Applies the newest wanted size until the page has it; a size wanted meanwhile is picked up before this ends. */
async #follow(session: string, entry: { wanted: PageSize; applied: string | null; applying: Promise<void> | null }): Promise<void> {
try {
while (this.#behind(session, entry)) {
await this.#host.lane(session, async () => {
const wanted = entry.wanted;
if (!this.#behind(session, entry)) return;
await this.#host.apply(session, wanted).catch(() => undefined);
entry.applied = key(wanted);
});
}
} finally {
entry.applying = null;
}
}
/** Before a bot's call, in its lane: true when the page had to be sized first. */
async beforeCall(session: string): Promise<boolean> {
const entry = this.#entry(session);
const wanted = entry.wanted;
if (entry.applied === key(wanted)) return false;
await this.#host.apply(session, wanted).catch(() => undefined);
entry.applied = key(wanted);
return true;
}
/** A browser the engine relaunched under a call: sized again at once. */
async again(session: string): Promise<void> {
this.forget(session);
await this.beforeCall(session);
}
/** A call's arguments as the engine gets them: a screenshot of an enlarged page is a JPEG unless the bot chose. */
callArguments(session: string, params: unknown): unknown {
const call = params as { name?: unknown; arguments?: Record<string, unknown> } | null;
const size = this.wanted(session);
if (call?.name !== "agent_browser_screenshot" || size.width * size.height <= BROWSER_VIEWPORT.width * BROWSER_VIEWPORT.height) return params;
if (call.arguments?.format !== undefined) return params;
return { ...call, arguments: { ...call.arguments, ...ENLARGED_PICTURE } };
}
}