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,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
+ }
@@ -0,0 +1,222 @@
1
+ import { writeCapture } from './files.js';
2
+ import path from 'node:path';
3
+ import webp from 'webp-wasm';
4
+ import { decodePng, encodePng, resizeFrame } from './encoding.js';
5
+ import { UnsupportedCaptureError } from './errors.js';
6
+ import { captureStableViewport, validateStableViewport } from './viewport.js';
7
+
8
+ /**
9
+ * @typedef {Object} ScreenshotOptions
10
+ * @property {string} [path]
11
+ * @property {boolean} [fullPage]
12
+ * @property {boolean} [stableViewport] Capture only the existing view without visual styling.
13
+ * @property {string} [selector]
14
+ * @property {Object} [element]
15
+ * @property {{x:number,y:number,width:number,height:number}} [clip]
16
+ * @property {string} [format] png, jpeg or webp
17
+ * @property {number} [quality]
18
+ * @property {string} [scale] css or device
19
+ * @property {boolean} [omitBackground]
20
+ * @property {string} [animations] allow or disabled
21
+ * @property {string} [caret] hide or initial
22
+ * @property {boolean} [hideScrollbars]
23
+ * @property {boolean} [hideCaret]
24
+ * @property {boolean} [disableAnimations]
25
+ * @property {boolean} [waitForFonts]
26
+ */
27
+ export async function screenshot(options = {}) {
28
+ const {
29
+ page,
30
+ engine = 'playwright',
31
+ selector,
32
+ element,
33
+ clip,
34
+ fullPage = false,
35
+ scale = 'device',
36
+ quality,
37
+ path: output,
38
+ } = options;
39
+ if (
40
+ !['playwright', 'puppeteer', 'selenium', 'chromiumoxide'].includes(
41
+ engine
42
+ ) ||
43
+ typeof page?.screenshot !== 'function'
44
+ ) {
45
+ throw new UnsupportedCaptureError('screenshot', engine);
46
+ }
47
+ const { format, animations, caret } = validateScreenshot(options);
48
+ const native = {
49
+ type: format === 'webp' ? 'png' : format,
50
+ fullPage,
51
+ ...(clip ? { clip } : {}),
52
+ ...(format === 'jpeg' && quality !== undefined ? { quality } : {}),
53
+ };
54
+ if (options.omitBackground) {
55
+ if (engine === 'selenium') {
56
+ throw new UnsupportedCaptureError('omitBackground', engine);
57
+ }
58
+ native.omitBackground = true;
59
+ }
60
+ const css = captureStyle(options, animations, caret);
61
+ if (options.waitForFonts) {
62
+ await page.evaluate(() => document.fonts.ready.then(() => true));
63
+ }
64
+ if (options.stableViewport) {
65
+ if (engine === 'playwright') {
66
+ native.scale = scale;
67
+ native.caret = 'initial';
68
+ }
69
+ const captured = await captureStableViewport(page, engine, native);
70
+ return finishScreenshot(captured.bytes, {
71
+ page,
72
+ engine: captured.engine,
73
+ scale,
74
+ format,
75
+ quality,
76
+ output,
77
+ });
78
+ }
79
+ let styleId;
80
+ if (engine === 'playwright') {
81
+ Object.assign(native, {
82
+ scale,
83
+ animations,
84
+ caret: 'initial',
85
+ ...(css ? { style: css } : {}),
86
+ });
87
+ } else if (css) {
88
+ styleId = await page.evaluate((css) => {
89
+ const style = document.createElement('style');
90
+ style.id = `browser-commander-capture-${Math.random().toString(36).slice(2)}`;
91
+ style.textContent = css;
92
+ document.documentElement.appendChild(style);
93
+ return style.id;
94
+ }, css);
95
+ }
96
+ let target = page;
97
+ try {
98
+ if (selector || element) {
99
+ target =
100
+ element ??
101
+ (engine === 'playwright'
102
+ ? page.locator(selector).first()
103
+ : await page.$(selector));
104
+ if (!target) {
105
+ throw new Error(`No element matches ${selector}`);
106
+ }
107
+ if (typeof target.screenshot !== 'function') {
108
+ throw new UnsupportedCaptureError('element screenshot', engine);
109
+ }
110
+ delete native.fullPage;
111
+ }
112
+ return await finishScreenshot(
113
+ Buffer.from(await target.screenshot(native)),
114
+ { page, engine, scale, format, quality, output }
115
+ );
116
+ } finally {
117
+ if (styleId) {
118
+ await page.evaluate(
119
+ (id) => document.getElementById(id)?.remove(),
120
+ styleId
121
+ );
122
+ }
123
+ }
124
+ }
125
+
126
+ async function finishScreenshot(
127
+ bytes,
128
+ { page, engine, scale, format, quality, output }
129
+ ) {
130
+ if (scale === 'css' && engine !== 'playwright') {
131
+ if (format === 'jpeg') {
132
+ throw new UnsupportedCaptureError('JPEG CSS scale', engine);
133
+ }
134
+ const ratio = await page.evaluate(() => window.devicePixelRatio);
135
+ if (ratio !== 1) {
136
+ bytes = encodePng(resizeFrame(decodePng(bytes), 1 / ratio));
137
+ }
138
+ }
139
+ if (format === 'webp') {
140
+ bytes = await webp.encode(decodePng(bytes), { quality: quality ?? 75 });
141
+ }
142
+ return writeCapture(bytes, output);
143
+ }
144
+
145
+ function validateScreenshot(options) {
146
+ const engine = options.engine ?? 'playwright';
147
+ const {
148
+ path: output,
149
+ quality,
150
+ scale = 'device',
151
+ selector,
152
+ element,
153
+ clip,
154
+ fullPage,
155
+ } = options;
156
+ const extension = output ? path.extname(output).slice(1).toLowerCase() : '';
157
+ const format =
158
+ options.format ??
159
+ options.type ??
160
+ (extension === 'jpg'
161
+ ? 'jpeg'
162
+ : ['png', 'jpeg', 'webp'].includes(extension)
163
+ ? extension
164
+ : 'png');
165
+ if (!['png', 'jpeg', 'webp'].includes(format)) {
166
+ throw new UnsupportedCaptureError(format, engine);
167
+ }
168
+ if (
169
+ quality !== undefined &&
170
+ (!Number.isInteger(quality) ||
171
+ quality < 0 ||
172
+ quality > 100 ||
173
+ format === 'png')
174
+ ) {
175
+ throw new RangeError('quality must be an integer 0–100 for JPEG/WebP');
176
+ }
177
+ if (!['css', 'device'].includes(scale)) {
178
+ throw new RangeError('scale must be css or device');
179
+ }
180
+ if ([selector || element, clip, fullPage].filter(Boolean).length > 1) {
181
+ throw new RangeError(
182
+ 'selector/element, clip and fullPage are mutually exclusive'
183
+ );
184
+ }
185
+ if (
186
+ clip &&
187
+ (!['x', 'y', 'width', 'height'].every((key) =>
188
+ Number.isFinite(clip[key])
189
+ ) ||
190
+ clip.x < 0 ||
191
+ clip.y < 0 ||
192
+ clip.width <= 0 ||
193
+ clip.height <= 0)
194
+ ) {
195
+ throw new RangeError(
196
+ 'clip must have nonnegative coordinates and positive dimensions'
197
+ );
198
+ }
199
+ const animations =
200
+ options.animations ?? (options.disableAnimations ? 'disabled' : 'allow');
201
+ const caret = options.caret ?? (options.hideCaret ? 'hide' : 'initial');
202
+ validateStableViewport(options, animations, caret);
203
+ if (
204
+ !['allow', 'disabled'].includes(animations) ||
205
+ !['hide', 'initial'].includes(caret)
206
+ ) {
207
+ throw new RangeError('invalid animations/caret option');
208
+ }
209
+ return { format, animations, caret };
210
+ }
211
+
212
+ function captureStyle(options, animations, caret) {
213
+ return [
214
+ options.hideScrollbars
215
+ ? '*::-webkit-scrollbar{display:none!important}*{scrollbar-width:none!important}'
216
+ : '',
217
+ caret === 'hide' ? '*{caret-color:transparent!important}' : '',
218
+ animations === 'disabled'
219
+ ? '*,*::before,*::after{animation:none!important;transition:none!important}'
220
+ : '',
221
+ ].join('');
222
+ }
@@ -0,0 +1,186 @@
1
+ import { writeCapture } from './files.js';
2
+ import fs from 'node:fs/promises';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { runCommand } from '../utilities/subprocess.js';
6
+ import { decodePng } from './encoding.js';
7
+ import { UnsupportedCaptureError } from './errors.js';
8
+
9
+ /** Optional binary backend; never inferred from PATH or invoked by default. */
10
+ export async function encodeVideo(frames, options = {}) {
11
+ if (!Array.isArray(frames) || frames.length < 1 || frames.length > 1000) {
12
+ throw new RangeError('video requires 1–1000 frames');
13
+ }
14
+ if (
15
+ !Number.isFinite(options.fps ?? 10) ||
16
+ (options.fps ?? 10) < 1 ||
17
+ (options.fps ?? 10) > 60
18
+ ) {
19
+ throw new RangeError('fps must be 1–60');
20
+ }
21
+ if (
22
+ options.quality !== undefined &&
23
+ (!Number.isInteger(options.quality) ||
24
+ options.quality < 0 ||
25
+ options.quality > 100)
26
+ ) {
27
+ throw new RangeError('quality must be 0–100');
28
+ }
29
+ if (frames.reduce((sum, frame) => sum + frame.length, 0) > 64 * 1024 * 1024) {
30
+ throw new RangeError('video frames exceed 64 MiB');
31
+ }
32
+ for (const frame of frames) {
33
+ decodePng(frame);
34
+ }
35
+ if (!options.ffmpeg) {
36
+ if (!options.page?.evaluate) {
37
+ throw new UnsupportedCaptureError(
38
+ options.format ?? 'webm',
39
+ 'video encoder (provide a page or set ffmpeg explicitly)'
40
+ );
41
+ }
42
+ const result = await options.page.evaluate(encodeVideoInPage, {
43
+ frames: frames.map((frame) => frame.toString('base64')),
44
+ format: options.format ?? 'webm',
45
+ fps: options.fps ?? 10,
46
+ quality: options.quality,
47
+ size: options.size,
48
+ });
49
+ if (result.unsupported) {
50
+ throw new UnsupportedCaptureError(
51
+ options.format ?? 'webm',
52
+ options.engine ?? 'MediaRecorder'
53
+ );
54
+ }
55
+ const bytes = Buffer.from(result.data);
56
+ return writeCapture(bytes, options.path);
57
+ }
58
+ return encodeFfmpeg(frames, options);
59
+ }
60
+
61
+ async function encodeFfmpeg(frames, options) {
62
+ const directory = await fs.mkdtemp(
63
+ path.join(os.tmpdir(), 'commander-video-')
64
+ );
65
+ try {
66
+ for (let i = 0; i < frames.length; i++) {
67
+ await fs.writeFile(
68
+ path.join(directory, `${String(i).padStart(6, '0')}.png`),
69
+ frames[i]
70
+ );
71
+ }
72
+ const output = path.join(directory, `output.${options.format ?? 'webm'}`);
73
+ const codec = { mp4: 'libx264', webm: 'libvpx-vp9', mov: 'libx264' }[
74
+ options.format ?? 'webm'
75
+ ];
76
+ if (!codec) {
77
+ throw new UnsupportedCaptureError(options.format, 'ffmpeg');
78
+ }
79
+ const args = [
80
+ '-y',
81
+ '-framerate',
82
+ String(options.fps ?? 10),
83
+ '-i',
84
+ path.join(directory, '%06d.png'),
85
+ '-c:v',
86
+ codec,
87
+ '-pix_fmt',
88
+ 'yuv420p',
89
+ '-crf',
90
+ String(Math.round(51 * (1 - (options.quality ?? 75) / 100))),
91
+ ...(options.size
92
+ ? ['-vf', `scale=${options.size.width}:${options.size.height}`]
93
+ : []),
94
+ output,
95
+ ];
96
+ try {
97
+ await runCommand(
98
+ options.ffmpeg === true ? 'ffmpeg' : options.ffmpeg,
99
+ args
100
+ );
101
+ } catch (cause) {
102
+ throw new UnsupportedCaptureError('ffmpeg', 'video encoder', cause);
103
+ }
104
+ const bytes = await fs.readFile(output);
105
+ return writeCapture(bytes, options.path);
106
+ } finally {
107
+ await fs.rm(directory, { recursive: true, force: true });
108
+ }
109
+ }
110
+
111
+ /** Browser-native encoding on a detached canvas; no live DOM markers. */
112
+ export async function encodeVideoInPage({
113
+ frames,
114
+ format,
115
+ fps,
116
+ quality,
117
+ size,
118
+ }) {
119
+ const mimeType = { webm: 'video/webm', mp4: 'video/mp4' }[format];
120
+ if (!mimeType || !globalThis.MediaRecorder?.isTypeSupported(mimeType)) {
121
+ return { unsupported: true };
122
+ }
123
+ const image = new globalThis.Image();
124
+ image.src = `data:image/png;base64,${frames[0]}`;
125
+ await image.decode();
126
+ const canvas = document.createElement('canvas');
127
+ canvas.width = size?.width ?? image.width;
128
+ canvas.height = size?.height ?? image.height;
129
+ if (
130
+ !Number.isInteger(canvas.width) ||
131
+ canvas.width < 1 ||
132
+ !Number.isInteger(canvas.height) ||
133
+ canvas.height < 1 ||
134
+ canvas.width * canvas.height > 16_777_216
135
+ ) {
136
+ throw new RangeError('video canvas exceeds 16 megapixels');
137
+ }
138
+ const context = canvas.getContext('2d');
139
+ const stream = canvas.captureStream(fps);
140
+ const chunks = [];
141
+ let encodedBytes = 0;
142
+ const recorder = new globalThis.MediaRecorder(stream, {
143
+ mimeType,
144
+ videoBitsPerSecond: Math.max(
145
+ 100_000,
146
+ Math.round((quality ?? 75) * 100_000)
147
+ ),
148
+ });
149
+ const done = new Promise((resolve, reject) => {
150
+ recorder.ondataavailable = (event) => {
151
+ encodedBytes += event.data.size;
152
+ if (encodedBytes > 64 * 1024 * 1024) {
153
+ reject(new RangeError('encoded video exceeds 64 MiB'));
154
+ } else {
155
+ chunks.push(event.data);
156
+ }
157
+ };
158
+ recorder.onerror = (event) =>
159
+ reject(event.error ?? new Error('Video encoding failed'));
160
+ recorder.onstop = resolve;
161
+ });
162
+ // Keep early codec failures handled while frames are still being decoded.
163
+ done.catch(() => {});
164
+ try {
165
+ recorder.start();
166
+ for (const bytes of frames) {
167
+ image.src = `data:image/png;base64,${bytes}`;
168
+ await image.decode();
169
+ context.clearRect(0, 0, canvas.width, canvas.height);
170
+ context.drawImage(image, 0, 0, canvas.width, canvas.height);
171
+ await new Promise((resolve) => setTimeout(resolve, 1000 / fps));
172
+ }
173
+ recorder.stop();
174
+ await done;
175
+ return {
176
+ data: Array.from(
177
+ new Uint8Array(await new Blob(chunks, { type: mimeType }).arrayBuffer())
178
+ ),
179
+ };
180
+ } finally {
181
+ if (recorder.state !== 'inactive') {
182
+ recorder.stop();
183
+ }
184
+ stream.getTracks().forEach((track) => track.stop());
185
+ }
186
+ }
@@ -0,0 +1,68 @@
1
+ import { createCdpSession } from '../browser/cdp-session.js';
2
+
3
+ export function validateStableViewport(options, animations, caret) {
4
+ const changes = [
5
+ options.selector,
6
+ options.element,
7
+ options.clip,
8
+ options.fullPage,
9
+ options.omitBackground,
10
+ options.hideScrollbars,
11
+ animations !== 'allow',
12
+ caret !== 'initial',
13
+ ];
14
+ if (options.stableViewport && changes.some(Boolean)) {
15
+ throw new RangeError(
16
+ 'stableViewport requires the unmodified viewport: no region, background or visual styling options'
17
+ );
18
+ }
19
+ }
20
+
21
+ /** Capture the existing view; never scroll, resize, activate, or capture beyond it. */
22
+ export async function captureStableViewport(page, engine, native) {
23
+ // Native-view capture bypasses emulated pixel density. Keep the engine's viewport path.
24
+ const viewport =
25
+ engine === 'playwright' ? page.viewportSize?.() : page.viewport?.();
26
+ if (viewport) {
27
+ return { bytes: Buffer.from(await page.screenshot(native)), engine };
28
+ }
29
+ let session;
30
+ if (
31
+ (engine === 'playwright' && typeof page.context === 'function') ||
32
+ (engine === 'puppeteer' &&
33
+ (typeof page.createCDPSession === 'function' ||
34
+ typeof page.target === 'function'))
35
+ ) {
36
+ try {
37
+ session = await createCdpSession(page, { engine });
38
+ } catch (error) {
39
+ if (
40
+ error.name !== 'UnsupportedOperation' &&
41
+ !/only supported by Chromium|CDP.*not supported|CDP.*unsupported|CDP support is required|does not support CDP/i.test(
42
+ error.message
43
+ )
44
+ ) {
45
+ throw error;
46
+ }
47
+ }
48
+ }
49
+ if (session) {
50
+ try {
51
+ const { data } = await session.send('Page.captureScreenshot', {
52
+ format: native.type,
53
+ fromSurface: false,
54
+ captureBeyondViewport: false,
55
+ ...(native.quality === undefined ? {} : { quality: native.quality }),
56
+ });
57
+ return { bytes: Buffer.from(data, 'base64'), engine: 'cdp' };
58
+ } catch (error) {
59
+ // Some headless builds cannot capture a native view. Preserve every other error.
60
+ if (!/Unable to capture screenshot/i.test(error.message)) {
61
+ throw error;
62
+ }
63
+ } finally {
64
+ await session.detach();
65
+ }
66
+ }
67
+ return { bytes: Buffer.from(await page.screenshot(native)), engine };
68
+ }
@@ -0,0 +1,22 @@
1
+ /** Companion codec worker for Rust. Uses the same bounded portable encoders. */
2
+ import { encodeAnimation, encodeVideo } from './index.js';
3
+ let input = '';
4
+ for await (const chunk of process.stdin) {
5
+ input += chunk;
6
+ if (input.length > 96 * 1024 * 1024) {
7
+ throw new RangeError('codec input exceeds 96 MiB');
8
+ }
9
+ }
10
+ try {
11
+ const { frames, options } = JSON.parse(input);
12
+ const data = frames.map((frame) => Buffer.from(frame, 'base64'));
13
+ const result = ['webm', 'mp4', 'mov'].includes(options.format)
14
+ ? await encodeVideo(data, options)
15
+ : await encodeAnimation(data, options);
16
+ process.stdout.write(JSON.stringify({ data: result.toString('base64') }));
17
+ } catch (error) {
18
+ process.stdout.write(
19
+ JSON.stringify({ error: { code: error.code, message: error.message } })
20
+ );
21
+ process.exitCode = 1;
22
+ }