Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cli/XCWH264Encoder.h
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ typedef void (^XCWH264EncoderOutputHandler)(NSData *sampleData,
- (void)encodePixelBuffer:(CVPixelBufferRef)pixelBuffer;
- (void)requestKeyFrame;
- (void)reconfigureForStreamQualityChange;
- (void)setClientForeground:(BOOL)foreground;
- (NSDictionary *)statsRepresentation;
- (void)invalidate;

Expand Down
165 changes: 142 additions & 23 deletions cli/XCWH264Encoder.m

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions cli/XCWPrivateSimulatorSession.h
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ typedef void (^XCWPrivateSimulatorEncodedFrameHandler)(NSData *sampleData,
- (void)requestKeyFrameRefresh;
- (void)requestFrameRefresh;
- (void)reconfigureVideoEncoder;
- (void)setClientForeground:(BOOL)foreground;
- (NSDictionary *)videoEncoderStats;
- (id)addEncodedFrameListener:(XCWPrivateSimulatorEncodedFrameHandler)handler;
- (void)removeEncodedFrameListener:(id)token;
Expand Down
8 changes: 8 additions & 0 deletions cli/XCWPrivateSimulatorSession.m
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,14 @@ - (void)reconfigureVideoEncoder {
[self refreshCurrentFrame];
}

- (void)setClientForeground:(BOOL)foreground {
[_videoEncoder setClientForeground:foreground];
if (foreground) {
[_videoEncoder requestKeyFrame];
[self refreshCurrentFrame];
}
}

- (NSDictionary *)videoEncoderStats {
return [_videoEncoder statsRepresentation];
}
Expand Down
1 change: 1 addition & 0 deletions cli/native/XCWNativeBridge.h
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ bool xcw_native_session_start(void * _Nonnull handle, char * _Nullable * _Nullab
void xcw_native_session_request_refresh(void * _Nonnull handle);
void xcw_native_session_request_keyframe(void * _Nonnull handle);
void xcw_native_session_reconfigure_video_encoder(void * _Nonnull handle);
void xcw_native_session_set_client_foreground(void * _Nonnull handle, bool foreground);
char * _Nullable xcw_native_session_video_encoder_stats(void * _Nonnull handle, char * _Nullable * _Nullable error_message);
int32_t xcw_native_session_rotation_quarter_turns(void * _Nonnull handle);
bool xcw_native_session_send_touch(void * _Nonnull handle, double x, double y, const char * _Nonnull phase, char * _Nullable * _Nullable error_message);
Expand Down
6 changes: 6 additions & 0 deletions cli/native/XCWNativeBridge.m
Original file line number Diff line number Diff line change
Expand Up @@ -937,6 +937,12 @@ void xcw_native_session_reconfigure_video_encoder(void *handle) {
}
}

void xcw_native_session_set_client_foreground(void *handle, bool foreground) {
@autoreleasepool {
[XCWNativeSessionFromHandle(handle) setClientForeground:foreground];
}
}

char *xcw_native_session_video_encoder_stats(void *handle, char **error_message) {
@autoreleasepool {
return XCWJSONStringFromObject([XCWNativeSessionFromHandle(handle) videoEncoderStats] ?: @{}, error_message);
Expand Down
1 change: 1 addition & 0 deletions cli/native/XCWNativeSession.h
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ NS_ASSUME_NONNULL_BEGIN
- (void)requestRefresh;
- (void)requestKeyFrame;
- (void)reconfigureVideoEncoder;
- (void)setClientForeground:(BOOL)foreground;
- (NSDictionary *)videoEncoderStats;
- (NSInteger)rotationQuarterTurns;
- (BOOL)sendTouchAtX:(double)x
Expand Down
4 changes: 4 additions & 0 deletions cli/native/XCWNativeSession.m
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,10 @@ - (void)reconfigureVideoEncoder {
[self.session reconfigureVideoEncoder];
}

- (void)setClientForeground:(BOOL)foreground {
[self.session setClientForeground:foreground];
}

- (NSDictionary *)videoEncoderStats {
return [self.session videoEncoderStats];
}
Expand Down
6 changes: 6 additions & 0 deletions client/src/api/types.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
export interface EncoderStats {
activeEncoderMode?: string;
averageEncodeLatencyUs?: number;
averageEncoderLoadPercent?: number;
autoHardwareRetries?: number;
autoSoftwareFallbackActive?: boolean;
autoSoftwareFallbackRemainingUs?: number;
autoSoftwareFallbacks?: number;
clientForeground?: boolean;
consecutiveOverBudgetFrames?: number;
encoderBudgetUs?: number;
encoderLoadPercent?: number;
Expand Down
9 changes: 8 additions & 1 deletion client/src/features/stream/streamWorkerClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,9 @@ export function sendStreamClientStats(stats: unknown): boolean {
}

export function sendWebRtcStreamControl(options: {
clientId?: string;
forceKeyframe?: boolean;
foreground?: boolean;
snapshot?: boolean;
}): boolean {
return sendDataChannelMessage(
Expand Down Expand Up @@ -3018,7 +3020,12 @@ export class StreamWorkerClient {
return (await this.backend?.collectVisualArtifactSample?.(udid)) ?? null;
}

sendStreamControl(options: { forceKeyframe?: boolean; snapshot?: boolean }) {
sendStreamControl(options: {
clientId?: string;
forceKeyframe?: boolean;
foreground?: boolean;
snapshot?: boolean;
}) {
return Boolean(
this.backend?.sendControl?.({ ...options, type: "streamControl" }),
);
Expand Down
45 changes: 45 additions & 0 deletions client/src/features/stream/useLiveStream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,10 @@ function currentClientBundle(): string {
);
}

function isDocumentForeground(): boolean {
return document.visibilityState === "visible" && document.hasFocus();
}

export function useLiveStream({
canvasElement,
paused = false,
Expand Down Expand Up @@ -346,6 +350,43 @@ export function useLiveStream({
streamTransport,
]);

useEffect(() => {
if (!simulator?.udid || paused) {
return;
}

const sendForegroundState = (foreground = isDocumentForeground()) => {
workerClientRef.current?.sendStreamControl({
clientId: clientTelemetryIdRef.current,
foreground,
});
};

const sendCurrentForegroundState = () => {
sendForegroundState();
};
const sendBackgroundState = () => {
sendForegroundState(false);
};

sendCurrentForegroundState();
document.addEventListener("visibilitychange", sendCurrentForegroundState);
window.addEventListener("focus", sendCurrentForegroundState);
window.addEventListener("blur", sendCurrentForegroundState);
window.addEventListener("pageshow", sendCurrentForegroundState);
window.addEventListener("pagehide", sendBackgroundState);
return () => {
document.removeEventListener(
"visibilitychange",
sendCurrentForegroundState,
);
window.removeEventListener("focus", sendCurrentForegroundState);
window.removeEventListener("blur", sendCurrentForegroundState);
window.removeEventListener("pageshow", sendCurrentForegroundState);
window.removeEventListener("pagehide", sendBackgroundState);
};
}, [paused, simulator?.udid, streamTransport]);

useEffect(() => {
if (
streamConfigApplyKey <= 0 ||
Expand Down Expand Up @@ -410,6 +451,10 @@ export function useLiveStream({
visualSampleCount: latestVisualArtifactSampleCountRef.current,
visibilityState: document.visibilityState,
};
workerClientRef.current?.sendStreamControl({
clientId: clientTelemetryIdRef.current,
foreground: isDocumentForeground(),
});
if (
sendStreamClientStats(payload) ||
remote ||
Expand Down
10 changes: 10 additions & 0 deletions client/src/features/toolbar/DebugPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,16 @@ export function DebugPanel({
if (encoder) {
rows.push(
{ label: "Encoder", value: encoder.encoderMode ?? "—" },
{ label: "Active Encoder", value: encoder.activeEncoderMode ?? "—" },
{
label: "Client Foreground",
value:
typeof encoder.clientForeground === "boolean"
? encoder.clientForeground
? "yes"
: "no"
: "—",
},
{ label: "Encoder State", value: encoder.overloadState ?? "—" },
{
label: "Encoder Load",
Expand Down
14 changes: 12 additions & 2 deletions docs/api/health.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,10 @@ Returns a snapshot of every server-side counter and the rolling buffer of client
{
"udid": "9D7E5BB7-...",
"encoder": {
"encoderMode": "hardware",
"encoderMode": "auto",
"activeEncoderMode": "hardware",
"clientForeground": true,
"autoSoftwareFallbackActive": false,
"hardwareAccelerated": true,
"overloadState": "nominal",
"averageEncoderLoadPercent": 42.1,
Expand Down Expand Up @@ -113,7 +116,14 @@ is derived from native VideoToolbox submit-to-output latency:

This is an inferred pressure signal rather than a private macOS hardware queue
counter. It is useful for deciding when to lower stream resolution/FPS or switch
from hardware to software encoding.
from hardware to software encoding. When `encoderMode` is `auto`, SimDeck uses
this signal to temporarily rebuild the active session with software H.264 if the
hardware encoder is overloaded, then retries hardware after a cooldown. The
`activeEncoderMode`, `autoSoftwareFallbackActive`, `autoSoftwareFallbacks`, and
`autoHardwareRetries` fields expose that state. `clientForeground` is `false`
when all current browser viewers for the simulator are hidden or unfocused; in
that state the native session uses software H.264 until a viewer returns
foreground.

### Client stream stats

Expand Down
12 changes: 12 additions & 0 deletions docs/api/rest.md
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,14 @@ stream attached to that peer:
{ "type": "streamControl", "forceKeyframe": true }
```

Clients can also report page focus/visibility through stream control. When all
current viewers for a simulator report `foreground: false`, the native session
uses software H.264 until a viewer returns foreground:

```json
{ "type": "streamControl", "clientId": "browser", "foreground": false }
```

```json
{ "type": "streamQuality", "config": { "profile": "low", "fps": 30 } }
```
Expand Down Expand Up @@ -258,6 +266,10 @@ socket:
{ "type": "streamControl", "forceKeyframe": true }
```

```json
{ "type": "streamControl", "clientId": "browser", "foreground": false }
```

```json
{ "type": "streamQuality", "config": { "profile": "low", "fps": 30 } }
```
Expand Down
2 changes: 1 addition & 1 deletion docs/guide/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ sudo xcode-select -s /Applications/Xcode.app

The encoder did not produce a keyframe within 3 seconds. The most common causes:

- **VideoToolbox is busy.** macOS screen recording can starve the hardware H.264 encoder. Switch to software H.264:
- **VideoToolbox is busy.** macOS screen recording can starve the hardware H.264 encoder. Auto mode detects sustained hardware encode overload and temporarily falls back to software H.264. For a fully software-only run, start with:

```sh
simdeck daemon stop
Expand Down
27 changes: 20 additions & 7 deletions docs/guide/video.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,11 @@ SimDeck streams the iOS Simulator over WebRTC using browser-native H.264 video p

The server can encode the simulator display in three modes, picked at startup with `--video-codec`:

| Value | Encoder | When to use it |
| ------------------ | ------------------------------------ | -------------------------------------------------------------------- |
| `auto` _(default)_ | VideoToolbox chooses the encoder | Normal local and remote preview. Does not require hardware encoding. |
| `hardware` | Required hardware H.264 | Use only when the hardware encoder is known to be available. |
| `software` | Software-only H.264 via VideoToolbox | Use when hardware encode stalls, is unavailable, or must be avoided. |
| Value | Encoder | When to use it |
| ------------------ | ------------------------------------ | ------------------------------------------------------------------------------------------- |
| `auto` _(default)_ | VideoToolbox chooses the encoder | Normal local and remote preview. Falls back to software when hardware encode is overloaded. |
| `hardware` | Required hardware H.264 | Use only when the hardware encoder is known to be available. |
| `software` | Software-only H.264 via VideoToolbox | Use when hardware encode stalls, is unavailable, or must be avoided. |

Restart the daemon to change encoder mode:

Expand All @@ -32,6 +32,13 @@ It is CLI-only because it is meant for less capable machines where freshness
matters more than maximum smoothness.

The requested encoder mode is reported to clients in the JSON `videoCodec` field on `GET /api/health`.
In `auto`, active simulator encoder metrics also report `activeEncoderMode`.
When the hardware encoder is overloaded, `auto` rebuilds the active compression
session as software H.264, then periodically retries hardware and stays there
when the measured encode latency returns to budget.
If all current browser viewers for a simulator are backgrounded or unfocused,
the active session also switches to software H.264 until at least one viewer is
foreground again.
The browser UI exposes stream controls for encoder, FPS, transport, and resolution. H264 resolution choices are `full` (4096 px at 60 fps), `balanced` (1280 px at 60 fps), `economy` (1080 px at 30 fps), `low` (720 px at 30 fps), and `tiny` (540 px at 30 fps). Local H264 WebSocket sessions default to full resolution at 60 fps. Remote browser sessions default to software H.264, 30 fps, and adaptive quality.

## Remote WebRTC ICE
Expand Down Expand Up @@ -119,7 +126,8 @@ The WebRTC path favors freshness: stale frames are dropped and the sender reques

A few practical guidelines:

- **Start on the default for local preview.** Browser realtime mode uses VideoToolbox H.264 with full resolution at 60 fps. Pass `--video-codec software` only when the shared hardware encoder is unavailable or performs worse on that host.
- **Start on the default for local preview.** Browser realtime mode uses VideoToolbox H.264 with full resolution at 60 fps. Auto mode moves active sessions to software when hardware H.264 is overloaded, then retries hardware after a cooldown.
- **Backgrounded web clients use software.** The browser sends page visibility/focus over the stream control channel. If every active viewer for a simulator is hidden or unfocused, the native session uses software H.264 until a viewer returns foreground.
- **Use `--local-stream-fps` above 60 only for local high-refresh testing.** The local quality stream defaults to 60 fps; higher targets pace both capture refresh and hardware encode submission so the stream does not build delay by pushing unbounded frames.
- **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.
- **Studio providers default to software H.264 plus `--stream-quality smooth`.** `smooth` is an internal/provider profile, not a browser picker item. It 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.
Expand Down Expand Up @@ -161,7 +169,12 @@ active simulator session. `strained` means encode latency is approaching the
active frame budget; `overloaded` means smoothed latency is over budget or
multiple frames in a row exceeded the budget. For hardware H.264 this usually
means the shared VideoToolbox encoder is saturated; lower resolution/FPS or
switch to software H.264.
switch to software H.264. In `auto`, `activeEncoderMode`,
`autoSoftwareFallbackActive`, `autoSoftwareFallbacks`, and
`autoHardwareRetries` show whether the active session is temporarily using
software H.264 while the hardware path cools down. `clientForeground` shows
whether any current browser viewer is visible and focused; when it is `false`,
the active encoder is software regardless of the requested mode.

Clients can also push their decoder/renderer stats back to the server. Browser
clients normally send these over the WebRTC telemetry data channel or the H264
Expand Down
5 changes: 5 additions & 0 deletions server/native_stubs.c
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,11 @@ void xcw_native_session_reconfigure_video_encoder(void *handle) {
(void)handle;
}

void xcw_native_session_set_client_foreground(void *handle, bool foreground) {
(void)handle;
(void)foreground;
}

char *xcw_native_session_video_encoder_stats(void *handle,
char **error_message) {
(void)handle;
Expand Down
Loading
Loading