@expo/serve-sim 0.1.35-canary.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.
Files changed (39) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +279 -0
  3. package/Sources/SimAXSettings/build.sh +21 -0
  4. package/Sources/SimAXSettings/sim-ax-settings.m +273 -0
  5. package/Sources/SimCameraHelper/build.sh +33 -0
  6. package/Sources/SimCameraHelper/main.m +955 -0
  7. package/Sources/SimCameraInjector/SimCamFakes.h +88 -0
  8. package/Sources/SimCameraInjector/SimCamFakes.m +704 -0
  9. package/Sources/SimCameraInjector/SimCamFrameSource.h +26 -0
  10. package/Sources/SimCameraInjector/SimCamFrameSource.m +577 -0
  11. package/Sources/SimCameraInjector/SimCamLog.h +5 -0
  12. package/Sources/SimCameraInjector/SimCamLog.m +9 -0
  13. package/Sources/SimCameraInjector/SimCamSwizzles.h +3 -0
  14. package/Sources/SimCameraInjector/SimCamSwizzles.m +1338 -0
  15. package/Sources/SimCameraInjector/SimCameraInjector.m +19 -0
  16. package/Sources/SimCameraInjector/build.sh +39 -0
  17. package/Sources/SimCameraInjector/include/SimCamShared.h +79 -0
  18. package/dist/bin/LiveKitWebRTC.framework/LiveKitWebRTC +0 -0
  19. package/dist/bin/LiveKitWebRTC.framework/Resources/Info.plist +36 -0
  20. package/dist/bin/LiveKitWebRTC.framework/Resources/LICENSE.webrtc +29 -0
  21. package/dist/bin/LiveKitWebRTC.framework/Resources/PrivacyInfo.xcprivacy +32 -0
  22. package/dist/bin/LiveKitWebRTC.framework/_CodeSignature/CodeResources +150 -0
  23. package/dist/middleware.cjs +2 -0
  24. package/dist/middleware.js +123 -0
  25. package/dist/native/serve-sim-native.node +0 -0
  26. package/dist/serve-sim.js +218 -0
  27. package/dist/simax/serve-sim-ax-settings +0 -0
  28. package/dist/simcam/libSimCameraInjector.dylib +0 -0
  29. package/dist/simcam/serve-sim-camera-helper +0 -0
  30. package/dist/state.js +1 -0
  31. package/package.json +99 -0
  32. package/src/ax-shared.ts +25 -0
  33. package/src/ax.ts +258 -0
  34. package/src/camera-helper.ts +150 -0
  35. package/src/connect-to-fetch.ts +239 -0
  36. package/src/middleware.ts +2208 -0
  37. package/src/native.ts +294 -0
  38. package/src/state.ts +86 -0
  39. package/src/stream-settings.ts +202 -0
package/src/native.ts ADDED
@@ -0,0 +1,294 @@
1
+ /**
2
+ * Typed loader + wrapper for serve-sim-native.node — the in-process N-API addon
3
+ * that replaces the spawned serve-sim-bin helper. HID is the first surface;
4
+ * frame capture + encoders land here next.
5
+ *
6
+ * The .node is resolved from disk (dist/native/) relative to either this module
7
+ * or the bun-compiled executable, so it loads under `npx serve-sim`, the
8
+ * compiled binary, and the mounted middleware alike.
9
+ */
10
+ import { createRequire } from "module";
11
+ import { dirname, join } from "path";
12
+ import { existsSync } from "fs";
13
+ import { fileURLToPath } from "url";
14
+ import {
15
+ DEFAULT_STREAM_ENCODER_SETTINGS,
16
+ type StreamEncoderSettings,
17
+ } from "./stream-settings";
18
+
19
+ const require = createRequire(import.meta.url);
20
+
21
+ // The addon exposes two NodeClasses (SimHID, SimCapture) plus two async
22
+ // functions. NodeClass instances clean up their native resources when the JS
23
+ // handle is garbage-collected (Swift `deinit`), so there are no explicit
24
+ // destroy/free calls here.
25
+ interface SimHIDHandle {
26
+ touch(type: TouchType, x: number, y: number, w: number, hh: number, edge: number): Promise<void>;
27
+ multiTouch(type: TouchType, x1: number, y1: number, x2: number, y2: number, w: number, hh: number): Promise<void>;
28
+ button(button: string): Promise<void>;
29
+ buttonHid(page: number, usage: number, phase: ButtonPhase): Promise<void>;
30
+ key(type: KeyType, usage: number): Promise<void>;
31
+ scroll(dx: number, dy: number, anchorX: number, anchorY: number, w: number, hh: number): Promise<void>;
32
+ digitalCrown(delta: number): Promise<void>;
33
+ orientation(orientation: number): Promise<boolean>;
34
+ memoryWarning(): Promise<void>;
35
+ softwareKeyboard(): Promise<void>;
36
+ caDebug(name: string, enabled: boolean): Promise<boolean>;
37
+ }
38
+
39
+ interface SimCaptureHandle {
40
+ start(): Promise<void>;
41
+ updateStreamSettings(
42
+ mjpegFps: number,
43
+ mjpegQuality: number,
44
+ maxDimension: number,
45
+ h264Fps: number,
46
+ h264Bitrate: number,
47
+ ): Promise<void>;
48
+ handleWebRTCOffer(offerJson: string): Promise<string>;
49
+ closeWebRTCSession(sessionId: string): Promise<void>;
50
+ screenSize(): Promise<{ width: number; height: number }>;
51
+ stop(): Promise<void>;
52
+ subscribe(codec: number, onFrame: RawFrameCallback): Promise<NativeUnsubscribe>;
53
+ }
54
+
55
+ interface NativeAddon {
56
+ SimHID: new (udid: string) => SimHIDHandle;
57
+ SimCapture: new (
58
+ udid: string,
59
+ mjpegFps: number,
60
+ mjpegQuality: number,
61
+ maxDimension: number,
62
+ h264Fps: number,
63
+ h264Bitrate: number,
64
+ ) => SimCaptureHandle;
65
+ axDescribe(udid: string): Promise<string>;
66
+ axFrontmost(udid: string): Promise<string>;
67
+ }
68
+
69
+ // (codec, data, width, height, flags) — codec 0=MJPEG 1=AVCC; flags bit0=desc bit1=keyframe.
70
+ type RawFrameCallback = (
71
+ data: Uint8Array,
72
+ width: number,
73
+ height: number,
74
+ flags: number,
75
+ ) => Promise<void>;
76
+
77
+ const CODEC_MJPEG = 0;
78
+ const CODEC_AVCC = 1;
79
+ const FLAG_DESCRIPTION = 1 << 0;
80
+ const FLAG_KEYFRAME = 1 << 1;
81
+
82
+ export type MjpegFrame = {
83
+ data: Uint8Array;
84
+ width: number;
85
+ height: number;
86
+ };
87
+
88
+ export type AvccFrame = {
89
+ data: Uint8Array;
90
+ width: number;
91
+ height: number;
92
+ isDescription: boolean;
93
+ isKeyframe: boolean;
94
+ };
95
+
96
+ export type NativeCaptureOptions = StreamEncoderSettings;
97
+
98
+ export type NativeUnsubscribe = () => Promise<void>;
99
+
100
+ export type TouchType = "begin" | "move" | "end";
101
+ export type KeyType = "down" | "up";
102
+ export type ButtonPhase = "down" | "up" | "press";
103
+
104
+ /** UIDeviceOrientation values the simulator's GraphicsServices accepts. */
105
+ export const Orientation = {
106
+ portrait: 1,
107
+ portraitUpsideDown: 2,
108
+ landscapeRight: 3,
109
+ landscapeLeft: 4,
110
+ } as const;
111
+
112
+ function resolveAddon(): string {
113
+ const candidates = [
114
+ // Beside the bun-compiled executable (dist/serve-sim → dist/native/…).
115
+ // Arm64 macOS addon; loaded by path so it works under npx, the
116
+ // compiled binary, and the dev server alike.
117
+ join(dirname(process.execPath), "native", "serve-sim-native.node"),
118
+ // Beside the bundled JS (dist/serve-sim.js or dist/middleware.js).
119
+ join(dirname(fileURLToPath(import.meta.url)), "native", "serve-sim-native.node"),
120
+ // Dev: running from source (src/native.ts → ../dist/native/…).
121
+ join(dirname(fileURLToPath(import.meta.url)), "..", "dist", "native", "serve-sim-native.node"),
122
+ ];
123
+ for (const p of candidates) {
124
+ if (existsSync(p)) return p;
125
+ }
126
+ throw new Error(
127
+ `serve-sim-native.node not found. Looked in:\n ${candidates.join("\n ")}\n` +
128
+ "Run `bun run build.ts` to build the native addon.",
129
+ );
130
+ }
131
+
132
+ let addon: NativeAddon | undefined;
133
+ function load(): NativeAddon {
134
+ if (!addon) addon = require(resolveAddon()) as NativeAddon;
135
+ return addon;
136
+ }
137
+
138
+ /**
139
+ * In-process HID injector for one simulator. Mirrors the WebSocket HID protocol
140
+ * the spawned helper used to handle, but as direct native calls.
141
+ */
142
+ export class NativeHid {
143
+ private readonly handle: SimHIDHandle;
144
+
145
+ constructor(udid: string) {
146
+ this.handle = new (load().SimHID)(udid);
147
+ }
148
+
149
+ // The N-API bindings throw synchronously when a JS value can't be coerced to
150
+ // the native parameter type (e.g. a touch with a non-string `type` →
151
+ // "Could not convert parameter 0 to type String"). HID now runs in-process,
152
+ // so an unhandled throw here crashes the whole server — and if it lands
153
+ // mid-gesture, the guest is left with a stuck finger that wedges input until
154
+ // the sim reboots. The spawned helper used to absorb this in its own process;
155
+ // `guard` restores that isolation by swallowing malformed-input errors.
156
+ private async guard<T>(op: string, fn: () => PromiseLike<T>, fallback: T): Promise<T> {
157
+ try {
158
+ return await fn();
159
+ } catch (err) {
160
+ console.error(`[hid] ${op} ignored bad input:`, err instanceof Error ? err.message : err);
161
+ return fallback;
162
+ }
163
+ }
164
+
165
+ touch(type: TouchType, x: number, y: number, w: number, h: number, edge = 0): Promise<void> {
166
+ return this.guard("touch", () => this.handle.touch(type, x, y, w, h, edge), undefined);
167
+ }
168
+
169
+ multiTouch(type: TouchType, x1: number, y1: number, x2: number, y2: number, w: number, h: number): Promise<void> {
170
+ return this.guard("multiTouch", () => this.handle.multiTouch(type, x1, y1, x2, y2, w, h), undefined);
171
+ }
172
+
173
+ button(button: string): Promise<void> {
174
+ return this.guard("button", () => this.handle.button(button), undefined);
175
+ }
176
+
177
+ buttonHid(page: number, usage: number, phase: ButtonPhase = "press"): Promise<void> {
178
+ return this.guard("buttonHid", () => this.handle.buttonHid(page, usage, phase), undefined);
179
+ }
180
+
181
+ key(type: KeyType, usage: number): Promise<void> {
182
+ return this.guard("key", () => this.handle.key(type, usage), undefined);
183
+ }
184
+
185
+ /** anchorX/anchorY default to screen center when omitted. */
186
+ scroll(dx: number, dy: number, w: number, h: number, anchorX?: number, anchorY?: number): Promise<void> {
187
+ return this.guard("scroll", () => this.handle.scroll(dx, dy, anchorX ?? NaN, anchorY ?? NaN, w, h), undefined);
188
+ }
189
+
190
+ digitalCrown(delta: number): Promise<void> {
191
+ return this.guard("digitalCrown", () => this.handle.digitalCrown(delta), undefined);
192
+ }
193
+
194
+ orientation(orientation: number): Promise<boolean> {
195
+ return this.guard("orientation", () => this.handle.orientation(orientation), false);
196
+ }
197
+
198
+ memoryWarning(): Promise<void> {
199
+ return this.guard("memoryWarning", () => this.handle.memoryWarning(), undefined);
200
+ }
201
+
202
+ softwareKeyboard(): Promise<void> {
203
+ return this.guard("softwareKeyboard", () => this.handle.softwareKeyboard(), undefined);
204
+ }
205
+
206
+ caDebug(name: string, enabled: boolean): Promise<boolean> {
207
+ return this.guard("caDebug", () => this.handle.caDebug(name, enabled), false);
208
+ }
209
+ }
210
+
211
+ /**
212
+ * In-process frame capture + encode for one simulator. Replaces the spawned
213
+ * helper's capture pipeline. MJPEG and H.264/AVCC frames are produced while
214
+ * callers hold codec-specific subscriptions; encoded frames arrive on the JS
215
+ * thread after being marshalled from the native encode thread.
216
+ */
217
+ export class NativeCapture {
218
+ private readonly handle: SimCaptureHandle;
219
+
220
+ constructor(udid: string, options: NativeCaptureOptions = DEFAULT_STREAM_ENCODER_SETTINGS) {
221
+ this.handle = new (load().SimCapture)(
222
+ udid,
223
+ options.mjpegFps,
224
+ options.mjpegQuality,
225
+ options.maxDimension,
226
+ options.h264Fps,
227
+ options.h264Bitrate,
228
+ );
229
+ }
230
+
231
+ /** Begin capturing. Throws if the device isn't booted. */
232
+ start(): Promise<void> {
233
+ return this.handle.start();
234
+ }
235
+
236
+ updateStreamSettings(options: NativeCaptureOptions): Promise<void> {
237
+ return this.handle.updateStreamSettings(
238
+ options.mjpegFps,
239
+ options.mjpegQuality,
240
+ options.maxDimension,
241
+ options.h264Fps,
242
+ options.h264Bitrate,
243
+ );
244
+ }
245
+
246
+ subscribeMjpeg(onFrame: (frame: MjpegFrame) => Promise<void>): Promise<NativeUnsubscribe> {
247
+ return this.handle.subscribe(CODEC_MJPEG, (data, width, height, _flags) => {
248
+ return onFrame({ data, width, height });
249
+ });
250
+ }
251
+
252
+ subscribeAvcc(onFrame: (frame: AvccFrame) => Promise<void>): Promise<NativeUnsubscribe> {
253
+ return this.handle.subscribe(CODEC_AVCC, (data, width, height, flags) => {
254
+ return onFrame({
255
+ data,
256
+ width,
257
+ height,
258
+ isDescription: (flags & FLAG_DESCRIPTION) !== 0,
259
+ isKeyframe: (flags & FLAG_KEYFRAME) !== 0,
260
+ });
261
+ });
262
+ }
263
+
264
+ async handleWebRTCOffer(offer: unknown): Promise<unknown> {
265
+ return JSON.parse(await this.handle.handleWebRTCOffer(JSON.stringify(offer)));
266
+ }
267
+
268
+ closeWebRTCSession(sessionId: string): Promise<void> {
269
+ return this.handle.closeWebRTCSession(sessionId);
270
+ }
271
+
272
+ screenSize(): Promise<{ width: number; height: number }> {
273
+ return this.handle.screenSize();
274
+ }
275
+
276
+ /** Halt frame production. Full teardown happens when this object is GC'd. */
277
+ stop(): Promise<void> {
278
+ return this.handle.stop();
279
+ }
280
+ }
281
+
282
+ /**
283
+ * Async accessibility-tree dump for `udid`, as an axe-shaped JSON string (the
284
+ * src/ax.ts normalizer consumes it unchanged). Runs native AX work off the JS
285
+ * event loop. Rejects if the sim's AX service isn't reachable yet.
286
+ */
287
+ export function axDescribeAsync(udid: string): Promise<string> {
288
+ return load().axDescribe(udid);
289
+ }
290
+
291
+ /** Async frontmost-app probe — JSON string `{ bundleId, pid }` for the visible app. */
292
+ export function axFrontmostAsync(udid: string): Promise<string> {
293
+ return load().axFrontmost(udid);
294
+ }
package/src/state.ts ADDED
@@ -0,0 +1,86 @@
1
+ import { tmpdir } from "os";
2
+ import { join } from "path";
3
+ import { readdirSync, mkdirSync, writeFileSync, renameSync } from "fs";
4
+ import type { StreamSettings } from "./stream-settings";
5
+ export type {
6
+ HttpStreamCodec,
7
+ StreamSettings,
8
+ WebRtcIceServer,
9
+ WebRtcStreamCodec,
10
+ } from "./stream-settings";
11
+
12
+ /** Directory where serve-sim stores runtime state. */
13
+ export const STATE_DIR = join(tmpdir(), "serve-sim");
14
+
15
+ /** Path to the serve-sim server state file (JSON with pid, port, URLs).
16
+ * @deprecated Use `stateFileForDevice(udid)` for multi-device support. Kept for backward compat. */
17
+ export const STATE_FILE = join(STATE_DIR, "server.json");
18
+
19
+ /** Per-device state file: `/tmp/serve-sim/server-{udid}.json` */
20
+ export function stateFileForDevice(udid: string): string {
21
+ return join(STATE_DIR, `server-${udid}.json`);
22
+ }
23
+
24
+ /** Runtime record for a device streamed in-process by a preview server. */
25
+ export interface ServeSimDeviceState {
26
+ pid: number;
27
+ port: number;
28
+ device: string;
29
+ url: string;
30
+ streamUrl: string;
31
+ wsUrl: string;
32
+ streamSettings?: StreamSettings;
33
+ }
34
+
35
+ /**
36
+ * Build the state for a device served in-process. There's no separate helper
37
+ * port — the URLs point at the preview server's own same-origin
38
+ * `{base}/helper/<device>/…` routes, which simMiddleware serves from a
39
+ * NativeCapture/NativeHid DeviceSession.
40
+ */
41
+ export function inProcessServeSimState(
42
+ udid: string,
43
+ port: number,
44
+ base = "/",
45
+ host = "127.0.0.1",
46
+ streamSettings?: StreamSettings,
47
+ ): ServeSimDeviceState {
48
+ const h = host === "0.0.0.0" || host === "::" ? "127.0.0.1" : host;
49
+ // Normalize to a leading-slash, no-trailing-slash prefix so a base without a
50
+ // leading slash (e.g. "foo") still yields well-formed `…:port/foo/helper/…`.
51
+ const trimmed = base.replace(/^\/+/, "").replace(/\/+$/, "");
52
+ const prefix = trimmed === "" ? "" : `/${trimmed}`;
53
+ return {
54
+ pid: process.pid,
55
+ port,
56
+ device: udid,
57
+ url: `http://${h}:${port}`,
58
+ streamUrl: `http://${h}:${port}${prefix}/helper/${udid}/stream.mjpeg`,
59
+ wsUrl: `ws://${h}:${port}${prefix}/helper/${udid}/ws`,
60
+ ...(streamSettings ? { streamSettings } : {}),
61
+ };
62
+ }
63
+
64
+ /** Persist a device's state so other processes / the grid can enumerate it.
65
+ * Writes atomically (temp file + rename) so a concurrent reader never observes
66
+ * a truncated or partially-written file. */
67
+ export function writeServeSimState(state: ServeSimDeviceState): void {
68
+ mkdirSync(STATE_DIR, { recursive: true });
69
+ const file = stateFileForDevice(state.device);
70
+ const tmp = `${file}.${process.pid}.tmp`;
71
+ // WebRTC state can contain short-lived TURN credentials. Keep the file
72
+ // readable only by the account running serve-sim.
73
+ writeFileSync(tmp, JSON.stringify(state, null, 2), { mode: 0o600 });
74
+ renameSync(tmp, file);
75
+ }
76
+
77
+ /** List all per-device state files in the state directory. */
78
+ export function listStateFiles(): string[] {
79
+ try {
80
+ return readdirSync(STATE_DIR)
81
+ .filter((f) => f.startsWith("server-") && f.endsWith(".json"))
82
+ .map((f) => join(STATE_DIR, f));
83
+ } catch {
84
+ return [];
85
+ }
86
+ }
@@ -0,0 +1,202 @@
1
+ export type HttpStreamCodec = "auto" | "mjpeg" | "h264";
2
+ export type WebRtcStreamCodec = "vp8" | "vp9" | "h264";
3
+ export type WebRtcIceServer = { urls: string[]; username?: string; credential?: string };
4
+
5
+ export type StreamSettings =
6
+ | { transport: "http"; codec?: HttpStreamCodec }
7
+ | { transport: "webrtc"; codec: WebRtcStreamCodec; iceServers?: WebRtcIceServer[] };
8
+
9
+ export interface StreamPlaybackSettings {
10
+ transport: "http" | "webrtc";
11
+ httpCodec: HttpStreamCodec;
12
+ webRtcCodec: WebRtcStreamCodec;
13
+ iceServers?: WebRtcIceServer[];
14
+ }
15
+
16
+ export interface StreamEncoderSettings {
17
+ mjpegFps: number;
18
+ mjpegQuality: number;
19
+ maxDimension: number;
20
+ h264Bitrate: number;
21
+ h264Fps: number;
22
+ }
23
+
24
+ export type StreamControlSettings = StreamPlaybackSettings & StreamEncoderSettings;
25
+
26
+ export const DEFAULT_STREAM_ENCODER_SETTINGS: StreamEncoderSettings = {
27
+ mjpegFps: 60,
28
+ mjpegQuality: 0.7,
29
+ maxDimension: 0,
30
+ h264Bitrate: 6_000_000,
31
+ h264Fps: 60,
32
+ };
33
+
34
+ export const DEFAULT_STREAM_CONTROL_SETTINGS: StreamControlSettings = {
35
+ transport: "http",
36
+ httpCodec: "auto",
37
+ webRtcCodec: "h264",
38
+ ...DEFAULT_STREAM_ENCODER_SETTINGS,
39
+ };
40
+
41
+ function finiteNumber(value: unknown): number | null {
42
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
43
+ }
44
+
45
+ function numberInRange(value: unknown, fallback: number, min: number, max: number): number {
46
+ const number = finiteNumber(value);
47
+ return number == null ? fallback : Math.min(max, Math.max(min, number));
48
+ }
49
+
50
+ function integerInRange(value: unknown, fallback: number, min: number, max: number): number {
51
+ return Math.round(numberInRange(value, fallback, min, max));
52
+ }
53
+
54
+ /** Invalid input returns null; an empty array is a valid explicit clear. */
55
+ function normalizedIceServers(value: unknown): WebRtcIceServer[] | null {
56
+ if (!Array.isArray(value) || value.length > 16) return null;
57
+ const servers: WebRtcIceServer[] = [];
58
+ for (const entry of value) {
59
+ if (!entry || typeof entry !== "object") return null;
60
+ const urls = (entry as { urls?: unknown }).urls;
61
+ if (
62
+ !Array.isArray(urls)
63
+ || urls.length === 0
64
+ || urls.length > 16
65
+ || !urls.every((url) =>
66
+ typeof url === "string"
67
+ && url.length <= 2_048
68
+ && /^(stun|stuns|turn|turns):/i.test(url)
69
+ )
70
+ ) {
71
+ return null;
72
+ }
73
+ const username = (entry as { username?: unknown }).username;
74
+ const credential = (entry as { credential?: unknown }).credential;
75
+ if (username !== undefined && typeof username !== "string") return null;
76
+ if (credential !== undefined && typeof credential !== "string") return null;
77
+ servers.push({
78
+ urls,
79
+ ...(typeof username === "string" ? { username } : {}),
80
+ ...(typeof credential === "string" ? { credential } : {}),
81
+ });
82
+ }
83
+ return servers;
84
+ }
85
+
86
+ const STREAM_ENCODER_SETTING_KEYS = new Set<keyof StreamEncoderSettings>([
87
+ "mjpegFps",
88
+ "mjpegQuality",
89
+ "maxDimension",
90
+ "h264Bitrate",
91
+ "h264Fps",
92
+ ]);
93
+
94
+ /** Validate an untrusted PATCH body without silently accepting typos or wrong types. */
95
+ export function parseStreamEncoderSettingsPatch(
96
+ input: unknown,
97
+ ): Partial<StreamEncoderSettings> | null {
98
+ if (!input || typeof input !== "object" || Array.isArray(input)) return null;
99
+ const patch = input as Record<string, unknown>;
100
+ const keys = Object.keys(patch);
101
+ if (
102
+ keys.length === 0
103
+ || keys.some((key) => !STREAM_ENCODER_SETTING_KEYS.has(key as keyof StreamEncoderSettings))
104
+ ) {
105
+ return null;
106
+ }
107
+ for (const key of ["mjpegFps", "mjpegQuality", "maxDimension", "h264Bitrate", "h264Fps"] as const) {
108
+ if (key in patch && finiteNumber(patch[key]) == null) return null;
109
+ }
110
+ return patch as Partial<StreamEncoderSettings>;
111
+ }
112
+
113
+ export function normalizeStreamControlSettings(
114
+ input: Partial<StreamControlSettings> = {},
115
+ fallback: StreamControlSettings = DEFAULT_STREAM_CONTROL_SETTINGS,
116
+ ): StreamControlSettings {
117
+ const hasIceServers = Object.prototype.hasOwnProperty.call(input, "iceServers");
118
+ const normalized = hasIceServers ? normalizedIceServers(input.iceServers) : fallback.iceServers;
119
+ const iceServers = normalized === null
120
+ ? fallback.iceServers
121
+ : normalized && normalized.length > 0 ? normalized : undefined;
122
+ return {
123
+ transport: input.transport === "http" || input.transport === "webrtc"
124
+ ? input.transport
125
+ : fallback.transport,
126
+ httpCodec: input.httpCodec === "auto" || input.httpCodec === "mjpeg" || input.httpCodec === "h264"
127
+ ? input.httpCodec
128
+ : fallback.httpCodec,
129
+ webRtcCodec: input.webRtcCodec === "vp8" || input.webRtcCodec === "vp9" || input.webRtcCodec === "h264"
130
+ ? input.webRtcCodec
131
+ : fallback.webRtcCodec,
132
+ ...(iceServers ? { iceServers } : {}),
133
+ ...normalizeStreamEncoderSettings(input, fallback),
134
+ };
135
+ }
136
+
137
+ export function normalizeStreamEncoderSettings(
138
+ input: Partial<StreamEncoderSettings> = {},
139
+ fallback: StreamEncoderSettings = DEFAULT_STREAM_ENCODER_SETTINGS,
140
+ ): StreamEncoderSettings {
141
+ return {
142
+ mjpegFps: integerInRange(input.mjpegFps, fallback.mjpegFps, 1, 120),
143
+ mjpegQuality: numberInRange(input.mjpegQuality, fallback.mjpegQuality, 0.05, 1),
144
+ maxDimension: integerInRange(input.maxDimension, fallback.maxDimension, 0, 4096),
145
+ h264Bitrate: integerInRange(input.h264Bitrate, fallback.h264Bitrate, 100_000, 50_000_000),
146
+ h264Fps: integerInRange(input.h264Fps, fallback.h264Fps, 1, 120),
147
+ };
148
+ }
149
+
150
+ export function streamEncoderSettingsFrom(
151
+ settings: StreamControlSettings,
152
+ ): StreamEncoderSettings {
153
+ return {
154
+ mjpegFps: settings.mjpegFps,
155
+ mjpegQuality: settings.mjpegQuality,
156
+ maxDimension: settings.maxDimension,
157
+ h264Bitrate: settings.h264Bitrate,
158
+ h264Fps: settings.h264Fps,
159
+ };
160
+ }
161
+
162
+ export function streamControlSettingsFrom(
163
+ settings: StreamSettings | undefined,
164
+ ): StreamControlSettings {
165
+ if (settings?.transport === "webrtc") {
166
+ return normalizeStreamControlSettings({
167
+ transport: "webrtc",
168
+ webRtcCodec: settings.codec,
169
+ iceServers: settings.iceServers,
170
+ });
171
+ }
172
+ return normalizeStreamControlSettings({
173
+ transport: "http",
174
+ httpCodec: settings?.codec ?? "auto",
175
+ });
176
+ }
177
+
178
+ export function mergeStreamControlSettings(
179
+ current: StreamControlSettings,
180
+ patch: Partial<StreamControlSettings>,
181
+ ): StreamControlSettings {
182
+ return normalizeStreamControlSettings({ ...current, ...patch }, current);
183
+ }
184
+
185
+ /** Apply shared encoder controls without replacing viewer-local playback values. */
186
+ export function mergeStreamEncoderSettings(
187
+ current: StreamControlSettings,
188
+ patch: Partial<StreamEncoderSettings>,
189
+ ): StreamControlSettings {
190
+ const previous = streamEncoderSettingsFrom(current);
191
+ const next = normalizeStreamEncoderSettings({ ...previous, ...patch }, previous);
192
+ if (
193
+ next.mjpegFps === previous.mjpegFps
194
+ && next.mjpegQuality === previous.mjpegQuality
195
+ && next.maxDimension === previous.maxDimension
196
+ && next.h264Bitrate === previous.h264Bitrate
197
+ && next.h264Fps === previous.h264Fps
198
+ ) {
199
+ return current;
200
+ }
201
+ return { ...current, ...next };
202
+ }