Skip to content

Commit 9864f62

Browse files
committed
Tune Studio expose for realtime H264
1 parent 2c95119 commit 9864f62

6 files changed

Lines changed: 105 additions & 23 deletions

File tree

README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,8 +78,9 @@ simdeck studio expose "iPhone 17 Pro"
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
8080
the outbound bridge alive until you press Ctrl-C. It uses hardware H.264 by
81-
default; pass `--low-latency` to switch to software H.264's low-latency profile
82-
for slower Macs or shared runners.
81+
default with realtime stream settings for remote viewing; pass `--low-latency`
82+
to switch to software H.264's low-latency profile for slower Macs or shared
83+
runners.
8384

8485
CLI commands automatically use the same warm daemon:
8586

cli/XCWH264Encoder.m

Lines changed: 30 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,16 +8,19 @@
88
#include <stdlib.h>
99

1010
static const int32_t XCWMaximumEncodedDimension = 1920;
11+
static const int32_t XCWMaximumRealtimeHardwareEncodedDimension = 1600;
1112
static const int32_t XCWMaximumSoftwareEncodedDimension = 1600;
1213
static const int32_t XCWMaximumLowLatencySoftwareEncodedDimension = 1170;
1314
static const int32_t XCWTargetRealTimeFrameRate = 60;
1415
static const int32_t XCWTargetSoftwareFrameRate = 60;
1516
static const int32_t XCWTargetLowLatencySoftwareFrameRate = 15;
1617
static const NSUInteger XCWMaximumInFlightFrames = 2;
1718
static const int32_t XCWMinimumAverageBitRate = 18000000;
19+
static const int32_t XCWMinimumRealtimeAverageBitRate = 5000000;
1820
static const int32_t XCWMinimumSoftwareAverageBitRate = 3000000;
1921
static const int32_t XCWMinimumLowLatencySoftwareAverageBitRate = 2000000;
2022
static const int64_t XCWBitsPerPixelBudget = 10;
23+
static const int64_t XCWRealtimeBitsPerPixelBudget = 5;
2124
static const int64_t XCWSoftwareBitsPerPixelBudget = 6;
2225
static const int64_t XCWLowLatencySoftwareBitsPerPixelBudget = 3;
2326
static const uint64_t XCWSoftwareMinimumFrameIntervalUs = 16667;
@@ -57,6 +60,15 @@ static BOOL XCWLowLatencyModeFromEnvironment(void) {
5760
[value isEqualToString:@"on"];
5861
}
5962

63+
static BOOL XCWRealtimeStreamModeFromEnvironment(void) {
64+
const char *rawValue = getenv("SIMDECK_REALTIME_STREAM");
65+
NSString *value = rawValue != NULL ? [[[NSString alloc] initWithUTF8String:rawValue] lowercaseString] : @"";
66+
return [value isEqualToString:@"1"] ||
67+
[value isEqualToString:@"true"] ||
68+
[value isEqualToString:@"yes"] ||
69+
[value isEqualToString:@"on"];
70+
}
71+
6072
static CMVideoCodecType XCWVideoCodecTypeForMode(XCWVideoEncoderMode mode) {
6173
switch (mode) {
6274
case XCWVideoEncoderModeH264Hardware:
@@ -206,13 +218,15 @@ static int32_t XCWRoundToEvenDimension(double value) {
206218
return rounded;
207219
}
208220

209-
static CGSize XCWScaledDimensionsForSourceSize(int32_t width, int32_t height, XCWVideoEncoderMode mode, BOOL lowLatencyMode) {
221+
static CGSize XCWScaledDimensionsForSourceSize(int32_t width, int32_t height, XCWVideoEncoderMode mode, BOOL lowLatencyMode, BOOL realtimeStreamMode) {
210222
if (width <= 0 || height <= 0) {
211223
return CGSizeZero;
212224
}
213225

214226
int32_t maximumDimension = XCWMaximumEncodedDimension;
215-
if (mode == XCWVideoEncoderModeH264Software) {
227+
if (mode == XCWVideoEncoderModeH264Hardware && realtimeStreamMode) {
228+
maximumDimension = XCWMaximumRealtimeHardwareEncodedDimension;
229+
} else if (mode == XCWVideoEncoderModeH264Software) {
216230
maximumDimension = lowLatencyMode
217231
? XCWMaximumLowLatencySoftwareEncodedDimension
218232
: XCWMaximumSoftwareEncodedDimension;
@@ -227,10 +241,13 @@ static CGSize XCWScaledDimensionsForSourceSize(int32_t width, int32_t height, XC
227241
XCWRoundToEvenDimension(height * scale));
228242
}
229243

230-
static int32_t XCWAverageBitRateForDimensions(int32_t width, int32_t height, XCWVideoEncoderMode mode, BOOL lowLatencyMode) {
244+
static int32_t XCWAverageBitRateForDimensions(int32_t width, int32_t height, XCWVideoEncoderMode mode, BOOL lowLatencyMode, BOOL realtimeStreamMode) {
231245
int64_t bitsPerPixelBudget = XCWBitsPerPixelBudget;
232246
int64_t minimumAverageBitRate = XCWMinimumAverageBitRate;
233-
if (mode == XCWVideoEncoderModeH264Software) {
247+
if (mode == XCWVideoEncoderModeH264Hardware && realtimeStreamMode) {
248+
bitsPerPixelBudget = XCWRealtimeBitsPerPixelBudget;
249+
minimumAverageBitRate = XCWMinimumRealtimeAverageBitRate;
250+
} else if (mode == XCWVideoEncoderModeH264Software) {
234251
bitsPerPixelBudget = lowLatencyMode
235252
? XCWLowLatencySoftwareBitsPerPixelBudget
236253
: XCWSoftwareBitsPerPixelBudget;
@@ -352,6 +369,7 @@ @implementation XCWH264Encoder {
352369
OSType _scaledPixelFormat;
353370
XCWVideoEncoderMode _encoderMode;
354371
BOOL _lowLatencyMode;
372+
BOOL _realtimeStreamMode;
355373
CMVideoCodecType _codecType;
356374
BOOL _hardwareAccelerated;
357375
NSUInteger _inputFrameCount;
@@ -386,6 +404,7 @@ - (instancetype)initWithOutputHandler:(XCWH264EncoderOutputHandler)outputHandler
386404
_needsKeyFrame = YES;
387405
_encoderMode = XCWVideoEncoderModeFromEnvironment();
388406
_lowLatencyMode = (_encoderMode == XCWVideoEncoderModeH264Software) && XCWLowLatencyModeFromEnvironment();
407+
_realtimeStreamMode = XCWRealtimeStreamModeFromEnvironment() || _lowLatencyMode;
389408
_codecType = XCWVideoCodecTypeForMode(_encoderMode);
390409
_softwareFrameIntervalUs = [self initialSoftwareFrameIntervalUsLocked];
391410
return self;
@@ -456,6 +475,7 @@ - (NSDictionary *)statsRepresentation {
456475
@"transportCodec": XCWCodecName(self->_codecType),
457476
@"encoderMode": XCWVideoEncoderModeName(self->_encoderMode),
458477
@"lowLatencyMode": @(self->_lowLatencyMode),
478+
@"realtimeStreamMode": @(self->_realtimeStreamMode),
459479
@"encoderId": XCWVideoEncoderIDForMode(self->_encoderMode) ?: @"automatic",
460480
@"hardwareAccelerated": @(self->_hardwareAccelerated),
461481
@"lastSessionStatus": @(self->_lastSessionStatus),
@@ -483,7 +503,7 @@ - (void)invalidate {
483503
}
484504

485505
- (NSUInteger)maximumInFlightFrameCountLocked {
486-
return (_encoderMode == XCWVideoEncoderModeH264Software && _lowLatencyMode) ? 1 : XCWMaximumInFlightFrames;
506+
return (_realtimeStreamMode || (_encoderMode == XCWVideoEncoderModeH264Software && _lowLatencyMode)) ? 1 : XCWMaximumInFlightFrames;
487507
}
488508

489509
- (uint64_t)minimumSoftwareFrameIntervalUsLocked {
@@ -600,7 +620,7 @@ - (BOOL)encodePixelBufferLocked:(CVPixelBufferRef)pixelBuffer {
600620
return NO;
601621
}
602622

603-
CGSize targetSize = XCWScaledDimensionsForSourceSize(sourceWidth, sourceHeight, _encoderMode, _lowLatencyMode);
623+
CGSize targetSize = XCWScaledDimensionsForSourceSize(sourceWidth, sourceHeight, _encoderMode, _lowLatencyMode, _realtimeStreamMode);
604624
int32_t targetWidth = (int32_t)targetSize.width;
605625
int32_t targetHeight = (int32_t)targetSize.height;
606626
if (targetWidth <= 0 || targetHeight <= 0) {
@@ -708,7 +728,7 @@ - (BOOL)ensureCompressionSessionWithWidth:(int32_t)width height:(int32_t)height
708728
_needsKeyFrame = YES;
709729

710730
int expectedFrameRate = [self expectedFrameRateLocked];
711-
int averageBitRate = XCWAverageBitRateForDimensions(width, height, _encoderMode, _lowLatencyMode);
731+
int averageBitRate = XCWAverageBitRateForDimensions(width, height, _encoderMode, _lowLatencyMode, _realtimeStreamMode);
712732

713733
VTSessionSetProperty(session, kVTCompressionPropertyKey_RealTime, kCFBooleanTrue);
714734
if (@available(macOS 10.14, *)) {
@@ -733,8 +753,9 @@ - (BOOL)ensureCompressionSessionWithWidth:(int32_t)width height:(int32_t)height
733753
VTSessionSetProperty(session, kVTCompressionPropertyKey_ProfileLevel, kVTProfileLevel_H264_High_AutoLevel);
734754
}
735755
VTSessionSetProperty(session, kVTCompressionPropertyKey_ExpectedFrameRate, (__bridge CFTypeRef)@(expectedFrameRate));
736-
VTSessionSetProperty(session, kVTCompressionPropertyKey_MaxKeyFrameInterval, (__bridge CFTypeRef)@(_lowLatencyMode ? expectedFrameRate : expectedFrameRate * 2));
737-
VTSessionSetProperty(session, kVTCompressionPropertyKey_MaxKeyFrameIntervalDuration, (__bridge CFTypeRef)@(_lowLatencyMode ? 1.0 : 2.0));
756+
BOOL shortKeyframeInterval = _lowLatencyMode || _realtimeStreamMode;
757+
VTSessionSetProperty(session, kVTCompressionPropertyKey_MaxKeyFrameInterval, (__bridge CFTypeRef)@(shortKeyframeInterval ? expectedFrameRate : expectedFrameRate * 2));
758+
VTSessionSetProperty(session, kVTCompressionPropertyKey_MaxKeyFrameIntervalDuration, (__bridge CFTypeRef)@(shortKeyframeInterval ? 1.0 : 2.0));
738759
VTSessionSetProperty(session, kVTCompressionPropertyKey_AverageBitRate, (__bridge CFTypeRef)@(averageBitRate));
739760
if (@available(macOS 11.0, *)) {
740761
VTSessionSetProperty(session,

server/src/api/routes.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -476,6 +476,7 @@ async fn health(State(state): State<AppState>) -> Json<Value> {
476476
"timestamp": SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or(Duration::ZERO).as_secs_f64(),
477477
"videoCodec": active_video_codec(&state.config),
478478
"lowLatency": state.config.low_latency,
479+
"realtimeStream": crate::transport::webrtc::realtime_stream_enabled(),
479480
"webRtc": {
480481
"iceServers": crate::transport::webrtc::client_ice_servers(),
481482
"iceTransportPolicy": crate::transport::webrtc::ice_transport_policy_label()

server/src/main.rs

Lines changed: 50 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -532,6 +532,12 @@ struct DaemonMetadata {
532532
started_at: u64,
533533
#[serde(default, skip_serializing_if = "Option::is_none")]
534534
log_path: Option<PathBuf>,
535+
#[serde(default, skip_serializing_if = "Option::is_none")]
536+
video_codec: Option<String>,
537+
#[serde(default)]
538+
low_latency: bool,
539+
#[serde(default)]
540+
realtime_stream: bool,
535541
}
536542

537543
#[derive(Clone, Debug)]
@@ -542,6 +548,7 @@ struct DaemonLaunchOptions {
542548
client_root: Option<PathBuf>,
543549
video_codec: VideoCodecMode,
544550
low_latency: bool,
551+
realtime_stream: bool,
545552
}
546553

547554
struct StudioExposeOptions {
@@ -581,6 +588,7 @@ impl Default for DaemonLaunchOptions {
581588
client_root: None,
582589
video_codec: VideoCodecMode::H264Software,
583590
low_latency: false,
591+
realtime_stream: false,
584592
}
585593
}
586594
}
@@ -593,9 +601,10 @@ fn ensure_project_daemon_with_status(
593601
options: DaemonLaunchOptions,
594602
) -> anyhow::Result<(DaemonMetadata, bool)> {
595603
if let Some(metadata) = read_daemon_metadata().ok().flatten() {
596-
if daemon_is_healthy(&metadata) {
604+
if daemon_is_healthy(&metadata) && daemon_matches_launch_options(&metadata, &options) {
597605
return Ok((metadata, false));
598606
}
607+
let _ = terminate_daemon_metadata(&metadata);
599608
}
600609
Ok((start_project_daemon(options)?, true))
601610
}
@@ -666,17 +675,21 @@ done
666675
recoverable_restart_exit_code = RECOVERABLE_RESTART_EXIT_CODE
667676
);
668677

669-
let child = ProcessCommand::new("/bin/sh")
678+
let mut command = ProcessCommand::new("/bin/sh");
679+
command
670680
.arg("-c")
671681
.arg(supervisor_script)
672682
.arg("simdeck-supervisor")
673683
.arg(&executable)
674684
.args(args)
685+
.env(
686+
"SIMDECK_REALTIME_STREAM",
687+
if options.realtime_stream { "1" } else { "0" },
688+
)
675689
.stdin(Stdio::null())
676690
.stdout(Stdio::from(log_stdout))
677-
.stderr(Stdio::from(log_stderr))
678-
.spawn()
679-
.context("start project SimDeck daemon")?;
691+
.stderr(Stdio::from(log_stderr));
692+
let child = command.spawn().context("start project SimDeck daemon")?;
680693

681694
let metadata = DaemonMetadata {
682695
project_root,
@@ -687,6 +700,9 @@ done
687700
binary_path: executable,
688701
started_at: now_secs(),
689702
log_path: Some(log_path),
703+
video_codec: Some(options.video_codec.as_env_value().to_owned()),
704+
low_latency: options.low_latency,
705+
realtime_stream: options.realtime_stream,
690706
};
691707
write_daemon_metadata(&metadata)?;
692708
wait_for_daemon(&metadata, Duration::from_secs(15))?;
@@ -820,6 +836,15 @@ fn daemon_is_healthy(metadata: &DaemonMetadata) -> bool {
820836
http_get_json(&metadata.http_url, "/api/health").is_ok()
821837
}
822838

839+
fn daemon_matches_launch_options(metadata: &DaemonMetadata, options: &DaemonLaunchOptions) -> bool {
840+
metadata
841+
.video_codec
842+
.as_deref()
843+
.is_some_and(|codec| codec == options.video_codec.as_env_value())
844+
&& metadata.low_latency == options.low_latency
845+
&& metadata.realtime_stream == options.realtime_stream
846+
}
847+
823848
fn read_daemon_metadata() -> anyhow::Result<Option<DaemonMetadata>> {
824849
let path = daemon_metadata_path()?;
825850
if !path.exists() {
@@ -1024,6 +1049,9 @@ fn run_foreground_ui(selector: Option<String>) -> anyhow::Result<()> {
10241049
binary_path: executable,
10251050
started_at: now_secs(),
10261051
log_path: None,
1052+
video_codec: Some(video_codec.as_env_value().to_owned()),
1053+
low_latency,
1054+
realtime_stream: false,
10271055
};
10281056
write_daemon_metadata(&metadata)?;
10291057

@@ -1089,6 +1117,7 @@ fn expose_to_studio(options: StudioExposeOptions) -> anyhow::Result<()> {
10891117
client_root: None,
10901118
video_codec: options.video_codec,
10911119
low_latency: options.low_latency,
1120+
realtime_stream: true,
10921121
})?;
10931122
let selected = if let Some(selector) = options.simulator.as_deref() {
10941123
select_studio_simulator(&metadata.http_url, selector)
@@ -1276,6 +1305,7 @@ fn main() -> anyhow::Result<()> {
12761305
client_root,
12771306
video_codec,
12781307
low_latency,
1308+
realtime_stream: false,
12791309
})?;
12801310
if open {
12811311
open_browser(&metadata.http_url)?;
@@ -1299,6 +1329,7 @@ fn main() -> anyhow::Result<()> {
12991329
client_root,
13001330
video_codec,
13011331
low_latency,
1332+
realtime_stream: false,
13021333
})?;
13031334
print_daemon_start_result(&metadata, started)
13041335
}
@@ -1316,6 +1347,7 @@ fn main() -> anyhow::Result<()> {
13161347
client_root,
13171348
video_codec,
13181349
low_latency,
1350+
realtime_stream: false,
13191351
}),
13201352
DaemonCommand::Stop => stop_project_daemon(),
13211353
DaemonCommand::Killall => kill_all_project_daemons(),
@@ -1345,6 +1377,10 @@ fn main() -> anyhow::Result<()> {
13451377
binary_path: env::current_exe().context("resolve daemon executable")?,
13461378
started_at: now_secs(),
13471379
log_path,
1380+
video_codec: Some(video_codec.as_env_value().to_owned()),
1381+
low_latency,
1382+
realtime_stream: crate::transport::webrtc::realtime_stream_enabled()
1383+
|| low_latency,
13481384
})?;
13491385
let result = serve_with_appkit(
13501386
port,
@@ -2144,6 +2180,15 @@ fn serve_with_appkit(
21442180
) -> anyhow::Result<()> {
21452181
std::env::set_var("SIMDECK_VIDEO_CODEC", video_codec.as_env_value());
21462182
std::env::set_var("SIMDECK_LOW_LATENCY", if low_latency { "1" } else { "0" });
2183+
let inherited_realtime_stream = crate::transport::webrtc::realtime_stream_enabled();
2184+
std::env::set_var(
2185+
"SIMDECK_REALTIME_STREAM",
2186+
if inherited_realtime_stream || low_latency {
2187+
"1"
2188+
} else {
2189+
"0"
2190+
},
2191+
);
21472192
std::env::set_var(RESTART_ON_CORE_SIMULATOR_MISMATCH_ENV, "1");
21482193
start_fd_pressure_watchdog();
21492194
unsafe {

server/src/transport/webrtc.rs

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ const WEBRTC_MAX_REFRESH_INTERVAL: Duration = Duration::from_millis(100);
3838
const WEBRTC_LOW_LATENCY_REFRESH_INTERVAL: Duration = Duration::from_millis(67);
3939
const WEBRTC_LOW_LATENCY_MAX_REFRESH_INTERVAL: Duration = Duration::from_millis(250);
4040
const WEBRTC_WRITE_TIMEOUT: Duration = Duration::from_millis(120);
41+
const WEBRTC_REALTIME_WRITE_TIMEOUT: Duration = Duration::from_millis(45);
4142
static WEBRTC_MEDIA_STREAMS: OnceLock<Mutex<HashMap<String, Vec<broadcast::Sender<()>>>>> =
4243
OnceLock::new();
4344

@@ -751,17 +752,28 @@ async fn write_frame_sample_with_timeout(
751752
frame: &crate::transport::packet::SharedFrame,
752753
duration: Duration,
753754
) -> anyhow::Result<bool> {
754-
match time::timeout(
755-
WEBRTC_WRITE_TIMEOUT,
756-
write_frame_sample(video_track, frame, duration),
757-
)
758-
.await
759-
{
755+
let timeout = if realtime_stream_enabled() {
756+
WEBRTC_REALTIME_WRITE_TIMEOUT
757+
} else {
758+
WEBRTC_WRITE_TIMEOUT
759+
};
760+
match time::timeout(timeout, write_frame_sample(video_track, frame, duration)).await {
760761
Ok(result) => result.map(|()| true),
761762
Err(_) => Ok(false),
762763
}
763764
}
764765

766+
pub fn realtime_stream_enabled() -> bool {
767+
std::env::var("SIMDECK_REALTIME_STREAM")
768+
.ok()
769+
.is_some_and(|value| {
770+
matches!(
771+
value.trim().to_ascii_lowercase().as_str(),
772+
"1" | "true" | "yes" | "on"
773+
)
774+
})
775+
}
776+
765777
fn h264_annex_b_sample(frame: &crate::transport::packet::FramePacket) -> anyhow::Result<Vec<u8>> {
766778
let data = frame.data.as_ref();
767779
let description = frame.description.as_ref().map(bytes::Bytes::as_ref);

skills/simdeck/SKILL.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,9 @@ directly with the runner.
4646
For an ad-hoc local provider that can be opened from another browser or phone,
4747
run `simdeck studio expose "iPhone 17 Pro"` and keep that process running. It
4848
prints the unique Studio simulator URL. This defaults to hardware H.264; add
49-
`--low-latency` to use software H.264's low-latency profile.
49+
`--low-latency` to use software H.264's low-latency profile. Studio expose uses
50+
realtime stream settings so remote viewers drop stale frames instead of building
51+
latency.
5052

5153
The local viewer gets the API token automatically. LAN browsers pair with the printed code before receiving the API cookie. Direct HTTP calls need `X-SimDeck-Token` or `Authorization: Bearer <token>`.
5254

0 commit comments

Comments
 (0)