browser-commander 0.26.3 → 0.28.0
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/.jscpd-baseline.json +24 -23
- package/CHANGELOG.md +22 -0
- package/README.md +6 -0
- package/eslint.config.js +11 -0
- package/package.json +9 -4
- package/src/bindings.js +19 -0
- package/src/browser/attach/snapshot-launch.js +8 -6
- package/src/browser/attach/snapshot.js +39 -2
- package/src/browser/browser-cookie-database.js +71 -2
- package/src/browser/browser-cookie-session.js +183 -0
- package/src/browser/browser-cookies.js +29 -0
- package/src/browser/connector.js +117 -13
- package/src/browser/debugging-port.js +4 -2
- package/src/browser/default-browser.js +33 -9
- package/src/browser/launch-diagnostics.js +102 -0
- package/src/browser/launch-options.js +2 -1
- package/src/browser/launch-restrictions.json +33 -2
- package/src/browser/launcher.js +54 -9
- package/src/browser/migration/sqlite-snapshot.js +5 -27
- package/src/browser/navigation.js +173 -98
- package/src/browser/persistent-session.js +360 -0
- package/src/browser/profile-directory.js +20 -0
- package/src/browser/real-browser.js +142 -70
- package/src/browser/restrictions.js +13 -4
- package/src/browser/session-cookies.js +78 -0
- package/src/browser/session-persistence.js +72 -0
- package/src/browser/session-watchdog.js +71 -0
- package/src/browser/session-worker.js +70 -0
- package/src/browser/storage-state.js +32 -6
- package/src/browser/webdriver.js +4 -0
- package/src/capture/encoding.js +327 -0
- package/src/capture/errors.js +12 -0
- package/src/capture/files.js +9 -0
- package/src/capture/index.js +5 -0
- package/src/capture/recording.js +136 -0
- package/src/capture/screenshot.js +222 -0
- package/src/capture/video.js +186 -0
- package/src/capture/viewport.js +68 -0
- package/src/capture/worker.js +22 -0
- package/src/cli/args.js +66 -1
- package/src/cli/commands.js +113 -2
- package/src/cli/high-level-methods.js +40 -3
- package/src/cli/sessions.js +11 -3
- package/src/core/commander-features.js +105 -0
- package/src/core/constants.js +1 -0
- package/src/core/dialog-manager.js +7 -0
- package/src/core/managed-navigation.js +101 -0
- package/src/core/navigation-manager.js +66 -80
- package/src/core/navigation-operation.js +25 -0
- package/src/core/navigation-readiness.js +68 -16
- package/src/core/navigation-safety.js +51 -0
- package/src/core/network-tracker.js +39 -92
- package/src/core/page-session.js +3 -2
- package/src/core/page-trigger-manager.js +130 -42
- package/src/core/readiness.js +76 -14
- package/src/core/subscriptions.js +20 -0
- package/src/core/webdriver-page.js +38 -0
- package/src/elements/locators.js +61 -37
- package/src/elements/reusable.js +92 -0
- package/src/elements/selectors.js +37 -81
- package/src/elements/visibility.js +38 -21
- package/src/exports.js +14 -0
- package/src/factory.js +17 -16
- package/src/high-level/universal-logic.js +57 -21
- package/src/index.js +2 -0
- package/src/interactions/click.js +47 -0
- package/src/interactions/overlays.js +28 -0
- package/src/interactions/scroll.js +8 -2
- package/src/traces/bundle.js +14 -5
- package/src/traces/checkpoints.js +99 -0
- package/src/traces/links.js +117 -1
- package/src/traces/mutation-stream.js +1 -0
- package/src/traces/network.js +257 -0
- package/src/traces/page-capture.js +143 -52
- package/src/traces/reader.js +8 -10
- package/src/traces/recorder.js +221 -245
- package/src/traces/render.js +168 -0
- package/src/traces/rolling.js +180 -0
- package/src/traces/start-cleanup.js +15 -0
- package/src/traces/stop.js +159 -0
- package/src/traces/storage.js +43 -0
- package/src/utilities/subprocess.js +47 -4
- package/tests/e2e/cli.e2e.test.js +20 -1
- package/tests/e2e/click-readiness.e2e.test.js +1 -1
- package/tests/e2e/navigation-budget.e2e.test.js +132 -0
- package/tests/e2e/reusable-session.e2e.test.js +231 -0
- package/tests/unit/browser/attach/snapshot-launch.test.js +2 -2
- package/tests/unit/browser/attach/snapshot.test.js +33 -0
- package/tests/unit/browser/default-browser.test.js +18 -0
- package/tests/unit/browser/launch-building-blocks.test.js +5 -1
- package/tests/unit/browser/launch-diagnostics.test.js +52 -0
- package/tests/unit/browser/launcher.test.js +5 -1
- package/tests/unit/browser/persistent-session.test.js +59 -0
- package/tests/unit/browser/quiet-ui.test.js +16 -0
- package/tests/unit/browser/real-browser.test.js +1 -1
- package/tests/unit/browser/session-workflows.test.js +206 -0
- package/tests/unit/browser/storage-state.test.js +7 -2
- package/tests/unit/browser/tab-selection.test.js +38 -0
- package/tests/unit/capture/capture.test.js +52 -0
- package/tests/unit/capture/cleanup.test.js +24 -0
- package/tests/unit/capture/viewport.test.js +168 -0
- package/tests/unit/cli/dispatcher.test.js +25 -0
- package/tests/unit/core/navigation-budget.test.js +120 -0
- package/tests/unit/core/navigation-manager.test.js +23 -1
- package/tests/unit/core/readiness.test.js +21 -0
- package/tests/unit/core/trigger-readiness.test.js +39 -0
- package/tests/unit/core/webdriver-page.test.js +33 -0
- package/tests/unit/elements/reusable-helpers.test.js +83 -0
- package/tests/unit/elements/visible-count.test.js +24 -0
- package/tests/unit/elements/wait-navigation.test.js +26 -0
- package/tests/unit/interactions/navigation-timeout.test.js +39 -0
- package/tests/unit/scripts/ci-timeout-budgets.test.js +1 -0
- package/tests/unit/traces/bundle.test.js +16 -0
- package/tests/unit/traces/links.test.js +2 -2
- package/tests/unit/traces/network.test.js +97 -0
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import { readFile, writeFile } from 'node:fs/promises';
|
|
1
|
+
import { readFile, writeFile, rename, rm } from 'node:fs/promises';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { normalizeSessionCookies } from './session-cookies.js';
|
|
2
4
|
|
|
3
5
|
/**
|
|
4
6
|
* Load a Playwright-compatible storage state from a JSON path or object.
|
|
@@ -91,7 +93,9 @@ export async function restorePuppeteerStorageState({ page, storageState }) {
|
|
|
91
93
|
|
|
92
94
|
const cookies = storageState.cookies ?? [];
|
|
93
95
|
if (cookies.length > 0) {
|
|
94
|
-
|
|
96
|
+
const context = page.browserContext?.();
|
|
97
|
+
const target = typeof context?.setCookie === 'function' ? context : page;
|
|
98
|
+
await target.setCookie(...normalizeSessionCookies(cookies, 'puppeteer'));
|
|
95
99
|
}
|
|
96
100
|
|
|
97
101
|
const origins = storageState.origins ?? [];
|
|
@@ -115,7 +119,7 @@ export async function restorePlaywrightStorageState({ context, storageState }) {
|
|
|
115
119
|
|
|
116
120
|
const cookies = storageState.cookies ?? [];
|
|
117
121
|
if (cookies.length > 0) {
|
|
118
|
-
await context.addCookies(cookies);
|
|
122
|
+
await context.addCookies(normalizeSessionCookies(cookies, 'playwright'));
|
|
119
123
|
}
|
|
120
124
|
|
|
121
125
|
const origins = storageState.origins ?? [];
|
|
@@ -137,7 +141,11 @@ function readCurrentLocalStorage() {
|
|
|
137
141
|
}
|
|
138
142
|
|
|
139
143
|
async function collectPuppeteerStorageState(page) {
|
|
140
|
-
const
|
|
144
|
+
const context = page.browserContext?.();
|
|
145
|
+
const cookies =
|
|
146
|
+
typeof context?.cookies === 'function'
|
|
147
|
+
? await context.cookies()
|
|
148
|
+
: await page.cookies();
|
|
141
149
|
const pageUrl = page.url();
|
|
142
150
|
const origin = new URL(pageUrl).origin;
|
|
143
151
|
const origins = [];
|
|
@@ -161,7 +169,11 @@ async function collectPuppeteerStorageState(page) {
|
|
|
161
169
|
export async function saveStorageState(page, filePath) {
|
|
162
170
|
const context = typeof page.context === 'function' ? page.context() : null;
|
|
163
171
|
if (typeof context?.storageState === 'function') {
|
|
164
|
-
|
|
172
|
+
const state = await context.storageState();
|
|
173
|
+
if (filePath) {
|
|
174
|
+
await writeRestrictedState(filePath, state);
|
|
175
|
+
}
|
|
176
|
+
return state;
|
|
165
177
|
}
|
|
166
178
|
|
|
167
179
|
if (
|
|
@@ -174,7 +186,21 @@ export async function saveStorageState(page, filePath) {
|
|
|
174
186
|
|
|
175
187
|
const storageState = await collectPuppeteerStorageState(page);
|
|
176
188
|
if (filePath) {
|
|
177
|
-
await
|
|
189
|
+
await writeRestrictedState(filePath, storageState);
|
|
178
190
|
}
|
|
179
191
|
return storageState;
|
|
180
192
|
}
|
|
193
|
+
|
|
194
|
+
/** Replace state atomically with an owner-only file, without following a destination symlink. */
|
|
195
|
+
export async function writeRestrictedState(filePath, state) {
|
|
196
|
+
const temporary = `${filePath}.${randomUUID()}.tmp`;
|
|
197
|
+
try {
|
|
198
|
+
await writeFile(temporary, `${JSON.stringify(state, null, 2)}\n`, {
|
|
199
|
+
mode: 0o600,
|
|
200
|
+
flag: 'wx',
|
|
201
|
+
});
|
|
202
|
+
await rename(temporary, filePath);
|
|
203
|
+
} finally {
|
|
204
|
+
await rm(temporary, { force: true });
|
|
205
|
+
}
|
|
206
|
+
}
|
package/src/browser/webdriver.js
CHANGED
|
@@ -326,6 +326,7 @@ export function buildBrowserOptions({
|
|
|
326
326
|
remoteDebuggingPort,
|
|
327
327
|
headless,
|
|
328
328
|
restrictions,
|
|
329
|
+
disableFeatures,
|
|
329
330
|
args,
|
|
330
331
|
automationParity,
|
|
331
332
|
bidi,
|
|
@@ -358,6 +359,7 @@ export function buildBrowserOptions({
|
|
|
358
359
|
remoteDebuggingPort,
|
|
359
360
|
headless,
|
|
360
361
|
restrictions,
|
|
362
|
+
disableFeatures,
|
|
361
363
|
args,
|
|
362
364
|
automationParity,
|
|
363
365
|
});
|
|
@@ -556,6 +558,7 @@ async function launchWebDriverOnce(options, dependencies) {
|
|
|
556
558
|
bidi = false,
|
|
557
559
|
args = [],
|
|
558
560
|
restrictions = [],
|
|
561
|
+
disableFeatures = [],
|
|
559
562
|
automationParity = true,
|
|
560
563
|
env,
|
|
561
564
|
startupTimeout = 30_000,
|
|
@@ -627,6 +630,7 @@ async function launchWebDriverOnce(options, dependencies) {
|
|
|
627
630
|
browser === 'chrome' ? await reservePort() : undefined,
|
|
628
631
|
headless,
|
|
629
632
|
restrictions,
|
|
633
|
+
disableFeatures,
|
|
630
634
|
args,
|
|
631
635
|
automationParity,
|
|
632
636
|
bidi,
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
import { writeCapture } from './files.js';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import { deflateSync } from 'node:zlib';
|
|
4
|
+
import UPNG from 'upng-js';
|
|
5
|
+
import webp from 'webp-wasm';
|
|
6
|
+
import gifenc from 'gifenc';
|
|
7
|
+
import { UnsupportedCaptureError } from './errors.js';
|
|
8
|
+
const { GIFEncoder, quantize, applyPalette } = gifenc;
|
|
9
|
+
|
|
10
|
+
export function resizeFrame(frame, scale = 1, size) {
|
|
11
|
+
const width = size?.width ?? Math.max(1, Math.round(frame.width * scale));
|
|
12
|
+
const height = size?.height ?? Math.max(1, Math.round(frame.height * scale));
|
|
13
|
+
if (
|
|
14
|
+
!Number.isSafeInteger(width) ||
|
|
15
|
+
!Number.isSafeInteger(height) ||
|
|
16
|
+
width < 1 ||
|
|
17
|
+
height < 1 ||
|
|
18
|
+
width * height > 16_777_216
|
|
19
|
+
) {
|
|
20
|
+
throw new RangeError(
|
|
21
|
+
'capture dimensions must be positive integers within 16 megapixels'
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
const data = new Uint8Array(width * height * 4);
|
|
25
|
+
for (let y = 0; y < height; y++) {
|
|
26
|
+
for (let x = 0; x < width; x++) {
|
|
27
|
+
const source =
|
|
28
|
+
(Math.min(frame.height - 1, Math.floor((y * frame.height) / height)) *
|
|
29
|
+
frame.width +
|
|
30
|
+
Math.min(frame.width - 1, Math.floor((x * frame.width) / width))) *
|
|
31
|
+
4;
|
|
32
|
+
data.set(frame.data.subarray(source, source + 4), (y * width + x) * 4);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return { width, height, data };
|
|
36
|
+
}
|
|
37
|
+
export function decodePng(bytes) {
|
|
38
|
+
const data = Buffer.from(bytes);
|
|
39
|
+
if (
|
|
40
|
+
data.length < 33 ||
|
|
41
|
+
!data.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))
|
|
42
|
+
) {
|
|
43
|
+
throw new RangeError('frame must be a PNG');
|
|
44
|
+
}
|
|
45
|
+
const pixels = data.readUInt32BE(16) * data.readUInt32BE(20);
|
|
46
|
+
if (pixels < 1 || pixels > 16_777_216) {
|
|
47
|
+
throw new RangeError('PNG exceeds 16 megapixels');
|
|
48
|
+
}
|
|
49
|
+
const decoded = UPNG.decode(
|
|
50
|
+
data.buffer.slice(data.byteOffset, data.byteOffset + data.length)
|
|
51
|
+
);
|
|
52
|
+
return {
|
|
53
|
+
width: decoded.width,
|
|
54
|
+
height: decoded.height,
|
|
55
|
+
data: new Uint8Array(UPNG.toRGBA8(decoded)[0]),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
export function encodePng(frame) {
|
|
59
|
+
return Buffer.from(
|
|
60
|
+
UPNG.encode(
|
|
61
|
+
[
|
|
62
|
+
frame.data.buffer.slice(
|
|
63
|
+
frame.data.byteOffset,
|
|
64
|
+
frame.data.byteOffset + frame.data.length
|
|
65
|
+
),
|
|
66
|
+
],
|
|
67
|
+
frame.width,
|
|
68
|
+
frame.height,
|
|
69
|
+
0,
|
|
70
|
+
undefined,
|
|
71
|
+
true
|
|
72
|
+
)
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function chunk(tag, data) {
|
|
77
|
+
const header = Buffer.alloc(8);
|
|
78
|
+
header.write(tag);
|
|
79
|
+
header.writeUInt32LE(data.length, 4);
|
|
80
|
+
return Buffer.concat([
|
|
81
|
+
header,
|
|
82
|
+
data,
|
|
83
|
+
...(data.length % 2 ? [Buffer.alloc(1)] : []),
|
|
84
|
+
]);
|
|
85
|
+
}
|
|
86
|
+
function write24(buffer, value, offset) {
|
|
87
|
+
buffer.writeUIntLE(value, offset, 3);
|
|
88
|
+
}
|
|
89
|
+
async function animatedWebp(frames, { fps, quality = 75, loop = 0 }) {
|
|
90
|
+
const { width, height } = frames[0];
|
|
91
|
+
const extended = Buffer.alloc(10);
|
|
92
|
+
extended[0] = 0x12;
|
|
93
|
+
write24(extended, width - 1, 4);
|
|
94
|
+
write24(extended, height - 1, 7);
|
|
95
|
+
const animation = Buffer.alloc(6);
|
|
96
|
+
animation.writeUInt16LE(loop, 4);
|
|
97
|
+
const chunks = [chunk('VP8X', extended), chunk('ANIM', animation)];
|
|
98
|
+
for (const frame of frames) {
|
|
99
|
+
const encoded = await webp.encode(frame, { quality });
|
|
100
|
+
const imageChunks = [];
|
|
101
|
+
for (let offset = 12; offset < encoded.length;) {
|
|
102
|
+
const length = encoded.readUInt32LE(offset + 4);
|
|
103
|
+
const tag = encoded.subarray(offset, offset + 4).toString();
|
|
104
|
+
if (['ALPH', 'VP8 ', 'VP8L'].includes(tag)) {
|
|
105
|
+
imageChunks.push(
|
|
106
|
+
encoded.subarray(offset, offset + 8 + length + (length % 2))
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
offset += 8 + length + (length % 2);
|
|
110
|
+
}
|
|
111
|
+
const header = Buffer.alloc(16);
|
|
112
|
+
write24(header, width - 1, 6);
|
|
113
|
+
write24(header, height - 1, 9);
|
|
114
|
+
write24(header, Math.round(1000 / fps), 12);
|
|
115
|
+
header[15] = 2;
|
|
116
|
+
chunks.push(chunk('ANMF', Buffer.concat([header, ...imageChunks])));
|
|
117
|
+
}
|
|
118
|
+
const payload = Buffer.concat([Buffer.from('WEBP'), ...chunks]);
|
|
119
|
+
const header = Buffer.alloc(8);
|
|
120
|
+
header.write('RIFF');
|
|
121
|
+
header.writeUInt32LE(payload.length, 4);
|
|
122
|
+
return Buffer.concat([header, payload]);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function pngChunk(tag, data) {
|
|
126
|
+
const bytes = Buffer.alloc(data.length + 12);
|
|
127
|
+
bytes.writeUInt32BE(data.length);
|
|
128
|
+
bytes.write(tag, 4);
|
|
129
|
+
data.copy(bytes, 8);
|
|
130
|
+
bytes.writeUInt32BE(
|
|
131
|
+
UPNG.crc.crc(bytes, 4, data.length + 4) >>> 0,
|
|
132
|
+
data.length + 8
|
|
133
|
+
);
|
|
134
|
+
return bytes;
|
|
135
|
+
}
|
|
136
|
+
function animatedPng(frames, fps, loop, optimize) {
|
|
137
|
+
const { width, height } = frames[0];
|
|
138
|
+
const ihdr = Buffer.alloc(13);
|
|
139
|
+
ihdr.writeUInt32BE(width);
|
|
140
|
+
ihdr.writeUInt32BE(height, 4);
|
|
141
|
+
ihdr[8] = 8;
|
|
142
|
+
ihdr[9] = 6;
|
|
143
|
+
const animation = Buffer.alloc(8);
|
|
144
|
+
animation.writeUInt32BE(frames.length);
|
|
145
|
+
animation.writeUInt32BE(loop, 4);
|
|
146
|
+
const chunks = [
|
|
147
|
+
Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]),
|
|
148
|
+
pngChunk('IHDR', ihdr),
|
|
149
|
+
pngChunk('acTL', animation),
|
|
150
|
+
];
|
|
151
|
+
let sequence = 0;
|
|
152
|
+
for (const [index, frame] of frames.entries()) {
|
|
153
|
+
const control = Buffer.alloc(26);
|
|
154
|
+
control.writeUInt32BE(sequence++);
|
|
155
|
+
control.writeUInt32BE(width, 4);
|
|
156
|
+
control.writeUInt32BE(height, 8);
|
|
157
|
+
control.writeUInt16BE(Math.round(1000 / fps), 20);
|
|
158
|
+
control.writeUInt16BE(1000, 22);
|
|
159
|
+
chunks.push(pngChunk('fcTL', control));
|
|
160
|
+
const scanlines = Buffer.alloc(height * (width * 4 + 1));
|
|
161
|
+
for (let y = 0; y < height; y++) {
|
|
162
|
+
scanlines.set(
|
|
163
|
+
frame.data.subarray(y * width * 4, (y + 1) * width * 4),
|
|
164
|
+
y * (width * 4 + 1) + 1
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
const compressed = deflateSync(scanlines, { level: optimize ? 9 : 1 });
|
|
168
|
+
if (index === 0) {
|
|
169
|
+
chunks.push(pngChunk('IDAT', compressed));
|
|
170
|
+
} else {
|
|
171
|
+
const number = Buffer.alloc(4);
|
|
172
|
+
number.writeUInt32BE(sequence++);
|
|
173
|
+
chunks.push(pngChunk('fdAT', Buffer.concat([number, compressed])));
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
chunks.push(pngChunk('IEND', Buffer.alloc(0)));
|
|
177
|
+
return Buffer.concat(chunks);
|
|
178
|
+
}
|
|
179
|
+
function ditherPalette(frame, palette) {
|
|
180
|
+
const data = Float32Array.from(frame.data);
|
|
181
|
+
const pixels = new Uint8Array(frame.width * frame.height);
|
|
182
|
+
const transparent = palette.findIndex((color) => color[3] === 0);
|
|
183
|
+
const add = (x, y, channel, error, weight) => {
|
|
184
|
+
if (x >= 0 && x < frame.width && y < frame.height) {
|
|
185
|
+
data[(y * frame.width + x) * 4 + channel] += error * weight;
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
for (let y = 0; y < frame.height; y++) {
|
|
189
|
+
for (let x = 0; x < frame.width; x++) {
|
|
190
|
+
const offset = (y * frame.width + x) * 4;
|
|
191
|
+
if (data[offset + 3] < 128 && transparent >= 0) {
|
|
192
|
+
pixels[y * frame.width + x] = transparent;
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
let best = 0,
|
|
196
|
+
distance = Infinity;
|
|
197
|
+
for (let i = 0; i < palette.length; i++) {
|
|
198
|
+
if (i === transparent) {
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
const delta = palette[i]
|
|
202
|
+
.slice(0, 3)
|
|
203
|
+
.reduce((sum, value, c) => sum + (data[offset + c] - value) ** 2, 0);
|
|
204
|
+
if (delta < distance) {
|
|
205
|
+
distance = delta;
|
|
206
|
+
best = i;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
pixels[y * frame.width + x] = best;
|
|
210
|
+
for (let c = 0; c < 3; c++) {
|
|
211
|
+
const error = data[offset + c] - palette[best][c];
|
|
212
|
+
add(x + 1, y, c, error, 7 / 16);
|
|
213
|
+
add(x - 1, y + 1, c, error, 3 / 16);
|
|
214
|
+
add(x, y + 1, c, error, 5 / 16);
|
|
215
|
+
add(x + 1, y + 1, c, error, 1 / 16);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return pixels;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Portable GIF/APNG/WebP animation from PNG bytes or paths. */
|
|
223
|
+
export async function encodeAnimation(input, options = {}) {
|
|
224
|
+
const {
|
|
225
|
+
format = 'gif',
|
|
226
|
+
fps = 10,
|
|
227
|
+
scale = 1,
|
|
228
|
+
loop = 0,
|
|
229
|
+
palette = 256,
|
|
230
|
+
dither = false,
|
|
231
|
+
optimize = true,
|
|
232
|
+
path: output,
|
|
233
|
+
} = options;
|
|
234
|
+
validateAnimation(input, { fps, scale, loop, dither, format });
|
|
235
|
+
const frames = [];
|
|
236
|
+
let decodedBytes = 0;
|
|
237
|
+
for (const value of input) {
|
|
238
|
+
const png = typeof value === 'string' ? await fs.readFile(value) : value;
|
|
239
|
+
const data = Buffer.from(png);
|
|
240
|
+
if (data.length < 33) {
|
|
241
|
+
throw new RangeError('frame must be a PNG');
|
|
242
|
+
}
|
|
243
|
+
const sourceBytes = data.readUInt32BE(16) * data.readUInt32BE(20) * 4;
|
|
244
|
+
const targetBytes = options.size
|
|
245
|
+
? options.size.width * options.size.height * 4
|
|
246
|
+
: sourceBytes * Math.max(1, scale * scale);
|
|
247
|
+
if (decodedBytes + sourceBytes + targetBytes > 256 * 1024 * 1024) {
|
|
248
|
+
throw new RangeError('decoded animation exceeds 256 MiB');
|
|
249
|
+
}
|
|
250
|
+
const frame = resizeFrame(decodePng(data), scale, options.size);
|
|
251
|
+
decodedBytes += frame.data.length;
|
|
252
|
+
frames.push(frame);
|
|
253
|
+
}
|
|
254
|
+
const { width, height } = frames[0];
|
|
255
|
+
if (
|
|
256
|
+
frames.some((frame) => frame.width !== width || frame.height !== height)
|
|
257
|
+
) {
|
|
258
|
+
throw new RangeError('animation frames must have equal dimensions');
|
|
259
|
+
}
|
|
260
|
+
if (frames.length * width * height * 4 > 256 * 1024 * 1024) {
|
|
261
|
+
throw new RangeError('decoded animation exceeds 256 MiB');
|
|
262
|
+
}
|
|
263
|
+
let bytes;
|
|
264
|
+
if (format === 'gif') {
|
|
265
|
+
bytes = encodeGif(frames, { width, height, palette, dither, fps, loop });
|
|
266
|
+
} else if (format === 'apng') {
|
|
267
|
+
bytes = animatedPng(frames, fps, loop, optimize);
|
|
268
|
+
} else if (format === 'webp') {
|
|
269
|
+
bytes = await animatedWebp(frames, { ...options, fps, loop });
|
|
270
|
+
} else {
|
|
271
|
+
throw new UnsupportedCaptureError(format, 'animation encoder');
|
|
272
|
+
}
|
|
273
|
+
if (bytes.length > 64 * 1024 * 1024) {
|
|
274
|
+
throw new RangeError('encoded animation exceeds 64 MiB');
|
|
275
|
+
}
|
|
276
|
+
return writeCapture(bytes, output);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function validateAnimation(input, { fps, scale, loop, dither, format }) {
|
|
280
|
+
if (
|
|
281
|
+
!Number.isFinite(fps) ||
|
|
282
|
+
fps < 1 ||
|
|
283
|
+
fps > 60 ||
|
|
284
|
+
!Number.isFinite(scale) ||
|
|
285
|
+
scale <= 0 ||
|
|
286
|
+
scale > 8
|
|
287
|
+
) {
|
|
288
|
+
throw new RangeError('fps must be 1–60 and scale must be >0 and <=8');
|
|
289
|
+
}
|
|
290
|
+
if (!Number.isInteger(loop) || loop < 0 || loop > 65535) {
|
|
291
|
+
throw new RangeError('loop must be 0–65535');
|
|
292
|
+
}
|
|
293
|
+
if (dither && format !== 'gif') {
|
|
294
|
+
throw new UnsupportedCaptureError('dither', format);
|
|
295
|
+
}
|
|
296
|
+
if (!Array.isArray(input) || input.length === 0 || input.length > 1000) {
|
|
297
|
+
throw new RangeError('animation requires 1–1000 frames');
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
function encodeGif(frames, { width, height, palette, dither, fps, loop }) {
|
|
301
|
+
if (
|
|
302
|
+
!Array.isArray(palette) &&
|
|
303
|
+
(!Number.isInteger(palette) || palette < 2 || palette > 256)
|
|
304
|
+
) {
|
|
305
|
+
throw new RangeError('palette must be 2–256 colors or a palette array');
|
|
306
|
+
}
|
|
307
|
+
const gif = GIFEncoder();
|
|
308
|
+
for (const frame of frames) {
|
|
309
|
+
const colors = Array.isArray(palette)
|
|
310
|
+
? palette
|
|
311
|
+
: quantize(frame.data, palette, { format: 'rgba4444' });
|
|
312
|
+
const pixels = dither
|
|
313
|
+
? ditherPalette(frame, colors)
|
|
314
|
+
: applyPalette(frame.data, colors, 'rgba4444');
|
|
315
|
+
const transparentIndex = colors.findIndex((color) => color[3] === 0);
|
|
316
|
+
gif.writeFrame(pixels, width, height, {
|
|
317
|
+
palette: colors,
|
|
318
|
+
delay: 1000 / fps,
|
|
319
|
+
repeat: loop,
|
|
320
|
+
dispose: 2,
|
|
321
|
+
transparent: transparentIndex >= 0,
|
|
322
|
+
transparentIndex: Math.max(0, transparentIndex),
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
gif.finish();
|
|
326
|
+
return Buffer.from(gif.bytes());
|
|
327
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** A requested capture capability is unavailable on this engine or codec. */
|
|
2
|
+
export class UnsupportedCaptureError extends Error {
|
|
3
|
+
constructor(capability, engine, cause) {
|
|
4
|
+
super(`Capture capability "${capability}" is unavailable on ${engine}`, {
|
|
5
|
+
cause,
|
|
6
|
+
});
|
|
7
|
+
this.name = 'UnsupportedCaptureError';
|
|
8
|
+
this.code = 'UNSUPPORTED_CAPTURE';
|
|
9
|
+
this.capability = capability;
|
|
10
|
+
this.engine = engine;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
|
|
3
|
+
/** Return capture bytes and optionally persist them with private permissions. */
|
|
4
|
+
export async function writeCapture(bytes, output) {
|
|
5
|
+
if (output) {
|
|
6
|
+
await fs.writeFile(output, bytes, { mode: 0o600 });
|
|
7
|
+
}
|
|
8
|
+
return bytes;
|
|
9
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
2
|
+
import { screenshot } from './screenshot.js';
|
|
3
|
+
import {
|
|
4
|
+
encodeAnimation,
|
|
5
|
+
decodePng,
|
|
6
|
+
encodePng,
|
|
7
|
+
resizeFrame,
|
|
8
|
+
} from './encoding.js';
|
|
9
|
+
import { encodeVideo } from './video.js';
|
|
10
|
+
import { UnsupportedCaptureError } from './errors.js';
|
|
11
|
+
|
|
12
|
+
/** A bounded, sequential screenshot recorder, usable on attached sessions. */
|
|
13
|
+
export async function startRecording(options = {}) {
|
|
14
|
+
options = { ...options, path: options.path ?? options.output };
|
|
15
|
+
const {
|
|
16
|
+
fps = 10,
|
|
17
|
+
maxFrames = 1000,
|
|
18
|
+
maxBytes = 64 * 1024 * 1024,
|
|
19
|
+
maxDurationMs = 60_000,
|
|
20
|
+
format = 'webm',
|
|
21
|
+
} = options;
|
|
22
|
+
if (
|
|
23
|
+
!Number.isFinite(fps) ||
|
|
24
|
+
fps < 1 ||
|
|
25
|
+
fps > 60 ||
|
|
26
|
+
!Number.isSafeInteger(maxFrames) ||
|
|
27
|
+
maxFrames < 1 ||
|
|
28
|
+
maxFrames > 1000 ||
|
|
29
|
+
!Number.isFinite(maxDurationMs) ||
|
|
30
|
+
maxDurationMs <= 0 ||
|
|
31
|
+
!Number.isSafeInteger(maxBytes) ||
|
|
32
|
+
maxBytes < 1
|
|
33
|
+
) {
|
|
34
|
+
throw new RangeError('invalid recording bounds');
|
|
35
|
+
}
|
|
36
|
+
if (
|
|
37
|
+
!['frames', 'gif', 'apng', 'webp', 'webm', 'mp4', 'mov'].includes(format)
|
|
38
|
+
) {
|
|
39
|
+
throw new UnsupportedCaptureError(format, options.engine);
|
|
40
|
+
}
|
|
41
|
+
if (['webm', 'mp4', 'mov'].includes(format) && !options.ffmpeg) {
|
|
42
|
+
const supported = await options.page.evaluate(
|
|
43
|
+
(format) =>
|
|
44
|
+
globalThis.MediaRecorder?.isTypeSupported(
|
|
45
|
+
{ webm: 'video/webm', mp4: 'video/mp4' }[format] ?? ''
|
|
46
|
+
) ?? false,
|
|
47
|
+
format
|
|
48
|
+
);
|
|
49
|
+
if (!supported) {
|
|
50
|
+
throw new UnsupportedCaptureError(
|
|
51
|
+
format,
|
|
52
|
+
options.engine ?? 'MediaRecorder'
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const frames = [];
|
|
57
|
+
const controller = new AbortController();
|
|
58
|
+
let bytes = 0,
|
|
59
|
+
failure,
|
|
60
|
+
stopping,
|
|
61
|
+
truncated = false;
|
|
62
|
+
const started = performance.now();
|
|
63
|
+
const grab = async () => {
|
|
64
|
+
let data = await screenshot({
|
|
65
|
+
page: options.page,
|
|
66
|
+
engine: options.engine,
|
|
67
|
+
...(options.screenshot ?? {}),
|
|
68
|
+
format: 'png',
|
|
69
|
+
});
|
|
70
|
+
if (options.size) {
|
|
71
|
+
data = encodePng(resizeFrame(decodePng(data), 1, options.size));
|
|
72
|
+
}
|
|
73
|
+
if (bytes + data.length > maxBytes) {
|
|
74
|
+
truncated = true;
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
frames.push(data);
|
|
78
|
+
bytes += data.length;
|
|
79
|
+
return true;
|
|
80
|
+
};
|
|
81
|
+
if (!(await grab())) {
|
|
82
|
+
throw new RangeError('The first recording frame exceeds maxBytes');
|
|
83
|
+
}
|
|
84
|
+
const task = (async () => {
|
|
85
|
+
while (!controller.signal.aborted) {
|
|
86
|
+
try {
|
|
87
|
+
await delay(Math.max(1, 1000 / fps), undefined, {
|
|
88
|
+
signal: controller.signal,
|
|
89
|
+
});
|
|
90
|
+
} catch {
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
if (
|
|
94
|
+
frames.length >= maxFrames ||
|
|
95
|
+
performance.now() - started >= maxDurationMs
|
|
96
|
+
) {
|
|
97
|
+
truncated = true;
|
|
98
|
+
break;
|
|
99
|
+
}
|
|
100
|
+
try {
|
|
101
|
+
if (!(await grab())) {
|
|
102
|
+
break;
|
|
103
|
+
}
|
|
104
|
+
} catch (error) {
|
|
105
|
+
failure = error;
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
})();
|
|
110
|
+
const stop = (stopOptions = {}) => {
|
|
111
|
+
stopping ??= (async () => {
|
|
112
|
+
controller.abort();
|
|
113
|
+
await task;
|
|
114
|
+
if (failure) {
|
|
115
|
+
throw failure;
|
|
116
|
+
}
|
|
117
|
+
const encoding = { ...options, format, ...stopOptions, fps };
|
|
118
|
+
const data =
|
|
119
|
+
encoding.format === 'frames'
|
|
120
|
+
? null
|
|
121
|
+
: ['gif', 'apng', 'webp'].includes(encoding.format)
|
|
122
|
+
? await encodeAnimation(frames, encoding)
|
|
123
|
+
: await encodeVideo(frames, encoding);
|
|
124
|
+
return {
|
|
125
|
+
frames,
|
|
126
|
+
bytes: data,
|
|
127
|
+
path: encoding.path ?? null,
|
|
128
|
+
format: encoding.format,
|
|
129
|
+
fps,
|
|
130
|
+
truncated,
|
|
131
|
+
};
|
|
132
|
+
})();
|
|
133
|
+
return stopping;
|
|
134
|
+
};
|
|
135
|
+
return { stop, frames, fps };
|
|
136
|
+
}
|