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
78 changes: 60 additions & 18 deletions cli/XCWH264Encoder.m
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,10 @@
static const double XCWHardwareFallbackLoadPercent = 500.0;
static const NSUInteger XCWHardwareFallbackConsecutiveOverBudgetFrameThreshold = 60;
static const uint64_t XCWAutoHardwareRetryIntervalUs = 10000000;
static const NSUInteger XCWMaximumAutoHardwareEncoders = 1;
static void *XCWH264EncoderQueueSpecificKey = &XCWH264EncoderQueueSpecificKey;
static os_unfair_lock XCWAutoHardwareEncoderLock = OS_UNFAIR_LOCK_INIT;
static NSUInteger XCWActiveAutoHardwareEncoderCount = 0;

typedef NS_ENUM(NSUInteger, XCWVideoEncoderMode) {
XCWVideoEncoderModeAuto,
Expand Down Expand Up @@ -575,6 +578,8 @@ - (void)recordEncodeLatencyLockedWithSubmittedAtUs:(uint64_t)submittedAtUs measu
- (void)invalidateX264EncoderLocked;
- (void)handleCompressionOutputSampleBuffer:(CMSampleBufferRef)sampleBuffer
submittedAtUs:(uint64_t)submittedAtUs;
- (BOOL)acquireAutoHardwareSlotIfNeededLocked;
- (void)releaseAutoHardwareSlotIfNeededLocked;
- (uint64_t)activeFrameIntervalUsLocked;
- (uint64_t)encoderLatencyBudgetUsLocked;
- (uint64_t)pacingDelayBeforeNextFrameAtTimeUs:(uint64_t)nowUs;
Expand Down Expand Up @@ -603,6 +608,7 @@ @implementation XCWH264Encoder {
BOOL _scalingActive;
XCWVideoEncoderMode _encoderMode;
XCWVideoEncoderMode _activeEncoderMode;
BOOL _holdsAutoHardwareSlot;
BOOL _clientForeground;
BOOL _acceptingFrameInput;
BOOL _lowLatencyMode;
Expand Down Expand Up @@ -725,6 +731,7 @@ - (void)requestKeyFrame {

- (void)reconfigureForStreamQualityChange {
dispatch_async(_queue, ^{
[self releaseAutoHardwareSlotIfNeededLocked];
[self invalidateCompressionSessionLocked];
self->_encoderMode = XCWVideoEncoderModeFromEnvironment();
self->_activeEncoderMode = self->_encoderMode;
Expand Down Expand Up @@ -761,6 +768,7 @@ - (void)setClientForeground:(BOOL)foreground {
}
os_unfair_lock_unlock(&self->_pendingLock);
if (!foreground) {
[self releaseAutoHardwareSlotIfNeededLocked];
[self invalidateCompressionSessionLocked];
self->_needsKeyFrame = YES;
return;
Expand Down Expand Up @@ -881,6 +889,7 @@ - (NSDictionary *)statsRepresentation {
@"encoderMode": XCWVideoEncoderModeName(self->_encoderMode),
@"activeEncoderMode": XCWVideoEncoderModeName(self->_activeEncoderMode),
@"clientForeground": @(self->_clientForeground),
@"autoHardwareSlot": @(self->_holdsAutoHardwareSlot),
@"autoSoftwareFallbackActive": @(autoSoftwareFallbackActive),
@"autoSoftwareFallbackRemainingUs": @(autoSoftwareFallbackRemainingUs),
@"autoSoftwareFallbacks": @(self->_autoSoftwareFallbackCount),
Expand All @@ -902,6 +911,7 @@ - (NSDictionary *)statsRepresentation {
- (void)invalidate {
dispatch_sync(_queue, ^{
[self drainPendingFramesLocked];
[self releaseAutoHardwareSlotIfNeededLocked];
[self invalidateCompressionSessionLocked];
});

Expand Down Expand Up @@ -976,10 +986,44 @@ - (void)resetAutoFallbackLatencyStateLocked {
_wasOverloaded = NO;
}

- (BOOL)acquireAutoHardwareSlotIfNeededLocked {
if (_encoderMode != XCWVideoEncoderModeAuto || !_clientForeground) {
return NO;
}
if (_holdsAutoHardwareSlot) {
return YES;
}

BOOL acquired = NO;
os_unfair_lock_lock(&XCWAutoHardwareEncoderLock);
if (XCWActiveAutoHardwareEncoderCount < XCWMaximumAutoHardwareEncoders) {
XCWActiveAutoHardwareEncoderCount += 1;
acquired = YES;
}
os_unfair_lock_unlock(&XCWAutoHardwareEncoderLock);
_holdsAutoHardwareSlot = acquired;
return acquired;
}

- (void)releaseAutoHardwareSlotIfNeededLocked {
if (!_holdsAutoHardwareSlot) {
return;
}
os_unfair_lock_lock(&XCWAutoHardwareEncoderLock);
if (XCWActiveAutoHardwareEncoderCount > 0) {
XCWActiveAutoHardwareEncoderCount -= 1;
}
os_unfair_lock_unlock(&XCWAutoHardwareEncoderLock);
_holdsAutoHardwareSlot = NO;
}

- (void)switchActiveEncoderModeLocked:(XCWVideoEncoderMode)mode {
if (_activeEncoderMode == mode) {
return;
}
if (mode != XCWVideoEncoderModeAuto) {
[self releaseAutoHardwareSlotIfNeededLocked];
}
_activeEncoderMode = mode;
_codecType = XCWVideoCodecTypeForMode(_activeEncoderMode);
if (_activeEncoderMode == XCWVideoEncoderModeH264Software) {
Expand All @@ -996,17 +1040,27 @@ - (void)switchActiveEncoderModeLocked:(XCWVideoEncoderMode)mode {
}

- (void)updateActiveEncoderModeForClientForegroundLockedAtTimeUs:(uint64_t)nowUs {
if (_encoderMode == XCWVideoEncoderModeAuto &&
_autoSoftwareFallbackUntilUs != 0 &&
nowUs < _autoSoftwareFallbackUntilUs) {
if (_encoderMode != XCWVideoEncoderModeAuto) {
[self switchActiveEncoderModeLocked:_encoderMode];
return;
}
if (!_clientForeground) {
[self switchActiveEncoderModeLocked:XCWVideoEncoderModeH264Software];
return;
}
if (_autoSoftwareFallbackUntilUs != 0 && nowUs < _autoSoftwareFallbackUntilUs) {
[self switchActiveEncoderModeLocked:XCWVideoEncoderModeH264Software];
return;
}
if (_encoderMode == XCWVideoEncoderModeAuto && _autoSoftwareFallbackUntilUs != 0) {
if (_autoSoftwareFallbackUntilUs != 0) {
_autoSoftwareFallbackUntilUs = 0;
_autoHardwareRetryCount += 1;
}
[self switchActiveEncoderModeLocked:_encoderMode];
if ([self acquireAutoHardwareSlotIfNeededLocked]) {
[self switchActiveEncoderModeLocked:XCWVideoEncoderModeAuto];
} else {
[self switchActiveEncoderModeLocked:XCWVideoEncoderModeH264Software];
}
}

- (void)enterAutoSoftwareFallbackLockedAtTimeUs:(uint64_t)nowUs {
Expand All @@ -1019,18 +1073,6 @@ - (void)enterAutoSoftwareFallbackLockedAtTimeUs:(uint64_t)nowUs {
[self switchActiveEncoderModeLocked:XCWVideoEncoderModeH264Software];
}

- (void)retryAutoHardwareIfNeededLockedAtTimeUs:(uint64_t)nowUs {
if (![self isAutoSoftwareFallbackActiveLocked] ||
!_clientForeground ||
_autoSoftwareFallbackUntilUs == 0 ||
nowUs < _autoSoftwareFallbackUntilUs) {
return;
}
_autoSoftwareFallbackUntilUs = 0;
_autoHardwareRetryCount += 1;
[self switchActiveEncoderModeLocked:XCWVideoEncoderModeAuto];
}

- (uint64_t)activeFrameIntervalUsLocked {
if (_activeEncoderMode == XCWVideoEncoderModeH264Software) {
return _softwareFrameIntervalUs > 0 ? _softwareFrameIntervalUs : [self initialSoftwareFrameIntervalUsLocked];
Expand Down Expand Up @@ -1238,7 +1280,7 @@ - (BOOL)encodePixelBufferLocked:(CVPixelBufferRef)pixelBuffer {
}

uint64_t nowUs = (uint64_t)(CACurrentMediaTime() * 1000000.0);
[self retryAutoHardwareIfNeededLockedAtTimeUs:nowUs];
[self updateActiveEncoderModeForClientForegroundLockedAtTimeUs:nowUs];

CGSize targetSize = XCWScaledDimensionsForSourceSize(sourceWidth, sourceHeight, _activeEncoderMode, _lowLatencyMode, _realtimeStreamMode);
int32_t targetWidth = (int32_t)targetSize.width;
Expand Down
1 change: 1 addition & 0 deletions client/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export interface EncoderStats {
activeEncoderMode?: string;
averageEncodeLatencyUs?: number;
averageEncoderLoadPercent?: number;
autoHardwareSlot?: boolean;
autoHardwareRetries?: number;
autoSoftwareFallbackActive?: boolean;
autoSoftwareFallbackRemainingUs?: number;
Expand Down
45 changes: 43 additions & 2 deletions client/src/features/stream/useLiveStream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,10 @@ function isDocumentForeground(): boolean {
return document.visibilityState === "visible";
}

function isViewerForeground(canvasVisible: boolean): boolean {
return isDocumentForeground() && canvasVisible;
}

export function useLiveStream({
canvasElement,
paused = false,
Expand All @@ -122,6 +126,7 @@ export function useLiveStream({
const retainedFrameRef = useRef(false);
const previousSimulatorUdidRef = useRef<string | undefined>(simulator?.udid);
const connectedStreamTargetKeyRef = useRef("");
const canvasVisibleRef = useRef(true);
const latestVisualArtifactRef = useRef<VisualArtifactSample | null>(null);
const latestVisualArtifactSampleCountRef = useRef(0);
const lastVisualArtifactSampleAtRef = useRef(0);
Expand Down Expand Up @@ -223,6 +228,40 @@ export function useLiveStream({
};
}, []);

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

const sendCanvasForegroundState = () => {
workerClientRef.current?.sendStreamControl({
clientId: clientTelemetryIdRef.current,
foreground: isViewerForeground(canvasVisibleRef.current),
});
};

if (typeof IntersectionObserver !== "function") {
canvasVisibleRef.current = true;
sendCanvasForegroundState();
return;
}

const observer = new IntersectionObserver(
(entries) => {
const entry = entries[entries.length - 1];
canvasVisibleRef.current = Boolean(
entry?.isIntersecting && entry.intersectionRatio > 0,
);
sendCanvasForegroundState();
},
{ threshold: [0, 0.01] },
);
observer.observe(canvasElement);
return () => {
observer.disconnect();
};
}, [canvasElement, paused, simulator?.udid]);

useEffect(() => {
latestDecodedFramesRef.current = stats.decodedFrames;
latestRenderedFramesRef.current = stats.renderedFrames;
Expand Down Expand Up @@ -355,7 +394,9 @@ export function useLiveStream({
return;
}

const sendForegroundState = (foreground = isDocumentForeground()) => {
const sendForegroundState = (
foreground = isViewerForeground(canvasVisibleRef.current),
) => {
workerClientRef.current?.sendStreamControl({
clientId: clientTelemetryIdRef.current,
foreground,
Expand Down Expand Up @@ -449,7 +490,7 @@ export function useLiveStream({
};
workerClientRef.current?.sendStreamControl({
clientId: clientTelemetryIdRef.current,
foreground: isDocumentForeground(),
foreground: isViewerForeground(canvasVisibleRef.current),
});
if (
sendStreamClientStats(payload) ||
Expand Down
9 changes: 9 additions & 0 deletions client/src/features/toolbar/DebugPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,15 @@ export function DebugPanel({
: "no"
: "—",
},
{
label: "Auto HW Slot",
value:
typeof encoder.autoHardwareSlot === "boolean"
? encoder.autoHardwareSlot
? "yes"
: "no"
: "—",
},
{ label: "Encoder State", value: encoder.overloadState ?? "—" },
{
label: "Encoder Load",
Expand Down
12 changes: 12 additions & 0 deletions docs/guide/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,3 +100,15 @@ Include simulator refresh traffic:
```sh
npm run test:stress -- --udid <udid> --iterations 2000 --concurrency 16
```

## Stress Test Daemon Cleanup

```sh
npm run build:cli
npm run test:stress:daemon -- --iterations 30 --concurrency 3
```

This starts isolated temporary project daemons, hits health and metrics, stops
them through the CLI, and verifies the process group, listener port, and daemon
status are cleaned up. Use `--binary /path/to/simdeck` to test an installed or
packaged binary.
16 changes: 16 additions & 0 deletions docs/guide/video.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,17 @@ SimDeck streams live device video to the browser. Local sessions default to high

iOS simulator H.264 uses VideoToolbox for hardware encoding and x264 for software encoding.

## When Encoding Runs

SimDeck starts encoding when a browser stream needs H.264 frames. The server
requests an initial keyframe to answer the WebRTC or H.264 WebSocket viewer,
then keeps a shared refresh pump active while frame subscribers exist.

The browser reports whether the page and stream canvas are foreground. When all
known viewers are hidden or the last frame subscriber disconnects, the native
session pauses encoder input and releases the active compression session. A
visible viewer, explicit refresh, or stream reconnect asks for a fresh keyframe.

## Pick A Stream Quality

Start with the default:
Expand Down Expand Up @@ -47,6 +58,11 @@ simdeck daemon restart --video-codec software
| `hardware` | Dedicated local machines where VideoToolbox hardware H.264 is reliable. |
| `software` | x264 software H.264 for CI, screen recording conflicts, or hardware encoder stalls. |

When multiple simulator streams run at the same time, `auto` keeps one active
stream on the hardware encoder path and routes additional active auto streams to
software encoding. This avoids saturating the shared VideoToolbox hardware
encoder while preserving explicit `--video-codec hardware` behavior.

For very constrained software sessions:

```sh
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@
"test:studio-provider": "node --test scripts/studio-provider-bridge.test.mjs scripts/studio-host-provider.test.mjs",
"test:github-actions": "node --test scripts/github-actions.test.mjs",
"test:stress": "node scripts/stress/simdeck.mjs",
"test:stress:daemon": "node scripts/stress/daemon-cleanup.mjs",
"bench:encoder:build": "scripts/bench/build-encoder-benchmark.sh",
"codex:setup": "node scripts/codex-setup.mjs",
"codex:cache:save": "node scripts/codex-worktree-cache.mjs save --best-effort",
Expand Down
Loading
Loading