@omercnet/paseo-shared-browser 1.2.2 → 2.0.0-next.249.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/OVERVIEW.md +2 -2
- package/README.md +316 -27
- package/REMOTE_BROWSER_RESEARCH.md +395 -0
- package/STREAMING_DESIGN.md +196 -0
- package/client/browser-auto-capture-density.test.ts +102 -0
- package/client/browser-auto-capture-density.ts +30 -0
- package/client/browser-canvas-input.test.ts +198 -0
- package/client/browser-canvas-input.ts +202 -0
- package/client/browser-canvas-keyboard-web.test.ts +113 -0
- package/client/browser-canvas-keyboard-web.ts +230 -0
- package/client/browser-canvas-keyboard.test.ts +123 -0
- package/client/browser-canvas-keyboard.ts +126 -0
- package/client/browser-canvas-layout.test.ts +109 -0
- package/client/browser-canvas-layout.ts +51 -0
- package/client/browser-canvas-viewport-web.test.ts +77 -0
- package/client/browser-canvas-viewport-web.ts +63 -0
- package/client/browser-canvas-viewport.tsx +152 -0
- package/client/browser-capture-cadence.test.ts +9 -0
- package/client/browser-capture-cadence.ts +6 -0
- package/client/browser-capture-density-controls.tsx +75 -0
- package/client/browser-chrome.tsx +234 -0
- package/client/browser-client-presence.ts +184 -0
- package/client/browser-compose-controls.test.tsx +275 -0
- package/client/browser-compose-controls.tsx +174 -0
- package/client/browser-control-button.tsx +109 -0
- package/client/browser-control-dialog.tsx +71 -0
- package/client/browser-control-request.test.ts +27 -0
- package/client/browser-control-request.ts +57 -0
- package/client/browser-control-tooltip-web.ts +10 -0
- package/client/browser-dialog-body.tsx +22 -0
- package/client/browser-emulation-mode.test.ts +75 -0
- package/client/browser-emulation-mode.ts +98 -0
- package/client/browser-fill-viewport.test.ts +45 -0
- package/client/browser-fill-viewport.ts +28 -0
- package/client/browser-frame-buffer.test.ts +157 -0
- package/client/browser-frame-buffer.ts +148 -0
- package/client/browser-frame-image.tsx +62 -0
- package/client/browser-image-load.test.ts +23 -0
- package/client/browser-image-load.ts +26 -0
- package/client/browser-input-queue.test.ts +1254 -0
- package/client/browser-input-queue.ts +519 -0
- package/client/browser-menu-placement.test.ts +90 -0
- package/client/browser-menu-placement.ts +78 -0
- package/client/browser-panel-styles.ts +384 -0
- package/client/browser-quality-controls.test.ts +26 -0
- package/client/browser-quality-controls.tsx +139 -0
- package/client/browser-resolution-picker.tsx +189 -0
- package/client/browser-tabs.tsx +117 -0
- package/client/browser-toolbar-menu-web.test.ts +185 -0
- package/client/browser-toolbar-menu-web.ts +159 -0
- package/client/browser-toolbar-menu.tsx +255 -0
- package/client/browser-toolbar-submenu.tsx +93 -0
- package/client/browser-video-bytes.test.ts +44 -0
- package/client/browser-video-currency.ts +45 -0
- package/client/browser-video-decoder.test.ts +473 -0
- package/client/browser-video-decoder.ts +311 -0
- package/client/browser-video-surface.tsx +21 -0
- package/client/browser-video-visibility.test.ts +45 -0
- package/client/browser-viewer-recovery.test.ts +111 -0
- package/client/browser-viewer-recovery.ts +78 -0
- package/client/browser.tsx +1650 -1579
- package/client/favorite-star.tsx +23 -0
- package/client/frame-lifecycle.ts +62 -59
- package/client/sidebar.tsx +1 -2
- package/client/use-browser-auto-capture-density.test.ts +104 -0
- package/client/use-browser-auto-capture-density.ts +43 -0
- package/client/use-browser-canvas-input.ts +375 -0
- package/client/use-browser-capture-density.ts +87 -0
- package/client/use-browser-emulation-mode.ts +56 -0
- package/client/use-browser-fill-viewport.test.ts +108 -0
- package/client/use-browser-fill-viewport.ts +62 -0
- package/client/use-browser-frame-buffer.ts +66 -0
- package/client/use-browser-image-capture.ts +139 -0
- package/client/use-browser-scoped-mutation.test.ts +118 -0
- package/client/use-browser-scoped-mutation.ts +59 -0
- package/client/use-browser-video.test.ts +1060 -0
- package/client/use-browser-video.ts +481 -0
- package/client/use-browser-viewer-recovery.ts +26 -0
- package/client/use-resolution-favorites.ts +90 -0
- package/client/web.test.ts +125 -0
- package/client/web.ts +405 -0
- package/index.client.tsx +0 -10
- package/index.server.ts +32 -4
- package/npm-shrinkwrap.json +9 -9
- package/package.json +10 -5
- package/paseo-plugin.json +1 -1
- package/scripts/prepare-runtime.mjs +1 -1
- package/server/agent-browser-runtime.ts +1601 -314
- package/server/browser-cursor.test.ts +64 -0
- package/server/browser-cursor.ts +69 -0
- package/server/browser-frame-context.ts +22 -0
- package/server/browser-frame-epoch.test.ts +95 -0
- package/server/browser-frame-rejection.ts +17 -0
- package/server/browser-gesture.ts +120 -0
- package/server/browser-operation-diagnostics.test.ts +79 -0
- package/server/browser-operation-diagnostics.ts +103 -0
- package/server/browser-policy.ts +1316 -151
- package/server/browser-video-input-authority.test.ts +211 -0
- package/server/browser-video-policy.test.ts +564 -0
- package/server/browser-video-socket.test.ts +368 -0
- package/server/browser.ts +151 -16
- package/server/capture-transport-policy.test.ts +105 -0
- package/server/capture-transport-policy.ts +154 -0
- package/server/capture-transport-runtime.test.ts +213 -0
- package/server/daemon-launch-chain.test.ts +70 -0
- package/server/daemon-shutdown.test.ts +289 -0
- package/server/discrete-input-freshness.test.ts +364 -0
- package/server/discrete-input-native-lifetime.test.ts +150 -0
- package/server/display-loss-recovery.test.ts +294 -0
- package/server/emulation-restoration.test.ts +262 -0
- package/server/input-cleanup.ts +23 -0
- package/server/input-generation.test.ts +31 -0
- package/server/input-generation.ts +36 -0
- package/server/jpeg-capture-demand.test.ts +76 -0
- package/server/jpeg-capture-demand.ts +98 -0
- package/server/jpeg-frame.ts +29 -0
- package/server/keyboard-input.test.ts +61 -0
- package/server/keyboard-input.ts +104 -0
- package/server/live-input-policy.test.ts +958 -0
- package/server/live-input-runtime.test.ts +732 -0
- package/server/live-input-supervisor.test.ts +147 -0
- package/server/mcp-entry.ts +44 -2
- package/server/native-video-backpressure.test.ts +228 -0
- package/server/native-video-buffer.test.ts +141 -0
- package/server/native-video-buffer.ts +114 -0
- package/server/native-video-capture.test.ts +599 -0
- package/server/native-video-capture.ts +497 -0
- package/server/native-video-extension-source.test.ts +201 -0
- package/server/native-video-extension-source.ts +364 -0
- package/server/native-video-extension.test.ts +41 -0
- package/server/native-video-extension.ts +83 -0
- package/server/native-video-packet.ts +9 -0
- package/server/native-video-settings.test.ts +245 -0
- package/server/navigation-budget.ts +8 -0
- package/server/navigation-commit.ts +125 -0
- package/server/navigation-policy.test.ts +153 -0
- package/server/process-identity.ts +58 -0
- package/server/reattach-held-input.test.ts +143 -0
- package/server/runtime-frame-reuse.test.ts +295 -0
- package/server/runtime-jpeg-demand.test.ts +200 -0
- package/server/runtime-jpeg-shutdown.test.ts +210 -0
- package/server/runtime-jpeg-source-order.test.ts +201 -0
- package/server/runtime-launch-environment.test.ts +111 -0
- package/server/runtime-navigation.test.ts +327 -0
- package/server/runtime-owner-display.test.ts +152 -0
- package/server/runtime-owner.ts +340 -31
- package/server/runtime-page-state.test.ts +249 -0
- package/server/runtime-protocol.ts +32 -2
- package/server/runtime-target-retention.test.ts +181 -0
- package/server/runtime-video-opt-in.test.ts +82 -0
- package/server/runtime-video.test.ts +444 -0
- package/server/screencast-source-time.ts +46 -0
- package/server/stale-wheel-admission.test.ts +136 -0
- package/server/supervisor-client.ts +28 -4
- package/server/supervisor-heartbeat.test.ts +80 -0
- package/server/supervisor.ts +553 -55
- package/server/tab-close-confirmation.test.ts +97 -0
- package/server/video-source-recovery.test.ts +20 -0
- package/server/video-source-recovery.ts +64 -0
- package/server/viewer-capture-lifetime.ts +51 -0
- package/server/virtual-display.test.ts +184 -0
- package/server/virtual-display.ts +236 -0
- package/shared/browser-display-preferences.ts +46 -0
- package/shared/browser-video.ts +83 -0
- package/shared/browser.ts +286 -65
- package/shared/capture-density.ts +16 -0
- package/shared/capture-settings.ts +17 -0
- package/shared/device-presets.ts +90 -0
- package/shared/resolution-menu.test.ts +120 -0
- package/shared/resolution-menu.ts +72 -0
- package/shared/video-settings.test.ts +38 -0
- package/shared/video-settings.ts +34 -0
- package/shared/viewport-limits.ts +5 -0
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { expect, it } from "vitest";
|
|
2
|
+
import { browserStateSchema } from "../shared/browser";
|
|
3
|
+
import type { JsonValue, RuntimeRequest } from "./runtime-protocol";
|
|
4
|
+
import { RuntimeSupervisor } from "./supervisor";
|
|
5
|
+
|
|
6
|
+
/** Exercise the immutable supervisor's human RPC path without a plugin or browser process. */
|
|
7
|
+
it("routes validated human gestures, fences replaced plugin bridges and never exposes live gesture operations to agent tickets", async () => {
|
|
8
|
+
const calls: string[] = [];
|
|
9
|
+
const supervisor = new RuntimeSupervisor({
|
|
10
|
+
owner: {
|
|
11
|
+
create: async () => ({ runtimeId: "r".repeat(32) }),
|
|
12
|
+
request: async (_runtime, operation) => {
|
|
13
|
+
calls.push(operation);
|
|
14
|
+
if (operation === "identity") return { userAgent: "Fixture Chromium" };
|
|
15
|
+
if (operation === "state")
|
|
16
|
+
return {
|
|
17
|
+
url: "https://fixture.invalid",
|
|
18
|
+
title: "Fixture",
|
|
19
|
+
canGoBack: false,
|
|
20
|
+
canGoForward: false,
|
|
21
|
+
inputGeneration: "0:0",
|
|
22
|
+
};
|
|
23
|
+
if (operation === "frame")
|
|
24
|
+
return {
|
|
25
|
+
dataBase64: "eA==",
|
|
26
|
+
byteLength: 1,
|
|
27
|
+
width: 1280,
|
|
28
|
+
height: 800,
|
|
29
|
+
capturedAt: new Date().toISOString(),
|
|
30
|
+
};
|
|
31
|
+
if (operation === "cursor") return "pointer";
|
|
32
|
+
return null;
|
|
33
|
+
},
|
|
34
|
+
stop: async () => {},
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
const lease = supervisor.claimBridge("bridge");
|
|
38
|
+
const request = async (
|
|
39
|
+
operation: string,
|
|
40
|
+
input: unknown,
|
|
41
|
+
epoch = lease.epoch,
|
|
42
|
+
bridgeId = "bridge",
|
|
43
|
+
) =>
|
|
44
|
+
supervisor.dispatch({
|
|
45
|
+
version: 3,
|
|
46
|
+
id: "request",
|
|
47
|
+
token: "unused-by-direct-fixture",
|
|
48
|
+
bridgeId,
|
|
49
|
+
epoch,
|
|
50
|
+
method: "browser.request",
|
|
51
|
+
operation,
|
|
52
|
+
input: input as JsonValue,
|
|
53
|
+
});
|
|
54
|
+
try {
|
|
55
|
+
const attached = (await request("attach", {
|
|
56
|
+
workspaceId: "workspace",
|
|
57
|
+
viewerLabel: "Viewer",
|
|
58
|
+
})) as { viewerToken: string };
|
|
59
|
+
const acquired = (await request("acquire-control", { viewerToken: attached.viewerToken })) as {
|
|
60
|
+
controlToken: string;
|
|
61
|
+
};
|
|
62
|
+
const capture = (await request("capture", { viewerToken: attached.viewerToken })) as {
|
|
63
|
+
state: unknown;
|
|
64
|
+
frame: { frameId: string };
|
|
65
|
+
};
|
|
66
|
+
const state = browserStateSchema.parse(capture.state);
|
|
67
|
+
const expected = {
|
|
68
|
+
sessionId: state.sessionId,
|
|
69
|
+
runtimeId: state.runtimeId!,
|
|
70
|
+
bridgeEpoch: state.bridgeEpoch!,
|
|
71
|
+
navigationGeneration: state.navigationGeneration,
|
|
72
|
+
viewportGeneration: state.viewportGeneration,
|
|
73
|
+
};
|
|
74
|
+
const context = {
|
|
75
|
+
viewerToken: attached.viewerToken,
|
|
76
|
+
controlToken: acquired.controlToken,
|
|
77
|
+
expected,
|
|
78
|
+
};
|
|
79
|
+
const target = {
|
|
80
|
+
frameId: capture.frame.frameId,
|
|
81
|
+
navigationGeneration: state.navigationGeneration,
|
|
82
|
+
viewportGeneration: state.viewportGeneration,
|
|
83
|
+
};
|
|
84
|
+
const begun = (await request("gesture.begin", {
|
|
85
|
+
...context,
|
|
86
|
+
target,
|
|
87
|
+
pointerKind: "mouse",
|
|
88
|
+
})) as { gestureId: string; nextSequence: number };
|
|
89
|
+
await request("gesture.update", {
|
|
90
|
+
...context,
|
|
91
|
+
gestureId: begun.gestureId,
|
|
92
|
+
sequence: 1,
|
|
93
|
+
event: {
|
|
94
|
+
kind: "key",
|
|
95
|
+
type: "down",
|
|
96
|
+
key: "Control",
|
|
97
|
+
code: "ControlLeft",
|
|
98
|
+
modifiers: 2,
|
|
99
|
+
repeat: false,
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
expect(calls.filter((value) => value === "input.key")).toHaveLength(1);
|
|
103
|
+
const afterKey = (await request("capture", { viewerToken: attached.viewerToken })) as {
|
|
104
|
+
frame: { frameId: string };
|
|
105
|
+
};
|
|
106
|
+
await request("gesture.update", {
|
|
107
|
+
...context,
|
|
108
|
+
gestureId: begun.gestureId,
|
|
109
|
+
sequence: 2,
|
|
110
|
+
target: { ...target, frameId: afterKey.frame.frameId },
|
|
111
|
+
event: { kind: "down", point: { x: 10, y: 20, width: 1280, height: 800 } },
|
|
112
|
+
});
|
|
113
|
+
expect(calls.filter((value) => value === "mouse.down")).toHaveLength(1);
|
|
114
|
+
await expect(
|
|
115
|
+
request("gesture.update", {
|
|
116
|
+
...context,
|
|
117
|
+
gestureId: begun.gestureId,
|
|
118
|
+
sequence: 3,
|
|
119
|
+
event: {
|
|
120
|
+
kind: "scroll",
|
|
121
|
+
point: { x: 10, y: 20, width: 1280, height: 800 },
|
|
122
|
+
deltaX: 0,
|
|
123
|
+
deltaY: Infinity,
|
|
124
|
+
},
|
|
125
|
+
}),
|
|
126
|
+
).rejects.toThrow();
|
|
127
|
+
supervisor.claimBridge("replacement", true);
|
|
128
|
+
await expect(
|
|
129
|
+
request("gesture.end", { ...context, gestureId: begun.gestureId, sequence: 1, cancel: true }),
|
|
130
|
+
).rejects.toThrow("lease");
|
|
131
|
+
// The old channel's supervisor-side cleanup still reaches its owned runtime.
|
|
132
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
133
|
+
expect(calls.filter((value) => value === "input.end")).toHaveLength(1);
|
|
134
|
+
await expect(
|
|
135
|
+
supervisor.dispatch({
|
|
136
|
+
version: 3,
|
|
137
|
+
id: "request",
|
|
138
|
+
method: "agent.request",
|
|
139
|
+
ticket: "invalid",
|
|
140
|
+
operation: "gesture.begin",
|
|
141
|
+
input: {},
|
|
142
|
+
} as unknown as RuntimeRequest),
|
|
143
|
+
).rejects.toThrow();
|
|
144
|
+
} finally {
|
|
145
|
+
await supervisor.stopAll();
|
|
146
|
+
}
|
|
147
|
+
});
|
package/server/mcp-entry.ts
CHANGED
|
@@ -3,13 +3,13 @@ import { join } from "node:path";
|
|
|
3
3
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
4
4
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
5
5
|
import { z } from "zod";
|
|
6
|
+
import { browserTabIdSchema } from "../shared/browser";
|
|
7
|
+
import { MAX_AGENT_VIEWPORT as MAX_VIEWPORT, MIN_VIEWPORT } from "../shared/viewport-limits";
|
|
6
8
|
import type { JsonValue } from "./runtime-protocol";
|
|
7
9
|
import { resolveSupervisorPaths } from "./supervisor";
|
|
8
10
|
import { AgentSupervisorClient } from "./supervisor-client";
|
|
9
11
|
|
|
10
12
|
const TICKET_ENV = "PASEO_SHARED_BROWSER_TICKET";
|
|
11
|
-
const MIN_VIEWPORT = { width: 320, height: 480 } as const;
|
|
12
|
-
const MAX_VIEWPORT = { width: 1600, height: 1200 } as const;
|
|
13
13
|
|
|
14
14
|
const pointSchema = z.object({
|
|
15
15
|
x: z.number().finite().nonnegative(),
|
|
@@ -130,6 +130,48 @@ async function main(): Promise<void> {
|
|
|
130
130
|
},
|
|
131
131
|
);
|
|
132
132
|
|
|
133
|
+
server.registerTool(
|
|
134
|
+
"shared_browser_tabs",
|
|
135
|
+
{
|
|
136
|
+
description: "List tabs in this agent's workspace and identify this agent's selected tab.",
|
|
137
|
+
inputSchema: z.object({}),
|
|
138
|
+
annotations: { readOnlyHint: true, idempotentHint: true },
|
|
139
|
+
},
|
|
140
|
+
async () => textResult(await client.request("tabs.list", {})),
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
server.registerTool(
|
|
144
|
+
"shared_browser_tab_open",
|
|
145
|
+
{
|
|
146
|
+
description: "Open and select a new tab in the shared browser profile for this agent only.",
|
|
147
|
+
inputSchema: z.object({}),
|
|
148
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
|
|
149
|
+
},
|
|
150
|
+
async () => textResult(await client.request("tabs.create", {})),
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
server.registerTool(
|
|
154
|
+
"shared_browser_tab_select",
|
|
155
|
+
{
|
|
156
|
+
description:
|
|
157
|
+
"Select an existing tab for this agent only. Capture its current frame before sending input.",
|
|
158
|
+
inputSchema: z.object({ tabId: browserTabIdSchema }),
|
|
159
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
|
|
160
|
+
},
|
|
161
|
+
async ({ tabId }) => textResult(await client.request("tabs.select", { tabId })),
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
server.registerTool(
|
|
165
|
+
"shared_browser_tab_close",
|
|
166
|
+
{
|
|
167
|
+
description:
|
|
168
|
+
"Close this agent's selected and controlled tab for every viewer. Other tabs remain open.",
|
|
169
|
+
inputSchema: z.object({}),
|
|
170
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false },
|
|
171
|
+
},
|
|
172
|
+
async () => textResult(await client.request("tabs.close", {})),
|
|
173
|
+
);
|
|
174
|
+
|
|
133
175
|
server.registerTool(
|
|
134
176
|
"shared_browser_acquire_control",
|
|
135
177
|
{
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/** Exercise producer backpressure using the actual shipped helper program.
|
|
2
|
+
* Unencoded source skips preserve intact dependencies; lost encoded output still
|
|
3
|
+
* requires a key. Delayed outputs/ACKs model ordinary codec and relay pressure.
|
|
4
|
+
*/
|
|
5
|
+
import { createContext, runInContext } from "node:vm";
|
|
6
|
+
import { expect, it } from "vitest";
|
|
7
|
+
import { NATIVE_VIDEO_EXTENSION_SOURCE } from "./native-video-extension-source";
|
|
8
|
+
|
|
9
|
+
/** Delayed native codec output and ACKs, with the shipped trusted program unmodified. */
|
|
10
|
+
async function fixture(failFirstEncoder = false) {
|
|
11
|
+
const packets: { sequence: number; type: string; timestampUs: number }[] = [];
|
|
12
|
+
let deliver: (result: unknown) => void = () => {};
|
|
13
|
+
const reader = {
|
|
14
|
+
read: () =>
|
|
15
|
+
new Promise((resolve) => {
|
|
16
|
+
deliver = resolve;
|
|
17
|
+
}),
|
|
18
|
+
cancel: async () => deliver({ done: true }),
|
|
19
|
+
};
|
|
20
|
+
const pending: { timestamp: number; type: string }[] = [];
|
|
21
|
+
const encoded: { timestamp: number; keyFrame: boolean }[] = [];
|
|
22
|
+
let encoder: Encoder;
|
|
23
|
+
const instances: Encoder[] = [];
|
|
24
|
+
class Encoder {
|
|
25
|
+
static async isConfigSupported(candidate: unknown) {
|
|
26
|
+
return { supported: true, config: candidate };
|
|
27
|
+
}
|
|
28
|
+
encodeQueueSize = 0;
|
|
29
|
+
constructor(readonly callbacks: { output(chunk: unknown, metadata: unknown): void }) {
|
|
30
|
+
encoder = this;
|
|
31
|
+
instances.push(this);
|
|
32
|
+
}
|
|
33
|
+
configure() {}
|
|
34
|
+
reset() {}
|
|
35
|
+
close() {}
|
|
36
|
+
encode(frame: { timestamp: number }, options: { keyFrame: boolean }) {
|
|
37
|
+
if (failFirstEncoder && this === instances[0]) throw new Error("Codec stopped");
|
|
38
|
+
this.encodeQueueSize++;
|
|
39
|
+
encoded.push({ timestamp: frame.timestamp, keyFrame: options.keyFrame });
|
|
40
|
+
pending.push({ timestamp: frame.timestamp, type: options.keyFrame ? "key" : "delta" });
|
|
41
|
+
}
|
|
42
|
+
output() {
|
|
43
|
+
const value = pending.shift();
|
|
44
|
+
if (!value) throw new Error("No queued fixture frame");
|
|
45
|
+
this.encodeQueueSize--;
|
|
46
|
+
this.callbacks.output(
|
|
47
|
+
{ ...value, byteLength: 10, copyTo: (bytes: Uint8Array) => bytes.fill(1) },
|
|
48
|
+
{},
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
const context = createContext({
|
|
53
|
+
chrome: {
|
|
54
|
+
debugger: { getTargets: async () => [{ id: "exact", type: "page", tabId: 7 }] },
|
|
55
|
+
tabCapture: { getMediaStreamId: async () => "stream" },
|
|
56
|
+
},
|
|
57
|
+
navigator: {
|
|
58
|
+
mediaDevices: {
|
|
59
|
+
getUserMedia: async () => ({
|
|
60
|
+
getVideoTracks: () => [{ getSettings: () => ({ width: 320, height: 240 }) }],
|
|
61
|
+
getTracks: () => [{ stop() {} }],
|
|
62
|
+
}),
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
MediaStreamTrackProcessor: class {
|
|
66
|
+
readable = { getReader: () => reader };
|
|
67
|
+
},
|
|
68
|
+
VideoEncoder: Encoder,
|
|
69
|
+
nativeVideoPacket: (json: string) => packets.push(JSON.parse(json)),
|
|
70
|
+
performance: { now: () => 100 },
|
|
71
|
+
Uint8Array,
|
|
72
|
+
Map,
|
|
73
|
+
Set,
|
|
74
|
+
Object,
|
|
75
|
+
JSON,
|
|
76
|
+
Number,
|
|
77
|
+
btoa: (value: string) => Buffer.from(value, "binary").toString("base64"),
|
|
78
|
+
});
|
|
79
|
+
runInContext(NATIVE_VIDEO_EXTENSION_SOURCE, context);
|
|
80
|
+
await runInContext('startCapture("exact",320,240,1)', context);
|
|
81
|
+
await runInContext('addEncoder("high","stream",12000000,30)', context);
|
|
82
|
+
if (failFirstEncoder) {
|
|
83
|
+
await runInContext('addEncoder("low","healthy",2000000,30)', context);
|
|
84
|
+
}
|
|
85
|
+
const turn = () => new Promise<void>((resolve) => setImmediate(resolve));
|
|
86
|
+
return {
|
|
87
|
+
packets,
|
|
88
|
+
encoded,
|
|
89
|
+
async frame(timestamp: number) {
|
|
90
|
+
deliver({
|
|
91
|
+
value: { timestamp, displayWidth: 320, displayHeight: 240, close() {} },
|
|
92
|
+
done: false,
|
|
93
|
+
});
|
|
94
|
+
await turn();
|
|
95
|
+
},
|
|
96
|
+
output: () => encoder!.output(),
|
|
97
|
+
ack: () =>
|
|
98
|
+
runInContext(
|
|
99
|
+
'for(const p of fixturePackets) acknowledge("stream",p.sequence)',
|
|
100
|
+
Object.assign(context, { fixturePackets: packets }),
|
|
101
|
+
),
|
|
102
|
+
request: () => runInContext('requestKeyFrame("high")', context),
|
|
103
|
+
stop: () => runInContext("stopCapture()", context),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
it("preserves encoded deltas during source backpressure with no extra recovery key", async () => {
|
|
108
|
+
const f = await fixture();
|
|
109
|
+
try {
|
|
110
|
+
await f.frame(0);
|
|
111
|
+
f.output();
|
|
112
|
+
f.ack();
|
|
113
|
+
await f.frame(33333);
|
|
114
|
+
await f.frame(66666);
|
|
115
|
+
await f.frame(99999);
|
|
116
|
+
expect(f.encoded).toHaveLength(3);
|
|
117
|
+
f.output();
|
|
118
|
+
f.output();
|
|
119
|
+
f.ack();
|
|
120
|
+
await f.frame(133332);
|
|
121
|
+
f.output();
|
|
122
|
+
expect(f.packets.map((p) => [p.timestampUs, p.type])).toEqual([
|
|
123
|
+
[0, "key"],
|
|
124
|
+
[33333, "delta"],
|
|
125
|
+
[66666, "delta"],
|
|
126
|
+
[133332, "delta"],
|
|
127
|
+
]);
|
|
128
|
+
expect(f.encoded.filter((e) => e.keyFrame)).toHaveLength(1);
|
|
129
|
+
} finally {
|
|
130
|
+
await f.stop();
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("keeps two-packet ACK pressure bounded without restarting an intact codec chain", async () => {
|
|
135
|
+
const f = await fixture();
|
|
136
|
+
try {
|
|
137
|
+
await f.frame(0);
|
|
138
|
+
f.output();
|
|
139
|
+
await f.frame(33333);
|
|
140
|
+
f.output();
|
|
141
|
+
await f.frame(66666);
|
|
142
|
+
expect(f.encoded).toHaveLength(2);
|
|
143
|
+
f.ack();
|
|
144
|
+
await f.frame(99999);
|
|
145
|
+
f.output();
|
|
146
|
+
expect(f.packets.map((packet) => packet.type)).toEqual(["key", "delta", "delta"]);
|
|
147
|
+
expect(f.encoded.filter((entry) => entry.keyFrame)).toHaveLength(1);
|
|
148
|
+
} finally {
|
|
149
|
+
await f.stop();
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("adds a requested key without suppressing an intact pending delta", async () => {
|
|
154
|
+
const f = await fixture();
|
|
155
|
+
try {
|
|
156
|
+
await f.frame(0);
|
|
157
|
+
f.output();
|
|
158
|
+
f.ack();
|
|
159
|
+
await f.frame(33333);
|
|
160
|
+
f.request();
|
|
161
|
+
f.output();
|
|
162
|
+
await f.frame(66666);
|
|
163
|
+
f.output();
|
|
164
|
+
expect(f.packets.map((p) => p.type)).toEqual(["key", "delta", "key"]);
|
|
165
|
+
} finally {
|
|
166
|
+
await f.stop();
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("still suppresses dependencies after an actual encoded output is dropped", async () => {
|
|
171
|
+
const f = await fixture();
|
|
172
|
+
try {
|
|
173
|
+
await f.frame(0);
|
|
174
|
+
f.output();
|
|
175
|
+
f.ack();
|
|
176
|
+
await f.frame(33333);
|
|
177
|
+
await f.frame(66666);
|
|
178
|
+
f.output();
|
|
179
|
+
await f.frame(99999);
|
|
180
|
+
f.output();
|
|
181
|
+
f.output();
|
|
182
|
+
expect(f.packets.map((p) => p.timestampUs)).toEqual([0, 33333, 66666]);
|
|
183
|
+
f.ack();
|
|
184
|
+
await f.frame(133332);
|
|
185
|
+
f.output();
|
|
186
|
+
expect(f.packets.at(-1)?.type).toBe("key");
|
|
187
|
+
} finally {
|
|
188
|
+
await f.stop();
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("shipped packet conversion is exact with native and legacy byte APIs", () => {
|
|
193
|
+
for (const native of [true, false]) {
|
|
194
|
+
const context = createContext({
|
|
195
|
+
btoa: (value: string) => Buffer.from(value, "binary").toString("base64"),
|
|
196
|
+
});
|
|
197
|
+
runInContext(NATIVE_VIDEO_EXTENSION_SOURCE, context);
|
|
198
|
+
if (!native) runInContext("Uint8Array.prototype.toBase64 = undefined", context);
|
|
199
|
+
for (const size of [0, 1, 2, 65537, 1048576]) {
|
|
200
|
+
const value = runInContext(
|
|
201
|
+
`bytes64(Uint8Array.from({length:${size}},(_,i)=>(i*17+13)%256))`,
|
|
202
|
+
context,
|
|
203
|
+
);
|
|
204
|
+
const expected = Buffer.alloc(size);
|
|
205
|
+
for (let i = 0; i < size; i += 1) expected[i] = (i * 17 + 13) % 256;
|
|
206
|
+
expect(value).toBe(expected.toString("base64"));
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("isolates synchronous encoder failure while a healthy peer keeps receiving frames", async () => {
|
|
212
|
+
const f = await fixture(true);
|
|
213
|
+
try {
|
|
214
|
+
await f.frame(0);
|
|
215
|
+
f.output();
|
|
216
|
+
f.ack();
|
|
217
|
+
await f.frame(33333);
|
|
218
|
+
f.output();
|
|
219
|
+
expect(f.packets).toContainEqual({ error: "Native video encoder stopped", streamId: "stream" });
|
|
220
|
+
expect(f.packets).not.toContainEqual({ error: "Native video capture stopped" });
|
|
221
|
+
expect(f.packets.filter((packet) => packet.type).map((packet) => packet.type)).toEqual([
|
|
222
|
+
"key",
|
|
223
|
+
"delta",
|
|
224
|
+
]);
|
|
225
|
+
} finally {
|
|
226
|
+
await f.stop();
|
|
227
|
+
}
|
|
228
|
+
});
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { createNativeVideoBuffer, videoSourceTime } from "./native-video-buffer";
|
|
3
|
+
import type { RuntimeVideoPacket } from "./native-video-packet";
|
|
4
|
+
|
|
5
|
+
const packet = (
|
|
6
|
+
sequence: number,
|
|
7
|
+
type: "key" | "delta" = "delta",
|
|
8
|
+
capturedAt = 1000,
|
|
9
|
+
): RuntimeVideoPacket => ({
|
|
10
|
+
streamId: "stream",
|
|
11
|
+
captureGeneration: 1,
|
|
12
|
+
sequence,
|
|
13
|
+
type,
|
|
14
|
+
timestampUs: 1000000 + sequence,
|
|
15
|
+
codec: "vp8",
|
|
16
|
+
width: 1280,
|
|
17
|
+
height: 800,
|
|
18
|
+
dataBase64: Buffer.from("owned-packet").toString("base64"),
|
|
19
|
+
capturedAt: new Date(capturedAt).toISOString(),
|
|
20
|
+
capturedAtMonotonicMs: capturedAt,
|
|
21
|
+
});
|
|
22
|
+
describe("native encoded receipt buffer", () => {
|
|
23
|
+
it("maps actual TimeTicks age instead of refreshing a delayed packet", () => {
|
|
24
|
+
const clock = { sourceMs: 112000000, monotonicMs: 500, wallMs: 2000 };
|
|
25
|
+
expect(videoSourceTime(112000100000, clock, 700, 2200)).toEqual({
|
|
26
|
+
capturedAt: new Date(2100).toISOString(),
|
|
27
|
+
capturedAtMonotonicMs: 600,
|
|
28
|
+
});
|
|
29
|
+
expect(videoSourceTime(112000100000, clock, 1701, 2200)).toBeNull();
|
|
30
|
+
expect(videoSourceTime(112001000000, clock, 700, 2200)).toBeNull();
|
|
31
|
+
});
|
|
32
|
+
it("does not bootstrap a decoder from an arbitrary delta", () => {
|
|
33
|
+
const buffer = createNativeVideoBuffer();
|
|
34
|
+
buffer.add(packet(1));
|
|
35
|
+
expect(buffer.read(0, 1100)).toEqual([]);
|
|
36
|
+
buffer.add(packet(2, "key"));
|
|
37
|
+
buffer.add(packet(3));
|
|
38
|
+
expect(buffer.read(0, 1100).map((p) => p.sequence)).toEqual([2, 3]);
|
|
39
|
+
});
|
|
40
|
+
it("lost output suppresses dependent deltas until a keyframe", () => {
|
|
41
|
+
const buffer = createNativeVideoBuffer();
|
|
42
|
+
buffer.add(packet(1, "key"));
|
|
43
|
+
buffer.add(packet(3));
|
|
44
|
+
buffer.add(packet(4));
|
|
45
|
+
expect(buffer.read(1, 1100)).toEqual([]);
|
|
46
|
+
buffer.add(packet(5, "key"));
|
|
47
|
+
buffer.add(packet(6));
|
|
48
|
+
expect(buffer.read(1, 1100).map((p) => p.sequence)).toEqual([5, 6]);
|
|
49
|
+
});
|
|
50
|
+
it("never relabels or returns source frames beyond one second", () => {
|
|
51
|
+
const buffer = createNativeVideoBuffer();
|
|
52
|
+
buffer.add(packet(1, "key"));
|
|
53
|
+
expect(buffer.read(0, 2000)[0]?.capturedAt).toBe(new Date(1000).toISOString());
|
|
54
|
+
expect(buffer.read(0, 2001)).toEqual([]);
|
|
55
|
+
});
|
|
56
|
+
it("bounds slow consumers and restarts from a retained keyframe", () => {
|
|
57
|
+
const buffer = createNativeVideoBuffer();
|
|
58
|
+
for (let i = 1; i <= 80; i++) buffer.add(packet(i, i % 10 === 0 ? "key" : "delta"));
|
|
59
|
+
const result = buffer.read(0, 1100);
|
|
60
|
+
expect(result.length).toBeLessThanOrEqual(32);
|
|
61
|
+
expect(result[0]?.type).toBe("key");
|
|
62
|
+
expect(result.at(-1)?.sequence).toBe(80);
|
|
63
|
+
});
|
|
64
|
+
it("signals recovery for a missing key, but not ordinary waiting on a valid chain", () => {
|
|
65
|
+
const buffer = createNativeVideoBuffer();
|
|
66
|
+
expect(buffer.needsKeyFrame(0, 1100)).toBe(false);
|
|
67
|
+
buffer.add(packet(1, "key"));
|
|
68
|
+
expect(buffer.needsKeyFrame(1, 1100)).toBe(false);
|
|
69
|
+
buffer.add(packet(3));
|
|
70
|
+
expect(buffer.needsKeyFrame(1, 1100)).toBe(true);
|
|
71
|
+
buffer.add(packet(4, "key"));
|
|
72
|
+
expect(buffer.needsKeyFrame(1, 1100)).toBe(false);
|
|
73
|
+
expect(buffer.needsKeyFrame(4, 2101)).toBe(false);
|
|
74
|
+
expect(buffer.needsKeyFrame(0, 2101)).toBe(true);
|
|
75
|
+
});
|
|
76
|
+
it("requests a bootstrap key after ring eviction without disturbing a current decoder", () => {
|
|
77
|
+
const buffer = createNativeVideoBuffer();
|
|
78
|
+
buffer.add(packet(1, "key"));
|
|
79
|
+
for (let sequence = 2; sequence <= 40; sequence++) buffer.add(packet(sequence));
|
|
80
|
+
expect(buffer.read(0, 1100)).toEqual([]);
|
|
81
|
+
expect(buffer.needsKeyFrame(0, 1100)).toBe(true);
|
|
82
|
+
expect(buffer.needsKeyFrame(39, 1100)).toBe(false);
|
|
83
|
+
expect(buffer.read(39, 1100).map((item) => item.sequence)).toEqual([40]);
|
|
84
|
+
});
|
|
85
|
+
it("a document barrier clears queued pixels without recycling sequence authority", () => {
|
|
86
|
+
const buffer = createNativeVideoBuffer();
|
|
87
|
+
buffer.add(packet(1, "key"));
|
|
88
|
+
buffer.clear();
|
|
89
|
+
buffer.add(packet(2));
|
|
90
|
+
expect(buffer.read(0, 1100)).toEqual([]);
|
|
91
|
+
buffer.add(packet(3, "key"));
|
|
92
|
+
expect(buffer.read(0, 1100).map((p) => p.sequence)).toEqual([3]);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("sends the latest complete key chain instead of an obsolete multi-megabyte backlog", () => {
|
|
97
|
+
const buffer = createNativeVideoBuffer();
|
|
98
|
+
const large = (sequence: number, type: "key" | "delta") => ({
|
|
99
|
+
...packet(sequence, type),
|
|
100
|
+
dataBase64: Buffer.alloc(type === "key" ? 512 * 1024 : 128 * 1024, sequence).toString("base64"),
|
|
101
|
+
});
|
|
102
|
+
buffer.add(large(1, "key"));
|
|
103
|
+
for (let sequence = 2; sequence <= 24; sequence++) buffer.add(large(sequence, "delta"));
|
|
104
|
+
buffer.add(large(25, "key"));
|
|
105
|
+
buffer.add(large(26, "delta"));
|
|
106
|
+
for (const after of [0, 1, 12, 24]) {
|
|
107
|
+
const selected = buffer.read(after, 1100);
|
|
108
|
+
expect(selected.map((value) => value.sequence)).toEqual([25, 26]);
|
|
109
|
+
expect(
|
|
110
|
+
selected.reduce((bytes, value) => bytes + Buffer.byteLength(value.dataBase64, "base64"), 0),
|
|
111
|
+
).toBe(640 * 1024);
|
|
112
|
+
expect(buffer.needsKeyFrame(after, 1100)).toBe(false);
|
|
113
|
+
}
|
|
114
|
+
// Fast and slow viewers have independent cursors; no arbitrary delta skips.
|
|
115
|
+
expect(buffer.read(25, 1100).map((value) => value.sequence)).toEqual([26]);
|
|
116
|
+
expect(buffer.read(26, 1100)).toEqual([]);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("never catches up across a delta dependency without a newer complete key", () => {
|
|
120
|
+
const buffer = createNativeVideoBuffer();
|
|
121
|
+
buffer.add(packet(1, "key"));
|
|
122
|
+
for (let sequence = 2; sequence <= 20; sequence++) buffer.add(packet(sequence));
|
|
123
|
+
expect(buffer.read(1, 1100).map((value) => value.sequence)).toEqual(
|
|
124
|
+
Array.from({ length: 19 }, (_, i) => i + 2),
|
|
125
|
+
);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it.each([-75_000, 75_000])(
|
|
129
|
+
"retains source age rather than calibration wall time after a %i ms correction",
|
|
130
|
+
(jump) => {
|
|
131
|
+
const clock = { sourceMs: 112000000, monotonicMs: 500 };
|
|
132
|
+
const time = videoSourceTime(112000100000, clock, 700, 2200 + jump)!;
|
|
133
|
+
expect(time.capturedAtMonotonicMs).toBe(600);
|
|
134
|
+
expect(Date.parse(time.capturedAt)).toBe(2100 + jump);
|
|
135
|
+
const buffer = createNativeVideoBuffer();
|
|
136
|
+
buffer.add({ ...packet(1, "key"), ...time });
|
|
137
|
+
expect(buffer.read(0, 700)).toHaveLength(1);
|
|
138
|
+
expect(buffer.read(0, 1601)).toEqual([]);
|
|
139
|
+
expect(videoSourceTime(112000100000, clock, 1701, 2200 + jump)).toBeNull();
|
|
140
|
+
},
|
|
141
|
+
);
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/** Bounded per-encoder packet history. Gaps return a fresh keyframe, never a broken delta chain. */
|
|
2
|
+
import {
|
|
3
|
+
VIDEO_MAX_BATCH_BYTES,
|
|
4
|
+
VIDEO_MAX_BATCH_PACKETS,
|
|
5
|
+
VIDEO_SOURCE_CLOCK_TOLERANCE_MS,
|
|
6
|
+
} from "../shared/browser-video";
|
|
7
|
+
import type { RuntimeVideoPacket } from "./native-video-packet";
|
|
8
|
+
export const NATIVE_VIDEO_MAX_AGE_MS = 1000;
|
|
9
|
+
const MAX_RING_BYTES = 8 * 1024 * 1024;
|
|
10
|
+
export interface VideoSourceClock {
|
|
11
|
+
sourceMs: number;
|
|
12
|
+
monotonicMs: number;
|
|
13
|
+
}
|
|
14
|
+
/** Map Chromium TimeTicks into Node elapsed time and validate source age.
|
|
15
|
+
* Wall-clock corrections affect outward metadata only, never retained freshness. */
|
|
16
|
+
export function videoSourceTime(
|
|
17
|
+
timestampUs: number,
|
|
18
|
+
clock: VideoSourceClock,
|
|
19
|
+
nowMonotonicMs: number,
|
|
20
|
+
nowWallMs: number,
|
|
21
|
+
): Pick<RuntimeVideoPacket, "capturedAt" | "capturedAtMonotonicMs"> | null {
|
|
22
|
+
const elapsedMs = timestampUs / 1000 - clock.sourceMs;
|
|
23
|
+
const capturedAtMonotonicMs = clock.monotonicMs + elapsedMs;
|
|
24
|
+
const ageMs = nowMonotonicMs - capturedAtMonotonicMs;
|
|
25
|
+
if (
|
|
26
|
+
!Number.isFinite(ageMs) ||
|
|
27
|
+
ageMs < -VIDEO_SOURCE_CLOCK_TOLERANCE_MS ||
|
|
28
|
+
ageMs > NATIVE_VIDEO_MAX_AGE_MS
|
|
29
|
+
)
|
|
30
|
+
return null;
|
|
31
|
+
// Wall time is presentation metadata only. Calibrating it once would make
|
|
32
|
+
// NTP/WSL clock corrections turn fresh pixels into future or expired frames.
|
|
33
|
+
return {
|
|
34
|
+
capturedAt: new Date(nowWallMs - ageMs).toISOString(),
|
|
35
|
+
capturedAtMonotonicMs,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
/** Retain source receipts with dependency-safe cursors. Read clocks are Node elapsed milliseconds. */
|
|
39
|
+
export function createNativeVideoBuffer() {
|
|
40
|
+
let packets: RuntimeVideoPacket[] = [];
|
|
41
|
+
let bytes = 0;
|
|
42
|
+
let lastSequence = 0;
|
|
43
|
+
let needsKey = true;
|
|
44
|
+
const size = (packet: RuntimeVideoPacket) => Buffer.byteLength(packet.dataBase64, "base64");
|
|
45
|
+
return {
|
|
46
|
+
/** Caller already validated identity/source age. A missing sequence suppresses dependent deltas. */
|
|
47
|
+
add(packet: RuntimeVideoPacket): boolean {
|
|
48
|
+
if (packet.sequence <= lastSequence) return false;
|
|
49
|
+
if (lastSequence && packet.sequence !== lastSequence + 1) needsKey = true;
|
|
50
|
+
lastSequence = packet.sequence;
|
|
51
|
+
if (needsKey && packet.type !== "key") return false;
|
|
52
|
+
if (packet.type === "key") needsKey = false;
|
|
53
|
+
packets.push(packet);
|
|
54
|
+
bytes += size(packet);
|
|
55
|
+
while (packets.length > VIDEO_MAX_BATCH_PACKETS || bytes > MAX_RING_BYTES) {
|
|
56
|
+
const removed = packets.shift();
|
|
57
|
+
if (removed) bytes -= size(removed);
|
|
58
|
+
}
|
|
59
|
+
return true;
|
|
60
|
+
},
|
|
61
|
+
clear(): void {
|
|
62
|
+
packets = [];
|
|
63
|
+
bytes = 0;
|
|
64
|
+
needsKey = true;
|
|
65
|
+
},
|
|
66
|
+
/** Ask for a fresh key only when this reader cannot recover its retained chain.
|
|
67
|
+
* No observed packet yet and an up-to-date decoder waiting for its next delta
|
|
68
|
+
* are ordinary waiting, not reasons to generate repeated recovery keys. */
|
|
69
|
+
needsKeyFrame(afterSequence: number, nowMonotonicMs: number): boolean {
|
|
70
|
+
if (!lastSequence) return false;
|
|
71
|
+
if (needsKey) return true;
|
|
72
|
+
const fresh = packets.filter(
|
|
73
|
+
(packet) =>
|
|
74
|
+
nowMonotonicMs - packet.capturedAtMonotonicMs >= -VIDEO_SOURCE_CLOCK_TOLERANCE_MS &&
|
|
75
|
+
nowMonotonicMs - packet.capturedAtMonotonicMs <= NATIVE_VIDEO_MAX_AGE_MS,
|
|
76
|
+
);
|
|
77
|
+
const first = fresh[0];
|
|
78
|
+
if (!first) return afterSequence < lastSequence;
|
|
79
|
+
if (afterSequence === 0 || afterSequence < first.sequence - 1) {
|
|
80
|
+
return !fresh.some((packet) => packet.type === "key");
|
|
81
|
+
}
|
|
82
|
+
return false;
|
|
83
|
+
},
|
|
84
|
+
/** Returning no packet is honest when all retained source receipts are older than one second. */
|
|
85
|
+
read(afterSequence: number, nowMonotonicMs: number): RuntimeVideoPacket[] {
|
|
86
|
+
let candidates = packets.filter(
|
|
87
|
+
(packet) =>
|
|
88
|
+
nowMonotonicMs - packet.capturedAtMonotonicMs >= -VIDEO_SOURCE_CLOCK_TOLERANCE_MS &&
|
|
89
|
+
nowMonotonicMs - packet.capturedAtMonotonicMs <= NATIVE_VIDEO_MAX_AGE_MS,
|
|
90
|
+
);
|
|
91
|
+
const first = candidates[0];
|
|
92
|
+
if (!first) return [];
|
|
93
|
+
candidates = candidates.filter((packet) => packet.sequence > afterSequence);
|
|
94
|
+
// An independently decodable newer key supersedes all earlier pictures.
|
|
95
|
+
// Catch up before serialization/transport instead of sending a stale GOP.
|
|
96
|
+
// Never skip an arbitrary delta: its successor can depend on that picture.
|
|
97
|
+
const keyIndex = candidates.findLastIndex((packet) => packet.type === "key");
|
|
98
|
+
if (keyIndex >= 0) {
|
|
99
|
+
candidates = candidates.slice(keyIndex);
|
|
100
|
+
} else if (afterSequence < first.sequence - 1 || afterSequence === 0) {
|
|
101
|
+
return [];
|
|
102
|
+
}
|
|
103
|
+
let batchBytes = 0;
|
|
104
|
+
const batch: RuntimeVideoPacket[] = [];
|
|
105
|
+
for (const packet of candidates) {
|
|
106
|
+
const packetBytes = size(packet);
|
|
107
|
+
if (batchBytes + packetBytes > VIDEO_MAX_BATCH_BYTES) break;
|
|
108
|
+
batchBytes += packetBytes;
|
|
109
|
+
batch.push(packet);
|
|
110
|
+
}
|
|
111
|
+
return batch;
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
}
|