diff --git a/README.md b/README.md index 009983a..c5050c5 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,13 @@ more important than full-resolution smoothness: simdeck daemon start --video-codec software --low-latency ``` +Local browser streams default to 60 fps. On high-refresh local displays, opt in +to a paced hardware stream up to 120 fps: + +```sh +simdeck daemon restart --local-stream-fps 120 +``` + Restart the CoreSimulator service layer when `simctl` reports a stale service version or the live display gets stuck before the first frame: diff --git a/cli/XCWH264Encoder.m b/cli/XCWH264Encoder.m index e405e52..3f0a2ba 100644 --- a/cli/XCWH264Encoder.m +++ b/cli/XCWH264Encoder.m @@ -14,6 +14,8 @@ static const int32_t XCWTargetRealTimeFrameRate = 60; static const int32_t XCWTargetRealtimeHardwareFrameRate = 30; static const int32_t XCWTargetSoftwareFrameRate = 60; +static const int32_t XCWMinimumLocalStreamFrameRate = 15; +static const int32_t XCWMaximumLocalStreamFrameRate = 120; static const int32_t XCWTargetLowLatencySoftwareFrameRate = 15; static const NSUInteger XCWMaximumInFlightFrames = 2; static const int32_t XCWMinimumAverageBitRate = 18000000; @@ -137,6 +139,22 @@ static uint64_t XCWRealtimeMaximumFrameIntervalUs(void) { return MAX(XCWRealtimeFrameIntervalUs() * 2, XCWRealtimeFrameIntervalUs()); } +static int32_t XCWLocalStreamTargetFrameRate(void) { + return XCWIntFromEnvironment(@"SIMDECK_LOCAL_STREAM_FPS", + XCWTargetRealTimeFrameRate, + XCWMinimumLocalStreamFrameRate, + XCWMaximumLocalStreamFrameRate); +} + +static uint64_t XCWLocalStreamFrameIntervalUs(void) { + int32_t fps = MAX(1, XCWLocalStreamTargetFrameRate()); + return (uint64_t)llround(1000000.0 / (double)fps); +} + +static uint64_t XCWLocalStreamMaximumFrameIntervalUs(void) { + return MAX(XCWSoftwareMaximumFrameIntervalUs, XCWLocalStreamFrameIntervalUs()); +} + static int64_t XCWRealtimeBitsPerPixelBudgetValue(void) { return XCWInt64FromEnvironment(@"SIMDECK_REALTIME_BITS_PER_PIXEL", XCWRealtimeBitsPerPixelBudget, @@ -490,10 +508,10 @@ @implementation XCWH264Encoder { uint64_t _lastSoftwareSubmissionUs; NSUInteger _softwarePacedFrameCount; NSUInteger _softwareHealthyFrameCount; - uint64_t _realtimeHardwareFrameIntervalUs; - uint64_t _lastRealtimeHardwareSubmissionUs; - NSUInteger _realtimeHardwarePacedFrameCount; - NSUInteger _realtimeHardwareHealthyFrameCount; + uint64_t _hardwareFrameIntervalUs; + uint64_t _lastHardwareSubmissionUs; + NSUInteger _hardwarePacedFrameCount; + NSUInteger _hardwareHealthyFrameCount; NSString *_selectedEncoderID; NSInteger _lastSessionStatus; NSInteger _lastPrepareStatus; @@ -518,7 +536,7 @@ - (instancetype)initWithOutputHandler:(XCWH264EncoderOutputHandler)outputHandler _realtimeStreamMode = XCWRealtimeStreamModeFromEnvironment() || _lowLatencyMode; _codecType = XCWVideoCodecTypeForMode(_encoderMode); _softwareFrameIntervalUs = [self initialSoftwareFrameIntervalUsLocked]; - _realtimeHardwareFrameIntervalUs = [self initialHardwareFrameIntervalUsLocked]; + _hardwareFrameIntervalUs = [self initialHardwareFrameIntervalUsLocked]; return self; } @@ -568,9 +586,9 @@ - (void)reconfigureForStreamQualityChange { self->_softwareFrameIntervalUs = [self initialSoftwareFrameIntervalUsLocked]; self->_softwarePacedFrameCount = 0; self->_softwareHealthyFrameCount = 0; - self->_realtimeHardwareFrameIntervalUs = [self initialHardwareFrameIntervalUsLocked]; - self->_realtimeHardwarePacedFrameCount = 0; - self->_realtimeHardwareHealthyFrameCount = 0; + self->_hardwareFrameIntervalUs = [self initialHardwareFrameIntervalUsLocked]; + self->_hardwarePacedFrameCount = 0; + self->_hardwareHealthyFrameCount = 0; }); } @@ -597,9 +615,13 @@ - (NSDictionary *)statsRepresentation { @"softwareFrameIntervalUs": @(self->_softwareFrameIntervalUs), @"softwareTargetFps": @(self->_softwareFrameIntervalUs > 0 ? (1000000.0 / (double)self->_softwareFrameIntervalUs) : 0.0), @"softwarePacedFrames": @(self->_softwarePacedFrameCount), - @"realtimeHardwareFrameIntervalUs": @(self->_realtimeHardwareFrameIntervalUs), - @"realtimeHardwareTargetFps": @(self->_realtimeHardwareFrameIntervalUs > 0 ? (1000000.0 / (double)self->_realtimeHardwareFrameIntervalUs) : 0.0), - @"realtimeHardwarePacedFrames": @(self->_realtimeHardwarePacedFrameCount), + @"localStreamTargetFps": @(XCWLocalStreamTargetFrameRate()), + @"hardwareFrameIntervalUs": @(self->_hardwareFrameIntervalUs), + @"hardwareTargetFps": @(self->_hardwareFrameIntervalUs > 0 ? (1000000.0 / (double)self->_hardwareFrameIntervalUs) : 0.0), + @"hardwarePacedFrames": @(self->_hardwarePacedFrameCount), + @"realtimeHardwareFrameIntervalUs": @(self->_hardwareFrameIntervalUs), + @"realtimeHardwareTargetFps": @(self->_hardwareFrameIntervalUs > 0 ? (1000000.0 / (double)self->_hardwareFrameIntervalUs) : 0.0), + @"realtimeHardwarePacedFrames": @(self->_hardwarePacedFrameCount), @"transportCodec": XCWCodecName(self->_codecType), @"encoderMode": XCWVideoEncoderModeName(self->_encoderMode), @"lowLatencyMode": @(self->_lowLatencyMode), @@ -665,15 +687,15 @@ - (NSUInteger)softwareHealthyFrameWindowLocked { } - (uint64_t)minimumHardwareFrameIntervalUsLocked { - return _realtimeStreamMode ? XCWRealtimeFrameIntervalUs() : XCWSoftwareMinimumFrameIntervalUs; + return _realtimeStreamMode ? XCWRealtimeFrameIntervalUs() : XCWLocalStreamFrameIntervalUs(); } - (uint64_t)initialHardwareFrameIntervalUsLocked { - return _realtimeStreamMode ? XCWRealtimeFrameIntervalUs() : XCWSoftwareInitialFrameIntervalUs; + return _realtimeStreamMode ? XCWRealtimeFrameIntervalUs() : XCWLocalStreamFrameIntervalUs(); } - (uint64_t)maximumHardwareFrameIntervalUsLocked { - return _realtimeStreamMode ? XCWRealtimeMaximumFrameIntervalUs() : XCWSoftwareMaximumFrameIntervalUs; + return _realtimeStreamMode ? XCWRealtimeMaximumFrameIntervalUs() : XCWLocalStreamMaximumFrameIntervalUs(); } - (int32_t)expectedFrameRateLocked { @@ -686,24 +708,24 @@ - (int32_t)expectedFrameRateLocked { if (_realtimeStreamMode) { return XCWRealtimeTargetFrameRate(); } - return XCWTargetRealTimeFrameRate; + return XCWLocalStreamTargetFrameRate(); } -- (BOOL)shouldPaceRealtimeHardwareFrameAtTimeUs:(uint64_t)nowUs { - if ((_encoderMode != XCWVideoEncoderModeAuto && _encoderMode != XCWVideoEncoderModeH264Hardware) || !_realtimeStreamMode || _needsKeyFrame) { +- (BOOL)shouldPaceHardwareFrameAtTimeUs:(uint64_t)nowUs { + if ((_encoderMode != XCWVideoEncoderModeAuto && _encoderMode != XCWVideoEncoderModeH264Hardware) || _needsKeyFrame) { return NO; } - if (_realtimeHardwareFrameIntervalUs == 0) { - _realtimeHardwareFrameIntervalUs = [self initialHardwareFrameIntervalUsLocked]; + if (_hardwareFrameIntervalUs == 0) { + _hardwareFrameIntervalUs = [self initialHardwareFrameIntervalUsLocked]; } - if (_lastRealtimeHardwareSubmissionUs == 0) { + if (_lastHardwareSubmissionUs == 0) { return NO; } - uint64_t elapsedUs = nowUs >= _lastRealtimeHardwareSubmissionUs ? nowUs - _lastRealtimeHardwareSubmissionUs : 0; - if (elapsedUs >= _realtimeHardwareFrameIntervalUs) { + uint64_t elapsedUs = nowUs >= _lastHardwareSubmissionUs ? nowUs - _lastHardwareSubmissionUs : 0; + if (elapsedUs >= _hardwareFrameIntervalUs) { return NO; } - _realtimeHardwarePacedFrameCount += 1; + _hardwarePacedFrameCount += 1; return YES; } @@ -764,41 +786,41 @@ - (void)adaptSoftwarePacingForLatencyUs:(uint64_t)latencyUs { _softwareHealthyFrameCount = 0; } -- (void)adaptRealtimeHardwarePacingForLatencyUs:(uint64_t)latencyUs { - if ((_encoderMode != XCWVideoEncoderModeAuto && _encoderMode != XCWVideoEncoderModeH264Hardware) || !_realtimeStreamMode || latencyUs == 0) { +- (void)adaptHardwarePacingForLatencyUs:(uint64_t)latencyUs { + if ((_encoderMode != XCWVideoEncoderModeAuto && _encoderMode != XCWVideoEncoderModeH264Hardware) || latencyUs == 0) { return; } - if (_realtimeHardwareFrameIntervalUs == 0) { - _realtimeHardwareFrameIntervalUs = [self initialHardwareFrameIntervalUsLocked]; + if (_hardwareFrameIntervalUs == 0) { + _hardwareFrameIntervalUs = [self initialHardwareFrameIntervalUsLocked]; } uint64_t minimumIntervalUs = [self minimumHardwareFrameIntervalUsLocked]; uint64_t maximumIntervalUs = [self maximumHardwareFrameIntervalUsLocked]; - if (latencyUs > _realtimeHardwareFrameIntervalUs) { - uint64_t nextIntervalUs = _realtimeHardwareFrameIntervalUs + XCWRealtimeHardwareFrameIntervalStepUs; + if (latencyUs > _hardwareFrameIntervalUs) { + uint64_t nextIntervalUs = _hardwareFrameIntervalUs + XCWRealtimeHardwareFrameIntervalStepUs; uint64_t latencyBoundIntervalUs = latencyUs + XCWRealtimeHardwareFrameIntervalStepUs; if (nextIntervalUs < latencyBoundIntervalUs) { nextIntervalUs = latencyBoundIntervalUs; } - _realtimeHardwareFrameIntervalUs = MIN(nextIntervalUs, maximumIntervalUs); - _realtimeHardwareHealthyFrameCount = 0; + _hardwareFrameIntervalUs = MIN(nextIntervalUs, maximumIntervalUs); + _hardwareHealthyFrameCount = 0; return; } - if (latencyUs < _realtimeHardwareFrameIntervalUs && - _realtimeHardwareFrameIntervalUs > minimumIntervalUs) { - _realtimeHardwareHealthyFrameCount += 1; - if (_realtimeHardwareHealthyFrameCount >= XCWRealtimeHardwareHealthyFrameWindow) { - uint64_t nextIntervalUs = _realtimeHardwareFrameIntervalUs > XCWRealtimeHardwareFrameIntervalStepUs - ? _realtimeHardwareFrameIntervalUs - XCWRealtimeHardwareFrameIntervalStepUs + if (latencyUs < _hardwareFrameIntervalUs && + _hardwareFrameIntervalUs > minimumIntervalUs) { + _hardwareHealthyFrameCount += 1; + if (_hardwareHealthyFrameCount >= XCWRealtimeHardwareHealthyFrameWindow) { + uint64_t nextIntervalUs = _hardwareFrameIntervalUs > XCWRealtimeHardwareFrameIntervalStepUs + ? _hardwareFrameIntervalUs - XCWRealtimeHardwareFrameIntervalStepUs : minimumIntervalUs; - _realtimeHardwareFrameIntervalUs = MAX(nextIntervalUs, minimumIntervalUs); - _realtimeHardwareHealthyFrameCount = 0; + _hardwareFrameIntervalUs = MAX(nextIntervalUs, minimumIntervalUs); + _hardwareHealthyFrameCount = 0; } return; } - _realtimeHardwareHealthyFrameCount = 0; + _hardwareHealthyFrameCount = 0; } - (void)drainPendingFramesLocked { @@ -839,7 +861,7 @@ - (BOOL)encodePixelBufferLocked:(CVPixelBufferRef)pixelBuffer { } uint64_t nowUs = (uint64_t)(CACurrentMediaTime() * 1000000.0); - if ([self shouldPaceSoftwareFrameAtTimeUs:nowUs] || [self shouldPaceRealtimeHardwareFrameAtTimeUs:nowUs]) { + if ([self shouldPaceSoftwareFrameAtTimeUs:nowUs] || [self shouldPaceHardwareFrameAtTimeUs:nowUs]) { return YES; } @@ -885,8 +907,8 @@ - (BOOL)encodePixelBufferLocked:(CVPixelBufferRef)pixelBuffer { _submittedFrameCount += 1; if (_encoderMode == XCWVideoEncoderModeH264Software) { _lastSoftwareSubmissionUs = nowUs; - } else if ((_encoderMode == XCWVideoEncoderModeAuto || _encoderMode == XCWVideoEncoderModeH264Hardware) && _realtimeStreamMode) { - _lastRealtimeHardwareSubmissionUs = nowUs; + } else if (_encoderMode == XCWVideoEncoderModeAuto || _encoderMode == XCWVideoEncoderModeH264Hardware) { + _lastHardwareSubmissionUs = nowUs; } _maxInFlightFrameCount = MAX(_maxInFlightFrameCount, _inFlightFrameCount); if (_encoderMode == XCWVideoEncoderModeH264Software || !_realtimeStreamMode) { @@ -1013,7 +1035,7 @@ - (void)invalidateCompressionSessionLocked { _timestampOriginUs = 0; _inFlightFrameCount = 0; _lastSoftwareSubmissionUs = 0; - _lastRealtimeHardwareSubmissionUs = 0; + _lastHardwareSubmissionUs = 0; _hardwareAccelerated = NO; _selectedEncoderID = nil; [self invalidateScalingResourcesLocked]; @@ -1203,7 +1225,7 @@ - (void)handleEncodedSampleBuffer:(CMSampleBufferRef)sampleBuffer uint64_t nowUs = (uint64_t)(CACurrentMediaTime() * 1000000.0); _latestEncodeLatencyUs = nowUs >= submittedAtUs ? nowUs - submittedAtUs : 0; [self adaptSoftwarePacingForLatencyUs:_latestEncodeLatencyUs]; - [self adaptRealtimeHardwarePacingForLatencyUs:_latestEncodeLatencyUs]; + [self adaptHardwarePacingForLatencyUs:_latestEncodeLatencyUs]; } NSString *codec = nil; NSData *decoderConfig = nil; diff --git a/client/src/features/stream/streamTypes.ts b/client/src/features/stream/streamTypes.ts index 5ed03b7..ba07a6e 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 { + clientId?: string; remote?: boolean; udid: string; } diff --git a/client/src/features/stream/streamWorkerClient.ts b/client/src/features/stream/streamWorkerClient.ts index f08fb78..c24571a 100644 --- a/client/src/features/stream/streamWorkerClient.ts +++ b/client/src/features/stream/streamWorkerClient.ts @@ -12,8 +12,9 @@ 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_DISCONNECTED_GRACE_MS = 8000; +const WEBRTC_STALLED_FRAME_TIMEOUT_MS = 15000; +const WEBRTC_REMOTE_RECEIVER_BUFFER_SECONDS = 0.06; +const WEBRTC_DISCONNECTED_GRACE_MS = 30000; const WEBRTC_RECONNECT_BASE_DELAY_MS = 3000; const WEBRTC_RECONNECT_MAX_DELAY_MS = 10000; @@ -47,9 +48,9 @@ function sendDataChannelMessage( export function buildStreamTarget( udid: string, - options: { remote?: boolean } = {}, + options: { clientId?: string; remote?: boolean } = {}, ): StreamConnectTarget { - return { remote: options.remote, udid }; + return { clientId: options.clientId, remote: options.remote, udid }; } export function canUseWebRtc(): boolean { @@ -145,7 +146,10 @@ class WebRtcStreamClient implements StreamClientBackend { direction: "recvonly", }); configureReceiverCodecPreferences(transceiver); - configureLowLatencyReceiver(transceiver.receiver); + configureLowLatencyReceiver( + transceiver.receiver, + target.remote ? WEBRTC_REMOTE_RECEIVER_BUFFER_SECONDS : null, + ); const controlChannel = peerConnection.createDataChannel( WEBRTC_CONTROL_CHANNEL_LABEL, { @@ -180,7 +184,10 @@ class WebRtcStreamClient implements StreamClientBackend { } event.track.contentHint = "motion"; for (const receiver of peerConnection.getReceivers()) { - configureLowLatencyReceiver(receiver); + configureLowLatencyReceiver( + receiver, + target.remote ? WEBRTC_REMOTE_RECEIVER_BUFFER_SECONDS : null, + ); } const stream = event.streams[0] ?? new MediaStream([event.track]); const video = document.createElement("video"); @@ -431,7 +438,7 @@ class WebRtcStreamClient implements StreamClientBackend { const hasRenderedFrame = this.stats.renderedFrames > 0; const frameAgeMs = this.lastVideoFrameAt > 0 ? now - this.lastVideoFrameAt : Infinity; - if (!hasRenderedFrame || frameAgeMs > WEBRTC_STALLED_FRAME_TIMEOUT_MS) { + if (!hasRenderedFrame) { this.handleConnectionError( target, generation, @@ -439,6 +446,14 @@ class WebRtcStreamClient implements StreamClientBackend { ); return; } + if (frameAgeMs > WEBRTC_STALLED_FRAME_TIMEOUT_MS) { + this.handleConnectionError( + target, + generation, + new Error("WebRTC video stalled after rendering frames."), + ); + return; + } this.scheduleFrameWatchdog(target, generation); }, this.stats.renderedFrames > 0 @@ -575,7 +590,7 @@ class WebRtcStreamClient implements StreamClientBackend { private postDiagnostics(target: StreamConnectTarget, detail: string) { const payload = { ...this.stats, - clientId: "webrtc-page", + clientId: target.clientId ?? "webrtc-page", connectionId: this.connectGeneration, detail, iceConnectionState: this.diagnostics.iceConnectionState, @@ -778,16 +793,22 @@ function postWebRtcOffer( ); } -function configureLowLatencyReceiver(receiver: RTCRtpReceiver) { +function configureLowLatencyReceiver( + receiver: RTCRtpReceiver, + bufferSeconds: number | null, +) { + if (!bufferSeconds || bufferSeconds <= 0) { + return; + } const lowLatencyReceiver = receiver as RTCRtpReceiver & { jitterBufferTarget?: number; playoutDelayHint?: number; }; if ("jitterBufferTarget" in lowLatencyReceiver) { - lowLatencyReceiver.jitterBufferTarget = 0.001; + lowLatencyReceiver.jitterBufferTarget = bufferSeconds; } if ("playoutDelayHint" in lowLatencyReceiver) { - lowLatencyReceiver.playoutDelayHint = 0.001; + lowLatencyReceiver.playoutDelayHint = bufferSeconds; } } diff --git a/client/src/features/stream/useLiveStream.ts b/client/src/features/stream/useLiveStream.ts index 9852b5e..4b6fc62 100644 --- a/client/src/features/stream/useLiveStream.ts +++ b/client/src/features/stream/useLiveStream.ts @@ -242,7 +242,12 @@ export function useLiveStream({ return; } - workerClient.connect(buildStreamTarget(simulator.udid, { remote })); + workerClient.connect( + buildStreamTarget(simulator.udid, { + clientId: clientTelemetryIdRef.current, + remote, + }), + ); return () => { workerClient.disconnect(); }; diff --git a/docs/api/health.md b/docs/api/health.md index d9397d8..91e3e34 100644 --- a/docs/api/health.md +++ b/docs/api/health.md @@ -28,6 +28,7 @@ Returns the static bootstrap information the browser client needs, plus a freshn | `videoCodec` | Requested encoder mode. One of `auto`, `hardware`, or `software`. See [Video Pipeline](/guide/video). | | `lowLatency` | `true` when software H.264 low-latency mode was enabled at daemon startup. | | `realtimeStream` | `true` when the WebRTC stream is configured to favor freshness and realtime pacing. | +| `localStreamFps` | Local quality stream frame cap, from 15 to 120 fps. Defaults to 60. | | `streamQuality` | Active realtime quality profile and encoder limits such as `maxEdge`, `fps`, and bitrate. | | `webRtc.iceServers` | ICE servers the browser should use when creating the WebRTC peer connection. | | `webRtc.iceTransportPolicy` | Browser ICE transport policy. One of `all` or `relay`. | diff --git a/docs/cli/commands.md b/docs/cli/commands.md index 9901622..0d34efa 100644 --- a/docs/cli/commands.md +++ b/docs/cli/commands.md @@ -32,7 +32,8 @@ Start or reuse the project daemon and serve the browser UI. ```sh simdeck ui [--port 4310] [--bind 127.0.0.1] [--advertise-host ] [--client-root ] [--video-codec auto|hardware|software] - [--low-latency] [--stream-quality ] [--open] + [--low-latency] [--stream-quality ] + [--local-stream-fps <15-120>] [--open] ``` `--open` opens the authenticated local URL after the daemon is ready. @@ -61,7 +62,7 @@ Start or reuse the project daemon without opening the browser: simdeck daemon start [--port 4310] [--bind 127.0.0.1] [--advertise-host ] [--client-root ] [--video-codec auto|hardware|software] [--low-latency] - [--stream-quality ] + [--stream-quality ] [--local-stream-fps <15-120>] ``` Output: @@ -97,7 +98,7 @@ options as `daemon start`: simdeck daemon restart [--port 4310] [--bind 127.0.0.1] [--advertise-host ] [--client-root ] [--video-codec auto|hardware|software] [--low-latency] - [--stream-quality ] + [--stream-quality ] [--local-stream-fps <15-120>] ``` ### `daemon stop` @@ -126,7 +127,8 @@ that starts after login and stays available. simdeck service on [--port 4310] [--bind 127.0.0.1] [--advertise-host ] [--client-root ] [--video-codec auto|hardware|software] [--low-latency] - [--stream-quality ] [--access-token ] + [--stream-quality ] [--local-stream-fps <15-120>] + [--access-token ] simdeck service restart [same options as service on] simdeck service off ``` diff --git a/docs/cli/flags.md b/docs/cli/flags.md index fafa451..dabccc9 100644 --- a/docs/cli/flags.md +++ b/docs/cli/flags.md @@ -28,16 +28,17 @@ Targets a specific running SimDeck daemon for commands that support the HTTP fas `ui`, `daemon start`, and `daemon restart` accept the same server options. `ui` also accepts `--open`. -| Flag | Default | Description | -| ------------------ | --------------------- | ------------------------------------------------------------------------------------------------------- | -| `--port ` | `4310` | HTTP port for the REST API, browser UI, and WebRTC offer endpoint. | -| `--bind ` | `127.0.0.1` | Bind address (`0.0.0.0` for [LAN access](/guide/lan-access), `::` for IPv6). | -| `--advertise-host` | matches local host | Hostname or IP printed for LAN browser access. | -| `--client-root` | bundled `client/dist` | Override the static browser client directory. | -| `--video-codec` | `auto` | One of `auto`, `hardware`, or `software`. See [Video Pipeline](/guide/video). | -| `--low-latency` | `false` | Software H.264 profile for slower runners: caps at 15 fps and favors freshness. | -| `--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. | +| Flag | Default | Description | +| -------------------- | --------------------- | ------------------------------------------------------------------------------------------------------- | +| `--port ` | `4310` | HTTP port for the REST API, browser UI, and WebRTC offer endpoint. | +| `--bind ` | `127.0.0.1` | Bind address (`0.0.0.0` for [LAN access](/guide/lan-access), `::` for IPv6). | +| `--advertise-host` | matches local host | Hostname or IP printed for LAN browser access. | +| `--client-root` | bundled `client/dist` | Override the static browser client directory. | +| `--video-codec` | `auto` | One of `auto`, `hardware`, or `software`. See [Video Pipeline](/guide/video). | +| `--low-latency` | `false` | Software H.264 profile for slower runners: caps at 15 fps and favors freshness. | +| `--stream-quality` | auto/default | Optional realtime stream quality profile: `quality`, `balanced`, `smooth`, `economy`, or `ci-software`. | +| `--local-stream-fps` | `60` | Local quality stream frame cap, from 15 to 120 fps. | +| `--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. diff --git a/docs/guide/daemon.md b/docs/guide/daemon.md index a023214..b0334be 100644 --- a/docs/guide/daemon.md +++ b/docs/guide/daemon.md @@ -54,16 +54,17 @@ This starts or reuses the project daemon, serves the bundled browser client, and `daemon start`, `daemon restart`, and `ui` accept the same server options: -| Flag | Default | Notes | -| ------------------ | --------------------- | ----------------------------------------------------------------------------------- | -| `--port ` | `4310` | HTTP port for the REST API, browser UI, and WebRTC offer endpoint. | -| `--bind ` | `127.0.0.1` | Bind address. Use `0.0.0.0` for [LAN access](/guide/lan-access). | -| `--advertise-host` | matches local host | Hostname or IP advertised to browser clients. | -| `--client-root` | bundled `client/dist` | Override the static browser client directory. | -| `--video-codec` | `auto` | One of `auto`, `hardware`, or `software`. See [Video](/guide/video). | -| `--low-latency` | `false` | Software H.264 profile for slower runners; caps at 15 fps and drops stale frames. | -| `--stream-quality` | auto/default | Optional realtime stream quality profile, including `ci-software` for CI providers. | -| `--open` | `false` | `ui` only. Open the browser after the daemon is ready. | +| Flag | Default | Notes | +| -------------------- | --------------------- | ----------------------------------------------------------------------------------- | +| `--port ` | `4310` | HTTP port for the REST API, browser UI, and WebRTC offer endpoint. | +| `--bind ` | `127.0.0.1` | Bind address. Use `0.0.0.0` for [LAN access](/guide/lan-access). | +| `--advertise-host` | matches local host | Hostname or IP advertised to browser clients. | +| `--client-root` | bundled `client/dist` | Override the static browser client directory. | +| `--video-codec` | `auto` | One of `auto`, `hardware`, or `software`. See [Video](/guide/video). | +| `--low-latency` | `false` | Software H.264 profile for slower runners; caps at 15 fps and drops stale frames. | +| `--stream-quality` | auto/default | Optional realtime stream quality profile, including `ci-software` for CI providers. | +| `--local-stream-fps` | `60` | Local quality stream frame cap, from 15 to 120 fps. | +| `--open` | `false` | `ui` only. Open the browser after the daemon is ready. | Example: diff --git a/docs/guide/video.md b/docs/guide/video.md index edcef47..ed142a6 100644 --- a/docs/guide/video.md +++ b/docs/guide/video.md @@ -78,6 +78,7 @@ The WebRTC path favors freshness: stale frames are dropped and the sender reques A few practical guidelines: - **Start on the default for local preview.** `auto` lets VideoToolbox choose without requiring the shared hardware encoder. +- **Use `--local-stream-fps 120` only for local high-refresh testing.** The local quality stream defaults to 60 fps; higher caps 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`.** 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 `