@push.rocks/smartpuppeteer 2.7.0 → 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.
- package/assets/livevideo/background.js +17 -0
- package/assets/livevideo/capture.html +3 -0
- package/assets/livevideo/capture.js +193 -0
- package/assets/livevideo/manifest.json +11 -0
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/classes.livevideo.d.ts +33 -0
- package/dist_ts/classes.livevideo.js +175 -0
- package/dist_ts/index.d.ts +1 -0
- package/dist_ts/index.js +2 -1
- package/dist_ts/interfaces.livevideo.d.ts +58 -0
- package/dist_ts/interfaces.livevideo.js +45 -0
- package/dist_ts/smartpuppeteer.classes.livebrowsersession.d.ts +11 -0
- package/dist_ts/smartpuppeteer.classes.livebrowsersession.js +137 -16
- package/dist_ts/smartpuppeteer.interfaces.livebrowser.d.ts +5 -0
- package/dist_ts/smartpuppeteer.interfaces.livebrowser.js +1 -1
- package/dist_ts/smartpuppeteer.plugins.d.ts +3 -1
- package/dist_ts/smartpuppeteer.plugins.js +4 -2
- package/package.json +3 -3
- package/readme.md +36 -0
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/classes.livevideo.ts +210 -0
- package/ts/index.ts +1 -0
- package/ts/interfaces.livevideo.ts +103 -0
- package/ts/smartpuppeteer.classes.livebrowsersession.ts +140 -17
- package/ts/smartpuppeteer.interfaces.livebrowser.ts +5 -0
- package/ts/smartpuppeteer.plugins.ts +3 -1
- package/readme.hints.md +0 -20
|
@@ -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,
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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.
|
|
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.
|
|
15
|
+
"@git.zone/tsbuild": "^4.4.3",
|
|
16
16
|
"@git.zone/tsrun": "^2.0.6",
|
|
17
|
-
"@git.zone/tstest": "^
|
|
17
|
+
"@git.zone/tstest": "^6.0.0",
|
|
18
18
|
"@types/node": "^26.4.1"
|
|
19
19
|
},
|
|
20
20
|
"dependencies": {
|
package/readme.md
CHANGED
|
@@ -190,6 +190,8 @@ if (identity) {
|
|
|
190
190
|
}
|
|
191
191
|
```
|
|
192
192
|
|
|
193
|
+
Synchronous screencast invalidation, including renderer-initiated navigation, immediately publishes a state event with `streaming: false`. The tab remains nonstreaming in public state until the replacement generation starts, so viewers can fence input before asynchronous page metadata refresh completes.
|
|
194
|
+
|
|
193
195
|
The live API includes:
|
|
194
196
|
|
|
195
197
|
- Lifecycle and state: `start()`, `stop()`, `terminate()`, `refreshScreencast()`, `updateScreencastOptions()`, `getProcessState()`, `onEvent()`, and `getState()`
|
|
@@ -252,6 +254,40 @@ The default evaluation limits are a 5-second timeout, 256 KiB transferred output
|
|
|
252
254
|
|
|
253
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.
|
|
254
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
|
+
|
|
255
291
|
### Handling Browser Events
|
|
256
292
|
It's important to handle browser events, such as disconnections, which might occur due to various reasons:
|
|
257
293
|
|
package/ts/00_commitinfo_data.ts
CHANGED
|
@@ -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
|
+
};
|