@push.rocks/smartpuppeteer 2.7.1 → 2.8.1

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.
@@ -1,4 +1,5 @@
1
1
  import type { IEnvAwareOptions } from './smartpuppeteer.classes.smartpuppeteer.js';
2
+ import type { ILiveVideoOptions, ILiveVideoAcceleration } from './interfaces.livevideo.js';
2
3
  export type TLiveBrowserStatus = 'stopped' | 'starting' | 'running' | 'stopping';
3
4
  export type TLiveBrowserImageFormat = 'jpeg' | 'png';
4
5
  export type TLiveBrowserTabStatus = 'open' | 'crashed';
@@ -11,6 +12,8 @@ export interface ILiveBrowserViewport {
11
12
  deviceScaleFactor: number;
12
13
  }
13
14
  export interface ILiveBrowserScreencastOptions {
15
+ /** JPEG/PNG production can be enabled on demand independently of input. */
16
+ enabled?: boolean;
14
17
  format?: TLiveBrowserImageFormat;
15
18
  quality?: number;
16
19
  maxWidth?: number;
@@ -53,6 +56,7 @@ export interface ILiveBrowserSessionOptions extends Omit<IEnvAwareOptions, 'laun
53
56
  launchOptions?: TLiveBrowserLaunchOptions;
54
57
  viewport?: ILiveBrowserViewport;
55
58
  screencast?: ILiveBrowserScreencastOptions;
59
+ video?: ILiveVideoOptions;
56
60
  security?: ILiveBrowserSecurityOptions;
57
61
  allowEvaluation?: boolean;
58
62
  }
@@ -97,6 +101,7 @@ export interface ILiveBrowserState {
97
101
  viewport: ILiveBrowserViewport;
98
102
  tabs: ILiveBrowserTabState[];
99
103
  lastError?: ILiveBrowserError;
104
+ videoAcceleration?: ILiveVideoAcceleration;
100
105
  }
101
106
  export interface ILiveBrowserScreencastMetadata {
102
107
  offsetTop: number;
@@ -1,3 +1,3 @@
1
1
  export const liveBrowserDefaultMaxOutstandingFrames = 3;
2
2
  export const liveBrowserMaxOutstandingFrames = 64;
3
- //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoic21hcnRwdXBwZXRlZXIuaW50ZXJmYWNlcy5saXZlYnJvd3Nlci5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbIi4uL3RzL3NtYXJ0cHVwcGV0ZWVyLmludGVyZmFjZXMubGl2ZWJyb3dzZXIudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBV0EsTUFBTSxDQUFDLE1BQU0sc0NBQXNDLEdBQUcsQ0FBQyxDQUFDO0FBQ3hELE1BQU0sQ0FBQyxNQUFNLCtCQUErQixHQUFHLEVBQUUsQ0FBQyJ9
3
+ //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoic21hcnRwdXBwZXRlZXIuaW50ZXJmYWNlcy5saXZlYnJvd3Nlci5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbIi4uL3RzL3NtYXJ0cHVwcGV0ZWVyLmludGVyZmFjZXMubGl2ZWJyb3dzZXIudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBWUEsTUFBTSxDQUFDLE1BQU0sc0NBQXNDLEdBQUcsQ0FBQyxDQUFDO0FBQ3hELE1BQU0sQ0FBQyxNQUFNLCtCQUErQixHQUFHLEVBQUUsQ0FBQyJ9
@@ -2,7 +2,9 @@ import { Buffer } from 'node:buffer';
2
2
  import * as fs from 'node:fs';
3
3
  import * as http from 'node:http';
4
4
  import * as os from 'node:os';
5
- export { Buffer, fs, http, os };
5
+ import * as crypto from 'node:crypto';
6
+ import * as url from 'node:url';
7
+ export { Buffer, fs, http, os, crypto, url };
6
8
  import * as smartdelay from '@push.rocks/smartdelay';
7
9
  import * as smartshell from '@push.rocks/smartshell';
8
10
  export { smartdelay, smartshell };
@@ -3,7 +3,9 @@ import { Buffer } from 'node:buffer';
3
3
  import * as fs from 'node:fs';
4
4
  import * as http from 'node:http';
5
5
  import * as os from 'node:os';
6
- export { Buffer, fs, http, os };
6
+ import * as crypto from 'node:crypto';
7
+ import * as url from 'node:url';
8
+ export { Buffer, fs, http, os, crypto, url };
7
9
  // @pushrocks scope
8
10
  import * as smartdelay from '@push.rocks/smartdelay';
9
11
  import * as smartshell from '@push.rocks/smartshell';
@@ -12,4 +14,4 @@ export { smartdelay, smartshell };
12
14
  import * as puppeteer from 'puppeteer';
13
15
  import treeKill from 'tree-kill';
14
16
  export { puppeteer, treeKill };
15
- //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoic21hcnRwdXBwZXRlZXIucGx1Z2lucy5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbIi4uL3RzL3NtYXJ0cHVwcGV0ZWVyLnBsdWdpbnMudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBQUEsb0JBQW9CO0FBQ3BCLE9BQU8sRUFBRSxNQUFNLEVBQUUsTUFBTSxhQUFhLENBQUM7QUFDckMsT0FBTyxLQUFLLEVBQUUsTUFBTSxTQUFTLENBQUM7QUFDOUIsT0FBTyxLQUFLLElBQUksTUFBTSxXQUFXLENBQUM7QUFDbEMsT0FBTyxLQUFLLEVBQUUsTUFBTSxTQUFTLENBQUM7QUFFOUIsT0FBTyxFQUFFLE1BQU0sRUFBRSxFQUFFLEVBQUUsSUFBSSxFQUFFLEVBQUUsRUFBRSxDQUFDO0FBRWhDLG1CQUFtQjtBQUNuQixPQUFPLEtBQUssVUFBVSxNQUFNLHdCQUF3QixDQUFDO0FBQ3JELE9BQU8sS0FBSyxVQUFVLE1BQU0sd0JBQXdCLENBQUM7QUFFckQsT0FBTyxFQUFFLFVBQVUsRUFBRSxVQUFVLEVBQUUsQ0FBQztBQUVsQyxvQkFBb0I7QUFDcEIsT0FBTyxLQUFLLFNBQVMsTUFBTSxXQUFXLENBQUM7QUFDdkMsT0FBTyxRQUFRLE1BQU0sV0FBVyxDQUFDO0FBRWpDLE9BQU8sRUFBRSxTQUFTLEVBQUUsUUFBUSxFQUFFLENBQUMifQ==
17
+ //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoic21hcnRwdXBwZXRlZXIucGx1Z2lucy5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbIi4uL3RzL3NtYXJ0cHVwcGV0ZWVyLnBsdWdpbnMudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBQUEsb0JBQW9CO0FBQ3BCLE9BQU8sRUFBRSxNQUFNLEVBQUUsTUFBTSxhQUFhLENBQUM7QUFDckMsT0FBTyxLQUFLLEVBQUUsTUFBTSxTQUFTLENBQUM7QUFDOUIsT0FBTyxLQUFLLElBQUksTUFBTSxXQUFXLENBQUM7QUFDbEMsT0FBTyxLQUFLLEVBQUUsTUFBTSxTQUFTLENBQUM7QUFDOUIsT0FBTyxLQUFLLE1BQU0sTUFBTSxhQUFhLENBQUM7QUFDdEMsT0FBTyxLQUFLLEdBQUcsTUFBTSxVQUFVLENBQUM7QUFFaEMsT0FBTyxFQUFFLE1BQU0sRUFBRSxFQUFFLEVBQUUsSUFBSSxFQUFFLEVBQUUsRUFBRSxNQUFNLEVBQUUsR0FBRyxFQUFFLENBQUM7QUFFN0MsbUJBQW1CO0FBQ25CLE9BQU8sS0FBSyxVQUFVLE1BQU0sd0JBQXdCLENBQUM7QUFDckQsT0FBTyxLQUFLLFVBQVUsTUFBTSx3QkFBd0IsQ0FBQztBQUVyRCxPQUFPLEVBQUUsVUFBVSxFQUFFLFVBQVUsRUFBRSxDQUFDO0FBRWxDLG9CQUFvQjtBQUNwQixPQUFPLEtBQUssU0FBUyxNQUFNLFdBQVcsQ0FBQztBQUN2QyxPQUFPLFFBQVEsTUFBTSxXQUFXLENBQUM7QUFFakMsT0FBTyxFQUFFLFNBQVMsRUFBRSxRQUFRLEVBQUUsQ0FBQyJ9
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@push.rocks/smartpuppeteer",
3
- "version": "2.7.1",
3
+ "version": "2.8.1",
4
4
  "private": false,
5
5
  "description": "Provides simplified access to Puppeteer for automation and testing purposes.",
6
6
  "main": "dist_ts/index.js",
@@ -12,9 +12,9 @@
12
12
  "node": ">=22.12.0"
13
13
  },
14
14
  "devDependencies": {
15
- "@git.zone/tsbuild": "^4.4.2",
15
+ "@git.zone/tsbuild": "^4.4.3",
16
16
  "@git.zone/tsrun": "^2.0.6",
17
- "@git.zone/tstest": "^4.0.0",
17
+ "@git.zone/tstest": "^6.0.0",
18
18
  "@types/node": "^26.4.1"
19
19
  },
20
20
  "dependencies": {
package/readme.md CHANGED
@@ -254,6 +254,40 @@ The default evaluation limits are a 5-second timeout, 256 KiB transferred output
254
254
 
255
255
  `LiveBrowserSession` remains a browser runtime, not a complete security policy layer. The optional guards do not authenticate callers, authorize actions, enforce network egress, own profile-directory cleanup, isolate operating-system resources, or contain a compromised Chromium process. Adapters must apply those controls before invoking it. `stop()` rejects active and queued operations and aborts Chromium independently of Puppeteer operation timeouts. Closing the final usable tab also stops the session. Browser-wide loss stops the runtime without automatic relaunch. Page or CDP loss is tab-scoped: another usable tab becomes active when possible, otherwise the runtime stops. Popup registration queue saturation emits `popup_registration_capacity_exceeded` and stops the session rather than leaving an untracked page.
256
256
 
257
+ ### Native WebRTC video
258
+
259
+ Enable `video` for native Chromium tab capture and WebRTC delivery:
260
+
261
+ ```typescript
262
+ const session = new LiveBrowserSession({
263
+ requireSandbox: true,
264
+ viewport: { width: 1280, height: 800, deviceScaleFactor: 1 },
265
+ screencast: { enabled: false },
266
+ video: { gpu: 'auto', maxFrameRate: 30, maxBitrate: 8_000_000 },
267
+ });
268
+ await session.start();
269
+ const offer = await session.openVideoPeer('authorized-viewer');
270
+ // Send offer to the authorized receiver over your existing signaling transport.
271
+ // Receiver creates an RTCPeerConnection using offer.iceServers/iceTransportPolicy,
272
+ // applies offer.description, and returns its gathered answer description.
273
+ await session.answerVideoPeer(offer.peerId, offer.negotiationId, answerDescription);
274
+ const statistics = await session.getVideoStatistics(offer.peerId);
275
+ await session.closeVideoPeer(offer.peerId);
276
+ await session.stop();
277
+ ```
278
+
279
+ Capture is a native tab `MediaStreamTrack` in a fixed packaged private extension. Video does not pass through JPEG, base64, a JavaScript canvas, or a Node video encoder. Chromium owns congestion control and codec selection; sustained encoder CPU pressure additionally reduces per-viewer resolution and recovery restores it. The source is shared by viewers, each with an independent peer and encoding budget. Closing the last peer stops capture. Opening another peer starts it again. The default capacity is eight viewers, bounded to 32.
280
+
281
+ Direct ICE with no external service is the default, suitable for reachable LAN and VPN peers. The host can supply `iceServers` and `iceTransportPolicy: 'relay'` for deployments requiring TURN. Relay-only configuration requires a TURN URL. Signaling and peer IDs must be authorized by the embedding host; never let a visited page configure peers or ICE services. SDP is limited to 48 KiB and one video media section; audio and data channels are not accepted. Offers finish ICE gathering before returning, and peers have a 30-second connection deadline.
282
+
283
+ The private extension requires Chromium's CDP pipe (the default); `--disable-extensions` is incompatible. It has no content scripts, externally connected messages, or web-accessible resources. Existing page WebRTC routing restrictions remain in force. The extension's host-authorized peer traffic is independent of the HTTP proxy, so the host must permit the intended LAN/VPN path.
284
+
285
+ `gpu: 'auto'` enables GPU rendering when Chrome and its drivers support it; Linux headless capture uses ANGLE Vulkan unless a caller selected another ANGLE backend. `gpu: 'disabled'` explicitly uses software rendering. `getState().videoAcceleration` reports Chrome's renderer, compositing and video-encoding capability; `getVideoStatistics()` reports the encoder actually used. GPU rendering does not imply hardware video encoding.
286
+
287
+ Every offer includes a unique `negotiationId`, required when submitting its answer, and its source `{ tabId, generation, viewportRevision, viewport }`. Navigation, tab activation and viewport changes retire existing peers. Receivers must reject stale negotiation, reopen against the current source, and allow input only after a frame from that source is presented. `openVideoPeer()` and `answerVideoPeer()` participate in the serialized browser operation queue. `closeVideoPeer()` is idempotent, and the private media queue is bounded.
288
+
289
+ `screencast.enabled` defaults to `true` for frame consumers. Set it to `false` when using video alone, then use `setFrameCaptureEnabled(true/false)` to follow actual JPEG/PNG subscriber demand. This leaves input and video available. Snapshot capture remains independent. `refreshScreencast()` requires frame capture to be enabled.
290
+
257
291
  ### Handling Browser Events
258
292
  It's important to handle browser events, such as disconnections, which might occur due to various reasons:
259
293
 
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@push.rocks/smartpuppeteer',
6
- version: '2.7.1',
6
+ version: '2.8.1',
7
7
  description: 'Provides simplified access to Puppeteer for automation and testing purposes.'
8
8
  }
@@ -0,0 +1,210 @@
1
+ import * as plugins from './smartpuppeteer.plugins.js';
2
+ import {
3
+ normalizeLiveVideoOptions, validateLiveVideoDescription,
4
+ type ILiveVideoOptions, type ILiveVideoSource, type ILiveVideoOffer,
5
+ type ILiveVideoDescription, type ILiveVideoStatistics, type ILiveVideoAcceleration,
6
+ } from './interfaces.livevideo.js';
7
+
8
+ interface IPrivateVideoApi {
9
+ offer(input: { peerId: string }): Promise<ILiveVideoDescription>;
10
+ answer(input: { peerId: string; description: ILiveVideoDescription }): Promise<void>;
11
+ stats(input: { peerId: string }): Promise<ILiveVideoStatistics>;
12
+ close(input: { peerId: string }): void;
13
+ stop(): void;
14
+ arm(input: { requestId: string; options: Required<ILiveVideoOptions> }): void;
15
+ result(requestId: string): { requestId: string; ready?: boolean; error?: string } | null;
16
+ }
17
+
18
+ /** Private extension bridge; never evaluates in a visited page or exposes CDP. */
19
+ export class LiveBrowserVideo {
20
+ public readonly options: Required<ILiveVideoOptions>;
21
+ private extensionId?: string;
22
+ private capturePage?: plugins.puppeteer.Page;
23
+ private captureSource?: ILiveVideoSource;
24
+ private readonly peers = new Map<string, string>();
25
+ private operation: Promise<unknown> = Promise.resolve();
26
+ private closed = false;
27
+ private pendingOperations = 0;
28
+ private acceleration?: ILiveVideoAcceleration;
29
+
30
+ constructor(private browser: plugins.puppeteer.Browser, options: ILiveVideoOptions) {
31
+ this.options = normalizeLiveVideoOptions(options);
32
+ }
33
+
34
+ public async start(): Promise<void> {
35
+ this.extensionId = await this.browser.installExtension(
36
+ plugins.url.fileURLToPath(new URL('../assets/livevideo/', import.meta.url)),
37
+ );
38
+ const session = await this.browser.target().createCDPSession();
39
+ try {
40
+ const { gpu } = await session.send('SystemInfo.getInfo');
41
+ const attributes = gpu.auxAttributes as Record<string, unknown> | undefined;
42
+ const features = gpu.featureStatus as Record<string, unknown> | undefined;
43
+ this.acceleration = {
44
+ renderer: String(attributes?.glRenderer ?? 'unknown'),
45
+ compositing: String(features?.gpu_compositing ?? 'unknown'),
46
+ videoEncoding: String(features?.video_encode ?? 'unknown'),
47
+ };
48
+ } finally { await session.detach(); }
49
+ }
50
+
51
+ public getAcceleration(): ILiveVideoAcceleration | undefined {
52
+ return this.acceleration ? { ...this.acceleration } : undefined;
53
+ }
54
+
55
+ public ownsTarget(target: plugins.puppeteer.Target): boolean {
56
+ return !!this.extensionId && target.url().startsWith(`chrome-extension://${this.extensionId}/`);
57
+ }
58
+
59
+ public hasPeer(peerId: string): boolean { return this.peers.has(peerId); }
60
+
61
+ public open(
62
+ peerId: string,
63
+ page: plugins.puppeteer.Page,
64
+ source: ILiveVideoSource,
65
+ signal?: AbortSignal,
66
+ ): Promise<ILiveVideoOffer> {
67
+ return this.enqueue(async () => {
68
+ signal?.throwIfAborted();
69
+ if (!/^[a-zA-Z0-9_-]{1,128}$/.test(peerId)) throw new Error('Invalid video peer identity.');
70
+ if (this.peers.has(peerId)) await this.closeInternal(peerId);
71
+ if (this.peers.size >= this.options.maxViewers) throw new Error('Video viewer capacity reached.');
72
+ try {
73
+ if (!this.sameSource(source)) await this.stopCapture();
74
+ if (!this.captureSource) await this.capture(page, source);
75
+ signal?.throwIfAborted();
76
+ const description = await this.capturePage!.evaluate(async input => (
77
+ (globalThis as unknown as { smartpuppeteerVideo: IPrivateVideoApi }).smartpuppeteerVideo.offer(input)
78
+ ), { peerId });
79
+ validateLiveVideoDescription(description, 'offer');
80
+ signal?.throwIfAborted();
81
+ const negotiationId = plugins.crypto.randomUUID();
82
+ this.peers.set(peerId, negotiationId);
83
+ return { peerId, negotiationId, source: structuredClone(source), description,
84
+ iceServers: structuredClone(this.options.iceServers), iceTransportPolicy: this.options.iceTransportPolicy };
85
+ } catch (error) {
86
+ await this.closeInternal(peerId);
87
+ throw error;
88
+ }
89
+ });
90
+ }
91
+
92
+ public answer(peerId: string, negotiationId: string, description: ILiveVideoDescription): Promise<void> {
93
+ validateLiveVideoDescription(description, 'answer');
94
+ return this.enqueue(async () => {
95
+ this.requirePeer(peerId);
96
+ if (this.peers.get(peerId) !== negotiationId) throw new Error('Video negotiation is stale.');
97
+ await this.capturePage!.evaluate(async input => (
98
+ (globalThis as unknown as { smartpuppeteerVideo: IPrivateVideoApi }).smartpuppeteerVideo.answer(input)
99
+ ), { peerId, description });
100
+ });
101
+ }
102
+
103
+ public getStatistics(peerId: string): Promise<ILiveVideoStatistics> {
104
+ return this.enqueue(async () => {
105
+ this.requirePeer(peerId);
106
+ return this.capturePage!.evaluate(async input => (
107
+ (globalThis as unknown as { smartpuppeteerVideo: IPrivateVideoApi }).smartpuppeteerVideo.stats(input)
108
+ ), { peerId });
109
+ });
110
+ }
111
+
112
+ public close(peerId: string): Promise<void> { return this.enqueue(() => this.closeInternal(peerId)); }
113
+
114
+ /** Retires media before changing tab/viewport identity. Control stays independent. */
115
+ public invalidate(): Promise<void> { return this.enqueue(() => this.stopCapture()); }
116
+
117
+ public async stop(): Promise<void> {
118
+ if (this.closed) return;
119
+ this.closed = true;
120
+ await this.operation;
121
+ await this.stopCapture();
122
+ this.capturePage = undefined;
123
+ // The extension is process-local and the owning browser closes immediately
124
+ // afterward; no extension or media profile is installed into a user's browser.
125
+ }
126
+
127
+ private enqueue<T>(operation: () => Promise<T>): Promise<T> {
128
+ if (this.closed) return Promise.reject(new Error('Browser video is closed.'));
129
+ if (this.pendingOperations >= 128) return Promise.reject(new Error('Browser video operation capacity reached.'));
130
+ this.pendingOperations++;
131
+ const task = this.operation.then(operation).finally(() => { this.pendingOperations--; });
132
+ this.operation = task.then(() => undefined, () => undefined);
133
+ return task;
134
+ }
135
+
136
+ private sameSource(source: ILiveVideoSource): boolean {
137
+ return this.captureSource?.tabId === source.tabId && this.captureSource.generation === source.generation
138
+ && this.captureSource.viewportRevision === source.viewportRevision;
139
+ }
140
+
141
+ private requirePeer(peerId: string): void {
142
+ if (!this.peers.has(peerId) || !this.capturePage || this.capturePage.isClosed()) {
143
+ throw new Error('Video peer is unavailable.');
144
+ }
145
+ }
146
+
147
+ private async closeInternal(peerId: string): Promise<void> {
148
+ if (this.capturePage && !this.capturePage.isClosed()) {
149
+ await this.capturePage.evaluate(input => (
150
+ (globalThis as unknown as { smartpuppeteerVideo: IPrivateVideoApi }).smartpuppeteerVideo.close(input)
151
+ ), { peerId });
152
+ }
153
+ this.peers.delete(peerId);
154
+ if (!this.peers.size) await this.stopCapture();
155
+ }
156
+
157
+ private async stopCapture(): Promise<void> {
158
+ if (this.capturePage && !this.capturePage.isClosed()) {
159
+ await this.capturePage.evaluate(() => (
160
+ (globalThis as unknown as { smartpuppeteerVideo: IPrivateVideoApi }).smartpuppeteerVideo.stop()
161
+ ));
162
+ }
163
+ this.peers.clear();
164
+ this.captureSource = undefined;
165
+ }
166
+
167
+ private async capture(page: plugins.puppeteer.Page, source: ILiveVideoSource): Promise<void> {
168
+ if (!this.extensionId) throw new Error('Private browser video extension is not initialized.');
169
+ const extension = (await this.browser.extensions()).get(this.extensionId);
170
+ if (!extension) throw new Error('Private browser video extension is unavailable.');
171
+ // The action wakes MV3 workers. Capture authority lives in the offscreen
172
+ // document so ordinary worker retirement cannot strand a saved CDP context.
173
+ if (!this.capturePage || this.capturePage.isClosed()) {
174
+ await page.triggerExtensionAction(extension);
175
+ const target = await this.browser.waitForTarget(candidate => (
176
+ candidate.url() === `chrome-extension://${this.extensionId}/capture.html`
177
+ ), { timeout: 10_000 });
178
+ this.capturePage = await target.asPage();
179
+ await this.capturePage.waitForFunction(() => (
180
+ !!(globalThis as unknown as { smartpuppeteerVideo?: IPrivateVideoApi }).smartpuppeteerVideo
181
+ ), { timeout: 10_000 });
182
+ }
183
+ const requestId = plugins.crypto.randomUUID();
184
+ try {
185
+ await this.capturePage.evaluate(input => (
186
+ (globalThis as unknown as { smartpuppeteerVideo: IPrivateVideoApi }).smartpuppeteerVideo.arm(input)
187
+ ), { requestId, options: { ...this.options,
188
+ maxWidth: Math.min(this.options.maxWidth, Math.round(source.viewport.width * source.viewport.deviceScaleFactor)),
189
+ maxHeight: Math.min(this.options.maxHeight, Math.round(source.viewport.height * source.viewport.deviceScaleFactor)),
190
+ } });
191
+ await page.triggerExtensionAction(extension);
192
+ const result = await this.capturePage.evaluate(async expected => {
193
+ const api = (globalThis as unknown as { smartpuppeteerVideo: IPrivateVideoApi }).smartpuppeteerVideo;
194
+ const deadline = Date.now() + 10_000;
195
+ while (Date.now() < deadline) {
196
+ const result = api.result(expected);
197
+ if (result) return result;
198
+ await new Promise(resolve => setTimeout(resolve, 25));
199
+ }
200
+ throw new Error('Private browser video capture startup timed out.');
201
+ }, requestId);
202
+ if (result.error || !result.ready) throw new Error(result.error ?? 'Browser video capture failed.');
203
+ this.captureSource = structuredClone(source);
204
+ } catch (error) {
205
+ try { await this.stopCapture(); }
206
+ catch (cleanupError) { throw new AggregateError([error, cleanupError], 'Browser video capture and cleanup failed.'); }
207
+ throw error;
208
+ }
209
+ }
210
+ }
package/ts/index.ts CHANGED
@@ -4,6 +4,7 @@ export * from './smartpuppeteer.classes.smartpuppeteer.js';
4
4
  export * from './smartpuppeteer.classes.incognitobrowser.js';
5
5
  export * from './smartpuppeteer.interfaces.livebrowser.js';
6
6
  export * from './smartpuppeteer.classes.livebrowsersession.js';
7
+ export * from './interfaces.livevideo.js';
7
8
 
8
9
  // direct exports
9
10
  import { puppeteer } from './smartpuppeteer.plugins.js';
@@ -0,0 +1,103 @@
1
+ /** Host-provided ICE services. Never accept relay configuration from a visited page. */
2
+ export interface ILiveVideoIceServer {
3
+ urls: string | string[];
4
+ username?: string;
5
+ credential?: string;
6
+ }
7
+
8
+ export interface ILiveVideoOptions {
9
+ iceServers?: ILiveVideoIceServer[];
10
+ iceTransportPolicy?: 'all' | 'relay';
11
+ maxViewers?: number;
12
+ maxWidth?: number;
13
+ maxHeight?: number;
14
+ maxFrameRate?: number;
15
+ maxBitrate?: number;
16
+ gpu?: 'auto' | 'disabled';
17
+ }
18
+
19
+ export interface ILiveVideoSource {
20
+ tabId: string;
21
+ generation: number;
22
+ viewportRevision: number;
23
+ viewport: { width: number; height: number; deviceScaleFactor: number };
24
+ }
25
+
26
+ export interface ILiveVideoDescription {
27
+ type: 'offer' | 'answer';
28
+ sdp: string;
29
+ }
30
+
31
+ export interface ILiveVideoOffer {
32
+ peerId: string;
33
+ negotiationId: string;
34
+ source: ILiveVideoSource;
35
+ description: ILiveVideoDescription;
36
+ iceServers: ILiveVideoIceServer[];
37
+ iceTransportPolicy: 'all' | 'relay';
38
+ }
39
+
40
+ export interface ILiveVideoStatistics {
41
+ connectionState: string;
42
+ visible: boolean;
43
+ framesEncoded: number;
44
+ bytesSent: number;
45
+ frameWidth?: number;
46
+ frameHeight?: number;
47
+ framesPerSecond?: number;
48
+ encoderImplementation?: string;
49
+ powerEfficientEncoder?: boolean;
50
+ qualityLimitationReason?: string;
51
+ roundTripTime?: number;
52
+ }
53
+
54
+ export interface ILiveVideoAcceleration {
55
+ renderer: string;
56
+ compositing: string;
57
+ videoEncoding: string;
58
+ }
59
+
60
+ export const normalizeLiveVideoOptions = (input: ILiveVideoOptions): Required<ILiveVideoOptions> => {
61
+ const integer = (value: number | undefined, fallback: number, min: number, max: number): number => {
62
+ const result = value ?? fallback;
63
+ if (!Number.isSafeInteger(result) || result < min || result > max) {
64
+ throw new Error(`Video option must be an integer between ${min} and ${max}.`);
65
+ }
66
+ return result;
67
+ };
68
+ if (input.gpu !== undefined && !['auto', 'disabled'].includes(input.gpu)) throw new Error('Invalid video GPU mode.');
69
+ if (input.iceTransportPolicy !== undefined && !['all', 'relay'].includes(input.iceTransportPolicy)) {
70
+ throw new Error('Invalid video ICE transport policy.');
71
+ }
72
+ if (input.iceServers !== undefined && (!Array.isArray(input.iceServers) || input.iceServers.length > 8)) {
73
+ throw new Error('Video supports at most eight ICE services.');
74
+ }
75
+ const iceServers = (input.iceServers ?? []).map(server => {
76
+ const urls = typeof server.urls === 'string' ? [server.urls] : server.urls;
77
+ if (!Array.isArray(urls) || !urls.length || urls.length > 8 || urls.some(url => (
78
+ typeof url !== 'string' || url.length > 2048 || !/^(stun|stuns|turn|turns):[^\s@]+$/.test(url)
79
+ ))) throw new Error('Invalid video ICE service URL.');
80
+ for (const value of [server.username, server.credential]) {
81
+ if (value !== undefined && (typeof value !== 'string' || value.length > 2048)) throw new Error('Invalid video ICE credentials.');
82
+ }
83
+ return { urls: [...urls], ...(server.username === undefined ? {} : { username: server.username }),
84
+ ...(server.credential === undefined ? {} : { credential: server.credential }) };
85
+ });
86
+ if (input.iceTransportPolicy === 'relay' && !iceServers.some(server => server.urls.some(url => /^turns?:/.test(url)))) {
87
+ throw new Error('Relay-only video requires a TURN service.');
88
+ }
89
+ return { iceServers, iceTransportPolicy: input.iceTransportPolicy ?? 'all',
90
+ maxViewers: integer(input.maxViewers, 8, 1, 32), maxWidth: integer(input.maxWidth, 2560, 320, 4096),
91
+ maxHeight: integer(input.maxHeight, 1600, 240, 4096), maxFrameRate: integer(input.maxFrameRate, 30, 1, 60),
92
+ maxBitrate: integer(input.maxBitrate, 8_000_000, 100_000, 50_000_000), gpu: input.gpu ?? 'auto' };
93
+ };
94
+
95
+ export const validateLiveVideoDescription = (description: ILiveVideoDescription, type: 'offer' | 'answer'): void => {
96
+ if (!description || description.type !== type || typeof description.sdp !== 'string'
97
+ || description.sdp.length > 48 * 1024 || !description.sdp.startsWith('v=0\r\n')) {
98
+ throw new Error('Invalid video session description.');
99
+ }
100
+ if ((description.sdp.match(/^m=/gm) ?? []).length !== 1 || !/^m=video /m.test(description.sdp)) {
101
+ throw new Error('Video negotiation accepts only a video media section.');
102
+ }
103
+ };