diff --git a/README.md b/README.md index e3e8ffb0..009983a2 100644 --- a/README.md +++ b/README.md @@ -77,11 +77,14 @@ simdeck studio expose "iPhone 17 Pro" The command starts or reuses the local daemon, creates an ephemeral Studio session, prints a unique `https://simdeck.djdev.me/simulator/...` URL, and keeps -the outbound bridge alive until you press Ctrl-C. It uses `auto` mode by default, -letting VideoToolbox choose the encoder. Pass `--video-codec software` when you -need to force software encoding; Studio then defaults to the `ci-software` -stream quality profile (`960` longest edge at `24` fps). Use -`--stream-quality quality|balanced|smooth|economy|ci-software` to override it. +the outbound bridge alive until you press Ctrl-C. It uses software H.264 by +default with realtime stream settings for remote viewing, and prints the active +codec/profile when it starts. Studio defaults to the `smooth` stream quality +profile (`1170` longest edge, dynamic up to `60` fps). Use +`--stream-quality quality|balanced|smooth|economy|ci-software` to override it, +or pass `--video-codec hardware` when a dedicated hardware encoder is preferable. +The remote viewer renders live video with the browser's native video element; +the canvas is only used for input geometry. CLI commands automatically use the same warm daemon: diff --git a/cli/XCWH264Encoder.m b/cli/XCWH264Encoder.m index cebc68da..265157a4 100644 --- a/cli/XCWH264Encoder.m +++ b/cli/XCWH264Encoder.m @@ -933,8 +933,8 @@ - (BOOL)ensureCompressionSessionWithWidth:(int32_t)width height:(int32_t)height } VTSessionSetProperty(session, kVTCompressionPropertyKey_ExpectedFrameRate, (__bridge CFTypeRef)@(expectedFrameRate)); BOOL shortKeyframeInterval = _lowLatencyMode || _realtimeStreamMode; - VTSessionSetProperty(session, kVTCompressionPropertyKey_MaxKeyFrameInterval, (__bridge CFTypeRef)@(shortKeyframeInterval ? expectedFrameRate : expectedFrameRate * 2)); - VTSessionSetProperty(session, kVTCompressionPropertyKey_MaxKeyFrameIntervalDuration, (__bridge CFTypeRef)@(shortKeyframeInterval ? 1.0 : 2.0)); + VTSessionSetProperty(session, kVTCompressionPropertyKey_MaxKeyFrameInterval, (__bridge CFTypeRef)@(shortKeyframeInterval ? MAX(1, expectedFrameRate / 2) : expectedFrameRate * 2)); + VTSessionSetProperty(session, kVTCompressionPropertyKey_MaxKeyFrameIntervalDuration, (__bridge CFTypeRef)@(shortKeyframeInterval ? 0.5 : 2.0)); VTSessionSetProperty(session, kVTCompressionPropertyKey_AverageBitRate, (__bridge CFTypeRef)@(averageBitRate)); if (_realtimeStreamMode) { NSArray *dataRateLimits = @[ diff --git a/client/src/api/controls.ts b/client/src/api/controls.ts index f5d0459d..8cc699ab 100644 --- a/client/src/api/controls.ts +++ b/client/src/api/controls.ts @@ -11,7 +11,13 @@ import type { export type ControlMessage = | ({ type: "touch" } & TouchPayload) - | ({ type: "key" } & KeyPayload); + | ({ type: "key" } & KeyPayload) + | { type: "dismissKeyboard" } + | { type: "home" } + | { type: "appSwitcher" } + | { type: "rotateLeft" } + | { type: "rotateRight" } + | { type: "toggleAppearance" }; async function postSimulatorAction( udid: string, diff --git a/client/src/api/simulators.ts b/client/src/api/simulators.ts index af5f359c..58a80325 100644 --- a/client/src/api/simulators.ts +++ b/client/src/api/simulators.ts @@ -9,8 +9,10 @@ import type { SimulatorsResponse, } from "./types"; -export async function listSimulators(): Promise { - const data = await apiRequest("/api/simulators"); +export async function listSimulators( + options: RequestInit = {}, +): Promise { + const data = await apiRequest("/api/simulators", options); return data.simulators ?? []; } diff --git a/client/src/app/AppShell.tsx b/client/src/app/AppShell.tsx index 395b678b..21d7181b 100644 --- a/client/src/app/AppShell.tsx +++ b/client/src/app/AppShell.tsx @@ -111,6 +111,15 @@ function buildAuthenticatedAssetUrl(path: string, stamp: number): string { return url.toString(); } +function shouldUseRemoteStreamDefault(apiRoot: string): boolean { + if (apiRoot) { + return true; + } + return ( + new URLSearchParams(window.location.search).get("remoteStream") === "1" + ); +} + function shouldRenderNativeChrome(simulator: SimulatorMetadata): boolean { const identifier = simulator.deviceTypeIdentifier ?? ""; const name = simulator.name ?? ""; @@ -177,6 +186,7 @@ export interface AppShellProps { fixedSimulatorUDID?: string | null; hideSimulatorSelection?: boolean; pairingEnabled?: boolean; + remoteStream?: boolean; } export function AppShell({ @@ -184,6 +194,7 @@ export function AppShell({ fixedSimulatorUDID = null, hideSimulatorSelection = false, pairingEnabled = true, + remoteStream = shouldUseRemoteStreamDefault(apiRoot), }: AppShellProps = {}) { configureSimDeckClient({ apiRoot }); const [initialUiState] = useState(readPersistedUiState); @@ -201,7 +212,7 @@ export function AppShell({ isLoading, refresh, simulators, - } = useSimulatorList(); + } = useSimulatorList({ remote: remoteStream }); const [debugVisible, setDebugVisible] = useState(false); const [hierarchyVisible, setHierarchyVisible] = useState(() => readStoredFlag(HIERARCHY_VISIBLE_STORAGE_KEY), @@ -370,6 +381,7 @@ export function AppShell({ streamCanvasKey, } = useLiveStream({ canvasElement: streamCanvasElement, + remote: remoteStream, simulator: selectedSimulator, }); @@ -843,9 +855,13 @@ export function AppShell({ pairingEnabled && listError === AUTH_REQUIRED_MESSAGE && !accessTokenFromLocation(); + const visibleListError = + remoteStream && hasFrame && listError === "Failed to fetch" + ? "" + : listError; const error = pairingRequired ? localError || streamError - : localError || streamError || listError; + : localError || streamError || visibleListError; const deviceTransform = `translate(${pan.x}px, ${pan.y + autoViewportOffsetY}px) scale(${effectiveZoom})`; const chromeScreenRect = computeChromeScreenRect( viewportChromeProfile, @@ -979,11 +995,11 @@ export function AppShell({ return state; }, []); - function sendControl(udid: string, message: ControlMessage) { + function sendControl(udid: string, message: ControlMessage): boolean { setLocalError(""); const encoded = JSON.stringify(message); if (sendWebRtcControlMessage(encoded)) { - return; + return true; } const state = ensureControlSocket(udid); if (state.socket.readyState === WebSocket.OPEN) { @@ -991,6 +1007,7 @@ export function AppShell({ } else { state.pending.push(encoded); } + return true; } useEffect(() => closeControlSocket, [closeControlSocket]); @@ -1281,7 +1298,14 @@ export function AppShell({ if (!selectedSimulator) { return; } - void runAction(() => dismissKeyboard(selectedSimulator.udid), false); + if ( + !sendControl(selectedSimulator.udid, { type: "dismissKeyboard" }) + ) { + void runAction( + () => dismissKeyboard(selectedSimulator.udid), + false, + ); + } }} onHome={() => { if (!selectedSimulator) { @@ -1289,7 +1313,9 @@ export function AppShell({ } setAccessibilitySelectedId(""); setAccessibilityHoveredId(null); - void runAction(() => pressHome(selectedSimulator.udid), false); + if (!sendControl(selectedSimulator.udid, { type: "home" })) { + void runAction(() => pressHome(selectedSimulator.udid), false); + } }} onOpenAppSwitcher={() => { if (!selectedSimulator) { @@ -1297,12 +1323,22 @@ export function AppShell({ } setAccessibilitySelectedId(""); setAccessibilityHoveredId(null); - void runAction(() => openAppSwitcher(selectedSimulator.udid), false); + if (!sendControl(selectedSimulator.udid, { type: "appSwitcher" })) { + void runAction( + () => openAppSwitcher(selectedSimulator.udid), + false, + ); + } }} onRotateLeft={() => { if (!selectedSimulator) { return; } + if (sendControl(selectedSimulator.udid, { type: "rotateLeft" })) { + setRotationQuarterTurns((current) => (current + 3) % 4); + setStreamStamp(Date.now()); + return; + } void runAction(async () => { await rotateLeft(selectedSimulator.udid); setRotationQuarterTurns((current) => (current + 3) % 4); @@ -1315,6 +1351,11 @@ export function AppShell({ if (!selectedSimulator) { return; } + if (sendControl(selectedSimulator.udid, { type: "rotateRight" })) { + setRotationQuarterTurns((current) => (current + 1) % 4); + setStreamStamp(Date.now()); + return; + } void runAction(async () => { await rotateRight(selectedSimulator.udid); setRotationQuarterTurns((current) => (current + 1) % 4); @@ -1339,7 +1380,10 @@ export function AppShell({ if (!selectedSimulator) { return; } - void runAction(() => toggleAppearance(selectedSimulator.udid)); + const encoded = JSON.stringify({ type: "toggleAppearance" }); + if (!sendWebRtcControlMessage(encoded)) { + void runAction(() => toggleAppearance(selectedSimulator.udid)); + } }} onToggleDebug={() => setDebugVisible((current) => !current)} onToggleHierarchy={() => { diff --git a/client/src/features/simulators/useSimulatorList.ts b/client/src/features/simulators/useSimulatorList.ts index dcfa0d5e..a9492742 100644 --- a/client/src/features/simulators/useSimulatorList.ts +++ b/client/src/features/simulators/useSimulatorList.ts @@ -1,30 +1,64 @@ -import { startTransition, useEffect, useState } from "react"; +import { startTransition, useEffect, useRef, useState } from "react"; import { listSimulators } from "../../api/simulators"; import type { SimulatorMetadata } from "../../api/types"; -export function useSimulatorList() { +const LOCAL_REFRESH_MS = 5000; +const REMOTE_REFRESH_MS = 10000; +const REMOTE_ERROR_REFRESH_MS = 15000; +const REMOTE_REQUEST_TIMEOUT_MS = 12000; + +interface UseSimulatorListOptions { + remote?: boolean; +} + +export function useSimulatorList({ + remote = false, +}: UseSimulatorListOptions = {}) { const [simulators, setSimulators] = useState([]); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(""); + const inFlightRef = useRef(false); + const lastLoadFailedRef = useRef(false); async function loadSimulators(cancelled = false) { + if (inFlightRef.current) { + return; + } + inFlightRef.current = true; + const controller = + remote && typeof AbortController !== "undefined" + ? new AbortController() + : null; + const timeoutId = controller + ? window.setTimeout(() => controller.abort(), REMOTE_REQUEST_TIMEOUT_MS) + : 0; try { - const nextSimulators = await listSimulators(); + const nextSimulators = await listSimulators( + controller ? { signal: controller.signal } : {}, + ); if (cancelled) { return; } startTransition(() => setSimulators(nextSimulators)); setError(""); + lastLoadFailedRef.current = false; } catch (loadError) { if (!cancelled) { setError( - loadError instanceof Error - ? loadError.message - : "Failed to load simulators.", + loadError instanceof DOMException && loadError.name === "AbortError" + ? "Timed out waiting for provider." + : loadError instanceof Error + ? loadError.message + : "Failed to load simulators.", ); + lastLoadFailedRef.current = true; } } finally { + if (timeoutId) { + window.clearTimeout(timeoutId); + } + inFlightRef.current = false; if (!cancelled) { setIsLoading(false); } @@ -37,17 +71,33 @@ export function useSimulatorList() { useEffect(() => { let cancelled = false; + let timeoutId = 0; - void loadSimulators(); - const intervalId = window.setInterval(() => { - void loadSimulators(cancelled); - }, 5000); + const scheduleNext = () => { + if (cancelled) { + return; + } + const delay = remote + ? lastLoadFailedRef.current + ? REMOTE_ERROR_REFRESH_MS + : REMOTE_REFRESH_MS + : LOCAL_REFRESH_MS; + timeoutId = window.setTimeout(run, delay); + }; + + const run = () => { + void loadSimulators(cancelled).finally(scheduleNext); + }; + + run(); return () => { cancelled = true; - clearInterval(intervalId); + if (timeoutId) { + window.clearTimeout(timeoutId); + } }; - }, []); + }, [remote]); return { error, diff --git a/client/src/features/stream/streamTypes.ts b/client/src/features/stream/streamTypes.ts index b08e1a9a..5ed03b7c 100644 --- a/client/src/features/stream/streamTypes.ts +++ b/client/src/features/stream/streamTypes.ts @@ -1,6 +1,7 @@ import type { Size } from "../viewport/types"; export interface StreamConnectTarget { + remote?: boolean; udid: string; } diff --git a/client/src/features/stream/streamWorkerClient.ts b/client/src/features/stream/streamWorkerClient.ts index 2b420df3..368a1601 100644 --- a/client/src/features/stream/streamWorkerClient.ts +++ b/client/src/features/stream/streamWorkerClient.ts @@ -10,24 +10,44 @@ import type { const HAVE_CURRENT_DATA = 2; const WEBRTC_CONTROL_CHANNEL_LABEL = "simdeck-control"; +const WEBRTC_TELEMETRY_CHANNEL_LABEL = "simdeck-telemetry"; const WEBRTC_FIRST_FRAME_TIMEOUT_MS = 10000; const WEBRTC_STALLED_FRAME_TIMEOUT_MS = 8000; +const WEBRTC_REMOTE_DISCONNECTED_GRACE_MS = 3000; let activeWebRtcControlChannel: RTCDataChannel | null = null; +let activeWebRtcTelemetryChannel: RTCDataChannel | null = null; let activeStreamClient: StreamWorkerClient | null = null; export type StreamBackend = "webrtc"; export function sendWebRtcControlMessage(encoded: string): boolean { - if (activeWebRtcControlChannel?.readyState !== "open") { + return sendDataChannelMessage(activeWebRtcControlChannel, encoded); +} + +export function sendWebRtcClientStats(stats: unknown): boolean { + return sendDataChannelMessage( + activeWebRtcTelemetryChannel, + JSON.stringify({ stats, type: "clientStats" }), + ); +} + +function sendDataChannelMessage( + channel: RTCDataChannel | null, + encoded: string, +): boolean { + if (channel?.readyState !== "open") { return false; } - activeWebRtcControlChannel.send(encoded); + channel.send(encoded); return true; } -export function buildStreamTarget(udid: string): StreamConnectTarget { - return { udid }; +export function buildStreamTarget( + udid: string, + options: { remote?: boolean } = {}, +): StreamConnectTarget { + return { remote: options.remote, udid }; } export function canUseWebRtc(): boolean { @@ -48,12 +68,15 @@ class WebRtcStreamClient implements StreamClientBackend { private connectGeneration = 0; private controlChannel: RTCDataChannel | null = null; private diagnostics = createWebRtcDiagnostics(); + private disconnectGraceTimeout = 0; private frameWatchdogTimeout = 0; private lastVideoFrameAt = 0; private peerConnection: RTCPeerConnection | null = null; private reconnectTimeout = 0; + private remoteMode = false; private reportedVideoConfig = false; private shouldReconnect = false; + private telemetryChannel: RTCDataChannel | null = null; private stats: StreamStats = createEmptyStreamStats(); private video: HTMLVideoElement | null = null; private videoFrameCallback = 0; @@ -80,6 +103,7 @@ class WebRtcStreamClient implements StreamClientBackend { const canvasElement = this.canvas; const generation = ++this.connectGeneration; this.shouldReconnect = true; + this.remoteMode = Boolean(target.remote); this.diagnostics = createWebRtcDiagnostics(); this.reportedVideoConfig = false; this.stats = createEmptyStreamStats(); @@ -117,6 +141,20 @@ class WebRtcStreamClient implements StreamClientBackend { activeWebRtcControlChannel = null; } }); + const telemetryChannel = peerConnection.createDataChannel( + WEBRTC_TELEMETRY_CHANNEL_LABEL, + { + maxRetransmits: 0, + ordered: false, + }, + ); + this.telemetryChannel = telemetryChannel; + activeWebRtcTelemetryChannel = telemetryChannel; + telemetryChannel.addEventListener("close", () => { + if (activeWebRtcTelemetryChannel === telemetryChannel) { + activeWebRtcTelemetryChannel = null; + } + }); peerConnection.ontrack = (event) => { if (generation !== this.connectGeneration) { @@ -171,23 +209,42 @@ class WebRtcStreamClient implements StreamClientBackend { peerConnection.onconnectionstatechange = () => { this.diagnostics.peerConnectionState = peerConnection.connectionState; this.postDiagnostics(target, "connectionstatechange"); - if ( - generation === this.connectGeneration && - (peerConnection.connectionState === "failed" || - peerConnection.connectionState === "disconnected") - ) { - if (peerConnection.connectionState === "failed") { - void this.updateSelectedCandidatePair(peerConnection, target); + if (generation !== this.connectGeneration) { + return; + } + if (peerConnection.connectionState === "connected") { + this.clearDisconnectGraceTimeout(); + if (this.reportedVideoConfig) { + this.onMessage({ + type: "status", + status: { detail: "WebRTC media connected", state: "streaming" }, + }); } + return; + } + if (peerConnection.connectionState === "disconnected") { + if (this.remoteMode && this.stats.renderedFrames > 0) { + this.scheduleRemoteDisconnectGrace(target, generation); + return; + } + this.handleConnectionError( + target, + generation, + new Error("WebRTC connection disconnected."), + ); + return; + } + if (peerConnection.connectionState === "failed") { + void this.updateSelectedCandidatePair(peerConnection, target); this.handleConnectionError( target, generation, - new Error(`WebRTC connection ${peerConnection.connectionState}.`), + new Error("WebRTC connection failed."), ); } }; - const offer = await peerConnection.createOffer(); + const offer = safariBaselineH264Offer(await peerConnection.createOffer()); if (generation !== this.connectGeneration) { return; } @@ -228,6 +285,7 @@ class WebRtcStreamClient implements StreamClientBackend { this.shouldReconnect = false; this.connectGeneration += 1; this.clearReconnectTimeout(); + this.clearDisconnectGraceTimeout(); this.clearFrameWatchdog(); this.closeActiveConnection(); this.onMessage({ type: "status", status: { state: "idle" } }); @@ -241,6 +299,7 @@ class WebRtcStreamClient implements StreamClientBackend { window.cancelAnimationFrame(this.animationFrame); this.animationFrame = 0; this.clearFrameWatchdog(); + this.clearDisconnectGraceTimeout(); this.cancelVideoFrameCallback(); this.video?.pause(); if (this.video) { @@ -254,6 +313,11 @@ class WebRtcStreamClient implements StreamClientBackend { activeWebRtcControlChannel = null; } this.controlChannel = null; + this.telemetryChannel?.close(); + if (activeWebRtcTelemetryChannel === this.telemetryChannel) { + activeWebRtcTelemetryChannel = null; + } + this.telemetryChannel = null; this.peerConnection?.close(); this.peerConnection = null; } @@ -268,13 +332,46 @@ class WebRtcStreamClient implements StreamClientBackend { } const message = error instanceof Error ? error.message : String(error); this.closeActiveConnection(); + const reconnecting = this.remoteMode && this.stats.renderedFrames > 0; this.onMessage({ type: "status", - status: { error: message, state: "error" }, + status: reconnecting + ? { + detail: "Connection interrupted. Reconnecting...", + state: "connecting", + } + : { error: message, state: "error" }, }); this.scheduleReconnect(target, generation); } + private scheduleRemoteDisconnectGrace( + target: StreamConnectTarget, + generation: number, + ) { + if (this.disconnectGraceTimeout) { + return; + } + this.onMessage({ + type: "status", + status: { + detail: "Connection interrupted. Reconnecting...", + state: "connecting", + }, + }); + this.disconnectGraceTimeout = window.setTimeout(() => { + this.disconnectGraceTimeout = 0; + if (generation !== this.connectGeneration || !this.shouldReconnect) { + return; + } + this.handleConnectionError( + target, + generation, + new Error("WebRTC connection disconnected."), + ); + }, WEBRTC_REMOTE_DISCONNECTED_GRACE_MS); + } + private scheduleReconnect(target: StreamConnectTarget, generation: number) { if ( this.reconnectTimeout || @@ -340,6 +437,14 @@ class WebRtcStreamClient implements StreamClientBackend { this.reconnectTimeout = 0; } + private clearDisconnectGraceTimeout() { + if (!this.disconnectGraceTimeout) { + return; + } + window.clearTimeout(this.disconnectGraceTimeout); + this.disconnectGraceTimeout = 0; + } + private attachDiagnostics( peerConnection: RTCPeerConnection, target: StreamConnectTarget, @@ -463,6 +568,9 @@ class WebRtcStreamClient implements StreamClientBackend { url: window.location.href, userAgent: window.navigator.userAgent, }; + if (sendWebRtcClientStats(payload) || this.remoteMode) { + return; + } void fetch( new URL(apiUrl("/api/client-stream-stats"), window.location.href), { @@ -488,8 +596,8 @@ class WebRtcStreamClient implements StreamClientBackend { ) { this.syncCanvasSize(this.video.videoWidth, this.video.videoHeight); this.reportVideoConfig(this.video.videoWidth, this.video.videoHeight); - const now = performance.now(); const renderStartedAt = performance.now(); + const now = performance.now(); const latestRenderMs = performance.now() - renderStartedAt; this.stats.decodedFrames += 1; this.stats.renderedFrames += 1; @@ -634,6 +742,26 @@ function configureReceiverCodecPreferences(transceiver: RTCRtpTransceiver) { ]); } +function safariBaselineH264Offer( + offer: RTCSessionDescriptionInit, +): RTCSessionDescriptionInit { + if (!isSafariBrowser() || !offer.sdp) { + return offer; + } + return { + ...offer, + sdp: offer.sdp.replace( + /(a=fmtp:\d+ .*profile-level-id=)[0-9a-fA-F]{6}/g, + "$142e01f", + ), + }; +} + +function isSafariBrowser(): boolean { + const ua = navigator.userAgent; + return /Safari\//.test(ua) && !/Chrome\/|Chromium\/|CriOS\/|FxiOS\//.test(ua); +} + function iceServers(health?: HealthResponse | null): RTCIceServer[] { const params = new URLSearchParams(window.location.search); const queryValue = params.get("iceServers"); diff --git a/client/src/features/stream/useLiveStream.ts b/client/src/features/stream/useLiveStream.ts index 2ac94e45..2b17fe19 100644 --- a/client/src/features/stream/useLiveStream.ts +++ b/client/src/features/stream/useLiveStream.ts @@ -8,6 +8,7 @@ import { createEmptyStreamStats } from "./stats"; import { buildStreamTarget, canUseWebRtc, + sendWebRtcClientStats, StreamWorkerClient, type StreamBackend, } from "./streamWorkerClient"; @@ -20,10 +21,12 @@ import type { const FPS_SAMPLE_INTERVAL_MS = 500; const CLIENT_TELEMETRY_INTERVAL_MS = 1000; +const REMOTE_CLIENT_TELEMETRY_INTERVAL_MS = 5000; interface UseLiveStreamOptions { canvasElement: HTMLCanvasElement | null; paused?: boolean; + remote?: boolean; simulator: SimulatorMetadata | null; } @@ -67,6 +70,7 @@ function buildClientTelemetryUrl(): string { export function useLiveStream({ canvasElement, paused = false, + remote = false, simulator, }: UseLiveStreamOptions): UseLiveStreamResult { const clientTelemetryIdRef = useRef(""); @@ -237,11 +241,11 @@ export function useLiveStream({ return; } - workerClient.connect(buildStreamTarget(simulator.udid)); + workerClient.connect(buildStreamTarget(simulator.udid, { remote })); return () => { workerClient.disconnect(); }; - }, [canvasElement, simulator?.isBooted, simulator?.udid, paused]); + }, [canvasElement, simulator?.isBooted, simulator?.udid, paused, remote]); useEffect(() => { if (!simulator?.udid) { @@ -251,21 +255,25 @@ export function useLiveStream({ const postTelemetry = () => { const latestStats = latestStatsRef.current; const latestStatus = latestStatusRef.current; + const payload = { + ...latestStats, + appFps: latestFpsRef.current, + clientId: clientTelemetryIdRef.current, + focused: document.hasFocus(), + kind: "page", + pageFps: pageFpsRef.current, + status: latestStatus.state, + timestampMs: Date.now(), + udid: simulator.udid, + url: window.location.href, + userAgent: window.navigator.userAgent, + visibilityState: document.visibilityState, + }; + if (sendWebRtcClientStats(payload) || remote) { + return; + } void fetch(buildClientTelemetryUrl(), { - body: JSON.stringify({ - ...latestStats, - appFps: latestFpsRef.current, - clientId: clientTelemetryIdRef.current, - focused: document.hasFocus(), - kind: "page", - pageFps: pageFpsRef.current, - status: latestStatus.state, - timestampMs: Date.now(), - udid: simulator.udid, - url: window.location.href, - userAgent: window.navigator.userAgent, - visibilityState: document.visibilityState, - }), + body: JSON.stringify(payload), cache: "no-store", headers: apiHeaders(), method: "POST", @@ -274,15 +282,15 @@ export function useLiveStream({ }); }; + const intervalMs = remote + ? REMOTE_CLIENT_TELEMETRY_INTERVAL_MS + : CLIENT_TELEMETRY_INTERVAL_MS; postTelemetry(); - const intervalId = window.setInterval( - postTelemetry, - CLIENT_TELEMETRY_INTERVAL_MS, - ); + const intervalId = window.setInterval(postTelemetry, intervalMs); return () => { window.clearInterval(intervalId); }; - }, [simulator?.udid]); + }, [remote, simulator?.udid]); return { deviceNaturalSize, diff --git a/docs/cli/commands.md b/docs/cli/commands.md index 99a1c3ca..99016228 100644 --- a/docs/cli/commands.md +++ b/docs/cli/commands.md @@ -37,6 +37,22 @@ simdeck ui [--port 4310] [--bind 127.0.0.1] [--advertise-host ] `--open` opens the authenticated local URL after the daemon is ready. +### `studio expose` + +Expose one local simulator through SimDeck Studio: + +```sh +simdeck studio expose [simulator] [--studio-url https://simdeck.djdev.me] + [--port 4310] [--bind 127.0.0.1] + [--video-codec auto|hardware|software] + [--low-latency] [--stream-quality ] +``` + +Studio expose defaults to software H.264, realtime stream delivery, and the +`smooth` stream quality profile. The process prints the Studio URL plus the +active codec/profile, and keeps the outbound bridge alive until Ctrl-C. +`--video-codec hardware` opts back into the hardware encoder when that is preferable. + ### `daemon start` Start or reuse the project daemon without opening the browser: diff --git a/docs/cli/flags.md b/docs/cli/flags.md index 314bbf3f..fafa451a 100644 --- a/docs/cli/flags.md +++ b/docs/cli/flags.md @@ -39,6 +39,9 @@ Targets a specific running SimDeck daemon for commands that support the HTTP fas | `--stream-quality` | auto/default | Optional realtime stream quality profile: `quality`, `balanced`, `smooth`, `economy`, or `ci-software`. | | `--open` | `false` | `ui` only. Open the browser after the daemon is ready. | +`studio expose` defaults to software H.264. Pass `--video-codec hardware` to +opt into the hardware encoder when that is preferable. + The public commands generate an access token automatically. Use `simdeck daemon status` to read it for direct API callers. ## `describe` diff --git a/docs/guide/video.md b/docs/guide/video.md index 631e9902..edcef475 100644 --- a/docs/guide/video.md +++ b/docs/guide/video.md @@ -79,7 +79,10 @@ A few practical guidelines: - **Start on the default for local preview.** `auto` lets VideoToolbox choose without requiring the shared hardware encoder. - **Switch to `software` when the hardware encoder stalls or is unavailable.** The encoder scales the longest edge to 1600 pixels, can climb toward 60 fps, and backs off dynamically under encode latency. -- **Use `--stream-quality ci-software` for Studio providers on virtualized CI Macs when hardware encode is unavailable.** This profile uses software H.264 at a 960-pixel longest edge, targets 24 fps, lowers bitrate pressure, and favors fresh frames over full-resolution sharpness. +- **Studio providers default to software H.264 plus `--stream-quality smooth`.** This profile uses a 1170-pixel longest edge, allows up to 60 fps, raises the bitrate budget to reduce compression artifacts, and lets multiple provider sessions share CPU cores without depending on one hardware encoder. +- **The remote browser renders the live stream as a native `