@push.rocks/smartbrowser 4.1.0 → 4.3.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/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts_web/00_commitinfo_data.js +1 -1
- package/dist_ts_web/classes.livebrowsercanvasrenderer.d.ts +4 -187
- package/dist_ts_web/classes.livebrowsercanvasrenderer.js +5 -1750
- package/dist_ts_web/classes.livebrowserrenderer.d.ts +194 -0
- package/dist_ts_web/classes.livebrowserrenderer.js +1830 -0
- package/dist_ts_web/classes.livebrowservideoreceiver.d.ts +46 -0
- package/dist_ts_web/classes.livebrowservideoreceiver.js +243 -0
- package/dist_ts_web/classes.livebrowservideorenderer.d.ts +6 -0
- package/dist_ts_web/classes.livebrowservideorenderer.js +8 -0
- package/dist_ts_web/index.d.ts +3 -0
- package/dist_ts_web/index.js +2 -1
- package/dist_ts_web/interfaces.livebrowsercanvas.d.ts +8 -2
- package/dist_ts_web/interfaces.livebrowservideo.d.ts +31 -0
- package/dist_ts_web/interfaces.livebrowservideo.js +2 -0
- package/package.json +4 -4
- package/readme.md +25 -1
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts_web/00_commitinfo_data.ts +1 -1
- package/ts_web/classes.livebrowsercanvasrenderer.ts +5 -2151
- package/ts_web/classes.livebrowserrenderer.ts +2232 -0
- package/ts_web/classes.livebrowservideoreceiver.ts +250 -0
- package/ts_web/classes.livebrowservideorenderer.ts +9 -0
- package/ts_web/index.ts +4 -0
- package/ts_web/interfaces.livebrowsercanvas.ts +11 -1
- package/ts_web/interfaces.livebrowservideo.ts +46 -0
- package/readme.hints.md +0 -34
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import type { ILiveBrowserState, ILiveVideoSource } from '@push.rocks/smartpuppeteer';
|
|
2
|
+
import type { ILiveBrowserCanvasError, ILiveBrowserCanvasOperationOptions } from './interfaces.livebrowsercanvas.js';
|
|
3
|
+
import type { ILiveBrowserVideoClient, ILiveBrowserVideoStatistics } from './interfaces.livebrowservideo.js';
|
|
4
|
+
|
|
5
|
+
interface IVideoReceiverOptions {
|
|
6
|
+
video: HTMLVideoElement;
|
|
7
|
+
client: ILiveBrowserVideoClient;
|
|
8
|
+
operationTimeoutMs: number;
|
|
9
|
+
onUnavailable(): void;
|
|
10
|
+
onPresented(sourceArg: ILiveVideoSource): void;
|
|
11
|
+
onError(errorArg: ILiveBrowserCanvasError): void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const sourceKey = (sourceArg: ILiveVideoSource): string => JSON.stringify([
|
|
15
|
+
sourceArg.tabId, sourceArg.generation, sourceArg.viewportRevision,
|
|
16
|
+
sourceArg.viewport.width, sourceArg.viewport.height, sourceArg.viewport.deviceScaleFactor,
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
/** One native receiver. Every replacement closes the previous host peer before opening another. */
|
|
20
|
+
export class LiveBrowserVideoReceiver {
|
|
21
|
+
private epoch = 0;
|
|
22
|
+
private desiredKey?: string;
|
|
23
|
+
private controller?: AbortController;
|
|
24
|
+
private peer?: RTCPeerConnection;
|
|
25
|
+
private frameCallback?: number;
|
|
26
|
+
private connectionTimer?: ReturnType<typeof setTimeout>;
|
|
27
|
+
private sampleTimer?: ReturnType<typeof setTimeout>;
|
|
28
|
+
private tail: Promise<void> = Promise.resolve();
|
|
29
|
+
private remoteMayExist = false;
|
|
30
|
+
private statistics?: ILiveBrowserVideoStatistics;
|
|
31
|
+
private decodedBeforePeer = 0;
|
|
32
|
+
private droppedBeforePeer = 0;
|
|
33
|
+
|
|
34
|
+
constructor(private readonly options: IVideoReceiverOptions) {
|
|
35
|
+
const video = options.video;
|
|
36
|
+
video.autoplay = true;
|
|
37
|
+
video.muted = true;
|
|
38
|
+
video.playsInline = true;
|
|
39
|
+
video.style.objectFit = 'contain';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
public getStatistics(): ILiveBrowserVideoStatistics | undefined {
|
|
43
|
+
return this.statistics ? { ...this.statistics } : undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
public getCounters(): { framesReceived: number; framesDecoded: number; framesSkipped: number } {
|
|
47
|
+
const decoded = this.decodedBeforePeer + (this.statistics?.framesDecoded ?? 0);
|
|
48
|
+
const dropped = this.droppedBeforePeer + (this.statistics?.framesDropped ?? 0);
|
|
49
|
+
return { framesReceived: decoded + dropped, framesDecoded: decoded, framesSkipped: dropped };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
public update(stateArg: ILiveBrowserState): void {
|
|
53
|
+
const tab = stateArg.tabs.find((tabArg) => tabArg.id === stateArg.activeTabId);
|
|
54
|
+
const source: ILiveVideoSource | undefined = document.visibilityState !== 'hidden'
|
|
55
|
+
&& stateArg.status === 'running' && tab?.status === 'open' && tab.streaming
|
|
56
|
+
? { tabId: tab.id, generation: tab.generation, viewportRevision: stateArg.viewportRevision,
|
|
57
|
+
viewport: { ...stateArg.viewport } }
|
|
58
|
+
: undefined;
|
|
59
|
+
const key = source ? sourceKey(source) : undefined;
|
|
60
|
+
if (key === this.desiredKey) return;
|
|
61
|
+
this.interrupt();
|
|
62
|
+
this.desiredKey = key;
|
|
63
|
+
const epoch = this.epoch;
|
|
64
|
+
const controller = new AbortController();
|
|
65
|
+
this.controller = controller;
|
|
66
|
+
this.enqueue(async () => {
|
|
67
|
+
await this.closeRemote();
|
|
68
|
+
if (!source || epoch !== this.epoch) return;
|
|
69
|
+
try {
|
|
70
|
+
await this.connect(source, controller.signal, epoch);
|
|
71
|
+
} catch (error) {
|
|
72
|
+
if (epoch !== this.epoch || controller.signal.aborted) return;
|
|
73
|
+
this.fail('video_negotiation_failed', error, epoch);
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Synchronously prevents old frames or input from surviving a transport/source change. */
|
|
79
|
+
public interrupt(): void {
|
|
80
|
+
this.epoch++;
|
|
81
|
+
this.desiredKey = undefined;
|
|
82
|
+
this.controller?.abort(new Error('Video source was superseded'));
|
|
83
|
+
this.controller = undefined;
|
|
84
|
+
this.options.onUnavailable();
|
|
85
|
+
if (this.frameCallback !== undefined) this.options.video.cancelVideoFrameCallback(this.frameCallback);
|
|
86
|
+
this.frameCallback = undefined;
|
|
87
|
+
clearTimeout(this.connectionTimer);
|
|
88
|
+
clearTimeout(this.sampleTimer);
|
|
89
|
+
this.connectionTimer = undefined;
|
|
90
|
+
this.sampleTimer = undefined;
|
|
91
|
+
this.peer?.close();
|
|
92
|
+
this.peer = undefined;
|
|
93
|
+
this.options.video.pause();
|
|
94
|
+
this.options.video.srcObject = null;
|
|
95
|
+
this.decodedBeforePeer += this.statistics?.framesDecoded ?? 0;
|
|
96
|
+
this.droppedBeforePeer += this.statistics?.framesDropped ?? 0;
|
|
97
|
+
this.statistics = undefined;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
public async stop(): Promise<void> {
|
|
101
|
+
this.interrupt();
|
|
102
|
+
await this.enqueue(() => this.closeRemote());
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
private enqueue(operationArg: () => Promise<void>): Promise<void> {
|
|
106
|
+
const operation = this.tail.then(operationArg, operationArg);
|
|
107
|
+
this.tail = operation.catch((error) => {
|
|
108
|
+
this.options.onError({ code: 'renderer_cleanup_failed', message: `Video peer cleanup failed: ${String(error)}`, cause: error });
|
|
109
|
+
});
|
|
110
|
+
return operation;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
private async closeRemote(): Promise<void> {
|
|
114
|
+
if (!this.remoteMayExist) return;
|
|
115
|
+
await this.operation((optionsArg) => this.options.client.closeVideoPeer(optionsArg));
|
|
116
|
+
this.remoteMayExist = false;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
private async connect(sourceArg: ILiveVideoSource, signalArg: AbortSignal, epochArg: number): Promise<void> {
|
|
120
|
+
this.remoteMayExist = true;
|
|
121
|
+
const offer = await this.operation((optionsArg) => this.options.client.openVideoPeer(optionsArg), signalArg);
|
|
122
|
+
if (sourceKey(offer.source) !== sourceKey(sourceArg)) throw new Error('Video offer does not match the current browser source');
|
|
123
|
+
if (signalArg.aborted || epochArg !== this.epoch) return;
|
|
124
|
+
const peer = new RTCPeerConnection({ iceServers: offer.iceServers, iceTransportPolicy: offer.iceTransportPolicy });
|
|
125
|
+
this.peer = peer;
|
|
126
|
+
this.statistics = { connectionState: 'new', framesDecoded: 0, framesDropped: 0, bytesReceived: 0, bitrate: 0 };
|
|
127
|
+
peer.addEventListener('connectionstatechange', () => {
|
|
128
|
+
if (epochArg !== this.epoch) return;
|
|
129
|
+
if (this.statistics) this.statistics.connectionState = peer.connectionState;
|
|
130
|
+
if (peer.connectionState === 'failed' || peer.connectionState === 'disconnected') {
|
|
131
|
+
this.fail('video_connection_failed', new Error(`Video connection ${peer.connectionState}`), epochArg);
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
let presented = false;
|
|
135
|
+
peer.addEventListener('track', (eventArg) => {
|
|
136
|
+
if (epochArg !== this.epoch || eventArg.track.kind !== 'video') return;
|
|
137
|
+
if ('jitterBufferTarget' in eventArg.receiver) eventArg.receiver.jitterBufferTarget = 0;
|
|
138
|
+
this.options.video.srcObject = new MediaStream([eventArg.track]);
|
|
139
|
+
this.frameCallback = this.options.video.requestVideoFrameCallback(() => {
|
|
140
|
+
this.frameCallback = undefined;
|
|
141
|
+
if (epochArg !== this.epoch) return;
|
|
142
|
+
clearTimeout(this.connectionTimer);
|
|
143
|
+
this.connectionTimer = undefined;
|
|
144
|
+
presented = true;
|
|
145
|
+
this.options.onPresented(sourceArg);
|
|
146
|
+
});
|
|
147
|
+
void this.options.video.play().catch((error) => this.fail('video_connection_failed', error, epochArg));
|
|
148
|
+
});
|
|
149
|
+
await this.operation(async ({ signal }) => {
|
|
150
|
+
await peer.setRemoteDescription(offer.description);
|
|
151
|
+
if (signal.aborted) throw signal.reason;
|
|
152
|
+
await peer.setLocalDescription(await peer.createAnswer());
|
|
153
|
+
await this.waitForIce(peer, signal);
|
|
154
|
+
}, signalArg);
|
|
155
|
+
if (signalArg.aborted || epochArg !== this.epoch) return;
|
|
156
|
+
const description = peer.localDescription;
|
|
157
|
+
if (!description?.sdp || description.type !== 'answer' || description.sdp.length > 48 * 1024) {
|
|
158
|
+
throw new Error('Invalid video answer');
|
|
159
|
+
}
|
|
160
|
+
await this.operation((optionsArg) => this.options.client.answerVideoPeer(offer.negotiationId,
|
|
161
|
+
{ type: 'answer', sdp: description.sdp }, optionsArg), signalArg);
|
|
162
|
+
if (signalArg.aborted || epochArg !== this.epoch) return;
|
|
163
|
+
if (!presented) this.connectionTimer = setTimeout(() => this.fail('video_connection_failed',
|
|
164
|
+
new Error('No video frame received within 30 seconds'), epochArg), 30000);
|
|
165
|
+
void this.sample(peer, signalArg, epochArg);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
private waitForIce(peerArg: RTCPeerConnection, signalArg: AbortSignal): Promise<void> {
|
|
169
|
+
return new Promise((resolve, reject) => {
|
|
170
|
+
const cleanup = () => {
|
|
171
|
+
peerArg.removeEventListener('icegatheringstatechange', check);
|
|
172
|
+
signalArg.removeEventListener('abort', abort);
|
|
173
|
+
};
|
|
174
|
+
const abort = () => { cleanup(); reject(signalArg.reason); };
|
|
175
|
+
const check = () => {
|
|
176
|
+
if (signalArg.aborted) abort();
|
|
177
|
+
else if (peerArg.iceGatheringState === 'complete') { cleanup(); resolve(); }
|
|
178
|
+
};
|
|
179
|
+
peerArg.addEventListener('icegatheringstatechange', check);
|
|
180
|
+
signalArg.addEventListener('abort', abort, { once: true });
|
|
181
|
+
check();
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
private fail(codeArg: 'video_negotiation_failed' | 'video_connection_failed', errorArg: unknown, epochArg: number): void {
|
|
186
|
+
if (epochArg !== this.epoch) return;
|
|
187
|
+
this.interrupt();
|
|
188
|
+
void this.enqueue(() => this.closeRemote()).catch(() => undefined);
|
|
189
|
+
this.options.onError({ code: codeArg, message: String(errorArg), cause: errorArg });
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
private async sample(peerArg: RTCPeerConnection, signalArg: AbortSignal, epochArg: number,
|
|
193
|
+
previousArg?: { timestamp: number; bytes: number; delay: number; emitted: number }): Promise<void> {
|
|
194
|
+
let previous = previousArg;
|
|
195
|
+
try {
|
|
196
|
+
const [report, sender] = await this.operation(async (optionsArg) => Promise.all([
|
|
197
|
+
peerArg.getStats(), this.options.client.getVideoStatistics(optionsArg),
|
|
198
|
+
]), signalArg);
|
|
199
|
+
if (epochArg !== this.epoch || !this.statistics) return;
|
|
200
|
+
this.statistics.sender = sender;
|
|
201
|
+
report.forEach((entryArg) => {
|
|
202
|
+
if (entryArg.type === 'inbound-rtp' && entryArg.kind === 'video') {
|
|
203
|
+
const interval = previous ? entryArg.timestamp - previous.timestamp : 0;
|
|
204
|
+
this.statistics!.framesDecoded = entryArg.framesDecoded ?? 0;
|
|
205
|
+
this.statistics!.framesDropped = entryArg.framesDropped ?? 0;
|
|
206
|
+
this.statistics!.bytesReceived = entryArg.bytesReceived ?? 0;
|
|
207
|
+
this.statistics!.framesPerSecond = entryArg.framesPerSecond;
|
|
208
|
+
this.statistics!.decoderImplementation = entryArg.decoderImplementation;
|
|
209
|
+
this.statistics!.powerEfficientDecoder = entryArg.powerEfficientDecoder;
|
|
210
|
+
this.statistics!.codec = report.get(entryArg.codecId)?.mimeType;
|
|
211
|
+
this.statistics!.bitrate = interval > 0 ? Math.max(0, (entryArg.bytesReceived - previous!.bytes) * 8000 / interval) : 0;
|
|
212
|
+
const emitted = entryArg.jitterBufferEmittedCount ?? 0;
|
|
213
|
+
const delay = entryArg.jitterBufferDelay ?? 0;
|
|
214
|
+
if (previous && emitted > previous.emitted) this.statistics!.jitterBufferMs =
|
|
215
|
+
(delay - previous.delay) * 1000 / (emitted - previous.emitted);
|
|
216
|
+
previous = { timestamp: entryArg.timestamp, bytes: entryArg.bytesReceived ?? 0, delay, emitted };
|
|
217
|
+
}
|
|
218
|
+
if (entryArg.type === 'transport' && entryArg.selectedCandidatePairId) {
|
|
219
|
+
const pair = report.get(entryArg.selectedCandidatePairId);
|
|
220
|
+
this.statistics!.roundTripMs = pair?.currentRoundTripTime === undefined ? undefined : pair.currentRoundTripTime * 1000;
|
|
221
|
+
this.statistics!.candidateType = report.get(pair?.remoteCandidateId)?.candidateType;
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
} catch (error) {
|
|
225
|
+
if (epochArg !== this.epoch || signalArg.aborted) return;
|
|
226
|
+
this.options.onError({ code: 'video_statistics_failed', message: `Could not read video statistics: ${String(error)}`, cause: error });
|
|
227
|
+
}
|
|
228
|
+
if (epochArg === this.epoch && !signalArg.aborted) {
|
|
229
|
+
this.sampleTimer = setTimeout(() => { void this.sample(peerArg, signalArg, epochArg, previous); }, 1000);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
private operation<T>(operationArg: (optionsArg: ILiveBrowserCanvasOperationOptions) => Promise<T>,
|
|
234
|
+
parentSignalArg?: AbortSignal): Promise<T> {
|
|
235
|
+
const controller = new AbortController();
|
|
236
|
+
const abort = () => controller.abort(parentSignalArg?.reason);
|
|
237
|
+
parentSignalArg?.addEventListener('abort', abort, { once: true });
|
|
238
|
+
if (parentSignalArg?.aborted) abort();
|
|
239
|
+
const timer = setTimeout(() => controller.abort(new Error('Video operation timed out')), this.options.operationTimeoutMs);
|
|
240
|
+
return new Promise<T>((resolve, reject) => {
|
|
241
|
+
const onAbort = () => reject(controller.signal.reason);
|
|
242
|
+
controller.signal.addEventListener('abort', onAbort, { once: true });
|
|
243
|
+
if (controller.signal.aborted) onAbort();
|
|
244
|
+
else Promise.resolve().then(() => operationArg({ signal: controller.signal })).then(resolve, reject);
|
|
245
|
+
}).finally(() => {
|
|
246
|
+
clearTimeout(timer);
|
|
247
|
+
parentSignalArg?.removeEventListener('abort', abort);
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { LiveBrowserRenderer } from './classes.livebrowserrenderer.js';
|
|
2
|
+
import type { ILiveBrowserVideoRendererOptions } from './interfaces.livebrowservideo.js';
|
|
3
|
+
|
|
4
|
+
/** Native video presentation; shares input, viewport and lifecycle handling with the canvas renderer. */
|
|
5
|
+
export class LiveBrowserVideoRenderer extends LiveBrowserRenderer {
|
|
6
|
+
constructor(optionsArg: ILiveBrowserVideoRendererOptions) {
|
|
7
|
+
super(optionsArg);
|
|
8
|
+
}
|
|
9
|
+
}
|
package/ts_web/index.ts
CHANGED
|
@@ -25,3 +25,7 @@ export type {
|
|
|
25
25
|
TLiveBrowserStatus,
|
|
26
26
|
TLiveBrowserTabStatus,
|
|
27
27
|
} from '@push.rocks/smartpuppeteer';
|
|
28
|
+
|
|
29
|
+
export { LiveBrowserVideoRenderer } from './classes.livebrowservideorenderer.js';
|
|
30
|
+
export type * from './interfaces.livebrowservideo.js';
|
|
31
|
+
export type { ILiveVideoDescription, ILiveVideoOffer, ILiveVideoSource, ILiveVideoStatistics, ILiveVideoAcceleration } from '@push.rocks/smartpuppeteer';
|
|
@@ -26,10 +26,11 @@ export interface ILiveBrowserCanvasClient {
|
|
|
26
26
|
requestArg: ILiveBrowserFrameAcknowledgementRequest,
|
|
27
27
|
optionsArg: ILiveBrowserCanvasOperationOptions,
|
|
28
28
|
): Promise<ILiveBrowserFrameAcknowledgement>;
|
|
29
|
+
/** Shared hosts return their effective viewport, which may be constrained by another viewer. */
|
|
29
30
|
setViewport(
|
|
30
31
|
viewportArg: ILiveBrowserViewport,
|
|
31
32
|
optionsArg: ILiveBrowserCanvasOperationOptions,
|
|
32
|
-
): Promise<void>;
|
|
33
|
+
): Promise<void | ILiveBrowserCanvasViewportResult>;
|
|
33
34
|
dispatchMouse(
|
|
34
35
|
inputArg: ILiveBrowserMouseInput,
|
|
35
36
|
optionsArg: ILiveBrowserCanvasOperationOptions,
|
|
@@ -48,12 +49,21 @@ export interface ILiveBrowserCanvasClient {
|
|
|
48
49
|
): Promise<void>;
|
|
49
50
|
}
|
|
50
51
|
|
|
52
|
+
export interface ILiveBrowserCanvasViewportResult {
|
|
53
|
+
viewport: ILiveBrowserViewport;
|
|
54
|
+
/** The authoritative revision, including when the aggregate viewport did not change. */
|
|
55
|
+
viewportRevision: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
51
58
|
export interface ILiveBrowserCanvasOperationOptions {
|
|
52
59
|
/** Adapters must propagate this signal and reject promptly when it aborts. */
|
|
53
60
|
signal: AbortSignal;
|
|
54
61
|
}
|
|
55
62
|
|
|
56
63
|
export type TLiveBrowserCanvasErrorCode =
|
|
64
|
+
| 'video_negotiation_failed'
|
|
65
|
+
| 'video_connection_failed'
|
|
66
|
+
| 'video_statistics_failed'
|
|
57
67
|
| 'frame_acknowledgement_failed'
|
|
58
68
|
| 'frame_render_failed'
|
|
59
69
|
| 'input_dispatch_failed'
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ILiveVideoDescription,
|
|
3
|
+
ILiveVideoOffer,
|
|
4
|
+
ILiveVideoStatistics,
|
|
5
|
+
} from '@push.rocks/smartpuppeteer';
|
|
6
|
+
import type {
|
|
7
|
+
ILiveBrowserCanvasClient,
|
|
8
|
+
ILiveBrowserCanvasOperationOptions,
|
|
9
|
+
ILiveBrowserCanvasRendererOptions,
|
|
10
|
+
ILiveBrowserCanvasRendererStatistics,
|
|
11
|
+
} from './interfaces.livebrowsercanvas.js';
|
|
12
|
+
|
|
13
|
+
/** Signaling is authorized by the host's existing viewer lease. */
|
|
14
|
+
export interface ILiveBrowserVideoClient extends Omit<ILiveBrowserCanvasClient, 'acknowledgeFrame'> {
|
|
15
|
+
openVideoPeer(optionsArg: ILiveBrowserCanvasOperationOptions): Promise<Omit<ILiveVideoOffer, 'peerId'>>;
|
|
16
|
+
answerVideoPeer(negotiationIdArg: string, descriptionArg: ILiveVideoDescription,
|
|
17
|
+
optionsArg: ILiveBrowserCanvasOperationOptions): Promise<void>;
|
|
18
|
+
closeVideoPeer(optionsArg: ILiveBrowserCanvasOperationOptions): Promise<void>;
|
|
19
|
+
getVideoStatistics(optionsArg: ILiveBrowserCanvasOperationOptions): Promise<ILiveVideoStatistics>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ILiveBrowserVideoRendererOptions extends Omit<ILiveBrowserCanvasRendererOptions,
|
|
23
|
+
'canvas' | 'client' | 'onFrameRendered' | 'frameDecodeTimeoutMs'> {
|
|
24
|
+
video: HTMLVideoElement;
|
|
25
|
+
client: ILiveBrowserVideoClient;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface ILiveBrowserVideoStatistics {
|
|
29
|
+
connectionState: RTCPeerConnectionState;
|
|
30
|
+
framesDecoded: number;
|
|
31
|
+
framesDropped: number;
|
|
32
|
+
bytesReceived: number;
|
|
33
|
+
bitrate: number;
|
|
34
|
+
framesPerSecond?: number;
|
|
35
|
+
roundTripMs?: number;
|
|
36
|
+
jitterBufferMs?: number;
|
|
37
|
+
codec?: string;
|
|
38
|
+
decoderImplementation?: string;
|
|
39
|
+
powerEfficientDecoder?: boolean;
|
|
40
|
+
candidateType?: string;
|
|
41
|
+
sender?: ILiveVideoStatistics;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface ILiveBrowserVideoRendererStatistics extends ILiveBrowserCanvasRendererStatistics {
|
|
45
|
+
video?: ILiveBrowserVideoStatistics;
|
|
46
|
+
}
|
package/readme.hints.md
DELETED
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
# SmartBrowser Hints
|
|
2
|
-
|
|
3
|
-
## Dependencies (as of 2026-08-24)
|
|
4
|
-
- `@push.rocks/smartpdf` v5.x uses SmartPuppeteer 2/Puppeteer 25 browser types. `SmartPdf` remains lazy and starts only for PDF methods.
|
|
5
|
-
- `@push.rocks/smartpuppeteer` v2.6 owns `LiveBrowserSession`, the canonical transport-neutral live browser contracts, bounded CDP frame acknowledgement flow, and authenticated-proxy service-worker coverage.
|
|
6
|
-
- Tests import `@git.zone/tstest/tapbundle`; browser tests use the `.chromium.ts` suffix.
|
|
7
|
-
- The Node.js entry requires Node.js 22.12 or newer. This repository uses pnpm 11.21 for development and release tooling.
|
|
8
|
-
- Puppeteer `page.screenshot({ encoding: 'binary' })` returns `Uint8Array`, not `Buffer` - wrap with `Buffer.from()`
|
|
9
|
-
|
|
10
|
-
## Build
|
|
11
|
-
- `pnpm run build` uses `tsbuild tsfolders` and emits both `dist_ts` and `dist_ts_web`.
|
|
12
|
-
- `@push.rocks/smartbrowser/web` must remain runtime-isolated from Puppeteer, SmartPDF, Buffer, and Node.js built-ins. Only type imports from SmartPuppeteer are allowed.
|
|
13
|
-
- `pnpm test` runs Node and Chromium files through tstest 4. The Chromium renderer tests claim a 2D context in `installCanvas()` so pixels can be read back; the renderer then keeps its 2D path. One test uses a fresh canvas to cover the `bitmaprenderer` path.
|
|
14
|
-
|
|
15
|
-
## Canvas Renderer Invariants
|
|
16
|
-
- The client facade is transport-neutral. Authentication, authorization, binary wire encoding, session ownership, and egress policy belong to the consuming adapter.
|
|
17
|
-
- Input always carries the identity of the actually displayed frame, never merely the newest state event.
|
|
18
|
-
- Input and frame rendering require the active tab state to report `streaming: true`; a stopped stream clears the displayed frame even when its identity fields have not advanced.
|
|
19
|
-
- Cached state may lag a new frame generation. A state identity ahead of the displayed frame invalidates it; same-identity metadata updates do not.
|
|
20
|
-
- One frame may decode while only the newest subsequent frame waits; a superseded queued frame is never decoded. Active-run frames must pass full static validation and then have a sequence strictly above the per-run high-water value. Duplicate and out-of-order frames suspend without acknowledgement; resume resets the high-water value.
|
|
21
|
-
- Validated frames are acknowledged at receipt, before decoding. The newest decoded bitmap is presented once per animation frame (16 ms timer while hidden), the canvas backing store is reallocated only when frame dimensions change, and a fresh canvas gets an `ImageBitmapRenderingContext`; a canvas that already owns a 2D context keeps the 2D path. Decoded-dimension integrity is still checked before presentation.
|
|
22
|
-
- The renderer makes exactly one acknowledgement request for each valid frame while its run remains active. Invalid-protocol frames do not start acknowledgement work. An acknowledgement that throws, rejects, or times out suspends the run; `{ accepted: false }` settles the local attempt and does not prove upstream retirement.
|
|
23
|
-
- Every asynchronous client method receives `ILiveBrowserCanvasOperationOptions` with a renderer-owned `AbortSignal`. Operation deadline, run suspension, and stop all abort the signal; adapters must propagate it through the underlying transport.
|
|
24
|
-
- Renderer starts, suspensions, resumes, and stops are serialized by run epoch. Suspension aborts owned work and drops state, frames, queued input, pressed state, and old-generation recovery. Resume creates a fresh epoch and requires newly read state plus a newly displayed frame before input.
|
|
25
|
-
- Native `createImageBitmap()` jobs are tracked outside per-run cleanup. A new start or resume waits for every prior raw decode to settle, and late bitmaps are closed after timeout or interruption. Start or resume can remain pending indefinitely if the browser never settles native decode work.
|
|
26
|
-
- Acknowledgement failures, input-release failures, acknowledgement-capacity exhaustion, and client/decode timeouts suspend only the current run. Discrete input-queue capacity exhaustion is a non-fatal `input_queue_capacity_exceeded` rejection of the new command; coalescable wheel and move commands never raise it. Malformed, oversized, or inconsistent frame protocol and deterministic decode-integrity failures are also terminal to that run. Recovery is explicit through `resume()`; none of these paths retries automatically.
|
|
27
|
-
- Internal renderer ceilings are 16 pending acknowledgements; 4 in-flight input commands, 128 queued discrete input commands, and 32 queued coalescable input commands; 12,288 per frame dimension and 8,294,400 frame pixels; 34,226,176 encoded bytes; and a 4,096 x 4,096 viewport, device scale factor 3, and 8,294,400 physical pixels.
|
|
28
|
-
- Repeated blur, visibility, and pointer-loss resets coalesce into one bounded reset operation.
|
|
29
|
-
- Input dispatch pipelines up to 4 commands in queue order; input idle means an empty queue and nothing in flight. Wheel deltas accumulate outside the queue and flush at most once per animation frame and only while no wheel dispatch is in flight; a discrete command forces the flush first so order is kept. Consecutive moves coalesce to the newest position. `getStatistics()` exposes cumulative frame and input counters.
|
|
30
|
-
- During an active run, pressed input is retained until release succeeds, with one retry for transient release failures. Input remains blocked while a release is unresolved. Suspension discards pressed state without dispatching releases into a later run.
|
|
31
|
-
- A rejected non-timeout input dispatch clears the ambiguous displayed frame and requires a newly rendered matching frame. A timed-out dispatch suspends the run and requires explicit resume plus a fresh frame.
|
|
32
|
-
- Resize uses a caller-supplied stable CSS element. `ResizeObserver` observations are debounced 100 ms trailing, `syncViewport()` is immediate. Requests are latest-only and serialized; input is released before resizing and remains blocked until a frame meeting the target revision fence is drawn.
|
|
33
|
-
- Intrinsic canvas dimensions come from encoded frame dimensions. Pointer coordinates map to the frame's logical CSS viewport.
|
|
34
|
-
- Pointer and wheel listeners remain on the canvas. Keyboard and composition listeners use `focusTarget`, which defaults to the canvas. Call `insertText()` for text committed by an external IME or dedicated input control.
|