cdp-toolkit 1.9.1 → 1.9.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +389 -0
- package/CONTRACT.md +59 -2
- package/README.md +53 -3
- package/dist/cli.js +2857 -114
- package/dist/index.js +934 -78
- package/dist/mcp.js +1130 -115
- package/package.json +2 -1
- package/src/backend.ts +156 -21
- package/src/bidi/client.ts +48 -10
- package/src/bidi/driver.ts +228 -33
- package/src/bidi/launch.ts +23 -13
- package/src/cdp/driver.ts +594 -9
- package/src/cli.ts +107 -10
- package/src/driver.ts +254 -3
- package/src/manifest.ts +20 -4
- package/src/mcp.ts +21 -12
- package/src/png.ts +809 -0
- package/src/shared-tools.ts +313 -11
package/src/shared-tools.ts
CHANGED
|
@@ -36,8 +36,10 @@
|
|
|
36
36
|
import { mkdir, writeFile } from "node:fs/promises";
|
|
37
37
|
import { dirname, join } from "node:path";
|
|
38
38
|
import type {
|
|
39
|
-
BrowserCookie, BrowserDriver, Capability, DragDestination, DriverUid, ElementLocator, NavigateResult, PageDriver, PageInfo, SnapshotNode,
|
|
39
|
+
BandedCapture, BrowserCookie, BrowserDriver, Capability, DragDestination, DriverUid, ElementLocator, NavigateResult, PageDriver, PageInfo, SnapshotNode,
|
|
40
40
|
} from "./driver.ts";
|
|
41
|
+
import { isTiledCapture } from "./driver.ts";
|
|
42
|
+
import { stitchPngBandsToFile } from "./png.ts";
|
|
41
43
|
import type { TargetSelector } from "./types.ts";
|
|
42
44
|
import {
|
|
43
45
|
assertLeaseOk, claimLease, defaultLabel, leaseTtlMs, listLeases, releaseLeaseFor, requireLease,
|
|
@@ -1044,31 +1046,331 @@ function stamp(): string {
|
|
|
1044
1046
|
return new Date(Date.now()).toISOString().replace(/[:.]/g, "-");
|
|
1045
1047
|
}
|
|
1046
1048
|
|
|
1049
|
+
/** Upper bound on take_screenshot's `scale`. 8 already gives ~11k px off a 1390 px viewport on a
|
|
1050
|
+
* ratio-2 display, past which the driver's own 16384 px encode guard is the binding limit anyway;
|
|
1051
|
+
* the point of a ceiling here is that a fat-fingered `scale: 800` is refused instantly instead of
|
|
1052
|
+
* wedging a real browser command for minutes. Refused, not clamped, like MAX_DRAG_STEPS. */
|
|
1053
|
+
const MAX_SCREENSHOT_SCALE = 8;
|
|
1054
|
+
|
|
1055
|
+
/**
|
|
1056
|
+
* take_screenshot's `scale`, plus the second backend gate in this file (resolveDragMode is the
|
|
1057
|
+
* first, and this follows its shape exactly). scale needs Capability "screenshot.scale", which
|
|
1058
|
+
* only the Chrome driver declares: Page.captureScreenshot takes a per-call clip.scale, while
|
|
1059
|
+
* WebDriver BiDi's browsingContext.captureScreenshot has no scale parameter at all. Per ADR-001
|
|
1060
|
+
* this is a PARAM-level gap, not a whole-tool one — take_screenshot stays in tools/list on Firefox
|
|
1061
|
+
* because every other argument works there, and asking for a scale on Firefox is a clear
|
|
1062
|
+
* validation error, never a silent 1x capture that would return an image the caller never asked
|
|
1063
|
+
* for and could not tell apart from the one they wanted. Exported for the unit tests.
|
|
1064
|
+
*/
|
|
1065
|
+
export function resolveScreenshotScale(scale: number | undefined, capabilities: ReadonlySet<Capability>): number {
|
|
1066
|
+
if (scale === undefined) return 1;
|
|
1067
|
+
if (typeof scale !== "number" || !Number.isFinite(scale) || scale <= 0 || scale > MAX_SCREENSHOT_SCALE) {
|
|
1068
|
+
throw new SharedToolError(`take_screenshot: 'scale' must be a finite number greater than 0 and at most ${MAX_SCREENSHOT_SCALE} (default 1)`);
|
|
1069
|
+
}
|
|
1070
|
+
if (scale !== 1 && !capabilities.has("screenshot.scale")) {
|
|
1071
|
+
throw new SharedToolError(
|
|
1072
|
+
"take_screenshot: 'scale' is not supported by this backend (WebDriver BiDi's browsingContext.captureScreenshot has no scale parameter). " +
|
|
1073
|
+
`Call emulate with deviceScaleFactor:${scale} first and capture at scale 1, or use --browser chrome for a per-capture scale.`,
|
|
1074
|
+
);
|
|
1075
|
+
}
|
|
1076
|
+
return scale;
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
/** Upper bound on a render-size side, in CSS px. Deliberately Chrome's own 16384 px per-side encode
|
|
1080
|
+
* ceiling: a viewport wider than the widest image the browser can encode cannot produce a
|
|
1081
|
+
* full-width capture of itself anyway, so anything past it is a typo rather than an intent. The
|
|
1082
|
+
* binding limit in practice is lower and is enforced where it can actually be computed — the
|
|
1083
|
+
* driver's assertClipEncodable, on the post-override numbers, since a 8192 css-px render is already
|
|
1084
|
+
* 16384 device px on a ratio-2 display. Refused, not clamped, like MAX_DRAG_STEPS. */
|
|
1085
|
+
const MAX_RENDER_SIZE_PX = 16384;
|
|
1086
|
+
|
|
1087
|
+
/**
|
|
1088
|
+
* take_screenshot's `renderWidth`/`renderHeight`, and the third backend gate in this file
|
|
1089
|
+
* (resolveDragMode is the first, resolveScreenshotScale the second, and this follows their shape).
|
|
1090
|
+
*
|
|
1091
|
+
* REQUIRED TOGETHER, mirroring the "device metrics require both width and height" rule emulate()
|
|
1092
|
+
* has always had: a viewport is two numbers, and honouring one while inventing the other would
|
|
1093
|
+
* render at a size the caller never named and never sees in the result.
|
|
1094
|
+
*
|
|
1095
|
+
* Unlike `scale` this is NOT a Chrome-only capability — both drivers ride their own viewport
|
|
1096
|
+
* emulation for it — so on today's backends the capability arm never fires. It is written anyway
|
|
1097
|
+
* because ADR-001 says availability is declared, not assumed: a future backend that cannot emulate
|
|
1098
|
+
* a viewport must refuse out loud here rather than silently capture at the tab's real size, which
|
|
1099
|
+
* would return a picture indistinguishable from the one the caller asked for. Exported for the tests.
|
|
1100
|
+
*/
|
|
1101
|
+
export function resolveRenderSize(
|
|
1102
|
+
renderWidth: number | undefined,
|
|
1103
|
+
renderHeight: number | undefined,
|
|
1104
|
+
capabilities: ReadonlySet<Capability>,
|
|
1105
|
+
): { width: number; height: number } | undefined {
|
|
1106
|
+
if (renderWidth === undefined && renderHeight === undefined) return undefined;
|
|
1107
|
+
if (renderWidth === undefined || renderHeight === undefined) {
|
|
1108
|
+
throw new SharedToolError(
|
|
1109
|
+
"take_screenshot: 'renderWidth' and 'renderHeight' are required together (a viewport is two numbers; " +
|
|
1110
|
+
`got ${renderWidth === undefined ? "only renderHeight" : "only renderWidth"})`,
|
|
1111
|
+
);
|
|
1112
|
+
}
|
|
1113
|
+
for (const [name, value] of [["renderWidth", renderWidth], ["renderHeight", renderHeight]] as const) {
|
|
1114
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value < 1 || value > MAX_RENDER_SIZE_PX) {
|
|
1115
|
+
throw new SharedToolError(`take_screenshot: '${name}' must be an integer between 1 and ${MAX_RENDER_SIZE_PX} css px (got ${JSON.stringify(value)})`);
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
if (!capabilities.has("screenshot.renderSize")) {
|
|
1119
|
+
throw new SharedToolError(
|
|
1120
|
+
"take_screenshot: 'renderWidth'/'renderHeight' are not supported by this backend (it cannot emulate a viewport for a capture). " +
|
|
1121
|
+
`Call emulate with width:${renderWidth}, height:${renderHeight} first, capture, then restore the viewport yourself.`,
|
|
1122
|
+
);
|
|
1123
|
+
}
|
|
1124
|
+
return { width: renderWidth, height: renderHeight };
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
/**
|
|
1128
|
+
* take_screenshot's `tile`, and the fourth backend gate in this file (resolveDragMode,
|
|
1129
|
+
* resolveScreenshotScale and resolveRenderSize are the first three, and this follows their shape).
|
|
1130
|
+
*
|
|
1131
|
+
* THREE-VALUED, AND THE DEFAULT IS AUTO — the one decision in this feature that is not a preference.
|
|
1132
|
+
* A capture past the backend's per-image encode limit does not fail politely: measured on
|
|
1133
|
+
* Chrome/151.0.7922.76, Page.captureScreenshot never answers and the tab is left resized to the
|
|
1134
|
+
* clip with every later screenshot timing out. Whole-page capture is what take_screenshot is for, so
|
|
1135
|
+
* the default has to be the one that returns the whole page: one shot when it fits, bands when it
|
|
1136
|
+
* does not. `false` is the escape hatch back to the exact pre-tiling behaviour (refuse rather than
|
|
1137
|
+
* band), `true` forces the banded path so it is directly testable.
|
|
1138
|
+
*
|
|
1139
|
+
* ONLY `true` COSTS A CAPABILITY. Auto asks a backend for nothing it cannot do — a driver without
|
|
1140
|
+
* "screenshot.tile" simply never bands, exactly as it behaved before this feature — so gating auto
|
|
1141
|
+
* would delete the default behaviour on Firefox for no gain. Exported for the unit tests.
|
|
1142
|
+
*/
|
|
1143
|
+
export function resolveTileMode(
|
|
1144
|
+
tile: boolean | undefined,
|
|
1145
|
+
capabilities: ReadonlySet<Capability>,
|
|
1146
|
+
format: "png" | "jpeg",
|
|
1147
|
+
returnBase64: boolean | undefined,
|
|
1148
|
+
): boolean | undefined {
|
|
1149
|
+
if (tile === undefined) return undefined;
|
|
1150
|
+
if (typeof tile !== "boolean") throw new SharedToolError(`take_screenshot: 'tile' must be true, false, or omitted for automatic (got ${JSON.stringify(tile)})`);
|
|
1151
|
+
if (!tile) return false;
|
|
1152
|
+
if (!capabilities.has("screenshot.tile")) {
|
|
1153
|
+
throw new SharedToolError(
|
|
1154
|
+
"take_screenshot: 'tile' is not supported by this backend (band tiling is declared only where the band arithmetic was measured against a real browser; " +
|
|
1155
|
+
"Firefox was not driven for it). Capture a smaller region or a single element, or use --browser chrome for a tiled capture.",
|
|
1156
|
+
);
|
|
1157
|
+
}
|
|
1158
|
+
// Asked for explicitly, so refuse explicitly and BEFORE a socket is opened. The same two
|
|
1159
|
+
// combinations reached by AUTO are refused with the same words, one layer later — see
|
|
1160
|
+
// tiledCaptureRefusal's call site inside takeScreenshot.
|
|
1161
|
+
if (format !== "png") throw tiledCaptureRefusal("format");
|
|
1162
|
+
if (returnBase64) throw tiledCaptureRefusal("base64");
|
|
1163
|
+
return true;
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
/**
|
|
1167
|
+
* The two things a tiled capture cannot also be, in one place so the explicit (`tile:true`, refused
|
|
1168
|
+
* before a socket is opened) and the automatic (refused the instant the driver offers bands, before
|
|
1169
|
+
* the first one is captured) paths cannot drift into two different explanations of the same wall.
|
|
1170
|
+
*
|
|
1171
|
+
* `banded` is present only on the automatic path, where the driver has already measured what it
|
|
1172
|
+
* would take: naming the band count and the region turns "you cannot have this" into "you cannot
|
|
1173
|
+
* have this because your page is this big".
|
|
1174
|
+
*/
|
|
1175
|
+
function tiledCaptureRefusal(reason: "format" | "base64", banded?: BandedCapture): SharedToolError {
|
|
1176
|
+
const because = banded
|
|
1177
|
+
? ` This capture needs ${banded.bandCount} bands (${banded.clip.width}x${banded.clip.height} css px is past what one image can encode).`
|
|
1178
|
+
: "";
|
|
1179
|
+
if (reason === "format") {
|
|
1180
|
+
return new SharedToolError(
|
|
1181
|
+
"take_screenshot: a tiled capture is PNG-only — bands are stitched by concatenating PNG scanlines, and there is no lossless " +
|
|
1182
|
+
`JPEG equivalent to concatenate.${because} Pass format:"png", or lower scale / narrow the region until one JPEG fits.`,
|
|
1183
|
+
);
|
|
1184
|
+
}
|
|
1185
|
+
return new SharedToolError(
|
|
1186
|
+
"take_screenshot: 'returnBase64' cannot be combined with a tiled capture — a tiled image runs to hundreds of megabytes and base64 " +
|
|
1187
|
+
`inflates it by a third, into the caller's response.${because} Read the file at the returned path instead, or capture a region small enough for one shot.`,
|
|
1188
|
+
);
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
/** PNG: an 8-byte signature, then an IHDR chunk whose payload opens with two big-endian uint32s. */
|
|
1192
|
+
function pngPixelSize(b: Uint8Array, view: DataView): { width: number; height: number } | undefined {
|
|
1193
|
+
if (b.length < 24) return undefined;
|
|
1194
|
+
if (view.getUint32(0) !== 0x89504e47 || view.getUint32(4) !== 0x0d0a1a0a) return undefined;
|
|
1195
|
+
if (String.fromCharCode(b[12]!, b[13]!, b[14]!, b[15]!) !== "IHDR") return undefined;
|
|
1196
|
+
return { width: view.getUint32(16), height: view.getUint32(20) };
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
/** SOF markers carrying frame dimensions. SOF4 (0xc4 DHT), SOF8 (0xc8), SOF12 (0xcc DAC) are NOT frame headers. */
|
|
1200
|
+
const JPEG_SOF = new Set([0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb, 0xcd, 0xce, 0xcf]);
|
|
1201
|
+
|
|
1202
|
+
/** JPEG: walk the marker segments to the first start-of-frame, which carries height then width. */
|
|
1203
|
+
function jpegPixelSize(b: Uint8Array, view: DataView): { width: number; height: number } | undefined {
|
|
1204
|
+
if (b.length < 4 || b[0] !== 0xff || b[1] !== 0xd8) return undefined;
|
|
1205
|
+
let i = 2;
|
|
1206
|
+
while (i + 3 < b.length) {
|
|
1207
|
+
if (b[i] !== 0xff) {
|
|
1208
|
+
i += 1; // resync past fill bytes / padding
|
|
1209
|
+
continue;
|
|
1210
|
+
}
|
|
1211
|
+
const marker = b[i + 1]!;
|
|
1212
|
+
if (marker === 0xff) {
|
|
1213
|
+
i += 1;
|
|
1214
|
+
continue;
|
|
1215
|
+
}
|
|
1216
|
+
// Standalone markers: no length field follows.
|
|
1217
|
+
if (marker === 0xd8 || marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) {
|
|
1218
|
+
i += 2;
|
|
1219
|
+
continue;
|
|
1220
|
+
}
|
|
1221
|
+
const segLen = view.getUint16(i + 2);
|
|
1222
|
+
if (JPEG_SOF.has(marker)) {
|
|
1223
|
+
if (i + 9 > b.length) return undefined;
|
|
1224
|
+
return { width: view.getUint16(i + 7), height: view.getUint16(i + 5) };
|
|
1225
|
+
}
|
|
1226
|
+
if (segLen < 2) return undefined;
|
|
1227
|
+
i += 2 + segLen;
|
|
1228
|
+
}
|
|
1229
|
+
return undefined;
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
/**
|
|
1233
|
+
* Output dimensions read from the ENCODED BYTES, never computed from the clip and the scale the
|
|
1234
|
+
* caller asked for: those two disagree the moment the device pixel ratio multiplies in (a scale-1
|
|
1235
|
+
* capture of a 1390 px viewport is a 2780 px PNG on a ratio-2 display), and a computed number that
|
|
1236
|
+
* quietly disagrees with the file on disk is worse than no number. `undefined` when the bytes are
|
|
1237
|
+
* neither PNG nor JPEG, so take_screenshot can OMIT width/height rather than report a guess.
|
|
1238
|
+
*
|
|
1239
|
+
* A deliberate second implementation of tools/screencast.ts's imageSize, not a shared one: this
|
|
1240
|
+
* file is the browser-NEUTRAL tool layer and imports nothing from src/tools/ (that Chrome-only
|
|
1241
|
+
* layer is what it replaced), and screencast.ts's copy takes a node Buffer while a driver hands
|
|
1242
|
+
* this one a Uint8Array. The two are pinned by their own tests and must stay in agreement.
|
|
1243
|
+
* Exported for the unit tests.
|
|
1244
|
+
*/
|
|
1245
|
+
export function imagePixelSize(bytes: Uint8Array): { width: number; height: number } | undefined {
|
|
1246
|
+
if (bytes.byteLength < 4) return undefined;
|
|
1247
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
1248
|
+
return pngPixelSize(bytes, view) ?? jpegPixelSize(bytes, view);
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
/**
|
|
1252
|
+
* take_screenshot's result. Named rather than inlined now that it carries the render-size outcome:
|
|
1253
|
+
* the shape appears three times in this function (signature, literal, and the widening the optional
|
|
1254
|
+
* fields need) and a fourth divergent copy is how a field goes missing.
|
|
1255
|
+
*
|
|
1256
|
+
* `scale` is ALWAYS present; `width`/`height` only when the bytes decoded; the three render-size
|
|
1257
|
+
* fields only when a render size was applied. Absent beats invented, everywhere here.
|
|
1258
|
+
*/
|
|
1259
|
+
export interface ScreenshotToolResult {
|
|
1260
|
+
path: string;
|
|
1261
|
+
bytes: number;
|
|
1262
|
+
format: "png" | "jpeg";
|
|
1263
|
+
target: { id: string; url: string; title: string };
|
|
1264
|
+
scale: number;
|
|
1265
|
+
width?: number;
|
|
1266
|
+
height?: number;
|
|
1267
|
+
/**
|
|
1268
|
+
* Present, and true, ONLY when this capture was taken as bands and stitched. Auto-tiling is
|
|
1269
|
+
* silent about nothing: a caller who never asked for tiling still learns their image came from
|
|
1270
|
+
* `bands` separate captures, which is what makes the lazy-content caveat theirs to reason about
|
|
1271
|
+
* (a page that only loads content on real scroll renders blank in bands past the first viewport —
|
|
1272
|
+
* a property of the page, not of the stitch, but invisible unless the tiling is reported).
|
|
1273
|
+
*/
|
|
1274
|
+
tiled?: true;
|
|
1275
|
+
/** How many captures the image was assembled from. Present only alongside `tiled`. */
|
|
1276
|
+
bands?: number;
|
|
1277
|
+
/** The emulated viewport the capture ran at. */
|
|
1278
|
+
renderSize?: { width: number; height: number };
|
|
1279
|
+
/** False means the page is STILL emulated at renderSize: the caller has to put it back. */
|
|
1280
|
+
renderRestored?: boolean;
|
|
1281
|
+
renderRestoreError?: string;
|
|
1282
|
+
base64?: string;
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1047
1285
|
export async function takeScreenshot(
|
|
1048
1286
|
driver: BrowserDriver,
|
|
1049
1287
|
args: {
|
|
1050
1288
|
target?: TargetSelector; format?: "png" | "jpeg"; quality?: number; fullPage?: boolean;
|
|
1051
|
-
uid?: DriverUid; selector?: string; savePath?: string; returnBase64?: boolean;
|
|
1289
|
+
uid?: DriverUid; selector?: string; savePath?: string; returnBase64?: boolean; scale?: number;
|
|
1290
|
+
renderWidth?: number; renderHeight?: number; tile?: boolean;
|
|
1052
1291
|
} = {},
|
|
1053
|
-
): Promise<
|
|
1292
|
+
): Promise<ScreenshotToolResult> {
|
|
1054
1293
|
if ((args.uid !== undefined && args.uid !== "") && args.selector) throw new SharedToolError("provide exactly one of uid or selector, not both");
|
|
1055
1294
|
const format = args.format ?? "png";
|
|
1295
|
+
// Range + capability checks BEFORE withPage: an argument this backend cannot honour must not claim
|
|
1296
|
+
// a lease or open a socket (same reasoning as drag's resolveDragMode call site). For the render
|
|
1297
|
+
// size that is stronger than tidiness — the refusal happens before any viewport is emulated, so a
|
|
1298
|
+
// rejected call cannot be the one that leaves a page mutated.
|
|
1299
|
+
const scale = resolveScreenshotScale(args.scale, driver.capabilities);
|
|
1300
|
+
const renderSize = resolveRenderSize(args.renderWidth, args.renderHeight, driver.capabilities);
|
|
1301
|
+
const tile = resolveTileMode(args.tile, driver.capabilities, format, args.returnBase64);
|
|
1056
1302
|
return withPage(driver, args.target, async (page) => {
|
|
1057
1303
|
const clip = args.uid !== undefined || args.selector ? locatorOf(args) : undefined;
|
|
1058
|
-
const
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1304
|
+
const ext = format === "jpeg" ? "jpg" : "png";
|
|
1305
|
+
// Named BEFORE the capture, because a banded capture writes straight to its destination as the
|
|
1306
|
+
// bands arrive — there is never a complete image in memory to name a file after. The one-shot
|
|
1307
|
+
// path names it at the same moment for the same reason a single path is easier to trust than
|
|
1308
|
+
// two; the only visible effect is that the default filename's stamp is the capture's start.
|
|
1062
1309
|
const path = args.savePath ?? join(ARTIFACT_DIR, `screenshot-${page.info.id.slice(0, 8)}-${stamp()}.${ext}`);
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1310
|
+
// The tool layer owns files, the driver owns the protocol: the driver decides that this capture
|
|
1311
|
+
// has to be banded and produces the bands, and this callback is the only thing that knows where
|
|
1312
|
+
// they go. It runs BEFORE the first band is captured, which is what lets the two combinations a
|
|
1313
|
+
// stitched capture cannot serve refuse without a single wasted Page.captureScreenshot.
|
|
1314
|
+
const stitch = async (banded: BandedCapture) => {
|
|
1315
|
+
if (format !== "png") throw tiledCaptureRefusal("format", banded);
|
|
1316
|
+
if (args.returnBase64) throw tiledCaptureRefusal("base64", banded);
|
|
1317
|
+
return stitchPngBandsToFile(banded.bands, path);
|
|
1066
1318
|
};
|
|
1067
|
-
|
|
1319
|
+
const shot = await page.screenshot({
|
|
1320
|
+
format, quality: args.quality, fullPage: args.fullPage, scale,
|
|
1321
|
+
...(clip ? { clip } : {}),
|
|
1322
|
+
...(renderSize ? { renderWidth: renderSize.width, renderHeight: renderSize.height } : {}),
|
|
1323
|
+
...(tile !== undefined ? { tile } : {}),
|
|
1324
|
+
}, stitch);
|
|
1325
|
+
const result: ScreenshotToolResult = isTiledCapture(shot)
|
|
1326
|
+
? {
|
|
1327
|
+
// width/height/bytes come from the STITCHER, which is the only thing that ever saw the
|
|
1328
|
+
// whole image: re-reading a several-hundred-MB PNG off disk to decode an IHDR this call
|
|
1329
|
+
// already computed would be slower and no more true. Still measured, never projected —
|
|
1330
|
+
// the stitcher patches the IHDR with the rows it actually wrote.
|
|
1331
|
+
path, bytes: shot.consumed.bytes, format: "png", target: target3(page.info), scale,
|
|
1332
|
+
width: shot.consumed.width, height: shot.consumed.height,
|
|
1333
|
+
tiled: true, bands: shot.consumed.bands,
|
|
1334
|
+
}
|
|
1335
|
+
: await writeOneShot(shot.data, shot.format, path, target3(page.info), scale, args.returnBase64);
|
|
1336
|
+
// What actually happened to the page, reported from the DRIVER's answer rather than from what
|
|
1337
|
+
// was asked for. renderRestored:false means the caller's tab is still emulated at renderSize
|
|
1338
|
+
// and they have to fix it — the one outcome that must never be swallowed, so it rides back on
|
|
1339
|
+
// the success result the same way a decoded width does.
|
|
1340
|
+
if (renderSize && shot.renderSize) {
|
|
1341
|
+
result.renderSize = shot.renderSize;
|
|
1342
|
+
result.renderRestored = shot.renderRestored ?? false;
|
|
1343
|
+
if (shot.renderRestoreError !== undefined) result.renderRestoreError = shot.renderRestoreError;
|
|
1344
|
+
}
|
|
1068
1345
|
return result;
|
|
1069
1346
|
});
|
|
1070
1347
|
}
|
|
1071
1348
|
|
|
1349
|
+
/** The one-shot half of takeScreenshot: bytes in hand, so the file is written here and the size is
|
|
1350
|
+
* DECODED from those bytes. Unchanged from before tiling existed, split out only so the tiled arm
|
|
1351
|
+
* above reads as the one difference it is. */
|
|
1352
|
+
async function writeOneShot(
|
|
1353
|
+
data: Uint8Array,
|
|
1354
|
+
outFormat: "png" | "jpeg",
|
|
1355
|
+
path: string,
|
|
1356
|
+
target: { id: string; url: string; title: string },
|
|
1357
|
+
scale: number,
|
|
1358
|
+
returnBase64: boolean | undefined,
|
|
1359
|
+
): Promise<ScreenshotToolResult> {
|
|
1360
|
+
if (data.byteLength === 0) throw new SharedToolError("captureScreenshot returned empty data");
|
|
1361
|
+
await mkdir(dirname(path), { recursive: true });
|
|
1362
|
+
await writeFile(path, Buffer.from(data));
|
|
1363
|
+
const size = imagePixelSize(data);
|
|
1364
|
+
const result: ScreenshotToolResult = { path, bytes: data.byteLength, format: outFormat, target, scale };
|
|
1365
|
+
// Absent beats invented: undecodable bytes leave width/height off the result entirely.
|
|
1366
|
+
if (size) {
|
|
1367
|
+
result.width = size.width;
|
|
1368
|
+
result.height = size.height;
|
|
1369
|
+
}
|
|
1370
|
+
if (returnBase64) result.base64 = Buffer.from(data).toString("base64");
|
|
1371
|
+
return result;
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1072
1374
|
export async function emulate(
|
|
1073
1375
|
driver: BrowserDriver,
|
|
1074
1376
|
args: {
|