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.
- package/.jscpd-baseline.json +5 -19
- package/CHANGELOG.md +22 -0
- package/README.md +23 -9
- package/eslint.config.js +11 -0
- package/package.json +5 -2
- package/src/browser/browser-profile-files.js +5 -2
- package/src/browser/connector.js +215 -36
- package/src/browser/launch-options.js +2 -1
- package/src/browser/launch-restrictions.json +33 -2
- package/src/browser/launcher.js +26 -1
- package/src/browser/persistent-session.js +432 -0
- package/src/browser/profile-directory.js +20 -0
- package/src/browser/real-browser.js +7 -2
- package/src/browser/restrictions.js +13 -4
- package/src/browser/session-adoption.js +96 -0
- package/src/browser/session-watchdog.js +71 -0
- package/src/browser/session-worker.js +82 -0
- 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 +69 -1
- package/src/cli/commands.js +116 -2
- package/src/cli/high-level-methods.js +47 -3
- package/src/cli/sessions.js +14 -3
- package/src/core/commander-features.js +110 -0
- package/src/core/constants.js +1 -0
- package/src/core/navigation-manager.js +4 -2
- package/src/core/navigation-readiness.js +30 -2
- package/src/core/navigation-safety.js +52 -0
- package/src/core/page-trigger-manager.js +280 -156
- package/src/core/readiness.js +10 -2
- package/src/elements/locators.js +26 -32
- package/src/elements/selectors.js +37 -81
- package/src/elements/visibility.js +32 -10
- package/src/factory.js +13 -12
- package/src/index.js +4 -0
- package/src/interactions/click.js +44 -0
- package/src/interactions/overlays.js +73 -0
- package/src/traces/bundle.js +55 -8
- package/src/traces/checkpoints.js +103 -0
- package/src/traces/links.js +157 -2
- package/src/traces/mutation-stream.js +9 -2
- package/src/traces/network.js +309 -0
- package/src/traces/page-capture.js +203 -68
- package/src/traces/reader.js +8 -10
- package/src/traces/recorder.js +253 -247
- package/src/traces/redaction.js +89 -6
- package/src/traces/render.js +168 -0
- package/src/traces/rolling-bundle.js +240 -0
- package/src/traces/rolling.js +77 -0
- package/src/traces/start-cleanup.js +15 -0
- package/src/traces/stop.js +160 -0
- package/src/traces/storage.js +43 -0
- package/src/utilities/subprocess.js +32 -0
- package/tests/e2e/cli.e2e.test.js +20 -1
- package/tests/e2e/click-readiness.e2e.test.js +1 -1
- package/tests/e2e/issue-160.e2e.test.js +291 -0
- package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0002.html +1 -1
- package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0003.html +1 -1
- package/tests/fixtures/traces/conformance/expected/bundle/events.ndjson +2 -2
- package/tests/fixtures/traces/conformance/expected/bundle/manifest.json +4 -0
- package/tests/fixtures/traces/conformance/expected/bundle/viewer.html +1 -1
- package/tests/fixtures/traces/conformance/expected/trace.lino +2 -2
- package/tests/unit/browser/issue-160-connector.test.js +157 -0
- package/tests/unit/browser/launch-building-blocks.test.js +5 -1
- 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/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/interrupted-navigation.test.js +32 -0
- package/tests/unit/core/readiness.test.js +21 -0
- package/tests/unit/core/trigger-readiness.test.js +39 -0
- package/tests/unit/core/trigger-stop-race.test.js +80 -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/interactions/overlays.test.js +58 -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/issue-160-privacy.test.js +110 -0
- package/tests/unit/traces/issue-160-rotation.test.js +140 -0
- 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
|
+
}
|
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
|
+
}
|