Skip to content

Commit c340112

Browse files
authored
Default Studio expose to software H.264 and smooth streaming (#13)
* Update SimDeck agent instructions * Update SimDeck agent instructions * Smooth Studio provider startup polling * Route remote realtime controls over WebRTC * Reduce touch latency and align Safari H264 * Send touch edges on low latency RTC input * Restore ordered touch control path * Accept software codec compatibility alias
1 parent a6446b3 commit c340112

25 files changed

Lines changed: 758 additions & 166 deletions

File tree

README.md

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -77,11 +77,14 @@ simdeck studio expose "iPhone 17 Pro"
7777

7878
The command starts or reuses the local daemon, creates an ephemeral Studio
7979
session, prints a unique `https://simdeck.djdev.me/simulator/...` URL, and keeps
80-
the outbound bridge alive until you press Ctrl-C. It uses `auto` mode by default,
81-
letting VideoToolbox choose the encoder. Pass `--video-codec software` when you
82-
need to force software encoding; Studio then defaults to the `ci-software`
83-
stream quality profile (`960` longest edge at `24` fps). Use
84-
`--stream-quality quality|balanced|smooth|economy|ci-software` to override it.
80+
the outbound bridge alive until you press Ctrl-C. It uses software H.264 by
81+
default with realtime stream settings for remote viewing, and prints the active
82+
codec/profile when it starts. Studio defaults to the `smooth` stream quality
83+
profile (`1170` longest edge, dynamic up to `60` fps). Use
84+
`--stream-quality quality|balanced|smooth|economy|ci-software` to override it,
85+
or pass `--video-codec hardware` when a dedicated hardware encoder is preferable.
86+
The remote viewer renders live video with the browser's native video element;
87+
the canvas is only used for input geometry.
8588

8689
CLI commands automatically use the same warm daemon:
8790

cli/XCWH264Encoder.m

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -933,8 +933,8 @@ - (BOOL)ensureCompressionSessionWithWidth:(int32_t)width height:(int32_t)height
933933
}
934934
VTSessionSetProperty(session, kVTCompressionPropertyKey_ExpectedFrameRate, (__bridge CFTypeRef)@(expectedFrameRate));
935935
BOOL shortKeyframeInterval = _lowLatencyMode || _realtimeStreamMode;
936-
VTSessionSetProperty(session, kVTCompressionPropertyKey_MaxKeyFrameInterval, (__bridge CFTypeRef)@(shortKeyframeInterval ? expectedFrameRate : expectedFrameRate * 2));
937-
VTSessionSetProperty(session, kVTCompressionPropertyKey_MaxKeyFrameIntervalDuration, (__bridge CFTypeRef)@(shortKeyframeInterval ? 1.0 : 2.0));
936+
VTSessionSetProperty(session, kVTCompressionPropertyKey_MaxKeyFrameInterval, (__bridge CFTypeRef)@(shortKeyframeInterval ? MAX(1, expectedFrameRate / 2) : expectedFrameRate * 2));
937+
VTSessionSetProperty(session, kVTCompressionPropertyKey_MaxKeyFrameIntervalDuration, (__bridge CFTypeRef)@(shortKeyframeInterval ? 0.5 : 2.0));
938938
VTSessionSetProperty(session, kVTCompressionPropertyKey_AverageBitRate, (__bridge CFTypeRef)@(averageBitRate));
939939
if (_realtimeStreamMode) {
940940
NSArray *dataRateLimits = @[

client/src/api/controls.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,13 @@ import type {
1111

1212
export type ControlMessage =
1313
| ({ type: "touch" } & TouchPayload)
14-
| ({ type: "key" } & KeyPayload);
14+
| ({ type: "key" } & KeyPayload)
15+
| { type: "dismissKeyboard" }
16+
| { type: "home" }
17+
| { type: "appSwitcher" }
18+
| { type: "rotateLeft" }
19+
| { type: "rotateRight" }
20+
| { type: "toggleAppearance" };
1521

1622
async function postSimulatorAction(
1723
udid: string,

client/src/api/simulators.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,10 @@ import type {
99
SimulatorsResponse,
1010
} from "./types";
1111

12-
export async function listSimulators(): Promise<SimulatorMetadata[]> {
13-
const data = await apiRequest<SimulatorsResponse>("/api/simulators");
12+
export async function listSimulators(
13+
options: RequestInit = {},
14+
): Promise<SimulatorMetadata[]> {
15+
const data = await apiRequest<SimulatorsResponse>("/api/simulators", options);
1416
return data.simulators ?? [];
1517
}
1618

client/src/app/AppShell.tsx

Lines changed: 52 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,15 @@ function buildAuthenticatedAssetUrl(path: string, stamp: number): string {
111111
return url.toString();
112112
}
113113

114+
function shouldUseRemoteStreamDefault(apiRoot: string): boolean {
115+
if (apiRoot) {
116+
return true;
117+
}
118+
return (
119+
new URLSearchParams(window.location.search).get("remoteStream") === "1"
120+
);
121+
}
122+
114123
function shouldRenderNativeChrome(simulator: SimulatorMetadata): boolean {
115124
const identifier = simulator.deviceTypeIdentifier ?? "";
116125
const name = simulator.name ?? "";
@@ -177,13 +186,15 @@ export interface AppShellProps {
177186
fixedSimulatorUDID?: string | null;
178187
hideSimulatorSelection?: boolean;
179188
pairingEnabled?: boolean;
189+
remoteStream?: boolean;
180190
}
181191

182192
export function AppShell({
183193
apiRoot = "",
184194
fixedSimulatorUDID = null,
185195
hideSimulatorSelection = false,
186196
pairingEnabled = true,
197+
remoteStream = shouldUseRemoteStreamDefault(apiRoot),
187198
}: AppShellProps = {}) {
188199
configureSimDeckClient({ apiRoot });
189200
const [initialUiState] = useState(readPersistedUiState);
@@ -201,7 +212,7 @@ export function AppShell({
201212
isLoading,
202213
refresh,
203214
simulators,
204-
} = useSimulatorList();
215+
} = useSimulatorList({ remote: remoteStream });
205216
const [debugVisible, setDebugVisible] = useState(false);
206217
const [hierarchyVisible, setHierarchyVisible] = useState(() =>
207218
readStoredFlag(HIERARCHY_VISIBLE_STORAGE_KEY),
@@ -370,6 +381,7 @@ export function AppShell({
370381
streamCanvasKey,
371382
} = useLiveStream({
372383
canvasElement: streamCanvasElement,
384+
remote: remoteStream,
373385
simulator: selectedSimulator,
374386
});
375387

@@ -843,9 +855,13 @@ export function AppShell({
843855
pairingEnabled &&
844856
listError === AUTH_REQUIRED_MESSAGE &&
845857
!accessTokenFromLocation();
858+
const visibleListError =
859+
remoteStream && hasFrame && listError === "Failed to fetch"
860+
? ""
861+
: listError;
846862
const error = pairingRequired
847863
? localError || streamError
848-
: localError || streamError || listError;
864+
: localError || streamError || visibleListError;
849865
const deviceTransform = `translate(${pan.x}px, ${pan.y + autoViewportOffsetY}px) scale(${effectiveZoom})`;
850866
const chromeScreenRect = computeChromeScreenRect(
851867
viewportChromeProfile,
@@ -979,18 +995,19 @@ export function AppShell({
979995
return state;
980996
}, []);
981997

982-
function sendControl(udid: string, message: ControlMessage) {
998+
function sendControl(udid: string, message: ControlMessage): boolean {
983999
setLocalError("");
9841000
const encoded = JSON.stringify(message);
9851001
if (sendWebRtcControlMessage(encoded)) {
986-
return;
1002+
return true;
9871003
}
9881004
const state = ensureControlSocket(udid);
9891005
if (state.socket.readyState === WebSocket.OPEN) {
9901006
state.socket.send(encoded);
9911007
} else {
9921008
state.pending.push(encoded);
9931009
}
1010+
return true;
9941011
}
9951012

9961013
useEffect(() => closeControlSocket, [closeControlSocket]);
@@ -1281,28 +1298,47 @@ export function AppShell({
12811298
if (!selectedSimulator) {
12821299
return;
12831300
}
1284-
void runAction(() => dismissKeyboard(selectedSimulator.udid), false);
1301+
if (
1302+
!sendControl(selectedSimulator.udid, { type: "dismissKeyboard" })
1303+
) {
1304+
void runAction(
1305+
() => dismissKeyboard(selectedSimulator.udid),
1306+
false,
1307+
);
1308+
}
12851309
}}
12861310
onHome={() => {
12871311
if (!selectedSimulator) {
12881312
return;
12891313
}
12901314
setAccessibilitySelectedId("");
12911315
setAccessibilityHoveredId(null);
1292-
void runAction(() => pressHome(selectedSimulator.udid), false);
1316+
if (!sendControl(selectedSimulator.udid, { type: "home" })) {
1317+
void runAction(() => pressHome(selectedSimulator.udid), false);
1318+
}
12931319
}}
12941320
onOpenAppSwitcher={() => {
12951321
if (!selectedSimulator) {
12961322
return;
12971323
}
12981324
setAccessibilitySelectedId("");
12991325
setAccessibilityHoveredId(null);
1300-
void runAction(() => openAppSwitcher(selectedSimulator.udid), false);
1326+
if (!sendControl(selectedSimulator.udid, { type: "appSwitcher" })) {
1327+
void runAction(
1328+
() => openAppSwitcher(selectedSimulator.udid),
1329+
false,
1330+
);
1331+
}
13011332
}}
13021333
onRotateLeft={() => {
13031334
if (!selectedSimulator) {
13041335
return;
13051336
}
1337+
if (sendControl(selectedSimulator.udid, { type: "rotateLeft" })) {
1338+
setRotationQuarterTurns((current) => (current + 3) % 4);
1339+
setStreamStamp(Date.now());
1340+
return;
1341+
}
13061342
void runAction(async () => {
13071343
await rotateLeft(selectedSimulator.udid);
13081344
setRotationQuarterTurns((current) => (current + 3) % 4);
@@ -1315,6 +1351,11 @@ export function AppShell({
13151351
if (!selectedSimulator) {
13161352
return;
13171353
}
1354+
if (sendControl(selectedSimulator.udid, { type: "rotateRight" })) {
1355+
setRotationQuarterTurns((current) => (current + 1) % 4);
1356+
setStreamStamp(Date.now());
1357+
return;
1358+
}
13181359
void runAction(async () => {
13191360
await rotateRight(selectedSimulator.udid);
13201361
setRotationQuarterTurns((current) => (current + 1) % 4);
@@ -1339,7 +1380,10 @@ export function AppShell({
13391380
if (!selectedSimulator) {
13401381
return;
13411382
}
1342-
void runAction(() => toggleAppearance(selectedSimulator.udid));
1383+
const encoded = JSON.stringify({ type: "toggleAppearance" });
1384+
if (!sendWebRtcControlMessage(encoded)) {
1385+
void runAction(() => toggleAppearance(selectedSimulator.udid));
1386+
}
13431387
}}
13441388
onToggleDebug={() => setDebugVisible((current) => !current)}
13451389
onToggleHierarchy={() => {

client/src/features/simulators/useSimulatorList.ts

Lines changed: 62 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,64 @@
1-
import { startTransition, useEffect, useState } from "react";
1+
import { startTransition, useEffect, useRef, useState } from "react";
22

33
import { listSimulators } from "../../api/simulators";
44
import type { SimulatorMetadata } from "../../api/types";
55

6-
export function useSimulatorList() {
6+
const LOCAL_REFRESH_MS = 5000;
7+
const REMOTE_REFRESH_MS = 10000;
8+
const REMOTE_ERROR_REFRESH_MS = 15000;
9+
const REMOTE_REQUEST_TIMEOUT_MS = 12000;
10+
11+
interface UseSimulatorListOptions {
12+
remote?: boolean;
13+
}
14+
15+
export function useSimulatorList({
16+
remote = false,
17+
}: UseSimulatorListOptions = {}) {
718
const [simulators, setSimulators] = useState<SimulatorMetadata[]>([]);
819
const [isLoading, setIsLoading] = useState(true);
920
const [error, setError] = useState("");
21+
const inFlightRef = useRef(false);
22+
const lastLoadFailedRef = useRef(false);
1023

1124
async function loadSimulators(cancelled = false) {
25+
if (inFlightRef.current) {
26+
return;
27+
}
28+
inFlightRef.current = true;
29+
const controller =
30+
remote && typeof AbortController !== "undefined"
31+
? new AbortController()
32+
: null;
33+
const timeoutId = controller
34+
? window.setTimeout(() => controller.abort(), REMOTE_REQUEST_TIMEOUT_MS)
35+
: 0;
1236
try {
13-
const nextSimulators = await listSimulators();
37+
const nextSimulators = await listSimulators(
38+
controller ? { signal: controller.signal } : {},
39+
);
1440
if (cancelled) {
1541
return;
1642
}
1743
startTransition(() => setSimulators(nextSimulators));
1844
setError("");
45+
lastLoadFailedRef.current = false;
1946
} catch (loadError) {
2047
if (!cancelled) {
2148
setError(
22-
loadError instanceof Error
23-
? loadError.message
24-
: "Failed to load simulators.",
49+
loadError instanceof DOMException && loadError.name === "AbortError"
50+
? "Timed out waiting for provider."
51+
: loadError instanceof Error
52+
? loadError.message
53+
: "Failed to load simulators.",
2554
);
55+
lastLoadFailedRef.current = true;
2656
}
2757
} finally {
58+
if (timeoutId) {
59+
window.clearTimeout(timeoutId);
60+
}
61+
inFlightRef.current = false;
2862
if (!cancelled) {
2963
setIsLoading(false);
3064
}
@@ -37,17 +71,33 @@ export function useSimulatorList() {
3771

3872
useEffect(() => {
3973
let cancelled = false;
74+
let timeoutId = 0;
4075

41-
void loadSimulators();
42-
const intervalId = window.setInterval(() => {
43-
void loadSimulators(cancelled);
44-
}, 5000);
76+
const scheduleNext = () => {
77+
if (cancelled) {
78+
return;
79+
}
80+
const delay = remote
81+
? lastLoadFailedRef.current
82+
? REMOTE_ERROR_REFRESH_MS
83+
: REMOTE_REFRESH_MS
84+
: LOCAL_REFRESH_MS;
85+
timeoutId = window.setTimeout(run, delay);
86+
};
87+
88+
const run = () => {
89+
void loadSimulators(cancelled).finally(scheduleNext);
90+
};
91+
92+
run();
4593

4694
return () => {
4795
cancelled = true;
48-
clearInterval(intervalId);
96+
if (timeoutId) {
97+
window.clearTimeout(timeoutId);
98+
}
4999
};
50-
}, []);
100+
}, [remote]);
51101

52102
return {
53103
error,

client/src/features/stream/streamTypes.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { Size } from "../viewport/types";
22

33
export interface StreamConnectTarget {
4+
remote?: boolean;
45
udid: string;
56
}
67

0 commit comments

Comments
 (0)