browser-commander 0.27.0 → 0.29.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.
Files changed (93) hide show
  1. package/.jscpd-baseline.json +5 -19
  2. package/CHANGELOG.md +22 -0
  3. package/README.md +23 -9
  4. package/eslint.config.js +11 -0
  5. package/package.json +5 -2
  6. package/src/browser/browser-profile-files.js +5 -2
  7. package/src/browser/connector.js +215 -36
  8. package/src/browser/launch-options.js +2 -1
  9. package/src/browser/launch-restrictions.json +33 -2
  10. package/src/browser/launcher.js +26 -1
  11. package/src/browser/persistent-session.js +432 -0
  12. package/src/browser/profile-directory.js +20 -0
  13. package/src/browser/real-browser.js +7 -2
  14. package/src/browser/restrictions.js +13 -4
  15. package/src/browser/session-adoption.js +96 -0
  16. package/src/browser/session-watchdog.js +71 -0
  17. package/src/browser/session-worker.js +82 -0
  18. package/src/browser/webdriver.js +4 -0
  19. package/src/capture/encoding.js +327 -0
  20. package/src/capture/errors.js +12 -0
  21. package/src/capture/files.js +9 -0
  22. package/src/capture/index.js +5 -0
  23. package/src/capture/recording.js +136 -0
  24. package/src/capture/screenshot.js +222 -0
  25. package/src/capture/video.js +186 -0
  26. package/src/capture/viewport.js +68 -0
  27. package/src/capture/worker.js +22 -0
  28. package/src/cli/args.js +69 -1
  29. package/src/cli/commands.js +116 -2
  30. package/src/cli/high-level-methods.js +47 -3
  31. package/src/cli/sessions.js +14 -3
  32. package/src/core/commander-features.js +110 -0
  33. package/src/core/constants.js +1 -0
  34. package/src/core/navigation-manager.js +4 -2
  35. package/src/core/navigation-readiness.js +30 -2
  36. package/src/core/navigation-safety.js +52 -0
  37. package/src/core/page-trigger-manager.js +280 -156
  38. package/src/core/readiness.js +10 -2
  39. package/src/elements/locators.js +26 -32
  40. package/src/elements/selectors.js +37 -81
  41. package/src/elements/visibility.js +32 -10
  42. package/src/factory.js +13 -12
  43. package/src/index.js +4 -0
  44. package/src/interactions/click.js +44 -0
  45. package/src/interactions/overlays.js +73 -0
  46. package/src/traces/bundle.js +55 -8
  47. package/src/traces/checkpoints.js +103 -0
  48. package/src/traces/links.js +157 -2
  49. package/src/traces/mutation-stream.js +9 -2
  50. package/src/traces/network.js +309 -0
  51. package/src/traces/page-capture.js +203 -68
  52. package/src/traces/reader.js +8 -10
  53. package/src/traces/recorder.js +253 -247
  54. package/src/traces/redaction.js +89 -6
  55. package/src/traces/render.js +168 -0
  56. package/src/traces/rolling-bundle.js +240 -0
  57. package/src/traces/rolling.js +77 -0
  58. package/src/traces/start-cleanup.js +15 -0
  59. package/src/traces/stop.js +160 -0
  60. package/src/traces/storage.js +43 -0
  61. package/src/utilities/subprocess.js +32 -0
  62. package/tests/e2e/cli.e2e.test.js +20 -1
  63. package/tests/e2e/click-readiness.e2e.test.js +1 -1
  64. package/tests/e2e/issue-160.e2e.test.js +291 -0
  65. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0002.html +1 -1
  66. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0003.html +1 -1
  67. package/tests/fixtures/traces/conformance/expected/bundle/events.ndjson +2 -2
  68. package/tests/fixtures/traces/conformance/expected/bundle/manifest.json +4 -0
  69. package/tests/fixtures/traces/conformance/expected/bundle/viewer.html +1 -1
  70. package/tests/fixtures/traces/conformance/expected/trace.lino +2 -2
  71. package/tests/unit/browser/issue-160-connector.test.js +157 -0
  72. package/tests/unit/browser/launch-building-blocks.test.js +5 -1
  73. package/tests/unit/browser/launcher.test.js +5 -1
  74. package/tests/unit/browser/persistent-session.test.js +59 -0
  75. package/tests/unit/browser/quiet-ui.test.js +16 -0
  76. package/tests/unit/browser/tab-selection.test.js +38 -0
  77. package/tests/unit/capture/capture.test.js +52 -0
  78. package/tests/unit/capture/cleanup.test.js +24 -0
  79. package/tests/unit/capture/viewport.test.js +168 -0
  80. package/tests/unit/cli/dispatcher.test.js +25 -0
  81. package/tests/unit/core/interrupted-navigation.test.js +32 -0
  82. package/tests/unit/core/readiness.test.js +21 -0
  83. package/tests/unit/core/trigger-readiness.test.js +39 -0
  84. package/tests/unit/core/trigger-stop-race.test.js +80 -0
  85. package/tests/unit/elements/visible-count.test.js +24 -0
  86. package/tests/unit/elements/wait-navigation.test.js +26 -0
  87. package/tests/unit/interactions/navigation-timeout.test.js +39 -0
  88. package/tests/unit/interactions/overlays.test.js +58 -0
  89. package/tests/unit/scripts/ci-timeout-budgets.test.js +1 -0
  90. package/tests/unit/traces/bundle.test.js +16 -0
  91. package/tests/unit/traces/issue-160-privacy.test.js +110 -0
  92. package/tests/unit/traces/issue-160-rotation.test.js +140 -0
  93. package/tests/unit/traces/network.test.js +149 -0
@@ -0,0 +1,96 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { runCommand } from '../utilities/subprocess.js';
4
+
5
+ /** Match both the dedicated profile and fixed port before adopting ownership. */
6
+ export function matchesSessionProcess(args, options) {
7
+ if (
8
+ args.filter((arg) => arg.startsWith('--user-data-dir=')).length !== 1 ||
9
+ args.filter((arg) => arg.startsWith('--remote-debugging-port=')).length !==
10
+ 1
11
+ ) {
12
+ return false;
13
+ }
14
+ const profile = args
15
+ .find((arg) => arg.startsWith('--user-data-dir='))
16
+ ?.slice(16);
17
+ const port = args
18
+ .find((arg) => arg.startsWith('--remote-debugging-port='))
19
+ ?.slice(24);
20
+ return (
21
+ /^(?:google[ -]chrome(?:[ -](?:for testing|stable|beta|unstable|canary|dev))?|chrome|chromium(?:-browser)?|brave(?: browser)?|microsoft edge|msedge)(?:\.exe)?$/i.test(
22
+ path.basename(args[0] ?? '')
23
+ ) &&
24
+ Boolean(profile) &&
25
+ path.resolve(profile) === path.resolve(options.userDataDir) &&
26
+ Number(port) === options.remoteDebuggingPort &&
27
+ !args.some((arg) => arg.startsWith('--type='))
28
+ );
29
+ }
30
+ export async function findSessionProcess(options) {
31
+ if (process.platform === 'linux') {
32
+ for (const name of await fs.readdir('/proc')) {
33
+ if (!/^\d+$/.test(name)) {
34
+ continue;
35
+ }
36
+ const args = await fs.readFile(`/proc/${name}/cmdline`, 'utf8').then(
37
+ (text) => text.split('\0'),
38
+ () => []
39
+ );
40
+ if (matchesSessionProcess(args, options)) {
41
+ return Number(name);
42
+ }
43
+ }
44
+ } else if (process.platform === 'darwin') {
45
+ const { stdout } = await runCommand('ps', ['-axo', 'pid=,command=']);
46
+ for (const line of stdout.split('\n')) {
47
+ const match = line.match(/^\s*(\d+)\s+(.+)$/);
48
+ if (!match) {
49
+ continue;
50
+ }
51
+ const args = processArguments(match[2]);
52
+ if (matchesSessionProcess(args, options)) {
53
+ return Number(match[1]);
54
+ }
55
+ }
56
+ } else if (process.platform === 'win32') {
57
+ const { stdout } = await runCommand('powershell.exe', [
58
+ '-NoProfile',
59
+ '-NonInteractive',
60
+ '-Command',
61
+ 'Get-CimInstance Win32_Process | Where-Object { $_.Name -match "^(chrome|chromium|brave|msedge)\\.exe$" } | Select-Object ProcessId,ExecutablePath,CommandLine | ConvertTo-Json -Compress',
62
+ ]);
63
+ const records = JSON.parse(stdout || '[]');
64
+ for (const record of Array.isArray(records) ? records : [records]) {
65
+ const args = processArguments(record.CommandLine ?? '');
66
+ args[0] = record.ExecutablePath ?? args[0];
67
+ if (matchesSessionProcess(args, options)) {
68
+ return record.ProcessId;
69
+ }
70
+ }
71
+ }
72
+ throw new Error(
73
+ 'Cannot verify the running browser profile and port for adoption'
74
+ );
75
+ }
76
+
77
+ /** Process listings omit argv boundaries; only accept unambiguous managed switches. */
78
+ export function processArguments(command) {
79
+ if (
80
+ command.match(/--user-data-dir=/g)?.length !== 1 ||
81
+ command.match(/--remote-debugging-port=/g)?.length !== 1
82
+ ) {
83
+ return [];
84
+ }
85
+ const executable = command.match(/^"([^"\n]+)"|^(.*?)(?=\s--)/);
86
+ const profile = command.match(
87
+ /--user-data-dir=(?:"([^"\n]+)"|'([^'\n]+)'|(.+?))(?=\s--|$)/
88
+ );
89
+ const port = command.match(/--remote-debugging-port=(\d+)(?=\s|$)/);
90
+ return [
91
+ executable?.[1] ?? executable?.[2] ?? '',
92
+ `--user-data-dir=${profile?.[1] ?? profile?.[2] ?? profile?.[3] ?? ''}`,
93
+ `--remote-debugging-port=${port?.[1] ?? ''}`,
94
+ ...(command.includes('--type=') ? ['--type=child'] : []),
95
+ ];
96
+ }
@@ -0,0 +1,71 @@
1
+ import fs from 'node:fs/promises';
2
+ import {
3
+ probeSession,
4
+ closeRemoteBrowser,
5
+ acquireLease,
6
+ } from './persistent-session.js';
7
+ const [, , file, token] = process.argv;
8
+ const read = () =>
9
+ fs
10
+ .readFile(file, 'utf8')
11
+ .then(JSON.parse)
12
+ .catch(() => null);
13
+ while (true) {
14
+ const session = await read();
15
+ if (session?.token !== token) {
16
+ break;
17
+ }
18
+ await new Promise((resolve) =>
19
+ setTimeout(
20
+ resolve,
21
+ Math.min(30_000, Math.max(100, session.idleTimeoutMs / 10))
22
+ )
23
+ );
24
+ const current = await read();
25
+ if (current?.token !== token) {
26
+ break;
27
+ }
28
+ if (Date.now() - current.lastActive < current.idleTimeoutMs) {
29
+ continue;
30
+ }
31
+ let release;
32
+ try {
33
+ release = await acquireLease(`${file}.lock`);
34
+ } catch {
35
+ continue;
36
+ }
37
+ try {
38
+ const version = await probeSession(
39
+ `http://127.0.0.1:${current.remoteDebuggingPort}`
40
+ );
41
+ const latest = await read();
42
+ if (latest?.token !== token) {
43
+ break;
44
+ }
45
+ if (Date.now() - latest.lastActive < latest.idleTimeoutMs) {
46
+ continue;
47
+ }
48
+ if (!version) {
49
+ try {
50
+ process.kill(current.pid, 0);
51
+ continue;
52
+ } catch (error) {
53
+ if (error.code !== 'ESRCH') {
54
+ continue;
55
+ }
56
+ }
57
+ } else if (version.webSocketDebuggerUrl === current.webSocketDebuggerUrl) {
58
+ try {
59
+ await closeRemoteBrowser(version.webSocketDebuggerUrl);
60
+ } catch {
61
+ continue;
62
+ }
63
+ }
64
+ if ((await read())?.token === token) {
65
+ await fs.rm(file, { force: true });
66
+ }
67
+ break;
68
+ } finally {
69
+ await release();
70
+ }
71
+ }
@@ -0,0 +1,82 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { connectBrowser } from './connector.js';
4
+ import {
5
+ connectOrLaunch,
6
+ SESSION_METADATA,
7
+ closeOwnedSession,
8
+ writeSessionMetadata,
9
+ acquireLease,
10
+ } from './persistent-session.js';
11
+ let input = '';
12
+ for await (const chunk of process.stdin) {
13
+ input += chunk;
14
+ if (input.length > 1024 * 1024) {
15
+ throw new RangeError('session options exceed 1 MiB');
16
+ }
17
+ }
18
+ const options = JSON.parse(input);
19
+ const file = path.join(path.resolve(options.userDataDir), SESSION_METADATA);
20
+ if (options.operation) {
21
+ const release = await acquireLease(`${file}.lock`);
22
+ try {
23
+ let metadata;
24
+ try {
25
+ metadata = JSON.parse(await fs.readFile(file, 'utf8'));
26
+ } catch (error) {
27
+ if (
28
+ error.code === 'ENOENT' &&
29
+ ['close', 'touch'].includes(options.operation)
30
+ ) {
31
+ process.stdout.write('{}');
32
+ process.exitCode = 0;
33
+ } else {
34
+ throw error;
35
+ }
36
+ }
37
+ if (metadata) {
38
+ if (metadata.token !== options.token) {
39
+ throw new Error('Persistent session ownership changed');
40
+ }
41
+ if (options.operation === 'close') {
42
+ await closeOwnedSession(file, metadata);
43
+ } else if (options.operation === 'touch') {
44
+ metadata.lastActive = Date.now();
45
+ if (options.targetId) {
46
+ metadata.targetId = options.targetId;
47
+ }
48
+ await writeSessionMetadata(file, metadata);
49
+ if (options.closeNewTabs) {
50
+ const connection = await connectBrowser({
51
+ cdpEndpoint: `http://127.0.0.1:${metadata.remoteDebuggingPort}`,
52
+ targetId: metadata.targetId,
53
+ singleTab: true,
54
+ noDefaults: true,
55
+ });
56
+ await connection.detach();
57
+ }
58
+ } else {
59
+ throw new TypeError('Unknown session operation');
60
+ }
61
+ process.stdout.write('{}');
62
+ }
63
+ } finally {
64
+ await release();
65
+ }
66
+ } else {
67
+ const session = await connectOrLaunch(options);
68
+ await session.detach();
69
+ const metadata = JSON.parse(await fs.readFile(file, 'utf8'));
70
+ process.stdout.write(
71
+ JSON.stringify({
72
+ cdpEndpoint: session.cdpEndpoint,
73
+ userDataDir: session.userDataDir,
74
+ remoteDebuggingPort: session.remoteDebuggingPort,
75
+ reused: session.reused,
76
+ token: metadata.token,
77
+ targetId: metadata.targetId,
78
+ idleTimeoutMs: metadata.idleTimeoutMs,
79
+ adopted: session.adopted,
80
+ })
81
+ );
82
+ }
@@ -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,5 @@
1
+ export { UnsupportedCaptureError } from './errors.js';
2
+ export { screenshot } from './screenshot.js';
3
+ export { encodeAnimation } from './encoding.js';
4
+ export { startRecording } from './recording.js';
5
+ export { encodeVideo } from './video.js';