@omercnet/paseo-shared-browser 1.2.2-next.232.1 → 1.2.2-next.239.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.
Files changed (169) hide show
  1. package/README.md +312 -25
  2. package/REMOTE_BROWSER_RESEARCH.md +395 -0
  3. package/STREAMING_DESIGN.md +191 -0
  4. package/client/browser-auto-capture-density.test.ts +102 -0
  5. package/client/browser-auto-capture-density.ts +30 -0
  6. package/client/browser-canvas-input.test.ts +198 -0
  7. package/client/browser-canvas-input.ts +202 -0
  8. package/client/browser-canvas-keyboard-web.test.ts +113 -0
  9. package/client/browser-canvas-keyboard-web.ts +230 -0
  10. package/client/browser-canvas-keyboard.test.ts +123 -0
  11. package/client/browser-canvas-keyboard.ts +126 -0
  12. package/client/browser-canvas-layout.test.ts +109 -0
  13. package/client/browser-canvas-layout.ts +51 -0
  14. package/client/browser-canvas-viewport-web.test.ts +77 -0
  15. package/client/browser-canvas-viewport-web.ts +63 -0
  16. package/client/browser-canvas-viewport.tsx +152 -0
  17. package/client/browser-capture-cadence.test.ts +9 -0
  18. package/client/browser-capture-cadence.ts +6 -0
  19. package/client/browser-capture-density-controls.tsx +75 -0
  20. package/client/browser-chrome.tsx +234 -0
  21. package/client/browser-client-presence.ts +184 -0
  22. package/client/browser-compose-controls.test.tsx +275 -0
  23. package/client/browser-compose-controls.tsx +174 -0
  24. package/client/browser-control-button.tsx +109 -0
  25. package/client/browser-control-dialog.tsx +71 -0
  26. package/client/browser-control-request.test.ts +27 -0
  27. package/client/browser-control-request.ts +57 -0
  28. package/client/browser-control-tooltip-web.ts +10 -0
  29. package/client/browser-dialog-body.tsx +22 -0
  30. package/client/browser-emulation-mode.test.ts +75 -0
  31. package/client/browser-emulation-mode.ts +98 -0
  32. package/client/browser-fill-viewport.test.ts +45 -0
  33. package/client/browser-fill-viewport.ts +28 -0
  34. package/client/browser-frame-buffer.test.ts +157 -0
  35. package/client/browser-frame-buffer.ts +148 -0
  36. package/client/browser-frame-image.tsx +62 -0
  37. package/client/browser-image-load.test.ts +23 -0
  38. package/client/browser-image-load.ts +26 -0
  39. package/client/browser-input-queue.test.ts +1201 -0
  40. package/client/browser-input-queue.ts +505 -0
  41. package/client/browser-menu-placement.test.ts +90 -0
  42. package/client/browser-menu-placement.ts +78 -0
  43. package/client/browser-panel-styles.ts +384 -0
  44. package/client/browser-quality-controls.test.ts +26 -0
  45. package/client/browser-quality-controls.tsx +139 -0
  46. package/client/browser-resolution-picker.tsx +189 -0
  47. package/client/browser-tabs.tsx +117 -0
  48. package/client/browser-toolbar-menu-web.test.ts +185 -0
  49. package/client/browser-toolbar-menu-web.ts +159 -0
  50. package/client/browser-toolbar-menu.tsx +255 -0
  51. package/client/browser-toolbar-submenu.tsx +93 -0
  52. package/client/browser-video-bytes.test.ts +44 -0
  53. package/client/browser-video-decoder.test.ts +473 -0
  54. package/client/browser-video-decoder.ts +311 -0
  55. package/client/browser-video-surface.tsx +21 -0
  56. package/client/browser-video-visibility.test.ts +45 -0
  57. package/client/browser-viewer-recovery.test.ts +111 -0
  58. package/client/browser-viewer-recovery.ts +78 -0
  59. package/client/browser.tsx +1642 -1569
  60. package/client/favorite-star.tsx +23 -0
  61. package/client/frame-lifecycle.ts +62 -59
  62. package/client/use-browser-auto-capture-density.test.ts +104 -0
  63. package/client/use-browser-auto-capture-density.ts +43 -0
  64. package/client/use-browser-canvas-input.ts +370 -0
  65. package/client/use-browser-capture-density.ts +87 -0
  66. package/client/use-browser-emulation-mode.ts +56 -0
  67. package/client/use-browser-fill-viewport.test.ts +108 -0
  68. package/client/use-browser-fill-viewport.ts +62 -0
  69. package/client/use-browser-frame-buffer.ts +66 -0
  70. package/client/use-browser-image-capture.ts +139 -0
  71. package/client/use-browser-scoped-mutation.test.ts +118 -0
  72. package/client/use-browser-scoped-mutation.ts +59 -0
  73. package/client/use-browser-video.test.ts +991 -0
  74. package/client/use-browser-video.ts +474 -0
  75. package/client/use-browser-viewer-recovery.ts +26 -0
  76. package/client/use-resolution-favorites.ts +90 -0
  77. package/client/web.test.ts +125 -0
  78. package/client/web.ts +405 -0
  79. package/index.client.tsx +0 -10
  80. package/index.server.ts +32 -0
  81. package/npm-shrinkwrap.json +7 -7
  82. package/package.json +9 -5
  83. package/paseo-plugin.json +3 -1
  84. package/scripts/prepare-runtime.mjs +1 -1
  85. package/server/agent-browser-runtime.ts +1598 -314
  86. package/server/browser-cursor.test.ts +64 -0
  87. package/server/browser-cursor.ts +69 -0
  88. package/server/browser-frame-context.ts +22 -0
  89. package/server/browser-frame-epoch.test.ts +95 -0
  90. package/server/browser-frame-rejection.ts +17 -0
  91. package/server/browser-gesture.ts +120 -0
  92. package/server/browser-operation-diagnostics.test.ts +79 -0
  93. package/server/browser-operation-diagnostics.ts +103 -0
  94. package/server/browser-policy.ts +1313 -151
  95. package/server/browser-video-input-authority.test.ts +208 -0
  96. package/server/browser-video-policy.test.ts +564 -0
  97. package/server/browser-video-socket.test.ts +368 -0
  98. package/server/browser.ts +151 -16
  99. package/server/capture-transport-policy.test.ts +105 -0
  100. package/server/capture-transport-policy.ts +154 -0
  101. package/server/capture-transport-runtime.test.ts +213 -0
  102. package/server/daemon-launch-chain.test.ts +70 -0
  103. package/server/daemon-shutdown.test.ts +289 -0
  104. package/server/discrete-input-freshness.test.ts +364 -0
  105. package/server/discrete-input-native-lifetime.test.ts +150 -0
  106. package/server/display-loss-recovery.test.ts +294 -0
  107. package/server/emulation-restoration.test.ts +262 -0
  108. package/server/input-cleanup.ts +23 -0
  109. package/server/input-generation.test.ts +31 -0
  110. package/server/input-generation.ts +36 -0
  111. package/server/jpeg-capture-demand.test.ts +76 -0
  112. package/server/jpeg-capture-demand.ts +98 -0
  113. package/server/jpeg-frame.ts +29 -0
  114. package/server/keyboard-input.test.ts +61 -0
  115. package/server/keyboard-input.ts +104 -0
  116. package/server/live-input-policy.test.ts +958 -0
  117. package/server/live-input-runtime.test.ts +732 -0
  118. package/server/live-input-supervisor.test.ts +147 -0
  119. package/server/mcp-entry.ts +44 -2
  120. package/server/native-video-backpressure.test.ts +228 -0
  121. package/server/native-video-buffer.test.ts +141 -0
  122. package/server/native-video-buffer.ts +114 -0
  123. package/server/native-video-capture.test.ts +599 -0
  124. package/server/native-video-capture.ts +497 -0
  125. package/server/native-video-extension-source.test.ts +201 -0
  126. package/server/native-video-extension-source.ts +364 -0
  127. package/server/native-video-extension.test.ts +41 -0
  128. package/server/native-video-extension.ts +83 -0
  129. package/server/native-video-packet.ts +9 -0
  130. package/server/native-video-settings.test.ts +245 -0
  131. package/server/navigation-budget.ts +8 -0
  132. package/server/navigation-commit.ts +125 -0
  133. package/server/navigation-policy.test.ts +153 -0
  134. package/server/process-identity.ts +58 -0
  135. package/server/reattach-held-input.test.ts +143 -0
  136. package/server/runtime-frame-reuse.test.ts +295 -0
  137. package/server/runtime-jpeg-demand.test.ts +200 -0
  138. package/server/runtime-jpeg-shutdown.test.ts +210 -0
  139. package/server/runtime-jpeg-source-order.test.ts +201 -0
  140. package/server/runtime-launch-environment.test.ts +111 -0
  141. package/server/runtime-navigation.test.ts +327 -0
  142. package/server/runtime-owner-display.test.ts +152 -0
  143. package/server/runtime-owner.ts +305 -31
  144. package/server/runtime-page-state.test.ts +249 -0
  145. package/server/runtime-protocol.ts +31 -1
  146. package/server/runtime-target-retention.test.ts +181 -0
  147. package/server/runtime-video-opt-in.test.ts +82 -0
  148. package/server/runtime-video.test.ts +444 -0
  149. package/server/screencast-source-time.ts +46 -0
  150. package/server/stale-wheel-admission.test.ts +136 -0
  151. package/server/supervisor-client.ts +28 -4
  152. package/server/supervisor-heartbeat.test.ts +80 -0
  153. package/server/supervisor.ts +553 -55
  154. package/server/video-source-recovery.test.ts +20 -0
  155. package/server/video-source-recovery.ts +64 -0
  156. package/server/viewer-capture-lifetime.ts +51 -0
  157. package/server/virtual-display.test.ts +184 -0
  158. package/server/virtual-display.ts +236 -0
  159. package/shared/browser-display-preferences.ts +46 -0
  160. package/shared/browser-video.ts +80 -0
  161. package/shared/browser.ts +286 -65
  162. package/shared/capture-density.ts +16 -0
  163. package/shared/capture-settings.ts +17 -0
  164. package/shared/device-presets.ts +90 -0
  165. package/shared/resolution-menu.test.ts +120 -0
  166. package/shared/resolution-menu.ts +72 -0
  167. package/shared/video-settings.test.ts +38 -0
  168. package/shared/video-settings.ts +34 -0
  169. package/shared/viewport-limits.ts +5 -0
@@ -0,0 +1,497 @@
1
+ /**
2
+ * Exact-tab native video acquisition, independently consumed from the input queue.
3
+ * The helper is a trusted hidden extension page, controlled only through its own
4
+ * CDP session. Receipts preserve native source age, never binding-arrival freshness.
5
+ * Stop/replacement revokes pending starts, callbacks, reads, and every encoder.
6
+ */
7
+ import { randomUUID } from "node:crypto";
8
+ import { performance } from "node:perf_hooks";
9
+ import {
10
+ type NativeVideoPacket,
11
+ VIDEO_MAX_WAIT_MS,
12
+ VIDEO_SOURCE_CLOCK_TOLERANCE_MS,
13
+ } from "../shared/browser-video";
14
+ import {
15
+ resolveVideoEncoderSettings,
16
+ VIDEO_ENCODER_IDLE_MS,
17
+ VIDEO_ENCODER_LIMIT,
18
+ type VideoEncoderSettings,
19
+ videoEncoderKey,
20
+ } from "../shared/video-settings";
21
+ import { attachToTarget, type CdpConnection, type CdpSession, CdpUnavailableError } from "./cdp";
22
+ import {
23
+ createNativeVideoBuffer,
24
+ type VideoSourceClock,
25
+ videoSourceTime,
26
+ } from "./native-video-buffer";
27
+ import { NATIVE_VIDEO_EXTENSION_ID } from "./native-video-extension";
28
+ import { type RuntimeVideoPacket, runtimeVideoPacketSchema } from "./native-video-packet";
29
+ import type { VideoSourceFailureCode } from "./video-source-recovery";
30
+ export type NativeVideoQuality = "low" | "medium" | "high";
31
+ export interface NativeVideoRead {
32
+ status: "ready" | "waiting" | "reset" | "unsupported";
33
+ streamId: string | null;
34
+ packets: RuntimeVideoPacket[];
35
+ reason?: string;
36
+ reasonCode?: "encoder-capacity" | "video-disabled";
37
+ }
38
+ interface EncoderState {
39
+ streamId: string;
40
+ buffer: ReturnType<typeof createNativeVideoBuffer>;
41
+ ready: Promise<void>;
42
+ failure: string | null;
43
+ lastRequestedAt: number;
44
+ activeReads: number;
45
+ /** Coalesce recovery demand while a key is pending; retry only after a bounded wait. */
46
+ keyRequestedAt: number;
47
+ }
48
+ class VideoEncoderLimitError extends Error {}
49
+ interface Evaluation<T> {
50
+ result?: { value?: T };
51
+ exceptionDetails?: unknown;
52
+ }
53
+ export interface NativeVideoCaptureOptions {
54
+ connection: CdpConnection;
55
+ targetId: string;
56
+ width: number;
57
+ height: number;
58
+ /** Exact owning attachment, including retained emulation, must still be current. */
59
+ isCurrent(): boolean;
60
+ /** Local monotonic time for cohort expiry, separate from native frame timestamps. */
61
+ now?(): number;
62
+ }
63
+ const HELPER_READY_EXPRESSION = `new Promise((resolve) => {
64
+ if (document.readyState === "complete") resolve();
65
+ else addEventListener("load", () => resolve(), { once: true });
66
+ }).then(() => typeof startCapture === "function")`;
67
+
68
+ export class NativeVideoCapture {
69
+ private helper: CdpSession | null = null;
70
+ private helperTargetId: string | null = null;
71
+ private readonly encoders = new Map<string, EncoderState>();
72
+ private encoderChanges: Promise<void> = Promise.resolve();
73
+ private readonly idleTimer: ReturnType<typeof setInterval>;
74
+ private idleMaintenance: Promise<void> | null = null;
75
+ private readonly waiters = new Set<() => void>();
76
+ private clock: VideoSourceClock | null = null;
77
+ private stopped = false;
78
+ private idleStopped = false;
79
+ private closing: Promise<void> | null = null;
80
+ private lastDemandAt: number;
81
+ private pendingSourceReads = 0;
82
+ private generation = 1;
83
+ private failure: string | null = null;
84
+ private failureCode: VideoSourceFailureCode | null = null;
85
+ private readonly ready: Promise<void>;
86
+ constructor(private readonly options: NativeVideoCaptureOptions) {
87
+ this.lastDemandAt = this.now();
88
+ this.ready = this.start();
89
+ this.idleTimer = setInterval(() => {
90
+ // Slow codec allocation/teardown must not queue a maintenance task per tick.
91
+ if (this.idleMaintenance || this.stopped) return;
92
+ const maintenance = this.serializeEncoderChange(async () => {
93
+ if (this.stopped) return;
94
+ await this.pruneIdleEncoders();
95
+ if (
96
+ !this.encoders.size &&
97
+ !this.pendingSourceReads &&
98
+ this.now() - this.lastDemandAt > VIDEO_ENCODER_IDLE_MS
99
+ ) {
100
+ // JPEG leases must not keep a 60fps native track alive without a video
101
+ // consumer. Runtime readVideo can reacquire through its existing fence.
102
+ this.idleStopped = true;
103
+ await this.stop();
104
+ }
105
+ });
106
+ this.idleMaintenance = maintenance;
107
+ void maintenance
108
+ .finally(() => {
109
+ if (this.idleMaintenance === maintenance) this.idleMaintenance = null;
110
+ })
111
+ .catch(() => undefined);
112
+ }, 1_000);
113
+ this.idleTimer.unref();
114
+ // Reads expose a safe unsupported reason; an abandoned start must not reject globally.
115
+ void this.ready.catch(() => {
116
+ this.recordSourceFailure("startup");
117
+ });
118
+ }
119
+ /** Source faults carry only fixed private classifications and safe reasons.
120
+ * A proven pixel mismatch cannot become retryable because a later cleanup,
121
+ * startup or reset error arrives after its original failure notification. */
122
+ private recordSourceFailure(code: VideoSourceFailureCode): void {
123
+ if (this.failureCode === "source-dimensions") {
124
+ return;
125
+ }
126
+ this.failureCode = code;
127
+ const reasons: Record<VideoSourceFailureCode, string> = {
128
+ startup: "Native tab video is unavailable",
129
+ "source-stopped": "Native tab video stopped",
130
+ "source-reset": "Native tab video reset failed",
131
+ "source-dimensions": "Native video dimensions do not match the configured viewport",
132
+ };
133
+ this.failure = reasons[code];
134
+ this.wake();
135
+ }
136
+ private assertCurrent(): void {
137
+ if (this.stopped || !this.options.connection.isOpen || !this.options.isCurrent()) {
138
+ throw new CdpUnavailableError("Native video attachment changed");
139
+ }
140
+ }
141
+ private async evaluate<T>(expression: string): Promise<T> {
142
+ this.assertCurrent();
143
+ const helper = this.helper;
144
+ if (!helper) throw new CdpUnavailableError("Native video helper is unavailable");
145
+ const result = await helper.send<Evaluation<T>>("Runtime.evaluate", {
146
+ expression,
147
+ awaitPromise: true,
148
+ returnByValue: true,
149
+ });
150
+ this.assertCurrent();
151
+ if (result.exceptionDetails) throw new Error("Native video helper rejected acquisition");
152
+ return result.result?.value as T;
153
+ }
154
+ private async start(): Promise<void> {
155
+ try {
156
+ this.assertCurrent();
157
+ const created = await this.options.connection.send<{ targetId: string }>(
158
+ "Target.createTarget",
159
+ {
160
+ url: `chrome-extension://${NATIVE_VIDEO_EXTENSION_ID}/recorder.html`,
161
+ background: true,
162
+ hidden: true,
163
+ },
164
+ { mutation: true },
165
+ );
166
+ this.helperTargetId = created.targetId;
167
+ this.assertCurrent();
168
+ this.helper = await attachToTarget(this.options.connection, created.targetId);
169
+ this.assertCurrent();
170
+ await this.helper.send("Runtime.enable");
171
+ await this.helper.send("Runtime.addBinding", { name: "nativeVideoPacket" });
172
+ await this.helper.send("Performance.enable");
173
+ this.helper.on("Runtime.bindingCalled", this.onPacket);
174
+ const before = performance.now();
175
+ const metrics = await this.helper.send<{ metrics: { name: string; value: number }[] }>(
176
+ "Performance.getMetrics",
177
+ );
178
+ const after = performance.now();
179
+ const timestamp = metrics.metrics.find((metric) => metric.name === "Timestamp")?.value;
180
+ if (timestamp === undefined || after - before > VIDEO_SOURCE_CLOCK_TOLERANCE_MS * 2)
181
+ throw new Error("Native video source clock is unavailable");
182
+ this.clock = {
183
+ sourceMs: timestamp * 1000,
184
+ monotonicMs: (before + after) / 2,
185
+ };
186
+ // The recorder is a classic script in the helper document. Runtime.evaluate can
187
+ // arrive while that document is still loading, before startCapture exists;
188
+ // wait for the document's own load event instead of failing acquisition.
189
+ const loaded = await this.evaluate<boolean>(HELPER_READY_EXPRESSION);
190
+ if (!loaded) throw new Error("Native video helper did not load");
191
+ await this.evaluate(
192
+ `startCapture(${JSON.stringify(this.options.targetId)},${this.options.width},${this.options.height},${this.generation})`,
193
+ );
194
+ } catch (error) {
195
+ await this.closeHelper();
196
+ throw error;
197
+ }
198
+ }
199
+ private readonly onPacket = (event: { name: string; payload: string }): void => {
200
+ if (event.name !== "nativeVideoPacket" || event.payload.length > 3_000_000) return;
201
+ let value: unknown;
202
+ try {
203
+ value = JSON.parse(event.payload);
204
+ } catch {
205
+ return;
206
+ }
207
+ if (value && typeof value === "object" && "error" in value) {
208
+ const streamId = "streamId" in value ? value.streamId : null;
209
+ const encoder =
210
+ typeof streamId === "string"
211
+ ? [...this.encoders.values()].find((state) => state.streamId === streamId)
212
+ : null;
213
+ if (encoder) encoder.failure = "Native video quality is unavailable";
214
+ else if (!streamId) {
215
+ const code =
216
+ "reasonCode" in value && value.reasonCode === "source-dimensions"
217
+ ? "source-dimensions"
218
+ : "source-stopped";
219
+ this.recordSourceFailure(code);
220
+ }
221
+ this.wake();
222
+ return;
223
+ }
224
+ const raw = value as Partial<NativeVideoPacket>;
225
+ // Ack only this hidden session. Even a refused stale packet must release producer pressure.
226
+ if (typeof raw.streamId === "string" && Number.isSafeInteger(raw.sequence)) {
227
+ void this.helper
228
+ ?.send("Runtime.evaluate", {
229
+ expression: `acknowledge(${JSON.stringify(raw.streamId)},${raw.sequence})`,
230
+ })
231
+ .catch(() => undefined);
232
+ }
233
+ if (
234
+ this.stopped ||
235
+ !this.options.isCurrent() ||
236
+ !this.clock ||
237
+ raw.captureGeneration !== this.generation
238
+ )
239
+ return;
240
+ const sourceTime =
241
+ typeof raw.timestampUs === "number"
242
+ ? videoSourceTime(raw.timestampUs, this.clock, performance.now(), Date.now())
243
+ : null;
244
+ if (!sourceTime) return;
245
+ const packet = runtimeVideoPacketSchema.safeParse({ ...raw, ...sourceTime });
246
+ if (
247
+ !packet.success ||
248
+ packet.data.width !== this.options.width ||
249
+ packet.data.height !== this.options.height
250
+ )
251
+ return;
252
+ const encoder = [...this.encoders.values()].find(
253
+ (state) => state.streamId === packet.data.streamId,
254
+ );
255
+ if (!encoder) return;
256
+ const accepted = encoder.buffer.add(packet.data);
257
+ if (accepted && packet.data.type === "key") encoder.keyRequestedAt = -Infinity;
258
+ this.wake();
259
+ };
260
+ private wake(): void {
261
+ for (const wake of this.waiters) wake();
262
+ this.waiters.clear();
263
+ }
264
+ private now(): number {
265
+ return this.options.now?.() ?? performance.now();
266
+ }
267
+ /** Serialize allocation/eviction only. Input and packet reads retain their own lanes. */
268
+ private async serializeEncoderChange<T>(operation: () => Promise<T>): Promise<T> {
269
+ const previous = this.encoderChanges;
270
+ const complete = Promise.withResolvers<void>();
271
+ this.encoderChanges = complete.promise;
272
+ await previous;
273
+ try {
274
+ return await operation();
275
+ } finally {
276
+ complete.resolve();
277
+ }
278
+ }
279
+ /** An idle cohort owns no active reader. Removal releases helper encoder and
280
+ * its retained bytes without restarting the track or another live cohort. */
281
+ private async pruneIdleEncoders(): Promise<void> {
282
+ for (const [key, state] of this.encoders) {
283
+ if (state.activeReads || this.now() - state.lastRequestedAt <= VIDEO_ENCODER_IDLE_MS)
284
+ continue;
285
+ await this.evaluate(`removeEncoder(${JSON.stringify(key)})`);
286
+ if (this.encoders.get(key) === state) this.encoders.delete(key);
287
+ state.buffer.clear();
288
+ }
289
+ }
290
+ private async ensureEncoder(settings: VideoEncoderSettings): Promise<EncoderState> {
291
+ this.lastDemandAt = this.now();
292
+ await this.ready;
293
+ this.assertCurrent();
294
+ return this.serializeEncoderChange(async () => {
295
+ this.assertCurrent();
296
+ const key = videoEncoderKey(settings);
297
+ // Touch the requested cohort before pruning; a resuming viewer can reuse
298
+ // its decoder stream if no other admission has already removed that cohort.
299
+ let state = this.encoders.get(key);
300
+ if (state) state.lastRequestedAt = this.now();
301
+ await this.pruneIdleEncoders();
302
+ if (!state) {
303
+ if (this.encoders.size >= VIDEO_ENCODER_LIMIT) throw new VideoEncoderLimitError();
304
+ state = {
305
+ streamId: randomUUID(),
306
+ buffer: createNativeVideoBuffer(),
307
+ ready: Promise.resolve(),
308
+ failure: null,
309
+ lastRequestedAt: this.now(),
310
+ activeReads: 0,
311
+ keyRequestedAt: -Infinity,
312
+ };
313
+ this.encoders.set(key, state);
314
+ const created = state;
315
+ state.ready = this.evaluate(
316
+ `addEncoder(${JSON.stringify(key)},${JSON.stringify(state.streamId)},${settings.bitrate},${settings.fps})`,
317
+ ).then(() => undefined);
318
+ try {
319
+ await state.ready;
320
+ } catch (error) {
321
+ // A timeout does not cancel helper JavaScript. Fence this exact pending
322
+ // allocation before admitting a replacement, without touching peers.
323
+ await this.evaluate(`removeEncoder(${JSON.stringify(key)})`).catch(() => undefined);
324
+ if (this.encoders.get(key) === created) this.encoders.delete(key);
325
+ throw error;
326
+ }
327
+ } else {
328
+ await state.ready;
329
+ }
330
+ this.assertCurrent();
331
+ state.lastRequestedAt = this.now();
332
+ this.lastDemandAt = this.now();
333
+ state.activeReads += 1;
334
+ return state;
335
+ });
336
+ }
337
+ /** Per-cohort recovery never resets the source, peer encoder or sequence.
338
+ * Reserve the request before CDP await so overlapping readers share it. */
339
+ private async requestRecoveryKey(key: string, state: EncoderState): Promise<boolean> {
340
+ const now = this.now();
341
+ if (now - state.keyRequestedAt < VIDEO_MAX_WAIT_MS) return false;
342
+ state.keyRequestedAt = now;
343
+ await this.evaluate(`requestKeyFrame(${JSON.stringify(key)})`);
344
+ return true;
345
+ }
346
+
347
+ /**
348
+ * After bounded CDP initialization, frame waiting is at most 500ms. Startup uses
349
+ * the existing CDP command timeout. No mutation serialization, stale receipt
350
+ * renewal, or screenshot fallback is performed here.
351
+ */
352
+ async read(input: {
353
+ quality?: NativeVideoQuality;
354
+ bitrate?: number;
355
+ fps?: number;
356
+ streamId?: string | null;
357
+ afterSequence?: number;
358
+ waitMs?: number;
359
+ requestKeyFrame?: boolean;
360
+ }): Promise<NativeVideoRead> {
361
+ let ownedState: EncoderState | null = null;
362
+ let sourcePinned = false;
363
+ try {
364
+ const settings = resolveVideoEncoderSettings(input);
365
+ // A valid requested read pins source setup itself, before ready/encoder
366
+ // awaits. Slow authenticated acquisition must not idle-expire underneath it.
367
+ this.pendingSourceReads += 1;
368
+ sourcePinned = true;
369
+ const key = videoEncoderKey(settings);
370
+ const state = await this.ensureEncoder(settings);
371
+ ownedState = state;
372
+ const reset =
373
+ input.streamId !== undefined &&
374
+ input.streamId !== null &&
375
+ input.streamId !== state.streamId;
376
+ const after = reset ? 0 : (input.afterSequence ?? 0);
377
+ const needsKey =
378
+ input.requestKeyFrame || state.buffer.needsKeyFrame(after, performance.now());
379
+ const requested = needsKey && (await this.requestRecoveryKey(key, state));
380
+ if (!requested) await this.evaluate(`touchEncoder(${JSON.stringify(key)})`);
381
+ let packets = state.buffer.read(after, performance.now());
382
+ const waitMs = Math.min(VIDEO_MAX_WAIT_MS, Math.max(0, input.waitMs ?? 250));
383
+ if (!packets.length && waitMs && !this.failure && !state.failure) {
384
+ await new Promise<void>((resolve) => {
385
+ const wake = () => {
386
+ clearTimeout(timer);
387
+ this.waiters.delete(wake);
388
+ resolve();
389
+ };
390
+ const timer = setTimeout(wake, waitMs);
391
+ this.waiters.add(wake);
392
+ });
393
+ this.assertCurrent();
394
+ packets = state.buffer.read(after, performance.now());
395
+ // A gap can arrive while the reader waits. Request recovery before
396
+ // returning waiting, rather than depending on the next periodic GOP.
397
+ if (
398
+ !packets.length &&
399
+ !state.failure &&
400
+ state.buffer.needsKeyFrame(after, performance.now())
401
+ ) {
402
+ await this.requestRecoveryKey(key, state);
403
+ }
404
+ }
405
+ if (this.failure || state.failure)
406
+ return {
407
+ status: "unsupported",
408
+ streamId: state.streamId,
409
+ packets: [],
410
+ reason: this.failure ?? state.failure ?? "Native video quality is unavailable",
411
+ };
412
+ return {
413
+ status: packets.length ? (reset ? "reset" : "ready") : "waiting",
414
+ streamId: state.streamId,
415
+ packets,
416
+ };
417
+ } catch (error) {
418
+ return {
419
+ status: "unsupported",
420
+ streamId: null,
421
+ packets: [],
422
+ ...(error instanceof VideoEncoderLimitError
423
+ ? { reasonCode: "encoder-capacity" as const }
424
+ : {}),
425
+ reason:
426
+ error instanceof VideoEncoderLimitError
427
+ ? "Three video profiles are active. Using JPEG for this viewer."
428
+ : "Native tab video is unavailable",
429
+ };
430
+ } finally {
431
+ if (ownedState) ownedState.activeReads -= 1;
432
+ if (sourcePinned) {
433
+ this.pendingSourceReads -= 1;
434
+ this.lastDemandAt = this.now();
435
+ }
436
+ }
437
+ }
438
+ /** Document/action boundary only. Live pointer samples must not call this operation. */
439
+ invalidateQueuedFrames(): void {
440
+ if (this.stopped) return;
441
+ this.generation += 1;
442
+ const generation = this.generation;
443
+ const clock = this.clock;
444
+ const minimumTimestampUs = clock
445
+ ? Math.ceil((clock.sourceMs + performance.now() - clock.monotonicMs) * 1000)
446
+ : 0;
447
+ for (const state of this.encoders.values()) state.buffer.clear();
448
+ this.wake();
449
+ void this.ready
450
+ .then(async () => {
451
+ if (!this.stopped) await this.evaluate(`resetCapture(${generation},${minimumTimestampUs})`);
452
+ })
453
+ .catch(() => {
454
+ this.recordSourceFailure("source-reset");
455
+ });
456
+ }
457
+ /** Private source-wide classification. Cohort errors deliberately do not
458
+ * appear here: recovering one encoder must not retire healthy peer encoders. */
459
+ get sourceFailure(): VideoSourceFailureCode | null {
460
+ return this.failureCode;
461
+ }
462
+ /** Automatic no-consumer retirement, independent of source-fault recovery. */
463
+ get idleExpired(): boolean {
464
+ return this.idleStopped;
465
+ }
466
+ /** Idempotent cleanup revokes admission immediately, then closes only the owned helper target. */
467
+ async stop(): Promise<void> {
468
+ if (this.closing) return this.closing;
469
+ this.stopped = true;
470
+ clearInterval(this.idleTimer);
471
+ this.wake();
472
+ this.closing = (async () => {
473
+ await this.ready.catch(() => undefined);
474
+ await this.closeHelper();
475
+ })();
476
+ return this.closing;
477
+ }
478
+ private async closeHelper(): Promise<void> {
479
+ const helper = this.helper;
480
+ this.helper = null;
481
+ helper?.off("Runtime.bindingCalled", this.onPacket);
482
+ if (helper && this.options.connection.isOpen) {
483
+ await helper
484
+ .send("Runtime.evaluate", { expression: "stopCapture()", awaitPromise: true })
485
+ .catch(() => undefined);
486
+ await helper.detach().catch(() => undefined);
487
+ }
488
+ const targetId = this.helperTargetId;
489
+ this.helperTargetId = null;
490
+ if (targetId && this.options.connection.isOpen) {
491
+ await this.options.connection
492
+ .send("Target.closeTarget", { targetId }, { mutation: true })
493
+ .catch(() => undefined);
494
+ }
495
+ this.encoders.clear();
496
+ }
497
+ }
@@ -0,0 +1,201 @@
1
+ /** Exercise the actual trusted helper program's producer bounds without a fake duplicate policy. */
2
+ import { createContext, runInContext } from "node:vm";
3
+ import { expect, it, vi } from "vitest";
4
+ import { NATIVE_VIDEO_EXTENSION_SOURCE } from "./native-video-extension-source";
5
+
6
+ it("oversize closes only that encoder, and repeated source frames cannot restart expensive keys", async () => {
7
+ const packets: { error?: string; streamId?: string }[] = [];
8
+ const encoders: { configuration: { bitrate: number }; calls: number; closed: boolean }[] = [];
9
+ let deliver: (result: unknown) => void = () => {};
10
+ let stopped = false;
11
+ const scaledFrames: {
12
+ timestamp: number;
13
+ displayWidth: number;
14
+ displayHeight: number;
15
+ close: ReturnType<typeof vi.fn>;
16
+ }[] = [];
17
+ const reader = {
18
+ read: () =>
19
+ new Promise((resolve) => {
20
+ deliver = resolve;
21
+ }),
22
+ cancel: async () => {
23
+ deliver({ done: true });
24
+ },
25
+ };
26
+ class Encoder {
27
+ static async isConfigSupported() {
28
+ return { supported: true };
29
+ }
30
+ configuration = { bitrate: 0 };
31
+ calls = 0;
32
+ closed = false;
33
+ encodeQueueSize = 0;
34
+ constructor(readonly callbacks: { output(chunk: unknown, metadata: unknown): void }) {
35
+ encoders.push(this);
36
+ }
37
+ configure(value: { bitrate: number }) {
38
+ this.configuration = value;
39
+ }
40
+ encode(frame: { timestamp: number }) {
41
+ this.calls++;
42
+ this.callbacks.output(
43
+ {
44
+ timestamp: frame.timestamp,
45
+ type: "key",
46
+ byteLength: this.configuration.bitrate > 5_000_000 ? 2_097_153 : 10,
47
+ copyTo: (bytes: Uint8Array) => bytes.fill(1),
48
+ },
49
+ {},
50
+ );
51
+ }
52
+ close() {
53
+ if (this.closed) throw new Error("already closed");
54
+ this.closed = true;
55
+ }
56
+ reset() {
57
+ if (this.closed) throw new Error("already closed");
58
+ }
59
+ }
60
+ const context = createContext({
61
+ chrome: {
62
+ debugger: { getTargets: async () => [{ id: "exact", type: "page", tabId: 7 }] },
63
+ tabCapture: { getMediaStreamId: async () => "exact-stream" },
64
+ },
65
+ navigator: {
66
+ mediaDevices: {
67
+ getUserMedia: async () => ({
68
+ getVideoTracks: () => [{ getSettings: () => ({ width: 1280, height: 800 }) }],
69
+ getTracks: () => [
70
+ {
71
+ stop: () => {
72
+ stopped = true;
73
+ },
74
+ },
75
+ ],
76
+ }),
77
+ },
78
+ },
79
+ MediaStreamTrackProcessor: class {
80
+ readable = { getReader: () => reader };
81
+ },
82
+ VideoEncoder: Encoder,
83
+ VideoFrame: class {
84
+ timestamp: number;
85
+ displayWidth: number;
86
+ displayHeight: number;
87
+ close = vi.fn();
88
+ constructor(
89
+ source: { timestamp: number },
90
+ options: { displayWidth: number; displayHeight: number },
91
+ ) {
92
+ this.timestamp = source.timestamp;
93
+ this.displayWidth = options.displayWidth;
94
+ this.displayHeight = options.displayHeight;
95
+ scaledFrames.push(this);
96
+ }
97
+ },
98
+ nativeVideoPacket: (json: string) => packets.push(JSON.parse(json)),
99
+ performance: { now: () => 100 },
100
+ Uint8Array,
101
+ Map,
102
+ Set,
103
+ Object,
104
+ JSON,
105
+ Number,
106
+ btoa: (value: string) => Buffer.from(value, "binary").toString("base64"),
107
+ });
108
+ runInContext(NATIVE_VIDEO_EXTENSION_SOURCE, context);
109
+ await runInContext('startCapture("exact",1280,800,1)', context);
110
+ await runInContext('addEncoder("high","high-stream")', context);
111
+ await runInContext('addEncoder("low","low-stream")', context);
112
+ const frame = (timestamp: number) => ({
113
+ timestamp,
114
+ displayWidth: 1280,
115
+ displayHeight: 800,
116
+ close() {},
117
+ });
118
+ deliver({ value: frame(100000), done: false });
119
+ await new Promise<void>((resolve) => setImmediate(resolve));
120
+ expect(encoders[0]?.closed).toBe(true);
121
+ expect(packets).toContainEqual({
122
+ error: "Native video quality exceeds its packet bound",
123
+ streamId: "high-stream",
124
+ });
125
+ deliver({ value: frame(133333), done: false });
126
+ await new Promise<void>((resolve) => setImmediate(resolve));
127
+ expect(encoders[0]?.calls).toBe(1);
128
+ expect(encoders[1]?.calls).toBe(2);
129
+ expect(packets.filter((packet) => packet.streamId === "low-stream")).toHaveLength(2);
130
+ await runInContext("resetCapture(2,150000)", context);
131
+ deliver({ value: { ...frame(200000), displayHeight: 799 }, done: false });
132
+ await new Promise<void>((resolve) => setImmediate(resolve));
133
+ expect(packets).toContainEqual({
134
+ error: "Native video dimensions changed",
135
+ reasonCode: "source-dimensions",
136
+ });
137
+ await runInContext("stopCapture()", context);
138
+ expect(stopped).toBe(true);
139
+ await runInContext('startCapture("exact",412,839,3)', context);
140
+ await runInContext('addEncoder("low","odd-stream")', context);
141
+ const close = vi.fn();
142
+ deliver({
143
+ value: { timestamp: 300000, displayWidth: 412, displayHeight: 838, format: "I420", close },
144
+ done: false,
145
+ });
146
+ await new Promise<void>((resolve) => setImmediate(resolve));
147
+ expect(packets).toContainEqual(
148
+ expect.objectContaining({
149
+ streamId: "odd-stream",
150
+ width: 412,
151
+ height: 839,
152
+ timestampUs: 300000,
153
+ }),
154
+ );
155
+ expect(close).toHaveBeenCalledOnce();
156
+ expect(scaledFrames[0]?.close).toHaveBeenCalledOnce();
157
+ await runInContext("stopCapture()", context);
158
+ });
159
+
160
+ it.each([
161
+ [412, 839, 412, 838],
162
+ [393, 659, 392, 658],
163
+ [413, 800, 412, 800],
164
+ ])(
165
+ "corrects only even-aligned YUV dimensions for %i by %i",
166
+ (width, height, nativeWidth, nativeHeight) => {
167
+ const frames: unknown[] = [];
168
+ const context = createContext({
169
+ VideoFrame: class {
170
+ constructor(
171
+ readonly source: unknown,
172
+ readonly options: unknown,
173
+ ) {
174
+ frames.push(this);
175
+ }
176
+ },
177
+ });
178
+ runInContext(NATIVE_VIDEO_EXTENSION_SOURCE, context);
179
+ const native = {
180
+ displayWidth: nativeWidth,
181
+ displayHeight: nativeHeight,
182
+ format: "I420",
183
+ timestamp: 42,
184
+ };
185
+ context.frame = native;
186
+ const result = runInContext(`frameForViewport(frame,${width},${height})`, context);
187
+ expect(result.source).toBe(native);
188
+ expect(result.options).toEqual({ displayWidth: width, displayHeight: height });
189
+ expect(frames).toHaveLength(1);
190
+ context.frame = { ...native, displayWidth: width, displayHeight: height };
191
+ expect(runInContext(`frameForViewport(frame,${width},${height})`, context)).toBe(context.frame);
192
+ for (const invalid of [
193
+ { ...native, displayHeight: nativeHeight - 2 },
194
+ { ...native, displayWidth: nativeWidth + 2 },
195
+ { ...native, format: "RGBA" },
196
+ ]) {
197
+ context.frame = invalid;
198
+ expect(runInContext(`frameForViewport(frame,${width},${height})`, context)).toBeNull();
199
+ }
200
+ },
201
+ );