cdp-toolkit 1.9.1 → 1.9.5
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 +434 -0
- package/CONTRACT.md +59 -2
- package/README.md +68 -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 +230 -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/dist/index.js
CHANGED
|
@@ -1119,6 +1119,8 @@ async function clearMocks(args = {}) {
|
|
|
1119
1119
|
}
|
|
1120
1120
|
|
|
1121
1121
|
// src/driver.ts
|
|
1122
|
+
import { mkdir as mkdir3, readFile as readFile3, unlink as unlink3, writeFile as writeFile3 } from "node:fs/promises";
|
|
1123
|
+
import { join as join3 } from "node:path";
|
|
1122
1124
|
var UID_STAMP_ATTR = "data-cdp-uid";
|
|
1123
1125
|
var LEGACY_NUMERIC_UID = /^[0-9]+$/;
|
|
1124
1126
|
var REQUIRED_CAPABILITIES = {
|
|
@@ -1148,6 +1150,41 @@ function interpolatePoints(from, to, steps) {
|
|
|
1148
1150
|
out.push({ x: to.x, y: to.y });
|
|
1149
1151
|
return out;
|
|
1150
1152
|
}
|
|
1153
|
+
function isTiledCapture(delivery) {
|
|
1154
|
+
return delivery.tiled === true;
|
|
1155
|
+
}
|
|
1156
|
+
function viewportRecordFile(scheme, targetId) {
|
|
1157
|
+
return join3(leaseDir(), `viewport-${scheme}-${targetId.replace(/[^A-Za-z0-9._-]/g, "_")}.json`);
|
|
1158
|
+
}
|
|
1159
|
+
async function readViewportRecord(scheme, targetId) {
|
|
1160
|
+
try {
|
|
1161
|
+
const raw = JSON.parse(await readFile3(viewportRecordFile(scheme, targetId), "utf8"));
|
|
1162
|
+
if (typeof raw?.width !== "number" || typeof raw?.height !== "number")
|
|
1163
|
+
return;
|
|
1164
|
+
return {
|
|
1165
|
+
width: raw.width,
|
|
1166
|
+
height: raw.height,
|
|
1167
|
+
...typeof raw.deviceScaleFactor === "number" ? { deviceScaleFactor: raw.deviceScaleFactor } : {},
|
|
1168
|
+
...typeof raw.mobile === "boolean" ? { mobile: raw.mobile } : {}
|
|
1169
|
+
};
|
|
1170
|
+
} catch {
|
|
1171
|
+
return;
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
async function writeViewportRecord(scheme, targetId, rec) {
|
|
1175
|
+
try {
|
|
1176
|
+
await mkdir3(leaseDir(), { recursive: true });
|
|
1177
|
+
await writeFile3(viewportRecordFile(scheme, targetId), JSON.stringify({ scheme, targetId, ...rec }), "utf8");
|
|
1178
|
+
} catch {}
|
|
1179
|
+
}
|
|
1180
|
+
function renderRestoreFailureNote(size, why) {
|
|
1181
|
+
return `— AND the ${size.width}x${size.height} render-size override could NOT be restored (${why}), so this page is STILL ` + `emulated at ${size.width}x${size.height}: put it back with emulate{width,height} or emulate{clearOverrides:true}.`;
|
|
1182
|
+
}
|
|
1183
|
+
async function clearViewportRecord(scheme, targetId) {
|
|
1184
|
+
try {
|
|
1185
|
+
await unlink3(viewportRecordFile(scheme, targetId));
|
|
1186
|
+
} catch {}
|
|
1187
|
+
}
|
|
1151
1188
|
|
|
1152
1189
|
// src/cdp/driver.ts
|
|
1153
1190
|
function driverError(code, message, data) {
|
|
@@ -1379,14 +1416,73 @@ async function backendNodeIdOf(conn, loc) {
|
|
|
1379
1416
|
}
|
|
1380
1417
|
return searchLocate(conn, "xpath" in loc ? loc.xpath : loc.text);
|
|
1381
1418
|
}
|
|
1382
|
-
async function
|
|
1383
|
-
|
|
1419
|
+
async function layoutMetrics(conn) {
|
|
1420
|
+
return conn.send("Page.getLayoutMetrics");
|
|
1421
|
+
}
|
|
1422
|
+
function contentClip(m) {
|
|
1384
1423
|
const size = m.cssContentSize ?? m.contentSize;
|
|
1385
1424
|
if (!size)
|
|
1386
1425
|
throw driverError("page-error", "Page.getLayoutMetrics returned no content size");
|
|
1387
1426
|
return { x: 0, y: 0, width: Math.ceil(size.width), height: Math.ceil(size.height) };
|
|
1388
1427
|
}
|
|
1389
|
-
|
|
1428
|
+
function devicePixelsPerCssPx(m) {
|
|
1429
|
+
const css = m.cssVisualViewport?.clientWidth ?? m.cssLayoutViewport?.clientWidth;
|
|
1430
|
+
const dev = m.visualViewport?.clientWidth ?? m.layoutViewport?.clientWidth;
|
|
1431
|
+
if (!css || !dev)
|
|
1432
|
+
return 1;
|
|
1433
|
+
const ratio = dev / css;
|
|
1434
|
+
return Number.isFinite(ratio) && ratio > 0 ? ratio : 1;
|
|
1435
|
+
}
|
|
1436
|
+
function scrollOffsetCssPx(m, devicePx) {
|
|
1437
|
+
const css = m.cssVisualViewport ?? m.cssLayoutViewport;
|
|
1438
|
+
if (css)
|
|
1439
|
+
return { x: css.pageX ?? 0, y: css.pageY ?? 0 };
|
|
1440
|
+
const legacy = m.layoutViewport;
|
|
1441
|
+
if (legacy)
|
|
1442
|
+
return { x: (legacy.pageX ?? 0) / devicePx, y: (legacy.pageY ?? 0) / devicePx };
|
|
1443
|
+
return { x: 0, y: 0 };
|
|
1444
|
+
}
|
|
1445
|
+
var MAX_ENCODED_SIDE_PX = 16384;
|
|
1446
|
+
function assertClipEncodable(clip, scale, devicePx, renderSize) {
|
|
1447
|
+
const outWidth = Math.ceil(clip.width * scale * devicePx);
|
|
1448
|
+
const outHeight = Math.ceil(clip.height * scale * devicePx);
|
|
1449
|
+
if (outWidth <= MAX_ENCODED_SIDE_PX && outHeight <= MAX_ENCODED_SIDE_PX)
|
|
1450
|
+
return;
|
|
1451
|
+
const at = renderSize ? ` at render size ${renderSize.width}x${renderSize.height}` : "";
|
|
1452
|
+
const projection = `(clip ${clip.width}x${clip.height} css px${at} x scale ${scale} x device pixel ratio ${devicePx}), ` + `past Chrome's ${MAX_ENCODED_SIDE_PX} px per-side limit; `;
|
|
1453
|
+
if (scale !== 1) {
|
|
1454
|
+
const fits = Math.floor(MAX_ENCODED_SIDE_PX / (Math.max(clip.width, clip.height) * devicePx) * 100) / 100;
|
|
1455
|
+
throw driverError("page-error", `screenshot scale ${scale} would encode ${outWidth}x${outHeight} px ${projection}` + (fits >= 0.01 ? `the largest scale that fits this clip is ${fits}.` : `this clip is already past the limit at scale 1 — capture a smaller region.`));
|
|
1456
|
+
}
|
|
1457
|
+
const maxCssSide = Math.floor(MAX_ENCODED_SIDE_PX / devicePx);
|
|
1458
|
+
throw driverError("page-error", `this capture would encode ${outWidth}x${outHeight} px ${projection}` + `at device pixel ratio ${devicePx} the clip may be at most ${maxCssSide} css px per side` + (renderSize ? ` — a narrow renderWidth reflows the document TALLER, so widen it, shorten the page, or drop fullPage.` : ` — capture a smaller region or drop fullPage.`));
|
|
1459
|
+
}
|
|
1460
|
+
function projectedSide(cssLength, scale, devicePx) {
|
|
1461
|
+
return Math.ceil(cssLength * scale * devicePx);
|
|
1462
|
+
}
|
|
1463
|
+
function planTiledCapture(clip, scale, devicePx, tile) {
|
|
1464
|
+
const untiled = { tiled: false, bandCount: 1, bandHeightCss: clip.height };
|
|
1465
|
+
if (tile === false)
|
|
1466
|
+
return untiled;
|
|
1467
|
+
const fits = projectedSide(clip.width, scale, devicePx) <= MAX_ENCODED_SIDE_PX && projectedSide(clip.height, scale, devicePx) <= MAX_ENCODED_SIDE_PX;
|
|
1468
|
+
if (fits && tile !== true)
|
|
1469
|
+
return untiled;
|
|
1470
|
+
const outWidth = projectedSide(clip.width, scale, devicePx);
|
|
1471
|
+
if (outWidth > MAX_ENCODED_SIDE_PX) {
|
|
1472
|
+
const maxCssWidth = Math.floor(MAX_ENCODED_SIDE_PX / (scale * devicePx));
|
|
1473
|
+
throw driverError("page-error", `this capture is ${outWidth} px WIDE (clip ${clip.width} css px x scale ${scale} x device pixel ratio ${devicePx}), ` + `past Chrome's ${MAX_ENCODED_SIDE_PX} px per-side limit, and width cannot be tiled: bands are stitched ` + `vertically only. Lower scale (at most ${Math.floor(MAX_ENCODED_SIDE_PX / (clip.width * devicePx) * 100) / 100}), ` + `or set renderWidth at most ${maxCssWidth} css px, or clip to a narrower element.`);
|
|
1474
|
+
}
|
|
1475
|
+
let bandHeightCss = Math.floor(MAX_ENCODED_SIDE_PX / (scale * devicePx));
|
|
1476
|
+
while (bandHeightCss > 1 && projectedSide(bandHeightCss, scale, devicePx) > MAX_ENCODED_SIDE_PX)
|
|
1477
|
+
bandHeightCss -= 1;
|
|
1478
|
+
if (bandHeightCss < 1) {
|
|
1479
|
+
throw driverError("page-error", `scale ${scale} at device pixel ratio ${devicePx} projects a single css pixel to ${scale * devicePx} device px, ` + `past the ${MAX_ENCODED_SIDE_PX} px limit — no band height can fit. Lower scale.`);
|
|
1480
|
+
}
|
|
1481
|
+
if (tile === true && clip.height <= bandHeightCss)
|
|
1482
|
+
bandHeightCss = Math.max(1, Math.ceil(clip.height / 2));
|
|
1483
|
+
return { tiled: true, bandCount: Math.max(1, Math.ceil(clip.height / bandHeightCss)), bandHeightCss };
|
|
1484
|
+
}
|
|
1485
|
+
async function elementBox(conn, loc) {
|
|
1390
1486
|
const backendNodeId = await backendNodeIdOf(conn, loc);
|
|
1391
1487
|
await conn.send("DOM.scrollIntoViewIfNeeded", { backendNodeId }).catch(() => {
|
|
1392
1488
|
return;
|
|
@@ -1403,6 +1499,10 @@ async function elementClip(conn, loc) {
|
|
|
1403
1499
|
throw driverError("page-error", "resolved element has zero area");
|
|
1404
1500
|
return { x: minX, y: minY, width, height };
|
|
1405
1501
|
}
|
|
1502
|
+
function documentClip(box, m, devicePx) {
|
|
1503
|
+
const scroll = scrollOffsetCssPx(m, devicePx);
|
|
1504
|
+
return { x: box.x + scroll.x, y: box.y + scroll.y, width: box.width, height: box.height };
|
|
1505
|
+
}
|
|
1406
1506
|
var CDP_CAPABILITIES = new Set([
|
|
1407
1507
|
"trace.performance",
|
|
1408
1508
|
"heap.snapshot",
|
|
@@ -1413,6 +1513,9 @@ var CDP_CAPABILITIES = new Set([
|
|
|
1413
1513
|
"emulate.networkConditions",
|
|
1414
1514
|
"screenshot.fullPage",
|
|
1415
1515
|
"screenshot.element",
|
|
1516
|
+
"screenshot.scale",
|
|
1517
|
+
"screenshot.renderSize",
|
|
1518
|
+
"screenshot.tile",
|
|
1416
1519
|
"network.intercept",
|
|
1417
1520
|
"snapshot.accessibilityTree",
|
|
1418
1521
|
"input.insertTextAtomic",
|
|
@@ -1703,31 +1806,128 @@ class CdpPageDriver {
|
|
|
1703
1806
|
const { objectId } = await resolveElementLocator(this.conn, loc);
|
|
1704
1807
|
await this.conn.send("DOM.setFileInputFiles", { files: [...files], objectId });
|
|
1705
1808
|
}
|
|
1706
|
-
async screenshot(opts) {
|
|
1809
|
+
async screenshot(opts, consumeBands) {
|
|
1810
|
+
const renderSize = opts?.renderWidth !== undefined && opts?.renderHeight !== undefined ? { width: opts.renderWidth, height: opts.renderHeight } : undefined;
|
|
1811
|
+
if (!renderSize)
|
|
1812
|
+
return this.capture(opts, consumeBands);
|
|
1813
|
+
const previous = await readViewportRecord(this.browser.scheme, this.info.id);
|
|
1814
|
+
await this.conn.send("Emulation.setDeviceMetricsOverride", {
|
|
1815
|
+
width: renderSize.width,
|
|
1816
|
+
height: renderSize.height,
|
|
1817
|
+
deviceScaleFactor: previous?.deviceScaleFactor ?? 0,
|
|
1818
|
+
mobile: previous?.mobile ?? false
|
|
1819
|
+
});
|
|
1820
|
+
let shot;
|
|
1821
|
+
let captureError;
|
|
1822
|
+
try {
|
|
1823
|
+
shot = await this.capture(opts, consumeBands);
|
|
1824
|
+
} catch (e) {
|
|
1825
|
+
captureError = e;
|
|
1826
|
+
}
|
|
1827
|
+
let restoreError;
|
|
1828
|
+
try {
|
|
1829
|
+
if (previous) {
|
|
1830
|
+
await this.conn.send("Emulation.setDeviceMetricsOverride", {
|
|
1831
|
+
width: previous.width,
|
|
1832
|
+
height: previous.height,
|
|
1833
|
+
deviceScaleFactor: previous.deviceScaleFactor ?? 0,
|
|
1834
|
+
mobile: previous.mobile ?? false
|
|
1835
|
+
});
|
|
1836
|
+
} else {
|
|
1837
|
+
await this.conn.send("Emulation.clearDeviceMetricsOverride");
|
|
1838
|
+
}
|
|
1839
|
+
} catch (e) {
|
|
1840
|
+
restoreError = e instanceof Error ? e.message : String(e);
|
|
1841
|
+
}
|
|
1842
|
+
if (!shot) {
|
|
1843
|
+
if (restoreError !== undefined && captureError instanceof Error) {
|
|
1844
|
+
captureError.message = `${captureError.message} ${renderRestoreFailureNote(renderSize, restoreError)}`;
|
|
1845
|
+
}
|
|
1846
|
+
throw captureError;
|
|
1847
|
+
}
|
|
1848
|
+
const result = Object.assign(shot, { renderSize, renderRestored: restoreError === undefined });
|
|
1849
|
+
if (restoreError !== undefined)
|
|
1850
|
+
result.renderRestoreError = restoreError;
|
|
1851
|
+
return result;
|
|
1852
|
+
}
|
|
1853
|
+
async capture(opts, consumeBands) {
|
|
1707
1854
|
await this.ensureDomain("Page");
|
|
1708
1855
|
const format = opts?.format ?? "png";
|
|
1709
1856
|
const quality = format === "jpeg" ? opts?.quality ?? 80 : undefined;
|
|
1857
|
+
const scale = opts?.scale ?? 1;
|
|
1858
|
+
const renderSize = opts?.renderWidth !== undefined && opts?.renderHeight !== undefined ? { width: opts.renderWidth, height: opts.renderHeight } : undefined;
|
|
1859
|
+
const box = opts?.clip ? await elementBox(this.conn, opts.clip) : undefined;
|
|
1860
|
+
const metrics = await layoutMetrics(this.conn);
|
|
1861
|
+
const devicePx = devicePixelsPerCssPx(metrics);
|
|
1862
|
+
const clip = box ? documentClip(box, metrics, devicePx) : contentClip(metrics);
|
|
1863
|
+
const sendsClip = opts?.clip !== undefined || opts?.fullPage === true || scale !== 1 || renderSize !== undefined;
|
|
1864
|
+
if (opts?.tile === true && !consumeBands) {
|
|
1865
|
+
throw driverError("unsupported", "screenshot tile:true needs a caller that can consume bands; this one passed no band consumer");
|
|
1866
|
+
}
|
|
1867
|
+
const plan = planTiledCapture(clip, scale, devicePx, consumeBands ? opts?.tile : false);
|
|
1868
|
+
if (plan.tiled)
|
|
1869
|
+
return this.captureTiled(clip, plan, scale, format, consumeBands);
|
|
1870
|
+
assertClipEncodable(clip, scale, devicePx, renderSize);
|
|
1710
1871
|
const params = { format, captureBeyondViewport: true };
|
|
1711
1872
|
if (quality != null)
|
|
1712
1873
|
params.quality = quality;
|
|
1713
|
-
if (
|
|
1714
|
-
params.clip = { ...
|
|
1715
|
-
else if (opts?.fullPage)
|
|
1716
|
-
params.clip = { ...await fullPageClip(this.conn), scale: 1 };
|
|
1874
|
+
if (sendsClip)
|
|
1875
|
+
params.clip = { ...clip, scale };
|
|
1717
1876
|
const { data } = await this.conn.send("Page.captureScreenshot", params);
|
|
1718
1877
|
return { data: new Uint8Array(Buffer.from(data, "base64")), format };
|
|
1719
1878
|
}
|
|
1879
|
+
async captureTiled(clip, plan, scale, format, consumeBands) {
|
|
1880
|
+
const capture = {
|
|
1881
|
+
bands: this.bands(clip, plan, scale, format),
|
|
1882
|
+
bandCount: plan.bandCount,
|
|
1883
|
+
bandHeightCss: plan.bandHeightCss,
|
|
1884
|
+
clip,
|
|
1885
|
+
format: "png"
|
|
1886
|
+
};
|
|
1887
|
+
const consumed = await consumeBands(capture);
|
|
1888
|
+
return { tiled: true, format: "png", bandCount: plan.bandCount, bandHeightCss: plan.bandHeightCss, consumed };
|
|
1889
|
+
}
|
|
1890
|
+
async* bands(clip, plan, scale, format) {
|
|
1891
|
+
if (format !== "png") {
|
|
1892
|
+
throw driverError("unsupported", `a tiled capture is PNG-only (bands are stitched as PNG scanlines); got format "${format}"`);
|
|
1893
|
+
}
|
|
1894
|
+
const bottom = clip.y + clip.height;
|
|
1895
|
+
for (let i = 0;i < plan.bandCount; i++) {
|
|
1896
|
+
const y = clip.y + i * plan.bandHeightCss;
|
|
1897
|
+
const height = Math.min(plan.bandHeightCss, bottom - y);
|
|
1898
|
+
const { data } = await this.conn.send("Page.captureScreenshot", {
|
|
1899
|
+
format: "png",
|
|
1900
|
+
captureBeyondViewport: true,
|
|
1901
|
+
clip: { x: clip.x, y, width: clip.width, height, scale }
|
|
1902
|
+
});
|
|
1903
|
+
yield new Uint8Array(Buffer.from(data, "base64"));
|
|
1904
|
+
}
|
|
1905
|
+
}
|
|
1720
1906
|
async emulate(opts) {
|
|
1721
1907
|
if (opts.clearOverrides) {
|
|
1722
1908
|
const c = () => {
|
|
1723
1909
|
return;
|
|
1724
1910
|
};
|
|
1725
|
-
|
|
1911
|
+
let metricsClearError;
|
|
1912
|
+
try {
|
|
1913
|
+
await this.ensureDomain("Page");
|
|
1914
|
+
const metrics = await layoutMetrics(this.conn);
|
|
1915
|
+
const width = metrics.cssVisualViewport?.clientWidth ?? metrics.cssLayoutViewport?.clientWidth ?? metrics.visualViewport?.clientWidth ?? metrics.layoutViewport?.clientWidth ?? 1;
|
|
1916
|
+
const height = metrics.cssVisualViewport?.clientHeight ?? metrics.cssLayoutViewport?.clientHeight ?? metrics.visualViewport?.clientHeight ?? metrics.layoutViewport?.clientHeight ?? 1;
|
|
1917
|
+
await this.conn.send("Emulation.setDeviceMetricsOverride", { width, height, deviceScaleFactor: 0, mobile: false });
|
|
1918
|
+
await this.conn.send("Emulation.clearDeviceMetricsOverride");
|
|
1919
|
+
} catch (e) {
|
|
1920
|
+
metricsClearError = e instanceof Error ? e.message : String(e);
|
|
1921
|
+
}
|
|
1726
1922
|
await this.conn.send("Emulation.setUserAgentOverride", { userAgent: "" }).catch(c);
|
|
1727
1923
|
await this.conn.send("Emulation.setCPUThrottlingRate", { rate: 1 }).catch(c);
|
|
1728
1924
|
await this.conn.send("Emulation.setEmulatedMedia", { media: "", features: [] }).catch(c);
|
|
1729
1925
|
await this.ensureDomainSoft("Network");
|
|
1730
1926
|
await this.conn.send("Network.emulateNetworkConditions", { offline: false, latency: 0, downloadThroughput: -1, uploadThroughput: -1 }).catch(c);
|
|
1927
|
+
if (metricsClearError !== undefined) {
|
|
1928
|
+
throw driverError("page-error", `emulate: could not verify the device-metrics override was cleared (${metricsClearError})`);
|
|
1929
|
+
}
|
|
1930
|
+
await clearViewportRecord(this.browser.scheme, this.info.id);
|
|
1731
1931
|
return { applied: [] };
|
|
1732
1932
|
}
|
|
1733
1933
|
const applied = [];
|
|
@@ -1741,6 +1941,12 @@ class CdpPageDriver {
|
|
|
1741
1941
|
deviceScaleFactor: opts.deviceScaleFactor ?? 0,
|
|
1742
1942
|
mobile: opts.mobile ?? false
|
|
1743
1943
|
});
|
|
1944
|
+
await writeViewportRecord(this.browser.scheme, this.info.id, {
|
|
1945
|
+
width: opts.width,
|
|
1946
|
+
height: opts.height,
|
|
1947
|
+
deviceScaleFactor: opts.deviceScaleFactor ?? 0,
|
|
1948
|
+
mobile: opts.mobile ?? false
|
|
1949
|
+
});
|
|
1744
1950
|
applied.push("deviceMetrics");
|
|
1745
1951
|
}
|
|
1746
1952
|
if (opts.userAgent != null) {
|
|
@@ -2128,8 +2334,522 @@ async function reapStaleAgentTabs(driver, backend) {
|
|
|
2128
2334
|
}
|
|
2129
2335
|
|
|
2130
2336
|
// src/shared-tools.ts
|
|
2131
|
-
import { mkdir as
|
|
2132
|
-
import { dirname, join as
|
|
2337
|
+
import { mkdir as mkdir5, writeFile as writeFile4 } from "node:fs/promises";
|
|
2338
|
+
import { dirname as dirname2, join as join4 } from "node:path";
|
|
2339
|
+
|
|
2340
|
+
// src/png.ts
|
|
2341
|
+
import { once } from "node:events";
|
|
2342
|
+
import { mkdir as mkdir4, open, rename, rm } from "node:fs/promises";
|
|
2343
|
+
import { dirname } from "node:path";
|
|
2344
|
+
import { createDeflate, createInflate, deflateSync } from "node:zlib";
|
|
2345
|
+
var PNG_SIGNATURE = Uint8Array.of(137, 80, 78, 71, 13, 10, 26, 10);
|
|
2346
|
+
var ZLIB_CHUNK = 1 << 18;
|
|
2347
|
+
var BLOCK_RAW_BYTES = 1 << 22;
|
|
2348
|
+
var MAX_BLOCK_ROWS = 4096;
|
|
2349
|
+
var DECIDE_LEVEL = 6;
|
|
2350
|
+
var CARRIED_CHUNKS = ["gAMA", "cHRM", "sRGB", "iCCP", "pHYs"];
|
|
2351
|
+
var CHANNELS = { 0: 1, 2: 3, 4: 2, 6: 4 };
|
|
2352
|
+
var LEGAL_DEPTHS = {
|
|
2353
|
+
0: [1, 2, 4, 8, 16],
|
|
2354
|
+
2: [8, 16],
|
|
2355
|
+
4: [8, 16],
|
|
2356
|
+
6: [8, 16]
|
|
2357
|
+
};
|
|
2358
|
+
var COLOUR_TYPE_NAMES = {
|
|
2359
|
+
0: "greyscale",
|
|
2360
|
+
2: "truecolour RGB",
|
|
2361
|
+
3: "palette",
|
|
2362
|
+
4: "greyscale+alpha",
|
|
2363
|
+
6: "RGBA"
|
|
2364
|
+
};
|
|
2365
|
+
|
|
2366
|
+
class PngStitchError extends Error {
|
|
2367
|
+
constructor(message) {
|
|
2368
|
+
super(message);
|
|
2369
|
+
this.name = "PngStitchError";
|
|
2370
|
+
}
|
|
2371
|
+
}
|
|
2372
|
+
var CRC_TABLE = (() => {
|
|
2373
|
+
const table = new Uint32Array(256);
|
|
2374
|
+
for (let n = 0;n < 256; n++) {
|
|
2375
|
+
let c = n;
|
|
2376
|
+
for (let k = 0;k < 8; k++)
|
|
2377
|
+
c = (c & 1) !== 0 ? 3988292384 ^ c >>> 1 : c >>> 1;
|
|
2378
|
+
table[n] = c >>> 0;
|
|
2379
|
+
}
|
|
2380
|
+
return table;
|
|
2381
|
+
})();
|
|
2382
|
+
function crcUpdate(crc, bytes) {
|
|
2383
|
+
let c = crc;
|
|
2384
|
+
for (let i = 0;i < bytes.length; i++)
|
|
2385
|
+
c = CRC_TABLE[(c ^ bytes[i]) & 255] ^ c >>> 8;
|
|
2386
|
+
return c >>> 0;
|
|
2387
|
+
}
|
|
2388
|
+
function crc32(bytes) {
|
|
2389
|
+
return (crcUpdate(4294967295, bytes) ^ 4294967295) >>> 0;
|
|
2390
|
+
}
|
|
2391
|
+
function readU32(b, off) {
|
|
2392
|
+
return (b[off] << 24 | b[off + 1] << 16 | b[off + 2] << 8 | b[off + 3]) >>> 0;
|
|
2393
|
+
}
|
|
2394
|
+
function chunkTypeAt(b, off) {
|
|
2395
|
+
return String.fromCharCode(b[off], b[off + 1], b[off + 2], b[off + 3]);
|
|
2396
|
+
}
|
|
2397
|
+
function sameBytes(a, b) {
|
|
2398
|
+
if (a.length !== b.length)
|
|
2399
|
+
return false;
|
|
2400
|
+
for (let i = 0;i < a.length; i++)
|
|
2401
|
+
if (a[i] !== b[i])
|
|
2402
|
+
return false;
|
|
2403
|
+
return true;
|
|
2404
|
+
}
|
|
2405
|
+
function paeth(a, b, c) {
|
|
2406
|
+
const p = a + b - c;
|
|
2407
|
+
const pa = p > a ? p - a : a - p;
|
|
2408
|
+
const pb = p > b ? p - b : b - p;
|
|
2409
|
+
const pc = p > c ? p - c : c - p;
|
|
2410
|
+
if (pa <= pb && pa <= pc)
|
|
2411
|
+
return a;
|
|
2412
|
+
return pb <= pc ? b : c;
|
|
2413
|
+
}
|
|
2414
|
+
function unfilterRow(type, row, prev, bpp, at) {
|
|
2415
|
+
const n = row.length;
|
|
2416
|
+
switch (type) {
|
|
2417
|
+
case 0:
|
|
2418
|
+
return;
|
|
2419
|
+
case 1:
|
|
2420
|
+
for (let i = bpp;i < n; i++)
|
|
2421
|
+
row[i] = row[i] + row[i - bpp] & 255;
|
|
2422
|
+
return;
|
|
2423
|
+
case 2:
|
|
2424
|
+
for (let i = 0;i < n; i++)
|
|
2425
|
+
row[i] = row[i] + prev[i] & 255;
|
|
2426
|
+
return;
|
|
2427
|
+
case 3:
|
|
2428
|
+
for (let i = 0;i < n; i++) {
|
|
2429
|
+
const a = i >= bpp ? row[i - bpp] : 0;
|
|
2430
|
+
row[i] = row[i] + (a + prev[i] >> 1) & 255;
|
|
2431
|
+
}
|
|
2432
|
+
return;
|
|
2433
|
+
case 4:
|
|
2434
|
+
for (let i = 0;i < n; i++) {
|
|
2435
|
+
const a = i >= bpp ? row[i - bpp] : 0;
|
|
2436
|
+
const c = i >= bpp ? prev[i - bpp] : 0;
|
|
2437
|
+
row[i] = row[i] + paeth(a, prev[i], c) & 255;
|
|
2438
|
+
}
|
|
2439
|
+
return;
|
|
2440
|
+
default:
|
|
2441
|
+
throw new PngStitchError(`${at}: unknown row filter type ${type} (PNG defines 0-4)`);
|
|
2442
|
+
}
|
|
2443
|
+
}
|
|
2444
|
+
function absSigned(v) {
|
|
2445
|
+
const u = v & 255;
|
|
2446
|
+
return u < 128 ? u : 256 - u;
|
|
2447
|
+
}
|
|
2448
|
+
function chooseFilter(row, prev, bpp) {
|
|
2449
|
+
const n = row.length;
|
|
2450
|
+
let s0 = 0, s1 = 0, s2 = 0, s3 = 0, s4 = 0;
|
|
2451
|
+
for (let i = 0;i < n; i++) {
|
|
2452
|
+
const x = row[i];
|
|
2453
|
+
const b = prev[i];
|
|
2454
|
+
const a = i >= bpp ? row[i - bpp] : 0;
|
|
2455
|
+
const c = i >= bpp ? prev[i - bpp] : 0;
|
|
2456
|
+
s0 += absSigned(x);
|
|
2457
|
+
s1 += absSigned(x - a);
|
|
2458
|
+
s2 += absSigned(x - b);
|
|
2459
|
+
s3 += absSigned(x - (a + b >> 1));
|
|
2460
|
+
s4 += absSigned(x - paeth(a, b, c));
|
|
2461
|
+
}
|
|
2462
|
+
let best = 0, bestSum = s0;
|
|
2463
|
+
if (s1 < bestSum) {
|
|
2464
|
+
best = 1;
|
|
2465
|
+
bestSum = s1;
|
|
2466
|
+
}
|
|
2467
|
+
if (s2 < bestSum) {
|
|
2468
|
+
best = 2;
|
|
2469
|
+
bestSum = s2;
|
|
2470
|
+
}
|
|
2471
|
+
if (s3 < bestSum) {
|
|
2472
|
+
best = 3;
|
|
2473
|
+
bestSum = s3;
|
|
2474
|
+
}
|
|
2475
|
+
if (s4 < bestSum) {
|
|
2476
|
+
best = 4;
|
|
2477
|
+
}
|
|
2478
|
+
return best;
|
|
2479
|
+
}
|
|
2480
|
+
function writeFilteredRow(type, row, prev, bpp, out, off) {
|
|
2481
|
+
const n = row.length;
|
|
2482
|
+
switch (type) {
|
|
2483
|
+
case 0:
|
|
2484
|
+
out.set(row, off);
|
|
2485
|
+
return;
|
|
2486
|
+
case 1:
|
|
2487
|
+
for (let i = 0;i < n; i++)
|
|
2488
|
+
out[off + i] = row[i] - (i >= bpp ? row[i - bpp] : 0) & 255;
|
|
2489
|
+
return;
|
|
2490
|
+
case 2:
|
|
2491
|
+
for (let i = 0;i < n; i++)
|
|
2492
|
+
out[off + i] = row[i] - prev[i] & 255;
|
|
2493
|
+
return;
|
|
2494
|
+
case 3:
|
|
2495
|
+
for (let i = 0;i < n; i++) {
|
|
2496
|
+
const a = i >= bpp ? row[i - bpp] : 0;
|
|
2497
|
+
out[off + i] = row[i] - (a + prev[i] >> 1) & 255;
|
|
2498
|
+
}
|
|
2499
|
+
return;
|
|
2500
|
+
default:
|
|
2501
|
+
for (let i = 0;i < n; i++) {
|
|
2502
|
+
const a = i >= bpp ? row[i - bpp] : 0;
|
|
2503
|
+
const c = i >= bpp ? prev[i - bpp] : 0;
|
|
2504
|
+
out[off + i] = row[i] - paeth(a, prev[i], c) & 255;
|
|
2505
|
+
}
|
|
2506
|
+
return;
|
|
2507
|
+
}
|
|
2508
|
+
}
|
|
2509
|
+
function filterUnitBytes(h) {
|
|
2510
|
+
return Math.max(1, Math.ceil(CHANNELS[h.colourType] * h.bitDepth / 8));
|
|
2511
|
+
}
|
|
2512
|
+
function bytesPerRow(h) {
|
|
2513
|
+
return Math.ceil(h.width * CHANNELS[h.colourType] * h.bitDepth / 8);
|
|
2514
|
+
}
|
|
2515
|
+
function readIhdr(band, dataStart, at) {
|
|
2516
|
+
const width = readU32(band, dataStart);
|
|
2517
|
+
const height = readU32(band, dataStart + 4);
|
|
2518
|
+
const bitDepth = band[dataStart + 8];
|
|
2519
|
+
const colourType = band[dataStart + 9];
|
|
2520
|
+
const compression = band[dataStart + 10];
|
|
2521
|
+
const filterMethod = band[dataStart + 11];
|
|
2522
|
+
const interlace = band[dataStart + 12];
|
|
2523
|
+
if (width < 1 || height < 1)
|
|
2524
|
+
throw new PngStitchError(`${at}: IHDR declares ${width}x${height}; both dimensions must be >= 1`);
|
|
2525
|
+
if (compression !== 0)
|
|
2526
|
+
throw new PngStitchError(`${at}: IHDR compression method ${compression}; PNG defines only 0 (deflate)`);
|
|
2527
|
+
if (filterMethod !== 0)
|
|
2528
|
+
throw new PngStitchError(`${at}: IHDR filter method ${filterMethod}; PNG defines only 0`);
|
|
2529
|
+
if (interlace === 1) {
|
|
2530
|
+
throw new PngStitchError(`${at}: interlaced (Adam7) PNG is not supported. Its seven reduced passes are not scanlines of the final image, ` + `so bands of them cannot be concatenated; re-capture without interlacing (Chrome's Page.captureScreenshot always does).`);
|
|
2531
|
+
}
|
|
2532
|
+
if (interlace !== 0)
|
|
2533
|
+
throw new PngStitchError(`${at}: IHDR interlace method ${interlace}; PNG defines only 0 (none) and 1 (Adam7)`);
|
|
2534
|
+
if (colourType === 3) {
|
|
2535
|
+
throw new PngStitchError(`${at}: colour type 3 (palette) is not supported. Concatenating palettized bands requires every band's PLTE/tRNS to ` + `be identical or a full palette remap; Chrome emits colour type 2, so this path is refused rather than guessed.`);
|
|
2536
|
+
}
|
|
2537
|
+
if (CHANNELS[colourType] === undefined)
|
|
2538
|
+
throw new PngStitchError(`${at}: IHDR colour type ${colourType}; PNG defines 0, 2, 3, 4 and 6`);
|
|
2539
|
+
if (!LEGAL_DEPTHS[colourType].includes(bitDepth)) {
|
|
2540
|
+
throw new PngStitchError(`${at}: bit depth ${bitDepth} is not legal for colour type ${colourType} (${COLOUR_TYPE_NAMES[colourType]}); ` + `allowed: ${LEGAL_DEPTHS[colourType].join(", ")}`);
|
|
2541
|
+
}
|
|
2542
|
+
return { width, height, bitDepth, colourType };
|
|
2543
|
+
}
|
|
2544
|
+
function parseBand(band, index) {
|
|
2545
|
+
const at = `band ${index}`;
|
|
2546
|
+
if (band.length < 8)
|
|
2547
|
+
throw new PngStitchError(`${at}: ${band.length} bytes is too short to be a PNG`);
|
|
2548
|
+
for (let i = 0;i < 8; i++) {
|
|
2549
|
+
if (band[i] !== PNG_SIGNATURE[i])
|
|
2550
|
+
throw new PngStitchError(`${at}: not a PNG (bad 8-byte signature)`);
|
|
2551
|
+
}
|
|
2552
|
+
let header;
|
|
2553
|
+
const idat = [];
|
|
2554
|
+
const carried = [];
|
|
2555
|
+
let sawIend = false;
|
|
2556
|
+
let off = 8;
|
|
2557
|
+
while (off + 8 <= band.length) {
|
|
2558
|
+
const len = readU32(band, off);
|
|
2559
|
+
const type = chunkTypeAt(band, off + 4);
|
|
2560
|
+
const dataStart = off + 8;
|
|
2561
|
+
const dataEnd = dataStart + len;
|
|
2562
|
+
if (dataEnd + 4 > band.length) {
|
|
2563
|
+
throw new PngStitchError(`${at}: chunk "${type}" at byte ${off} claims ${len} payload bytes but only ${band.length - dataStart} remain (truncated PNG)`);
|
|
2564
|
+
}
|
|
2565
|
+
if (type === "IHDR") {
|
|
2566
|
+
if (off !== 8)
|
|
2567
|
+
throw new PngStitchError(`${at}: IHDR must be the first chunk, found it at byte ${off}`);
|
|
2568
|
+
if (len !== 13)
|
|
2569
|
+
throw new PngStitchError(`${at}: IHDR payload is ${len} bytes, must be 13`);
|
|
2570
|
+
const want = readU32(band, dataEnd);
|
|
2571
|
+
const got = crc32(band.subarray(off + 4, dataEnd));
|
|
2572
|
+
if (want !== got)
|
|
2573
|
+
throw new PngStitchError(`${at}: IHDR CRC mismatch (file says 0x${want.toString(16)}, computed 0x${got.toString(16)})`);
|
|
2574
|
+
header = readIhdr(band, dataStart, at);
|
|
2575
|
+
} else if (!header) {
|
|
2576
|
+
throw new PngStitchError(`${at}: first chunk is "${type}", expected IHDR`);
|
|
2577
|
+
} else if (type === "IDAT") {
|
|
2578
|
+
idat.push([dataStart, dataEnd]);
|
|
2579
|
+
} else if (type === "IEND") {
|
|
2580
|
+
sawIend = true;
|
|
2581
|
+
break;
|
|
2582
|
+
} else if (CARRIED_CHUNKS.includes(type)) {
|
|
2583
|
+
carried.push({ type, data: band.subarray(dataStart, dataEnd) });
|
|
2584
|
+
}
|
|
2585
|
+
off = dataEnd + 4;
|
|
2586
|
+
}
|
|
2587
|
+
if (!header)
|
|
2588
|
+
throw new PngStitchError(`${at}: no IHDR chunk`);
|
|
2589
|
+
if (idat.length === 0)
|
|
2590
|
+
throw new PngStitchError(`${at}: no IDAT chunk`);
|
|
2591
|
+
if (!sawIend)
|
|
2592
|
+
throw new PngStitchError(`${at}: no IEND chunk (truncated PNG)`);
|
|
2593
|
+
return { header, idat, carried };
|
|
2594
|
+
}
|
|
2595
|
+
function assertCompatible(first, next, firstCarried, nextCarried, index) {
|
|
2596
|
+
const at = `band ${index}`;
|
|
2597
|
+
if (next.width !== first.width)
|
|
2598
|
+
throw new PngStitchError(`${at}: width ${next.width} does not match band 1's width ${first.width}`);
|
|
2599
|
+
if (next.bitDepth !== first.bitDepth)
|
|
2600
|
+
throw new PngStitchError(`${at}: bit depth ${next.bitDepth} does not match band 1's bit depth ${first.bitDepth}`);
|
|
2601
|
+
if (next.colourType !== first.colourType) {
|
|
2602
|
+
throw new PngStitchError(`${at}: colour type ${next.colourType} (${COLOUR_TYPE_NAMES[next.colourType] ?? "?"}) does not match band 1's ` + `colour type ${first.colourType} (${COLOUR_TYPE_NAMES[first.colourType] ?? "?"})`);
|
|
2603
|
+
}
|
|
2604
|
+
for (const type of CARRIED_CHUNKS) {
|
|
2605
|
+
const a = firstCarried.find((c) => c.type === type);
|
|
2606
|
+
const b = nextCarried.find((c) => c.type === type);
|
|
2607
|
+
if (!a && !b)
|
|
2608
|
+
continue;
|
|
2609
|
+
if (!a || !b)
|
|
2610
|
+
throw new PngStitchError(`${at}: ${b ? "has" : "is missing"} a "${type}" chunk that band 1 ${b ? "does not have" : "has"}; the bands disagree about how their samples are interpreted`);
|
|
2611
|
+
if (!sameBytes(a.data, b.data))
|
|
2612
|
+
throw new PngStitchError(`${at}: its "${type}" chunk differs from band 1's; the bands disagree about how their samples are interpreted`);
|
|
2613
|
+
}
|
|
2614
|
+
}
|
|
2615
|
+
|
|
2616
|
+
class ChunkWriter {
|
|
2617
|
+
fh;
|
|
2618
|
+
pos = 0;
|
|
2619
|
+
constructor(fh) {
|
|
2620
|
+
this.fh = fh;
|
|
2621
|
+
}
|
|
2622
|
+
get bytes() {
|
|
2623
|
+
return this.pos;
|
|
2624
|
+
}
|
|
2625
|
+
async writeRaw(b) {
|
|
2626
|
+
await this.fh.write(b, 0, b.length, this.pos);
|
|
2627
|
+
this.pos += b.length;
|
|
2628
|
+
}
|
|
2629
|
+
async writeChunk(type, payload) {
|
|
2630
|
+
const head = Buffer.allocUnsafe(8);
|
|
2631
|
+
head.writeUInt32BE(payload.length, 0);
|
|
2632
|
+
head.write(type, 4, "latin1");
|
|
2633
|
+
const crc = (crcUpdate(crcUpdate(4294967295, head.subarray(4)), payload) ^ 4294967295) >>> 0;
|
|
2634
|
+
const tail = Buffer.allocUnsafe(4);
|
|
2635
|
+
tail.writeUInt32BE(crc, 0);
|
|
2636
|
+
await this.fh.writev([head, payload, tail], this.pos);
|
|
2637
|
+
this.pos += 8 + payload.length + 4;
|
|
2638
|
+
}
|
|
2639
|
+
async patchAt(position, b) {
|
|
2640
|
+
await this.fh.write(b, 0, b.length, position);
|
|
2641
|
+
}
|
|
2642
|
+
}
|
|
2643
|
+
function ihdrChunk(h, height) {
|
|
2644
|
+
const buf = Buffer.allocUnsafe(25);
|
|
2645
|
+
buf.writeUInt32BE(13, 0);
|
|
2646
|
+
buf.write("IHDR", 4, "latin1");
|
|
2647
|
+
buf.writeUInt32BE(h.width, 8);
|
|
2648
|
+
buf.writeUInt32BE(height, 12);
|
|
2649
|
+
buf[16] = h.bitDepth;
|
|
2650
|
+
buf[17] = h.colourType;
|
|
2651
|
+
buf[18] = 0;
|
|
2652
|
+
buf[19] = 0;
|
|
2653
|
+
buf[20] = 0;
|
|
2654
|
+
buf.writeUInt32BE(crc32(buf.subarray(4, 21)), 21);
|
|
2655
|
+
return buf;
|
|
2656
|
+
}
|
|
2657
|
+
|
|
2658
|
+
class RowEncoder {
|
|
2659
|
+
writer;
|
|
2660
|
+
bpr;
|
|
2661
|
+
bpp;
|
|
2662
|
+
deflate = createDeflate({ chunkSize: ZLIB_CHUNK });
|
|
2663
|
+
pump;
|
|
2664
|
+
rowsPerBlock;
|
|
2665
|
+
block;
|
|
2666
|
+
blockRows = 0;
|
|
2667
|
+
candNone;
|
|
2668
|
+
candHeuristic;
|
|
2669
|
+
prevRaw;
|
|
2670
|
+
constructor(writer, bpr, bpp) {
|
|
2671
|
+
this.writer = writer;
|
|
2672
|
+
this.bpr = bpr;
|
|
2673
|
+
this.bpp = bpp;
|
|
2674
|
+
this.rowsPerBlock = Math.min(MAX_BLOCK_ROWS, Math.max(1, Math.floor(BLOCK_RAW_BYTES / bpr)));
|
|
2675
|
+
this.block = Buffer.allocUnsafe(this.rowsPerBlock * bpr);
|
|
2676
|
+
this.candNone = Buffer.allocUnsafe(this.rowsPerBlock * (bpr + 1));
|
|
2677
|
+
this.candHeuristic = Buffer.allocUnsafe(this.rowsPerBlock * (bpr + 1));
|
|
2678
|
+
this.prevRaw = new Uint8Array(bpr);
|
|
2679
|
+
this.pump = (async () => {
|
|
2680
|
+
for await (const chunk of this.deflate)
|
|
2681
|
+
await this.writer.writeChunk("IDAT", chunk);
|
|
2682
|
+
})();
|
|
2683
|
+
this.pump.catch(() => {});
|
|
2684
|
+
}
|
|
2685
|
+
async addRow(raw) {
|
|
2686
|
+
this.block.set(raw, this.blockRows * this.bpr);
|
|
2687
|
+
this.blockRows += 1;
|
|
2688
|
+
if (this.blockRows === this.rowsPerBlock)
|
|
2689
|
+
await this.emitBlock();
|
|
2690
|
+
}
|
|
2691
|
+
async emitBlock() {
|
|
2692
|
+
const n = this.blockRows;
|
|
2693
|
+
if (n === 0)
|
|
2694
|
+
return;
|
|
2695
|
+
const { bpr, bpp } = this;
|
|
2696
|
+
const stride = bpr + 1;
|
|
2697
|
+
let prev = this.prevRaw;
|
|
2698
|
+
for (let r = 0;r < n; r++) {
|
|
2699
|
+
const row = this.block.subarray(r * bpr, (r + 1) * bpr);
|
|
2700
|
+
const at = r * stride;
|
|
2701
|
+
this.candNone[at] = 0;
|
|
2702
|
+
this.candNone.set(row, at + 1);
|
|
2703
|
+
const filter = chooseFilter(row, prev, bpp);
|
|
2704
|
+
this.candHeuristic[at] = filter;
|
|
2705
|
+
writeFilteredRow(filter, row, prev, bpp, this.candHeuristic, at + 1);
|
|
2706
|
+
prev = row;
|
|
2707
|
+
}
|
|
2708
|
+
const used = n * stride;
|
|
2709
|
+
const a = this.candNone.subarray(0, used);
|
|
2710
|
+
const b = this.candHeuristic.subarray(0, used);
|
|
2711
|
+
const heuristicWins = deflateSync(b, { level: DECIDE_LEVEL }).length < deflateSync(a, { level: DECIDE_LEVEL }).length;
|
|
2712
|
+
this.prevRaw.set(prev);
|
|
2713
|
+
this.blockRows = 0;
|
|
2714
|
+
const out = Buffer.from(heuristicWins ? b : a);
|
|
2715
|
+
if (!this.deflate.write(out))
|
|
2716
|
+
await once(this.deflate, "drain");
|
|
2717
|
+
}
|
|
2718
|
+
async finish() {
|
|
2719
|
+
await this.emitBlock();
|
|
2720
|
+
this.deflate.end();
|
|
2721
|
+
await this.pump;
|
|
2722
|
+
}
|
|
2723
|
+
async abort() {
|
|
2724
|
+
this.deflate.destroy();
|
|
2725
|
+
await this.pump.catch(() => {});
|
|
2726
|
+
}
|
|
2727
|
+
}
|
|
2728
|
+
async function* inflateBandIdat(band, slices, at) {
|
|
2729
|
+
const inf = createInflate({ chunkSize: ZLIB_CHUNK });
|
|
2730
|
+
const feed = (async () => {
|
|
2731
|
+
for (const [start, end] of slices) {
|
|
2732
|
+
const view = Buffer.from(band.buffer, band.byteOffset + start, end - start);
|
|
2733
|
+
if (!inf.write(view))
|
|
2734
|
+
await once(inf, "drain");
|
|
2735
|
+
}
|
|
2736
|
+
inf.end();
|
|
2737
|
+
})();
|
|
2738
|
+
feed.catch(() => {});
|
|
2739
|
+
try {
|
|
2740
|
+
for await (const chunk of inf)
|
|
2741
|
+
yield chunk;
|
|
2742
|
+
} catch (err) {
|
|
2743
|
+
throw new PngStitchError(`${at}: IDAT stream would not inflate (${err.message})`);
|
|
2744
|
+
} finally {
|
|
2745
|
+
inf.destroy();
|
|
2746
|
+
}
|
|
2747
|
+
}
|
|
2748
|
+
function isAsyncIterable(x) {
|
|
2749
|
+
return typeof x === "object" && x !== null && Symbol.asyncIterator in x;
|
|
2750
|
+
}
|
|
2751
|
+
async function* eachBand(bands) {
|
|
2752
|
+
if (isAsyncIterable(bands)) {
|
|
2753
|
+
for await (const b of bands)
|
|
2754
|
+
yield b;
|
|
2755
|
+
} else {
|
|
2756
|
+
for (const b of bands)
|
|
2757
|
+
yield b;
|
|
2758
|
+
}
|
|
2759
|
+
}
|
|
2760
|
+
async function stitchPngBandsToFile(bands, outPath) {
|
|
2761
|
+
await mkdir4(dirname(outPath), { recursive: true });
|
|
2762
|
+
const partPath = `${outPath}.stitch-partial`;
|
|
2763
|
+
const fh = await open(partPath, "w");
|
|
2764
|
+
let done = false;
|
|
2765
|
+
try {
|
|
2766
|
+
const result = await stitchInto(fh, bands);
|
|
2767
|
+
await fh.close();
|
|
2768
|
+
await rename(partPath, outPath);
|
|
2769
|
+
done = true;
|
|
2770
|
+
return result;
|
|
2771
|
+
} finally {
|
|
2772
|
+
if (!done) {
|
|
2773
|
+
await fh.close().catch(() => {});
|
|
2774
|
+
await rm(partPath, { force: true }).catch(() => {});
|
|
2775
|
+
}
|
|
2776
|
+
}
|
|
2777
|
+
}
|
|
2778
|
+
async function stitchInto(fh, bands) {
|
|
2779
|
+
const writer = new ChunkWriter(fh);
|
|
2780
|
+
let first;
|
|
2781
|
+
let firstCarried = [];
|
|
2782
|
+
let encoder;
|
|
2783
|
+
let prevRow = new Uint8Array(0);
|
|
2784
|
+
let rowBuf = new Uint8Array(0);
|
|
2785
|
+
let bpp = 0;
|
|
2786
|
+
let bpr = 0;
|
|
2787
|
+
let totalRows = 0;
|
|
2788
|
+
let index = 0;
|
|
2789
|
+
try {
|
|
2790
|
+
for await (const band of eachBand(bands)) {
|
|
2791
|
+
index += 1;
|
|
2792
|
+
const parsed = parseBand(band, index);
|
|
2793
|
+
const at = `band ${index}`;
|
|
2794
|
+
if (!first) {
|
|
2795
|
+
first = parsed.header;
|
|
2796
|
+
firstCarried = parsed.carried;
|
|
2797
|
+
bpp = filterUnitBytes(first);
|
|
2798
|
+
bpr = bytesPerRow(first);
|
|
2799
|
+
prevRow = new Uint8Array(bpr);
|
|
2800
|
+
rowBuf = new Uint8Array(1 + bpr);
|
|
2801
|
+
await writer.writeRaw(PNG_SIGNATURE);
|
|
2802
|
+
await writer.writeRaw(ihdrChunk(first, 1));
|
|
2803
|
+
for (const c of parsed.carried)
|
|
2804
|
+
await writer.writeChunk(c.type, c.data);
|
|
2805
|
+
encoder = new RowEncoder(writer, bpr, bpp);
|
|
2806
|
+
} else {
|
|
2807
|
+
assertCompatible(first, parsed.header, firstCarried, parsed.carried, index);
|
|
2808
|
+
}
|
|
2809
|
+
const enc = encoder;
|
|
2810
|
+
const raw = rowBuf.subarray(1);
|
|
2811
|
+
prevRow.fill(0);
|
|
2812
|
+
let rows = 0;
|
|
2813
|
+
let filled = 0;
|
|
2814
|
+
for await (const chunk of inflateBandIdat(band, parsed.idat, at)) {
|
|
2815
|
+
let off = 0;
|
|
2816
|
+
while (off < chunk.length) {
|
|
2817
|
+
const take = Math.min(rowBuf.length - filled, chunk.length - off);
|
|
2818
|
+
rowBuf.set(chunk.subarray(off, off + take), filled);
|
|
2819
|
+
filled += take;
|
|
2820
|
+
off += take;
|
|
2821
|
+
if (filled !== rowBuf.length)
|
|
2822
|
+
continue;
|
|
2823
|
+
if (rows >= parsed.header.height) {
|
|
2824
|
+
throw new PngStitchError(`${at}: IDAT holds more scanlines than its IHDR height of ${parsed.header.height}`);
|
|
2825
|
+
}
|
|
2826
|
+
unfilterRow(rowBuf[0], raw, prevRow, bpp, at);
|
|
2827
|
+
await enc.addRow(raw);
|
|
2828
|
+
prevRow.set(raw);
|
|
2829
|
+
rows += 1;
|
|
2830
|
+
filled = 0;
|
|
2831
|
+
}
|
|
2832
|
+
}
|
|
2833
|
+
if (filled !== 0 || rows !== parsed.header.height) {
|
|
2834
|
+
throw new PngStitchError(`${at}: IDAT decoded to ${rows} complete scanlines${filled > 0 ? ` plus ${filled} trailing bytes` : ""}, but its IHDR declares ${parsed.header.height}`);
|
|
2835
|
+
}
|
|
2836
|
+
totalRows += parsed.header.height;
|
|
2837
|
+
if (totalRows > 2147483647)
|
|
2838
|
+
throw new PngStitchError(`stitched height would be ${totalRows}, past PNG's 2^31-1 limit`);
|
|
2839
|
+
}
|
|
2840
|
+
if (!first || !encoder)
|
|
2841
|
+
throw new PngStitchError("no bands to stitch");
|
|
2842
|
+
await encoder.finish();
|
|
2843
|
+
await writer.writeChunk("IEND", new Uint8Array(0));
|
|
2844
|
+
await writer.patchAt(8, ihdrChunk(first, totalRows));
|
|
2845
|
+
return { width: first.width, height: totalRows, bytes: writer.bytes, bands: index };
|
|
2846
|
+
} catch (err) {
|
|
2847
|
+
await encoder?.abort();
|
|
2848
|
+
throw err;
|
|
2849
|
+
}
|
|
2850
|
+
}
|
|
2851
|
+
|
|
2852
|
+
// src/shared-tools.ts
|
|
2133
2853
|
var ARTIFACT_DIR = process.env.CDP_ARTIFACT_DIR ?? "/tmp/cdp-toolkit";
|
|
2134
2854
|
var STATE_DIR = process.env.CDP_STATE_DIR ?? "/tmp/cdp-toolkit";
|
|
2135
2855
|
|
|
@@ -2274,8 +2994,8 @@ async function closePage(driver, args) {
|
|
|
2274
2994
|
return { closed: p.id, success: true, ...released.released ? { leaseReleased: true } : {} };
|
|
2275
2995
|
}
|
|
2276
2996
|
async function writeSelectedFile(id) {
|
|
2277
|
-
await
|
|
2278
|
-
await
|
|
2997
|
+
await mkdir5(STATE_DIR, { recursive: true });
|
|
2998
|
+
await writeFile4(join4(STATE_DIR, "selected"), id, "utf8");
|
|
2279
2999
|
}
|
|
2280
3000
|
async function selectPage(driver, args) {
|
|
2281
3001
|
if (args.target === undefined || args.target === "")
|
|
@@ -2339,10 +3059,10 @@ function serializeForSink(value) {
|
|
|
2339
3059
|
return json ?? "null";
|
|
2340
3060
|
}
|
|
2341
3061
|
async function writeJsonSink(savePath, value) {
|
|
2342
|
-
const path = savePath.startsWith("/") ? savePath :
|
|
2343
|
-
await
|
|
3062
|
+
const path = savePath.startsWith("/") ? savePath : join4(ARTIFACT_DIR, savePath);
|
|
3063
|
+
await mkdir5(dirname2(path), { recursive: true });
|
|
2344
3064
|
const json = serializeForSink(value);
|
|
2345
|
-
await
|
|
3065
|
+
await writeFile4(path, json, "utf8");
|
|
2346
3066
|
return { path, bytes: Buffer.byteLength(json, "utf8") };
|
|
2347
3067
|
}
|
|
2348
3068
|
function sinkRequested(savePath) {
|
|
@@ -2646,30 +3366,166 @@ async function uploadFile(driver, args) {
|
|
|
2646
3366
|
function stamp() {
|
|
2647
3367
|
return new Date(Date.now()).toISOString().replace(/[:.]/g, "-");
|
|
2648
3368
|
}
|
|
3369
|
+
var MAX_SCREENSHOT_SCALE = 8;
|
|
3370
|
+
function resolveScreenshotScale(scale, capabilities) {
|
|
3371
|
+
if (scale === undefined)
|
|
3372
|
+
return 1;
|
|
3373
|
+
if (typeof scale !== "number" || !Number.isFinite(scale) || scale <= 0 || scale > MAX_SCREENSHOT_SCALE) {
|
|
3374
|
+
throw new SharedToolError(`take_screenshot: 'scale' must be a finite number greater than 0 and at most ${MAX_SCREENSHOT_SCALE} (default 1)`);
|
|
3375
|
+
}
|
|
3376
|
+
if (scale !== 1 && !capabilities.has("screenshot.scale")) {
|
|
3377
|
+
throw new SharedToolError("take_screenshot: 'scale' is not supported by this backend (WebDriver BiDi's browsingContext.captureScreenshot has no scale parameter). " + `Call emulate with deviceScaleFactor:${scale} first and capture at scale 1, or use --browser chrome for a per-capture scale.`);
|
|
3378
|
+
}
|
|
3379
|
+
return scale;
|
|
3380
|
+
}
|
|
3381
|
+
var MAX_RENDER_SIZE_PX = 16384;
|
|
3382
|
+
function resolveRenderSize(renderWidth, renderHeight, capabilities) {
|
|
3383
|
+
if (renderWidth === undefined && renderHeight === undefined)
|
|
3384
|
+
return;
|
|
3385
|
+
if (renderWidth === undefined || renderHeight === undefined) {
|
|
3386
|
+
throw new SharedToolError("take_screenshot: 'renderWidth' and 'renderHeight' are required together (a viewport is two numbers; " + `got ${renderWidth === undefined ? "only renderHeight" : "only renderWidth"})`);
|
|
3387
|
+
}
|
|
3388
|
+
for (const [name, value] of [["renderWidth", renderWidth], ["renderHeight", renderHeight]]) {
|
|
3389
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value < 1 || value > MAX_RENDER_SIZE_PX) {
|
|
3390
|
+
throw new SharedToolError(`take_screenshot: '${name}' must be an integer between 1 and ${MAX_RENDER_SIZE_PX} css px (got ${JSON.stringify(value)})`);
|
|
3391
|
+
}
|
|
3392
|
+
}
|
|
3393
|
+
if (!capabilities.has("screenshot.renderSize")) {
|
|
3394
|
+
throw new SharedToolError("take_screenshot: 'renderWidth'/'renderHeight' are not supported by this backend (it cannot emulate a viewport for a capture). " + `Call emulate with width:${renderWidth}, height:${renderHeight} first, capture, then restore the viewport yourself.`);
|
|
3395
|
+
}
|
|
3396
|
+
return { width: renderWidth, height: renderHeight };
|
|
3397
|
+
}
|
|
3398
|
+
function resolveTileMode(tile, capabilities, format, returnBase64) {
|
|
3399
|
+
if (tile === undefined)
|
|
3400
|
+
return;
|
|
3401
|
+
if (typeof tile !== "boolean")
|
|
3402
|
+
throw new SharedToolError(`take_screenshot: 'tile' must be true, false, or omitted for automatic (got ${JSON.stringify(tile)})`);
|
|
3403
|
+
if (!tile)
|
|
3404
|
+
return false;
|
|
3405
|
+
if (!capabilities.has("screenshot.tile")) {
|
|
3406
|
+
throw new SharedToolError("take_screenshot: 'tile' is not supported by this backend (band tiling is declared only where the band arithmetic was measured against a real browser; " + "Firefox was not driven for it). Capture a smaller region or a single element, or use --browser chrome for a tiled capture.");
|
|
3407
|
+
}
|
|
3408
|
+
if (format !== "png")
|
|
3409
|
+
throw tiledCaptureRefusal("format");
|
|
3410
|
+
if (returnBase64)
|
|
3411
|
+
throw tiledCaptureRefusal("base64");
|
|
3412
|
+
return true;
|
|
3413
|
+
}
|
|
3414
|
+
function tiledCaptureRefusal(reason, banded) {
|
|
3415
|
+
const because = banded ? ` This capture needs ${banded.bandCount} bands (${banded.clip.width}x${banded.clip.height} css px is past what one image can encode).` : "";
|
|
3416
|
+
if (reason === "format") {
|
|
3417
|
+
return new SharedToolError("take_screenshot: a tiled capture is PNG-only — bands are stitched by concatenating PNG scanlines, and there is no lossless " + `JPEG equivalent to concatenate.${because} Pass format:"png", or lower scale / narrow the region until one JPEG fits.`);
|
|
3418
|
+
}
|
|
3419
|
+
return new SharedToolError("take_screenshot: 'returnBase64' cannot be combined with a tiled capture — a tiled image runs to hundreds of megabytes and base64 " + `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.`);
|
|
3420
|
+
}
|
|
3421
|
+
function pngPixelSize(b, view) {
|
|
3422
|
+
if (b.length < 24)
|
|
3423
|
+
return;
|
|
3424
|
+
if (view.getUint32(0) !== 2303741511 || view.getUint32(4) !== 218765834)
|
|
3425
|
+
return;
|
|
3426
|
+
if (String.fromCharCode(b[12], b[13], b[14], b[15]) !== "IHDR")
|
|
3427
|
+
return;
|
|
3428
|
+
return { width: view.getUint32(16), height: view.getUint32(20) };
|
|
3429
|
+
}
|
|
3430
|
+
var JPEG_SOF = new Set([192, 193, 194, 195, 197, 198, 199, 201, 202, 203, 205, 206, 207]);
|
|
3431
|
+
function jpegPixelSize(b, view) {
|
|
3432
|
+
if (b.length < 4 || b[0] !== 255 || b[1] !== 216)
|
|
3433
|
+
return;
|
|
3434
|
+
let i = 2;
|
|
3435
|
+
while (i + 3 < b.length) {
|
|
3436
|
+
if (b[i] !== 255) {
|
|
3437
|
+
i += 1;
|
|
3438
|
+
continue;
|
|
3439
|
+
}
|
|
3440
|
+
const marker = b[i + 1];
|
|
3441
|
+
if (marker === 255) {
|
|
3442
|
+
i += 1;
|
|
3443
|
+
continue;
|
|
3444
|
+
}
|
|
3445
|
+
if (marker === 216 || marker === 1 || marker >= 208 && marker <= 215) {
|
|
3446
|
+
i += 2;
|
|
3447
|
+
continue;
|
|
3448
|
+
}
|
|
3449
|
+
const segLen = view.getUint16(i + 2);
|
|
3450
|
+
if (JPEG_SOF.has(marker)) {
|
|
3451
|
+
if (i + 9 > b.length)
|
|
3452
|
+
return;
|
|
3453
|
+
return { width: view.getUint16(i + 7), height: view.getUint16(i + 5) };
|
|
3454
|
+
}
|
|
3455
|
+
if (segLen < 2)
|
|
3456
|
+
return;
|
|
3457
|
+
i += 2 + segLen;
|
|
3458
|
+
}
|
|
3459
|
+
return;
|
|
3460
|
+
}
|
|
3461
|
+
function imagePixelSize(bytes) {
|
|
3462
|
+
if (bytes.byteLength < 4)
|
|
3463
|
+
return;
|
|
3464
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
3465
|
+
return pngPixelSize(bytes, view) ?? jpegPixelSize(bytes, view);
|
|
3466
|
+
}
|
|
2649
3467
|
async function takeScreenshot(driver, args = {}) {
|
|
2650
3468
|
if (args.uid !== undefined && args.uid !== "" && args.selector)
|
|
2651
3469
|
throw new SharedToolError("provide exactly one of uid or selector, not both");
|
|
2652
3470
|
const format = args.format ?? "png";
|
|
3471
|
+
const scale = resolveScreenshotScale(args.scale, driver.capabilities);
|
|
3472
|
+
const renderSize = resolveRenderSize(args.renderWidth, args.renderHeight, driver.capabilities);
|
|
3473
|
+
const tile = resolveTileMode(args.tile, driver.capabilities, format, args.returnBase64);
|
|
2653
3474
|
return withPage2(driver, args.target, async (page) => {
|
|
2654
3475
|
const clip = args.uid !== undefined || args.selector ? locatorOf(args) : undefined;
|
|
2655
|
-
const
|
|
2656
|
-
|
|
2657
|
-
|
|
2658
|
-
|
|
2659
|
-
|
|
2660
|
-
|
|
2661
|
-
|
|
2662
|
-
|
|
2663
|
-
path,
|
|
2664
|
-
bytes: data.byteLength,
|
|
2665
|
-
format: outFormat,
|
|
2666
|
-
target: target3(page.info)
|
|
3476
|
+
const ext = format === "jpeg" ? "jpg" : "png";
|
|
3477
|
+
const path = args.savePath ?? join4(ARTIFACT_DIR, `screenshot-${page.info.id.slice(0, 8)}-${stamp()}.${ext}`);
|
|
3478
|
+
const stitch = async (banded) => {
|
|
3479
|
+
if (format !== "png")
|
|
3480
|
+
throw tiledCaptureRefusal("format", banded);
|
|
3481
|
+
if (args.returnBase64)
|
|
3482
|
+
throw tiledCaptureRefusal("base64", banded);
|
|
3483
|
+
return stitchPngBandsToFile(banded.bands, path);
|
|
2667
3484
|
};
|
|
2668
|
-
|
|
2669
|
-
|
|
3485
|
+
const shot = await page.screenshot({
|
|
3486
|
+
format,
|
|
3487
|
+
quality: args.quality,
|
|
3488
|
+
fullPage: args.fullPage,
|
|
3489
|
+
scale,
|
|
3490
|
+
...clip ? { clip } : {},
|
|
3491
|
+
...renderSize ? { renderWidth: renderSize.width, renderHeight: renderSize.height } : {},
|
|
3492
|
+
...tile !== undefined ? { tile } : {}
|
|
3493
|
+
}, stitch);
|
|
3494
|
+
const result = isTiledCapture(shot) ? {
|
|
3495
|
+
path,
|
|
3496
|
+
bytes: shot.consumed.bytes,
|
|
3497
|
+
format: "png",
|
|
3498
|
+
target: target3(page.info),
|
|
3499
|
+
scale,
|
|
3500
|
+
width: shot.consumed.width,
|
|
3501
|
+
height: shot.consumed.height,
|
|
3502
|
+
tiled: true,
|
|
3503
|
+
bands: shot.consumed.bands
|
|
3504
|
+
} : await writeOneShot(shot.data, shot.format, path, target3(page.info), scale, args.returnBase64);
|
|
3505
|
+
if (renderSize && shot.renderSize) {
|
|
3506
|
+
result.renderSize = shot.renderSize;
|
|
3507
|
+
result.renderRestored = shot.renderRestored ?? false;
|
|
3508
|
+
if (shot.renderRestoreError !== undefined)
|
|
3509
|
+
result.renderRestoreError = shot.renderRestoreError;
|
|
3510
|
+
}
|
|
2670
3511
|
return result;
|
|
2671
3512
|
});
|
|
2672
3513
|
}
|
|
3514
|
+
async function writeOneShot(data, outFormat, path, target, scale, returnBase64) {
|
|
3515
|
+
if (data.byteLength === 0)
|
|
3516
|
+
throw new SharedToolError("captureScreenshot returned empty data");
|
|
3517
|
+
await mkdir5(dirname2(path), { recursive: true });
|
|
3518
|
+
await writeFile4(path, Buffer.from(data));
|
|
3519
|
+
const size = imagePixelSize(data);
|
|
3520
|
+
const result = { path, bytes: data.byteLength, format: outFormat, target, scale };
|
|
3521
|
+
if (size) {
|
|
3522
|
+
result.width = size.width;
|
|
3523
|
+
result.height = size.height;
|
|
3524
|
+
}
|
|
3525
|
+
if (returnBase64)
|
|
3526
|
+
result.base64 = Buffer.from(data).toString("base64");
|
|
3527
|
+
return result;
|
|
3528
|
+
}
|
|
2673
3529
|
async function emulate(driver, args = {}) {
|
|
2674
3530
|
return withPage2(driver, args.target, async (page) => {
|
|
2675
3531
|
if (args.clearOverrides) {
|
|
@@ -2840,10 +3696,10 @@ var LEASE_TOOLS = {
|
|
|
2840
3696
|
};
|
|
2841
3697
|
|
|
2842
3698
|
// src/tools/console.ts
|
|
2843
|
-
import { readFile as
|
|
3699
|
+
import { readFile as readFile4 } from "node:fs/promises";
|
|
2844
3700
|
|
|
2845
3701
|
// src/tools/recorder.ts
|
|
2846
|
-
import { mkdir as
|
|
3702
|
+
import { mkdir as mkdir6, appendFile, writeFile as writeFile5, copyFile } from "node:fs/promises";
|
|
2847
3703
|
var ARTIFACT_DIR2 = process.env.CDP_ARTIFACT_DIR ?? "/tmp/cdp-toolkit";
|
|
2848
3704
|
function recFile(targetId) {
|
|
2849
3705
|
return `${ARTIFACT_DIR2}/rec-${targetId}.jsonl`;
|
|
@@ -2889,11 +3745,11 @@ var CONSOLE_EVENTS = ["Runtime.consoleAPICalled", "Runtime.exceptionThrown", "Lo
|
|
|
2889
3745
|
async function startRecorder(target, opts = {}) {
|
|
2890
3746
|
const network = opts.network ?? true;
|
|
2891
3747
|
const consoleCap = opts.console ?? true;
|
|
2892
|
-
await
|
|
3748
|
+
await mkdir6(ARTIFACT_DIR2, { recursive: true });
|
|
2893
3749
|
const { conn, target: resolved } = await openPage(target, { timeoutMs: opts.timeoutMs });
|
|
2894
3750
|
const file = opts.file ?? recFile(resolved.id);
|
|
2895
3751
|
if (opts.truncate) {
|
|
2896
|
-
await
|
|
3752
|
+
await writeFile5(file, "");
|
|
2897
3753
|
}
|
|
2898
3754
|
let chain = Promise.resolve();
|
|
2899
3755
|
let dropped = 0;
|
|
@@ -3039,7 +3895,7 @@ function lineToEntry(line, index) {
|
|
|
3039
3895
|
async function readConsoleEntries(file) {
|
|
3040
3896
|
let raw;
|
|
3041
3897
|
try {
|
|
3042
|
-
raw = await
|
|
3898
|
+
raw = await readFile4(file, "utf8");
|
|
3043
3899
|
} catch {
|
|
3044
3900
|
return [];
|
|
3045
3901
|
}
|
|
@@ -3096,7 +3952,7 @@ async function getConsoleMessage(args = {}) {
|
|
|
3096
3952
|
}
|
|
3097
3953
|
|
|
3098
3954
|
// src/tools/network.ts
|
|
3099
|
-
import { readFile as
|
|
3955
|
+
import { readFile as readFile5 } from "node:fs/promises";
|
|
3100
3956
|
var DEFAULT_CAPTURE_MS2 = 2500;
|
|
3101
3957
|
function asHeaders(v) {
|
|
3102
3958
|
if (!v || typeof v !== "object")
|
|
@@ -3163,7 +4019,7 @@ function correlate(lines) {
|
|
|
3163
4019
|
async function readNetworkRequests(file) {
|
|
3164
4020
|
let raw;
|
|
3165
4021
|
try {
|
|
3166
|
-
raw = await
|
|
4022
|
+
raw = await readFile5(file, "utf8");
|
|
3167
4023
|
} catch {
|
|
3168
4024
|
return [];
|
|
3169
4025
|
}
|
|
@@ -3247,8 +4103,8 @@ async function getNetworkRequest(args = {}) {
|
|
|
3247
4103
|
}
|
|
3248
4104
|
|
|
3249
4105
|
// src/tools/performance.ts
|
|
3250
|
-
import { mkdir as
|
|
3251
|
-
import { join as
|
|
4106
|
+
import { mkdir as mkdir7, readFile as readFile6, writeFile as writeFile6, stat, unlink as unlink4 } from "node:fs/promises";
|
|
4107
|
+
import { join as join5 } from "node:path";
|
|
3252
4108
|
var ARTIFACT_DIR3 = process.env.CDP_ARTIFACT_DIR ?? "/tmp/cdp-toolkit";
|
|
3253
4109
|
var STATE_DIR2 = process.env.CDP_STATE_DIR ?? "/tmp/cdp-toolkit";
|
|
3254
4110
|
var DEFAULT_CATEGORIES = [
|
|
@@ -3266,7 +4122,7 @@ function shortId(id) {
|
|
|
3266
4122
|
return id.slice(0, 8);
|
|
3267
4123
|
}
|
|
3268
4124
|
function stateFilePath() {
|
|
3269
|
-
return
|
|
4125
|
+
return join5(STATE_DIR2, "trace-state.json");
|
|
3270
4126
|
}
|
|
3271
4127
|
function bufferTraceData(conn, sink) {
|
|
3272
4128
|
return conn.on("Tracing.dataCollected", (params) => {
|
|
@@ -3288,10 +4144,10 @@ async function endTrace(conn, sink, timeoutMs = 30000) {
|
|
|
3288
4144
|
return sink;
|
|
3289
4145
|
}
|
|
3290
4146
|
async function writeTrace(events, targetId) {
|
|
3291
|
-
await
|
|
3292
|
-
const path =
|
|
4147
|
+
await mkdir7(ARTIFACT_DIR3, { recursive: true });
|
|
4148
|
+
const path = join5(ARTIFACT_DIR3, `trace-${shortId(targetId)}-${stamp2()}.json`);
|
|
3293
4149
|
const body = JSON.stringify({ traceEvents: events, metadata: { source: "cdp-toolkit" } });
|
|
3294
|
-
await
|
|
4150
|
+
await writeFile6(path, body, "utf8");
|
|
3295
4151
|
const { size } = await stat(path);
|
|
3296
4152
|
return { path, bytes: size };
|
|
3297
4153
|
}
|
|
@@ -3311,7 +4167,7 @@ async function performanceStartTrace(args = {}) {
|
|
|
3311
4167
|
throw err;
|
|
3312
4168
|
}
|
|
3313
4169
|
liveTraces.set(target.id, { conn, target, events, startedAt: Date.now() });
|
|
3314
|
-
await
|
|
4170
|
+
await mkdir7(STATE_DIR2, { recursive: true });
|
|
3315
4171
|
const state = {
|
|
3316
4172
|
targetId: target.id,
|
|
3317
4173
|
url: target.url,
|
|
@@ -3319,7 +4175,7 @@ async function performanceStartTrace(args = {}) {
|
|
|
3319
4175
|
pid: process.pid,
|
|
3320
4176
|
categories
|
|
3321
4177
|
};
|
|
3322
|
-
await
|
|
4178
|
+
await writeFile6(stateFilePath(), JSON.stringify(state), "utf8");
|
|
3323
4179
|
return {
|
|
3324
4180
|
target: { id: target.id, url: target.url, title: target.title },
|
|
3325
4181
|
categories,
|
|
@@ -3332,7 +4188,7 @@ async function performanceStopTrace(args = {}) {
|
|
|
3332
4188
|
if (!entry) {
|
|
3333
4189
|
let hint = "";
|
|
3334
4190
|
try {
|
|
3335
|
-
const raw = await
|
|
4191
|
+
const raw = await readFile6(stateFilePath(), "utf8");
|
|
3336
4192
|
const state = JSON.parse(raw);
|
|
3337
4193
|
hint = state.pid === process.pid ? ` (state file lists target ${shortId(state.targetId)} but its live connection is gone)` : ` (trace was started by a DIFFERENT process pid=${state.pid}; live recordings cannot cross process boundaries)`;
|
|
3338
4194
|
} catch {}
|
|
@@ -3344,7 +4200,7 @@ async function performanceStopTrace(args = {}) {
|
|
|
3344
4200
|
} finally {
|
|
3345
4201
|
conn.close();
|
|
3346
4202
|
liveTraces.delete(target.id);
|
|
3347
|
-
await
|
|
4203
|
+
await unlink4(stateFilePath()).catch(() => {});
|
|
3348
4204
|
}
|
|
3349
4205
|
const written = await writeTrace(events, target.id);
|
|
3350
4206
|
const metrics = analyzeEvents(events);
|
|
@@ -3462,7 +4318,7 @@ async function performanceAnalyzeInsight(args = {}) {
|
|
|
3462
4318
|
}
|
|
3463
4319
|
let raw;
|
|
3464
4320
|
try {
|
|
3465
|
-
raw = await
|
|
4321
|
+
raw = await readFile6(args.tracePath, "utf8");
|
|
3466
4322
|
} catch (err) {
|
|
3467
4323
|
throw new CdpError(`cannot read trace file '${args.tracePath}': ${err.message}`);
|
|
3468
4324
|
}
|
|
@@ -3480,8 +4336,8 @@ async function performanceAnalyzeInsight(args = {}) {
|
|
|
3480
4336
|
|
|
3481
4337
|
// src/tools/screencast.ts
|
|
3482
4338
|
import { spawn } from "node:child_process";
|
|
3483
|
-
import { mkdir as
|
|
3484
|
-
import { join as
|
|
4339
|
+
import { mkdir as mkdir8, rm as rm2, stat as stat2, writeFile as writeFile7 } from "node:fs/promises";
|
|
4340
|
+
import { join as join6 } from "node:path";
|
|
3485
4341
|
var ARTIFACT_DIR4 = process.env.CDP_ARTIFACT_DIR ?? "/tmp/cdp-toolkit";
|
|
3486
4342
|
var FFMPEG_BIN = process.env.CDP_FFMPEG ?? "ffmpeg";
|
|
3487
4343
|
function frameDurationsMs(ledger, stopWallclockMs) {
|
|
@@ -3537,7 +4393,7 @@ function pngSize(buf) {
|
|
|
3537
4393
|
return;
|
|
3538
4394
|
return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
|
|
3539
4395
|
}
|
|
3540
|
-
var
|
|
4396
|
+
var JPEG_SOF2 = new Set([192, 193, 194, 195, 197, 198, 199, 201, 202, 203, 205, 206, 207]);
|
|
3541
4397
|
function jpegSize(buf) {
|
|
3542
4398
|
if (buf.length < 4 || buf[0] !== 255 || buf[1] !== 216)
|
|
3543
4399
|
return;
|
|
@@ -3559,7 +4415,7 @@ function jpegSize(buf) {
|
|
|
3559
4415
|
if (i + 3 >= buf.length)
|
|
3560
4416
|
return;
|
|
3561
4417
|
const segLen = buf.readUInt16BE(i + 2);
|
|
3562
|
-
if (
|
|
4418
|
+
if (JPEG_SOF2.has(marker)) {
|
|
3563
4419
|
if (i + 9 > buf.length)
|
|
3564
4420
|
return;
|
|
3565
4421
|
return { width: buf.readUInt16BE(i + 7), height: buf.readUInt16BE(i + 5) };
|
|
@@ -3702,9 +4558,9 @@ async function startScreenRecording(args = {}) {
|
|
|
3702
4558
|
conn.close();
|
|
3703
4559
|
throw err;
|
|
3704
4560
|
}
|
|
3705
|
-
const spoolDir =
|
|
4561
|
+
const spoolDir = join6(ARTIFACT_DIR4, `screencast-${shortId2(target.id)}-${stamp3()}`);
|
|
3706
4562
|
try {
|
|
3707
|
-
await
|
|
4563
|
+
await mkdir8(spoolDir, { recursive: true });
|
|
3708
4564
|
} catch (err) {
|
|
3709
4565
|
conn.close();
|
|
3710
4566
|
throw new CdpError(`cannot create the screencast spool directory '${spoolDir}': ${err.message}`);
|
|
@@ -3745,7 +4601,7 @@ async function startScreenRecording(args = {}) {
|
|
|
3745
4601
|
seq += 1;
|
|
3746
4602
|
const file = `frame-${String(seq).padStart(6, "0")}.${ext}`;
|
|
3747
4603
|
rec.ledger.push({ file, timestamp, width: size.width, height: size.height });
|
|
3748
|
-
rec.writes = rec.writes.then(() =>
|
|
4604
|
+
rec.writes = rec.writes.then(() => writeFile7(join6(spoolDir, file), buf)).catch(() => {
|
|
3749
4605
|
rec.dropped += 1;
|
|
3750
4606
|
rec.unwritten.add(file);
|
|
3751
4607
|
});
|
|
@@ -3767,7 +4623,7 @@ async function startScreenRecording(args = {}) {
|
|
|
3767
4623
|
} catch (err) {
|
|
3768
4624
|
rec.off();
|
|
3769
4625
|
conn.close();
|
|
3770
|
-
await
|
|
4626
|
+
await rm2(spoolDir, { recursive: true, force: true }).catch(() => {});
|
|
3771
4627
|
throw err;
|
|
3772
4628
|
}
|
|
3773
4629
|
liveRecordings.set(target.id, rec);
|
|
@@ -3805,14 +4661,14 @@ async function stopScreenRecording(args = {}) {
|
|
|
3805
4661
|
liveRecordings.delete(rec.target.id);
|
|
3806
4662
|
const ledger = rec.unwritten.size ? rec.ledger.filter((entry) => !rec.unwritten.has(entry.file)) : rec.ledger;
|
|
3807
4663
|
if (!ledger.length) {
|
|
3808
|
-
await
|
|
4664
|
+
await rm2(rec.spoolDir, { recursive: true, force: true }).catch(() => {});
|
|
3809
4665
|
throw new CdpError(`screen recording captured 0 frames for target ${shortId2(rec.target.id)}. Chrome emits a screencast frame only on repaint: a fully backgrounded or occluded tab may never paint. Retry with bringToFront:true, or drive the page while recording.`);
|
|
3810
4666
|
}
|
|
3811
|
-
await
|
|
3812
|
-
const outPath = args.savePath ??
|
|
4667
|
+
await mkdir8(ARTIFACT_DIR4, { recursive: true });
|
|
4668
|
+
const outPath = args.savePath ?? join6(ARTIFACT_DIR4, `screen-recording-${shortId2(rec.target.id)}-${stamp3()}.mp4`);
|
|
3813
4669
|
const frames = coalesceToConcatGrid(ledger, stoppedAt);
|
|
3814
|
-
const manifestPath =
|
|
3815
|
-
await
|
|
4670
|
+
const manifestPath = join6(rec.spoolDir, "frames.ffconcat");
|
|
4671
|
+
await writeFile7(manifestPath, buildConcatManifest(frames), "utf8");
|
|
3816
4672
|
const durationMs = frames.reduce((total, f) => total + f.durationMs, 0);
|
|
3817
4673
|
const { width, height } = evenDimensions(ledger[0].width, ledger[0].height);
|
|
3818
4674
|
let res;
|
|
@@ -3826,7 +4682,7 @@ async function stopScreenRecording(args = {}) {
|
|
|
3826
4682
|
ffmpeg said: ${res.stderr.trim().slice(0, 800)}`);
|
|
3827
4683
|
}
|
|
3828
4684
|
const { size } = await stat2(outPath);
|
|
3829
|
-
await
|
|
4685
|
+
await rm2(rec.spoolDir, { recursive: true, force: true }).catch(() => {});
|
|
3830
4686
|
return {
|
|
3831
4687
|
path: outPath,
|
|
3832
4688
|
bytes: size,
|
|
@@ -3843,8 +4699,8 @@ ffmpeg said: ${res.stderr.trim().slice(0, 800)}`);
|
|
|
3843
4699
|
}
|
|
3844
4700
|
|
|
3845
4701
|
// src/tools/heap.ts
|
|
3846
|
-
import { mkdir as
|
|
3847
|
-
import { join as
|
|
4702
|
+
import { mkdir as mkdir9, writeFile as writeFile8 } from "node:fs/promises";
|
|
4703
|
+
import { join as join7 } from "node:path";
|
|
3848
4704
|
var ARTIFACT_DIR5 = process.env.CDP_ARTIFACT_DIR ?? "/tmp/cdp-toolkit";
|
|
3849
4705
|
function stamp4(targetId) {
|
|
3850
4706
|
const short = targetId.slice(0, 8);
|
|
@@ -3869,9 +4725,9 @@ async function takeHeapsnapshot(args = {}) {
|
|
|
3869
4725
|
if (snapshot.length === 0) {
|
|
3870
4726
|
throw new Error("HeapProfiler.takeHeapSnapshot produced no chunks");
|
|
3871
4727
|
}
|
|
3872
|
-
await
|
|
3873
|
-
const path = args.savePath ? args.savePath.startsWith("/") ? args.savePath :
|
|
3874
|
-
await
|
|
4728
|
+
await mkdir9(ARTIFACT_DIR5, { recursive: true });
|
|
4729
|
+
const path = args.savePath ? args.savePath.startsWith("/") ? args.savePath : join7(ARTIFACT_DIR5, args.savePath) : join7(ARTIFACT_DIR5, `take_heapsnapshot-${stamp4(target.id)}.heapsnapshot`);
|
|
4730
|
+
await writeFile8(path, snapshot, "utf8");
|
|
3875
4731
|
const bytes = Buffer.byteLength(snapshot, "utf8");
|
|
3876
4732
|
return {
|
|
3877
4733
|
path,
|
|
@@ -3884,7 +4740,7 @@ async function takeHeapsnapshot(args = {}) {
|
|
|
3884
4740
|
|
|
3885
4741
|
// src/tools/lighthouse.ts
|
|
3886
4742
|
import { spawn as spawn2 } from "node:child_process";
|
|
3887
|
-
import { mkdir as
|
|
4743
|
+
import { mkdir as mkdir10, readFile as readFile7, stat as stat3 } from "node:fs/promises";
|
|
3888
4744
|
var ARTIFACT_DIR6 = process.env.CDP_ARTIFACT_DIR ?? "/tmp/cdp-toolkit";
|
|
3889
4745
|
function cdpPort() {
|
|
3890
4746
|
const base = process.env.CDP_BASE ?? "http://127.0.0.1:9222";
|
|
@@ -3945,7 +4801,7 @@ async function lighthouseAudit(args) {
|
|
|
3945
4801
|
const url = args.url.trim();
|
|
3946
4802
|
const timeoutMs = args.timeoutMs ?? 120000;
|
|
3947
4803
|
const port = cdpPort();
|
|
3948
|
-
await
|
|
4804
|
+
await mkdir10(ARTIFACT_DIR6, { recursive: true });
|
|
3949
4805
|
const outPath = args.savePath ?? `${ARTIFACT_DIR6}/lighthouse-${shortHost(url)}-${stamp5()}.json`;
|
|
3950
4806
|
const formFactor = args.formFactor ?? "desktop";
|
|
3951
4807
|
const lhArgs = [
|
|
@@ -3974,7 +4830,7 @@ async function lighthouseAudit(args) {
|
|
|
3974
4830
|
}
|
|
3975
4831
|
let raw;
|
|
3976
4832
|
try {
|
|
3977
|
-
raw = await
|
|
4833
|
+
raw = await readFile7(outPath, "utf8");
|
|
3978
4834
|
} catch {
|
|
3979
4835
|
raw = undefined;
|
|
3980
4836
|
}
|
|
@@ -4061,14 +4917,14 @@ async function dispatchMouse(args) {
|
|
|
4061
4917
|
}
|
|
4062
4918
|
|
|
4063
4919
|
// src/tools/downloads.ts
|
|
4064
|
-
import { readdir as readdir3, rename, stat as stat4 } from "node:fs/promises";
|
|
4065
|
-
import { basename, extname, join as
|
|
4920
|
+
import { readdir as readdir3, rename as rename2, stat as stat4 } from "node:fs/promises";
|
|
4921
|
+
import { basename, extname, join as join9 } from "node:path";
|
|
4066
4922
|
|
|
4067
4923
|
// src/tools/browser-session.ts
|
|
4068
|
-
import { mkdir as
|
|
4069
|
-
import { join as
|
|
4924
|
+
import { mkdir as mkdir11 } from "node:fs/promises";
|
|
4925
|
+
import { join as join8 } from "node:path";
|
|
4070
4926
|
var ARTIFACT_DIR7 = process.env.CDP_ARTIFACT_DIR ?? "/tmp/cdp-toolkit";
|
|
4071
|
-
var DOWNLOADS_DIR =
|
|
4927
|
+
var DOWNLOADS_DIR = join8(ARTIFACT_DIR7, "downloads");
|
|
4072
4928
|
var DOWNLOAD_BUFFER_LIMIT = 50;
|
|
4073
4929
|
var session;
|
|
4074
4930
|
function isDisconnect(e) {
|
|
@@ -4140,7 +4996,7 @@ async function browserSend(method, params = {}) {
|
|
|
4140
4996
|
}
|
|
4141
4997
|
}
|
|
4142
4998
|
async function armDownloadsAt(path) {
|
|
4143
|
-
await
|
|
4999
|
+
await mkdir11(path, { recursive: true });
|
|
4144
5000
|
const s = await getBrowserSession();
|
|
4145
5001
|
await s.conn.send("Browser.setDownloadBehavior", { behavior: "allowAndName", downloadPath: path, eventsEnabled: true });
|
|
4146
5002
|
s.armedPath = path;
|
|
@@ -4230,8 +5086,8 @@ async function waitForDownload(args = {}) {
|
|
|
4230
5086
|
const taken = new Set(await readdir3(DOWNLOADS_DIR).catch(() => []));
|
|
4231
5087
|
taken.delete(basename(rec.filePath));
|
|
4232
5088
|
const finalName = nextFreeName(suggestedFilename, taken);
|
|
4233
|
-
const path =
|
|
4234
|
-
await
|
|
5089
|
+
const path = join9(DOWNLOADS_DIR, finalName);
|
|
5090
|
+
await rename2(rec.filePath, path);
|
|
4235
5091
|
const bytes = (await stat4(path)).size;
|
|
4236
5092
|
return { path, suggestedFilename, bytes, ...rec.url ? { url: rec.url } : {}, target };
|
|
4237
5093
|
}
|