diff --git a/crates/osdl-core/src/media/mediamtx.rs b/crates/osdl-core/src/media/mediamtx.rs index 12d4488..0707e83 100644 --- a/crates/osdl-core/src/media/mediamtx.rs +++ b/crates/osdl-core/src/media/mediamtx.rs @@ -54,6 +54,8 @@ pub struct ListenerPorts { pub hls: u16, #[serde(default = "default_webrtc_port")] pub webrtc: u16, + #[serde(default = "default_webrtc_udp_port")] + pub webrtc_udp: u16, } impl Default for ListenerPorts { @@ -62,6 +64,7 @@ impl Default for ListenerPorts { rtsp: default_rtsp_port(), hls: default_hls_port(), webrtc: default_webrtc_port(), + webrtc_udp: default_webrtc_udp_port(), } } } @@ -78,6 +81,9 @@ fn default_hls_port() -> u16 { fn default_webrtc_port() -> u16 { 8889 } +fn default_webrtc_udp_port() -> u16 { + 8189 +} /// Validate a `MediaPath` field that will be string-interpolated into the /// generated mediamtx YAML. Rejects anything that could break out of the @@ -89,16 +95,24 @@ fn default_webrtc_port() -> u16 { /// characters or quotes that could confuse the ffmpeg argv splitter. fn validate_path(p: &MediaPath) -> Result<(), MediamtxError> { if p.name.is_empty() - || !p.name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') + || !p + .name + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') { return Err(MediamtxError::InvalidConfig(format!( - "path name {:?}: must match [A-Za-z0-9_-]+", p.name + "path name {:?}: must match [A-Za-z0-9_-]+", + p.name ))); } for (label, value, schemes) in [ - ("source_uri", p.source_uri.as_deref(), &["rtsp", "rtmp"][..]), - ("transcode_from", p.transcode_from.as_deref(), &["rtsp", "rtmp"][..]), - ("push_to", p.push_to.as_deref(), &["rtmp", "rtsp"][..]), + ("source_uri", p.source_uri.as_deref(), &["rtsp", "rtmp"][..]), + ( + "transcode_from", + p.transcode_from.as_deref(), + &["rtsp", "rtmp"][..], + ), + ("push_to", p.push_to.as_deref(), &["rtmp", "rtsp"][..]), ] { if let Some(v) = value { validate_url(label, v, schemes)?; @@ -141,14 +155,52 @@ fn validate_url(label: &str, value: &str, schemes: &[&str]) -> Result<(), Mediam const FFMPEG_LOW_LATENCY_INPUT: &str = "-fflags nobuffer -flags low_delay -probesize 32 -analyzeduration 0"; +/// libx264 + AAC encode flags shared by every transcode path (both +/// rtsp-republish and direct-RTMP-push variants). Kept separate from +/// FFMPEG_LOW_LATENCY_INPUT because that one applies to copy-only +/// runOnReady jobs too, while these flags only matter when we encode. +/// +/// Tuning rationale: +/// - libx264 ultrafast / zerolatency / -bf 0 — no B-frames, no lookahead +/// - -profile:v baseline -pix_fmt yuv420p — broadest decoder support +/// - -g 8 + x264 keyint=8 scenecut=0 — IDR ~every 0.5 s @15fps so a +/// fresh subscriber waits at most ~500 ms for a keyframe +/// - repeat_headers=1 — SPS/PPS prepended to every IDR for mid-stream +/// joins without out-of-band negotiation +/// - rc-lookahead=0 / sync-lookahead=0 — encoder doesn't queue frames +/// - -c:a aac -ar 44100 -b:a 64k — lightweight, browser-friendly audio +/// - -flush_packets 1 — muxer doesn't pool packets before writing +/// (matters for FLV/RTSP intermediates) +const FFMPEG_H264_LOWLATENCY_ENCODE: &str = "-c:v libx264 -preset ultrafast -tune zerolatency \ + -profile:v baseline -pix_fmt yuv420p \ + -bf 0 -g 8 \ + -x264-params keyint=8:scenecut=0:repeat_headers=1:rc-lookahead=0:sync-lookahead=0:bframes=0 \ + -c:a aac -ar 44100 -b:a 64k \ + -flush_packets 1"; + /// Render a mediamtx YAML config from gateway settings + path entries. -pub fn render_config(cfg: &MediaGatewayConfig, paths: &[MediaPath]) -> Result { +pub fn render_config( + cfg: &MediaGatewayConfig, + paths: &[MediaPath], +) -> Result { for p in paths { validate_path(p)?; } Ok(render_config_unchecked(cfg, paths)) } +/// Whether the rendered mediamtx config must avoid binding UDP 8000. +/// SRS hard-codes `a=candidate:... 8000` into its SDP answer, so +/// SRS must own UDP 8000 on the host whenever a path pushes to SRS *and* +/// we want to read back over WHEP. Generic RTMP relays (Twitch, YouTube) +/// don't re-serve over WebRTC, so they don't trigger this — see the +/// `pushes_to_srs` field on `MediaPath`. mediamtx can still serve local +/// WHEP as long as its own WebRTC UDP mux is pinned elsewhere +/// (`webrtcLocalUDPAddress`, default :8189) and RTSP over UDP is disabled. +fn should_reserve_webrtc_udp_for_srs(paths: &[MediaPath]) -> bool { + paths.iter().any(|p| p.pushes_to_srs) +} + fn render_config_unchecked(cfg: &MediaGatewayConfig, paths: &[MediaPath]) -> String { let mut s = String::new(); s.push_str("# Generated by OpenSDL — do not edit by hand.\n"); @@ -157,7 +209,18 @@ fn render_config_unchecked(cfg: &MediaGatewayConfig, paths: &[MediaPath]) -> Str s.push_str("rtsp: yes\n"); s.push_str(&format!("rtspAddress: :{}\n", cfg.ports.rtsp)); - s.push_str("rtspTransports: [tcp, udp]\n\n"); + let reserve_udp_8000 = should_reserve_webrtc_udp_for_srs(paths); + // Default RTSP transport list. When a path pushes to SRS, reserve UDP + // 8000 for SRS WebRTC by forcing mediamtx RTSP to TCP-only. mediamtx's + // own local WebRTC stays enabled on `webrtc_udp` instead. + if reserve_udp_8000 { + s.push_str("rtspTransports: [tcp]\n"); + s.push_str("rtpAddress: :0\n"); + s.push_str("rtcpAddress: :0\n"); + s.push_str("multicastRTPPort: 0\n\n"); + } else { + s.push_str("rtspTransports: [tcp, udp]\n\n"); + } s.push_str("hls: yes\n"); s.push_str(&format!("hlsAddress: :{}\n", cfg.ports.hls)); @@ -165,6 +228,10 @@ fn render_config_unchecked(cfg: &MediaGatewayConfig, paths: &[MediaPath]) -> Str s.push_str("webrtc: yes\n"); s.push_str(&format!("webrtcAddress: :{}\n", cfg.ports.webrtc)); + s.push_str(&format!( + "webrtcLocalUDPAddress: :{}\n", + cfg.ports.webrtc_udp + )); // Pin the ICE candidate's host to whatever we advertise to consumers. // Without this, mediamtx gathers every interface — including macOS's // mDNS-anonymized `*.local` candidate that Chromium 110+ emits for @@ -193,50 +260,68 @@ fn render_config_unchecked(cfg: &MediaGatewayConfig, paths: &[MediaPath]) -> Str } // When this path also republishes to a remote, keep it always-on // so the upstream pull stays alive between local consumers — the - // remote ingest is itself a permanent consumer. - if p.push_to.is_none() { + // remote ingest is itself a permanent consumer. Without this, + // mediamtx waits for a local viewer before pulling the camera, + // and the remote push never fires because the SRS ingest side + // has nothing to consume yet → deadlock. + if p.push_to.is_some() { + s.push_str(" sourceOnDemand: no\n"); + } else { s.push_str(" sourceOnDemand: yes\n"); s.push_str(" sourceOnDemandStartTimeout: 10s\n"); s.push_str(" sourceOnDemandCloseAfter: 30s\n"); } } else if let Some(upstream) = &p.transcode_from { - // ffmpeg pulls upstream and republishes to localhost as this path. - // Software libx264 ultrafast/zerolatency keeps cross-platform CPU - // cost low; hardware encoders can be substituted later per-host. + // ffmpeg pulls upstream and republishes as H.264. // // Two driving modes: // - on-demand: only transcode while a consumer is connected. // - always-on: start immediately and keep running. Required // when `push_to` is set, otherwise nothing forces the // upstream link to stay alive between consumer connects. - let transport = if p.rtsp_transport_tcp { "-rtsp_transport tcp" } else { "" }; + // + // When `push_to` is set we push to the remote target directly + // (RTMP/FLV). We do NOT also publish to the local mediamtx + // path because mediamtx's RTSP listener in our config is + // TCP-only (UDP is disabled to free port 8000 for SRS's + // WebRTC egress), and ffmpeg's tee muxer can't be told to + // publish over RTSP-over-TCP for one sub-output only — + // publishing over UDP gets rejected with "method SETUP + // failed: 461 (Unsupported Transport)" and the tee muxer + // then aborts BOTH outputs, killing the SRS push too. + // The local `cam131_h264` path stays empty unless a local + // viewer connects; in that case the source config below + // (sourceOnDemand-style) re-pulls from the upstream RTSP + // and serves it via mediamtx — but for SRS delivery we + // bypass mediamtx entirely. + let transport = if p.rtsp_transport_tcp { + "-rtsp_transport tcp" + } else { + "" + }; let always_on = p.push_to.is_some(); s.push_str(" source: publisher\n"); - let runner_keyword = if always_on { "runOnInit" } else { "runOnDemand" }; - // Low-latency knobs, in priority order: - // - FFMPEG_LOW_LATENCY_INPUT → skip ffmpeg's default ~5s probe - // and reorder buffer (shared with the push path). - // - -tune zerolatency / -bf 0 → no B frames, no lookahead. - // - -g 8 / x264 keyint=8 scenecut=0 → IDR every ~0.5s @15fps, - // so a fresh subscriber waits at most ~500ms for keyframe. - // - repeat_headers=1 → SPS/PPS prepended to every IDR, lets - // mid-stream join work without out-of-band negotiation. - // - rc-lookahead=0 / sync-lookahead=0 → encoder doesn't queue - // frames waiting on rate control. - // - -flush_packets 1 → muxer doesn't pool packets before - // writing (matters for FLV/RTSP intermediates). + let runner_keyword = if always_on { + "runOnInit" + } else { + "runOnDemand" + }; + // When `push_to` is set, the encoder writes to RTMP/FLV + // directly — one ffmpeg process, one encoder, one output. + // No tee, no runOnReady re-pull → no flap, no UDP-vs-TCP + // transport mismatch with mediamtx. Otherwise we publish + // locally and let mediamtx serve consumers. + let output = match p.push_to.as_deref() { + Some(target) => format!("-f flv {target}"), + None => "-f rtsp rtsp://localhost:$RTSP_PORT/$MTX_PATH".to_string(), + }; s.push_str(&format!( " {runner_keyword}: >\n ffmpeg -hide_banner -loglevel warning \ {FFMPEG_LOW_LATENCY_INPUT} \ {transport} \ -i {upstream} \ - -c:v libx264 -preset ultrafast -tune zerolatency \ - -profile:v baseline -pix_fmt yuv420p \ - -bf 0 -g 8 \ - -x264-params keyint=8:scenecut=0:repeat_headers=1:rc-lookahead=0:sync-lookahead=0:bframes=0 \ - -c:a aac -ar 44100 -b:a 64k \ - -flush_packets 1 \ - -f rtsp rtsp://localhost:$RTSP_PORT/$MTX_PATH\n", + {FFMPEG_H264_LOWLATENCY_ENCODE} \ + {output}\n", )); if always_on { s.push_str(" runOnInitRestart: yes\n"); @@ -246,21 +331,20 @@ fn render_config_unchecked(cfg: &MediaGatewayConfig, paths: &[MediaPath]) -> Str s.push_str(" runOnDemandCloseAfter: 30s\n"); } } - if let Some(target) = &p.push_to { - // runOnReady fires whenever the path becomes available. ffmpeg - // `-c copy` since path content is already H.264/AAC. -f flv for - // RTMP ingest. Reuses FFMPEG_LOW_LATENCY_INPUT so the push side - // doesn't drift from the transcode side; -c copy means no - // encoder is in the middle to queue. mediamtx auto-restarts - // the command if it exits. - s.push_str(&format!( - " runOnReady: >\n ffmpeg -hide_banner -loglevel warning \ - {FFMPEG_LOW_LATENCY_INPUT} \ - -rtsp_transport tcp \ - -i rtsp://localhost:$RTSP_PORT/$MTX_PATH \ - -c copy -f flv {target}\n" - )); - s.push_str(" runOnReadyRestart: yes\n"); + // Passthrough path (no transcode): encoder is upstream, + // so we can't bake the push into the transcode ffmpeg. Use runOnReady + // that `-c copy`s from mediamtx to SRS. + if p.push_to.is_some() && p.transcode_from.is_none() { + if let Some(target) = &p.push_to { + s.push_str(&format!( + " runOnReady: >\n ffmpeg -hide_banner -loglevel warning \ + {FFMPEG_LOW_LATENCY_INPUT} \ + -rtsp_transport tcp \ + -i rtsp://localhost:$RTSP_PORT/$MTX_PATH \ + -c copy -f flv {target}\n" + )); + s.push_str(" runOnReadyRestart: yes\n"); + } } } s @@ -284,9 +368,11 @@ pub enum MediamtxError { /// or until `timeout` elapses. mediamtx logs `[RTSP] listener opened` to /// stdout when it's ready, but we'd rather not parse logs when a TCP /// connect probe is straightforward and language-agnostic. -async fn wait_listening(child: &mut Child, port: u16, timeout: Duration) - -> Result<(), MediamtxError> -{ +async fn wait_listening( + child: &mut Child, + port: u16, + timeout: Duration, +) -> Result<(), MediamtxError> { let addr = format!("127.0.0.1:{port}"); let deadline = tokio::time::Instant::now() + timeout; loop { @@ -342,7 +428,10 @@ pub struct MediamtxProcess { } impl MediamtxProcess { - pub async fn spawn(cfg: &MediaGatewayConfig, paths: &[MediaPath]) -> Result { + pub async fn spawn( + cfg: &MediaGatewayConfig, + paths: &[MediaPath], + ) -> Result { let bin = locate_binary(cfg.binary.as_ref())?; let yaml = render_config(cfg, paths)?; @@ -372,8 +461,13 @@ impl MediamtxProcess { // first), or fail. Without this we'd advertise endpoint URLs before // the listener is ready, and surface a "bind failed" only when a // consumer connects — too late. - if let Err(e) = wait_listening(&mut child, cfg.ports.rtsp, - std::time::Duration::from_secs(5)).await { + if let Err(e) = wait_listening( + &mut child, + cfg.ports.rtsp, + std::time::Duration::from_secs(5), + ) + .await + { // Process exited or never bound. Make sure it's gone. let _ = child.start_kill(); let _ = child.wait().await; @@ -439,6 +533,7 @@ mod tests { rtsp_transport_tcp: true, transcode_from: None, push_to: None, + pushes_to_srs: false, }]; let yaml = render_config(&cfg, &paths).unwrap(); assert!(yaml.contains("rtspAddress: :8554")); @@ -459,6 +554,7 @@ mod tests { rtsp_transport_tcp: true, transcode_from: None, push_to: None, + pushes_to_srs: false, }, MediaPath { name: "cam1_h264".into(), @@ -466,6 +562,7 @@ mod tests { rtsp_transport_tcp: true, transcode_from: Some("rtsp://1.2.3.4/sub".into()), push_to: None, + pushes_to_srs: false, }, ]; let yaml = render_config(&cfg, &paths).unwrap(); @@ -486,13 +583,59 @@ mod tests { rtsp_transport_tcp: true, transcode_from: Some("rtsp://1.2.3.4/sub".into()), push_to: Some("rtmp://srs.example.com:1935/openSDL/cam1".into()), + pushes_to_srs: true, }]; let yaml = render_config(&cfg, &paths).unwrap(); - assert!(yaml.contains("runOnReady:")); - assert!(yaml.contains("rtmp://srs.example.com:1935/openSDL/cam1")); - assert!(yaml.contains("-c copy")); - assert!(yaml.contains("-f flv")); - assert!(yaml.contains("runOnReadyRestart: yes")); + // Push on a transcode path: encoder writes directly to SRS in the + // SAME ffmpeg process (no tee, no runOnReady hook). + assert!( + yaml.contains("rtmp://srs.example.com:1935/openSDL/cam1"), + "should contain the SRS push URL" + ); + assert!( + yaml.contains("-f flv"), + "should output FLV (RTMP-compatible)" + ); + assert!( + !yaml.contains("-f tee"), + "should NOT use tee muxer (rtsp-over-udp publish rejected by mediamtx TCP-only)" + ); + assert!( + yaml.contains("runOnInitRestart: yes"), + "push_to makes the path always-on" + ); + } + + #[test] + fn srs_push_reserves_udp_8000_but_generic_rtmp_does_not() { + // SRS push: gateway must yield host UDP 8000 → RTSP forced TCP-only, + // RTP/RTCP UDP zeroed, mediamtx WebRTC pinned to webrtc_udp instead. + let cfg = MediaGatewayConfig::default(); + let srs_paths = vec![MediaPath { + name: "cam".into(), + source_uri: Some("rtsp://1.2.3.4/main".into()), + rtsp_transport_tcp: true, + transcode_from: None, + push_to: Some("rtmp://127.0.0.1:1935/live/cam".into()), + pushes_to_srs: true, + }]; + let yaml = render_config(&cfg, &srs_paths).unwrap(); + assert!(yaml.contains("rtspTransports: [tcp]")); + assert!(yaml.contains("rtpAddress: :0")); + + // Generic RTMP relay (Twitch, YouTube, etc.) — no WHEP read-back, no + // need to surrender UDP 8000. Default transports stay enabled. + let twitch_paths = vec![MediaPath { + name: "cam".into(), + source_uri: Some("rtsp://1.2.3.4/main".into()), + rtsp_transport_tcp: true, + transcode_from: None, + push_to: Some("rtmp://live.twitch.tv/app/streamkey".into()), + pushes_to_srs: false, + }]; + let yaml = render_config(&cfg, &twitch_paths).unwrap(); + assert!(yaml.contains("rtspTransports: [tcp, udp]")); + assert!(!yaml.contains("rtpAddress: :0")); } #[test] @@ -515,6 +658,7 @@ mod tests { rtsp_transport_tcp: true, transcode_from: None, push_to: None, + pushes_to_srs: false, }]; assert!(matches!( render_config(&cfg, &paths), @@ -538,9 +682,13 @@ mod tests { rtsp_transport_tcp: true, transcode_from: None, push_to: None, + pushes_to_srs: false, }]; assert!( - matches!(render_config(&cfg, &paths), Err(MediamtxError::InvalidConfig(_))), + matches!( + render_config(&cfg, &paths), + Err(MediamtxError::InvalidConfig(_)) + ), "expected rejection for {u:?}", ); } @@ -555,6 +703,7 @@ mod tests { rtsp_transport_tcp: true, transcode_from: Some("rtsp://1.2.3.4/foo".into()), push_to: Some("http://attacker.example.com/exfil".into()), + pushes_to_srs: false, }]; assert!(matches!( render_config(&cfg, &paths), diff --git a/crates/osdl-core/src/media/mod.rs b/crates/osdl-core/src/media/mod.rs index a5c78b7..5b44f23 100644 --- a/crates/osdl-core/src/media/mod.rs +++ b/crates/osdl-core/src/media/mod.rs @@ -54,10 +54,19 @@ impl MediaSourceConfig { /// External-facing endpoints to advertise via `MediaSourceOnline`. /// Includes both local gateway endpoints (RTSP/HLS/WebRTC at the /// `gateway_host`) and any configured remote ingest playback URLs. - pub fn endpoints(&self, gateway_host: &str, gateway: &mediamtx::ListenerPorts) -> Vec { + /// + /// Local WebRTC remains advertised even when this source pushes to + /// remote ingest. mediamtx pins its own ICE UDP mux away from SRS's + /// UDP 8000, so local viewers can stay on the low-latency LAN path + /// while teammates use SRS. + pub fn endpoints( + &self, + gateway_host: &str, + gateway: &mediamtx::ListenerPorts, + ) -> Vec { let mut out = Vec::new(); for p in self.paths() { - for proto in [Protocol::Rtsp, Protocol::Hls, Protocol::Webrtc] { + for proto in [Protocol::Rtsp, Protocol::Hls] { out.push(MediaEndpoint { source_id: self.id().to_string(), path: p.name.clone(), @@ -66,6 +75,13 @@ impl MediaSourceConfig { location: Location::Local, }); } + out.push(MediaEndpoint { + source_id: self.id().to_string(), + path: p.name.clone(), + protocol: Protocol::Webrtc, + url: build_url(gateway_host, gateway, Protocol::Webrtc, &p.name), + location: Location::Local, + }); } out.extend(self.remote_endpoints()); out @@ -86,10 +102,7 @@ impl MediaSourceConfig { } } -fn build_remote_endpoints( - id: &str, - remote: &onvif_camera::RemoteRtmpConfig, -) -> Vec { +fn build_remote_endpoints(id: &str, remote: &onvif_camera::RemoteRtmpConfig) -> Vec { let stream = remote.stream.as_deref().unwrap_or(id); let app = remote.app(); let mut out = vec![MediaEndpoint { @@ -121,16 +134,35 @@ fn build_remote_endpoints( }); } if let Some(webrtc_host) = &remote.webrtc_host { - let path = if app.is_empty() { - stream.to_string() + // SRS WHEP endpoint. WHEP is HTTP-POST-application/sdp, so the URL + // must be a real http(s):// — a `webrtc://` URI would crash the + // fetch() in CameraTile.tsx before any candidate exchange happens. + // + // SRS puts WHEP under /rtc/v1/whep/ and takes the stream path as + // query params (`app=` + `stream=`) rather than URL segments, + // because the resource being POSTed to is the *WHEP signalling + // endpoint*, not the stream itself. + // + // mediamtx's local WHEP URL shape is different + // (`http://host:port//whep`, see `build_url`) — we don't try + // to unify them because the local and remote viewers use different + // fetch code paths in practice (the local one has no `app`). + // + // Production cameras are pinned to H.264 Baseline. Make that explicit + // for SRS so Electron builds with optional HEVC WebRTC support don't + // send a broader offer that SRS answers on the wrong codec path. + let app_q = if app.is_empty() { + "live".to_string() } else { - format!("{app}/{stream}") + app.to_string() }; out.push(MediaEndpoint { source_id: id.to_string(), path: stream.to_string(), protocol: Protocol::Webrtc, - url: format!("webrtc://{webrtc_host}/{path}"), + url: format!( + "http://{webrtc_host}/rtc/v1/whep/?app={app_q}&stream={stream}&codec=h264" + ), location: Location::Remote, }); } @@ -141,10 +173,16 @@ fn build_remote_endpoints( /// /// `source_uri` is the upstream RTSP URL when mediamtx pulls directly. /// `transcode_from` is set when an ffmpeg sidecar must transcode another path -/// or upstream URL into this one (used for HEVC→H.264 rewrites). +/// or upstream URL into this one (used for optional H.264 fallback rewrites). /// `push_to` is set when this path's output should additionally be republished /// to a remote ingest (e.g. RTMP at SRS) — uses ffmpeg `-c copy`, no extra /// encoding cost since the path is already H.264. +/// `pushes_to_srs` is true when the push target is an SRS server we also +/// want to read from over WHEP. SRS hard-codes UDP 8000 in its WebRTC SDP, +/// so the gateway must yield that port to SRS even though mediamtx itself +/// would otherwise bind it for RTSP-over-UDP. False for non-SRS RTMP +/// targets (Twitch, YouTube, generic RTMP relays), where the gateway can +/// keep its default transports. #[derive(Debug, Clone)] pub struct MediaPath { pub name: String, @@ -152,6 +190,7 @@ pub struct MediaPath { pub rtsp_transport_tcp: bool, pub transcode_from: Option, pub push_to: Option, + pub pushes_to_srs: bool, } /// One protocol/URL pair pointing at either the local gateway or a remote diff --git a/crates/osdl-core/src/media/onvif_camera.rs b/crates/osdl-core/src/media/onvif_camera.rs index 959ed2e..2149544 100644 --- a/crates/osdl-core/src/media/onvif_camera.rs +++ b/crates/osdl-core/src/media/onvif_camera.rs @@ -31,14 +31,14 @@ pub struct OnvifCameraConfig { pub rtsp_sub: Option, /// If true, generate an additional H.264 transcoded path `{id}_h264` - /// alongside the main passthrough. Lets clients that can't decode - /// HEVC (older browsers, some WebRTC stacks) still play the camera. - /// Default false — modern Mac/Win Electron handles HEVC natively. + /// alongside the main passthrough. Production cameras should already + /// output H.264 Baseline, so default false keeps mediamtx/ffmpeg on + /// `-c copy` with no extra encoding cost. #[serde(default)] pub h264_transcode: Option, /// Where the H.264 transcode pulls from when `h264_transcode` is on. - /// Defaults to `Main` so the H.264 path matches the HEVC path's + /// Defaults to `Main` so the H.264 path matches the passthrough path's /// resolution / quality. Use `Sub` (and set `rtsp_sub`) only when /// the host is CPU-constrained and you accept lower-res H.264. #[serde(default)] @@ -49,9 +49,10 @@ pub struct OnvifCameraConfig { #[serde(default = "default_true")] pub rtsp_transport_tcp: bool, - /// Push the H.264 stream to a remote RTMP ingest (e.g. SRS). The - /// transcoded `{id}_h264` path is reused — ffmpeg uses `-c copy` so - /// there's no extra encoding cost. + /// Push the camera's H.264 stream to a remote RTMP ingest (e.g. SRS). + /// With `h264_transcode=false`, the main passthrough path is pushed + /// with `-c copy`; if transcoding is enabled, the `{id}_h264` path is + /// pushed instead. #[serde(default)] pub remote_rtmp: Option, @@ -86,12 +87,12 @@ pub struct OnvifControlConfig { } /// Selects which upstream feeds the optional `{id}_h264` transcode path. -/// Only consulted when `h264_transcode` is on; the HEVC main passthrough +/// Only consulted when `h264_transcode` is on; the main passthrough /// path always sources from `rtsp_main` regardless. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum H264TranscodeSource { - /// Transcode from `rtsp_main`. Same resolution as the HEVC path, + /// Transcode from `rtsp_main`. Same resolution as the passthrough path, /// higher CPU on the host. #[default] Main, @@ -118,8 +119,8 @@ pub struct RemoteRtmpConfig { #[serde(default)] pub http_host: Option, - /// Public host (no port) for WebRTC playback. Example: - /// `srs.sciol.ac.cn`. Used only to assemble `MediaEndpoint`s. + /// Public host[:port] for WebRTC playback. Example: + /// `srs.sciol.ac.cn:1985`. Used only to assemble `MediaEndpoint`s. #[serde(default)] pub webrtc_host: Option, } @@ -153,16 +154,9 @@ fn default_true() -> bool { } impl OnvifCameraConfig { - /// Whether this config will produce a path that pushes to remote RTMP. - /// `remote_rtmp` only takes effect on the H.264 transcode path; if the - /// caller disabled transcoding, no push happens. Used by both `paths()` - /// (to attach `push_to`) and `endpoints()` (to suppress remote URL - /// advertisement that no one would actually be publishing). fn produces_h264_path(&self) -> bool { - // Default off — modern downstreams (Electron 33+, Safari, Chrome 107+) - // play HEVC fine, and skipping the transcode preserves 4K quality at - // near-zero CPU cost. Set explicitly when a legacy / WebRTC consumer - // really needs an H.264 fallback. + // Default off — the camera is expected to output H.264 Baseline in + // production, so mediamtx can pass packets through directly. self.h264_transcode.unwrap_or(false) } @@ -185,32 +179,43 @@ impl OnvifCameraConfig { } /// Whether this camera will advertise remote-ingest endpoints. With - /// HEVC-passthrough as the default, any `remote_rtmp` setup pushes — - /// the transcode path is purely an optional H.264 fallback. + /// H.264 passthrough as the default, any `remote_rtmp` setup pushes. pub fn pushes_to_remote(&self) -> bool { self.remote_rtmp.is_some() } pub fn paths(&self) -> Vec { - // Main path: HEVC passthrough. ffmpeg/mediamtx needs no encoder — + // Main path: camera passthrough. ffmpeg/mediamtx needs no encoder — // RTMP push (when configured) is `-c copy`, ~0 CPU. - let main_push_to = self + let main_push_to = if self.produces_h264_path() { + None + } else { + self.remote_rtmp.as_ref().map(|r| r.full_push_url(&self.id)) + }; + // SRS is the only push target we know about that *also* re-reads + // the stream over WebRTC and needs host UDP 8000 free. Treat + // `webrtc_host` as the explicit "this is SRS" marker — generic + // RTMP relays leave it unset and don't trigger the gateway's + // transport restrictions. + let pushes_to_srs = self .remote_rtmp .as_ref() - .map(|r| r.full_push_url(&self.id)); + .map(|r| r.webrtc_host.is_some()) + .unwrap_or(false); let mut out = vec![MediaPath { name: self.id.clone(), source_uri: Some(self.rtsp_main.clone()), rtsp_transport_tcp: self.rtsp_transport_tcp, transcode_from: None, push_to: main_push_to, + pushes_to_srs: pushes_to_srs && !self.produces_h264_path(), }]; if self.produces_h264_path() { - // Optional H.264 transcode for clients that can't play HEVC - // (some browser WebRTC stacks, older mobile decoders). + // Optional H.264 transcode for deployments where the camera + // cannot be configured to emit browser/SRS-friendly H.264. // - // Source: `Main` (default) so the H.264 path matches the HEVC + // Source: `Main` (default) so the H.264 path matches the main // path's resolution; `Sub` for CPU-constrained hosts willing // to accept the low-res feed. validate() guarantees rtsp_sub // is set whenever we reach the Sub branch here. @@ -221,14 +226,15 @@ impl OnvifCameraConfig { .clone() .expect("validate() ensures rtsp_sub is Some when source=sub"), }; - // Don't double-push to remote — the HEVC main path already does. - // Local-only H.264 fallback for now. + // When remote_rtmp is set, the H.264 path carries the push. + let h264_push_to = self.remote_rtmp.as_ref().map(|r| r.full_push_url(&self.id)); out.push(MediaPath { name: format!("{}_h264", self.id), source_uri: None, rtsp_transport_tcp: self.rtsp_transport_tcp, transcode_from: Some(upstream), - push_to: None, + push_to: h264_push_to, + pushes_to_srs, }); } @@ -265,8 +271,8 @@ mod tests { #[test] fn remote_rtmp_pushes_main_path_by_default() { - // Modern default: HEVC main passthrough is what gets pushed. - // No explicit transcode needed. + // Production default: camera-side H.264 passthrough is what gets + // pushed. No explicit transcode needed. let mut c = cam("cam1"); c.remote_rtmp = Some(rtmp()); assert!(c.validate().is_ok()); @@ -280,20 +286,21 @@ mod tests { } #[test] - fn explicit_h264_transcode_adds_local_only_h264_path() { + fn explicit_h264_transcode_moves_push_to_h264_path() { + // If a deployment must transcode, the push moves to the generated + // H.264 path. The main path stays local-only to avoid double-push. let mut c = cam("cam1"); c.rtsp_sub = Some("rtsp://1.2.3.4/sub".into()); c.h264_transcode = Some(true); c.remote_rtmp = Some(rtmp()); let paths = c.paths(); assert_eq!(paths.len(), 2); - // Main HEVC: source + push_to (the remote push lives here now). + // Main passthrough: local-only now (push moved to H.264 path). assert_eq!(paths[0].name, "cam1"); - assert!(paths[0].push_to.is_some()); - // H.264 fallback: transcoded from MAIN (matches HEVC path quality), - // local-only (no double push to remote). + assert!(paths[0].push_to.is_none(), "main path no longer pushes"); + // H.264 fallback: transcoded from MAIN, owns the remote push. assert_eq!(paths[1].name, "cam1_h264"); - assert!(paths[1].push_to.is_none(), "no double-push to remote"); + assert!(paths[1].push_to.is_some(), "H.264 path pushes to remote"); assert_eq!( paths[1].transcode_from.as_deref(), Some("rtsp://1.2.3.4/main"), diff --git a/docs/recipes/configs/cameras-jzt31.yaml b/docs/recipes/configs/cameras-jzt31.yaml index 2a514d7..d52b9c8 100644 --- a/docs/recipes/configs/cameras-jzt31.yaml +++ b/docs/recipes/configs/cameras-jzt31.yaml @@ -1,13 +1,9 @@ # Two ONVIF cameras (JZT31 family, firmware V4.0) on the bring-up subnet # 192.168.1.0/24, reachable via the host's en7 alias 192.168.1.10. # -# Default mode: HEVC main passthrough — no transcoding. Downstream -# (Electron 33+, Safari, Chrome 107+ on Mac/Win) plays HEVC natively, so -# we keep the camera's full 4K quality at near-zero CPU cost. Set -# `h264_transcode: true` on a per-camera basis if a legacy / WebRTC-only -# consumer needs an H.264 fallback alongside the main HEVC path; pair -# with `h264_transcode_source: sub` if the host is CPU-constrained and -# the lower-resolution sub stream is acceptable. +# Production mode: H.264 Baseline from the camera, passthrough only. Keep +# `h264_transcode: false` so mediamtx and SRS both use `-c copy`; the camera +# web UI owns codec / GOP / bitrate tuning. # # Credentials are bring-up values from camera-bringup/.env.cam131 / .env.cam53. # Move these to env-templated values before sharing the host. @@ -16,10 +12,10 @@ # osdl serve --config docs/recipes/configs/cameras-jzt31.yaml # # After it boots, paths come up at: -# rtsp://:8554/cam131 (HEVC main passthrough) +# rtsp://:8554/cam131 (H.264 passthrough) # rtsp://:8554/cam53 -# plus HLS at :8888//index.m3u8 and (with h264_transcode) the -# WebRTC-friendly :8889/_h264. +# plus local WebRTC at :8889//whep, local HLS at :8888//index.m3u8, +# and remote SRS WebRTC at :1985/rtc/v1/whep/?app=live&stream=. mqtt: ~ # No MQTT bridge. adapters: [] # No physical instruments — pure media gateway. @@ -32,10 +28,16 @@ media_gateway: media_sources: - kind: onvif_camera id: cam131 - description: JZT31X PTZ dome (WiFi). Main 2304x1296 HEVC, passthrough. + description: JZT31X PTZ dome (WiFi). H.264 Baseline passthrough. rtsp_main: rtsp://onvif_op:IK1tCNzKP7VyAPIl@192.168.1.131:554/ch01.264 rtsp_sub: rtsp://onvif_op:IK1tCNzKP7VyAPIl@192.168.1.131:554/ch01_sub.264 + h264_transcode: false rtsp_transport_tcp: true + remote_rtmp: + base_url: rtmp://127.0.0.1:1935/live + stream: cam131 + http_host: 127.0.0.1:18085 + webrtc_host: 127.0.0.1:1985 control: onvif_url: http://192.168.1.131:80/onvif/device_service username: onvif_op @@ -43,12 +45,18 @@ media_sources: - kind: onvif_camera id: cam53 - description: JZT31N V2 PTZ. Main 2560x1440 HEVC, passthrough. + description: JZT31N V2 PTZ. H.264 Baseline passthrough. # Operator account hasn't been provisioned on this unit yet; using # factory admin until the manual web-UI step in camera-bringup is done. rtsp_main: rtsp://admin:123456@192.168.1.53:554/ch01.264 rtsp_sub: rtsp://admin:123456@192.168.1.53:554/ch01_sub.264 + h264_transcode: false rtsp_transport_tcp: true + remote_rtmp: + base_url: rtmp://127.0.0.1:1935/live + stream: cam53 + http_host: 127.0.0.1:18085 + webrtc_host: 127.0.0.1:1985 control: onvif_url: http://192.168.1.53:80/onvif/device_service username: admin diff --git a/docs/recipes/configs/onvif-camera.yaml b/docs/recipes/configs/onvif-camera.yaml index b20225b..c16b9ec 100644 --- a/docs/recipes/configs/onvif-camera.yaml +++ b/docs/recipes/configs/onvif-camera.yaml @@ -36,7 +36,7 @@ media_sources: # profile_token: Profile_1 # optional; auto-discovered otherwise # Optional remote ingest: # remote_rtmp: - # base_url: rtmp://srs.example.com:1935/camera - # stream: lab-1 - # http_host: srs.example.com - # webrtc_host: srs.example.com + # base_url: rtmp://localhost:1935/live # local dev SRS — see docker-compose.srs.yaml + # stream: cam1 # defaults to camera id + # http_host: localhost:18085 # HLS / FLV egress (http_server port) + # webrtc_host: localhost:1985 # WHEP signalling (http_api port) diff --git a/docs/recipes/media-gateway.md b/docs/recipes/media-gateway.md index 045ac78..bd5541b 100644 --- a/docs/recipes/media-gateway.md +++ b/docs/recipes/media-gateway.md @@ -76,5 +76,102 @@ The engine signals mediamtx to terminate gracefully on shutdown. suffix path. - For remote ingest (push to SRS), uncomment the `remote_rtmp:` block. See `crates/osdl-core/src/media/onvif_camera.rs` for the validation - rules — most importantly, you must have transcoding enabled to have an - H.264 source path to republish from. + rules. Note: with HEVC passthrough as the default, the mediamtx push + to SRS is `-c copy` of the HEVC stream — vanilla RTMP carries only + H.264, so this path relies on SRS's Enhanced RTMP / HEVC-over-RTMP + support (SRS 5+ has it). If the downstream browser's WHEP stack + refuses to negotiate HEVC, flip `h264_transcode: true` to force an + H.264 republish from the camera's main or sub stream. + +## Remote ingest (push to SRS) + +Each camera can optionally push to a remote SRS instance so viewers +outside the LAN (e.g. a teammate the camera is shared with) can still +see the live feed. mediamtx re-publishes the camera's already-encoded +stream to SRS via `ffmpeg -c copy`, so there is no extra encoding cost +on the lab host. + +### Local dev — bring up SRS with `just dev` + +A local SRS container is included in the dev stack automatically +(`docker/docker-compose.srs.yaml`). After `just dev`: + +- RTMP ingest: `rtmp://localhost:1935//` +- HTTP-FLV: `http://localhost:18085//.flv` +- HLS: `http://localhost:18085//.m3u8` +- WHEP (POST): `http://localhost:1985/rtc/v1/whep/?app=&stream=` +- WebRTC media: UDP `localhost:8000` + +**Two HTTP ports** — SRS splits its HTTP surface: HLS/FLV on the +`http_server` port (18085) and the WHEP signalling POST on the `http_api` +port (1985). The browser's WebRTC media flows over UDP 8000. + +**UDP 8000 is not remapped** — SRS bakes the internal UDP port +(`a=candidate:... 127.0.0.1 8000`) into its SDP answer, so Docker's +host-side port remapping is invisible to the browser. SRS_RTC_PORT +must stay `8000`. UDP 8000 is free on the dev host (TCP 8000 is taken +by MinIO; TCP and UDP are independent sockets). + +(HTTP-server port defaults to 18085, not 8080, because the dev stack's +OpenFGA `network-service` already occupies 8080. See +`docker/docker-compose.srs.yaml`.) + +If any of the dev host ports 1935, 18085, or 1985 are taken, override via +`SRS_RTMP_PORT` / `SRS_HTTP_PORT` / `SRS_API_PORT` in +`docker/.env.dev`. + +### Point a camera at the local SRS + +Drop this into the camera YAML. Note `http_host` and `webrtc_host` +point at *different* ports — the FLV/HLS server vs the WHEP signalling +API. Use `localhost` when the mediamtx publisher and the browser +viewer run on the same host as the dev stack. + +```yaml +remote_rtmp: + base_url: rtmp://localhost:1935/live + stream: cam1 + http_host: localhost:18085 # HLS / FLV egress port + webrtc_host: localhost:1985 # WHEP signalling port (http_api) +``` + +Restart `osdl serve`. The `MediaSourceOnline` event will now include +four extra `location: "remote"` endpoints alongside the three local +mediamtx ones: + +``` +{"protocol":"rtmp", "location":"remote","url":"rtmp://localhost:1935/live/cam1"}, +{"protocol":"flv", "location":"remote","url":"http://localhost:18085/live/cam1.flv"}, +{"protocol":"hls", "location":"remote","url":"http://localhost:18085/live/cam1.m3u8"}, +{"protocol":"webrtc", "location":"remote", + "url":"http://localhost:1985/rtc/v1/whep/?app=live&stream=cam1"} +``` + +### Smoke test + +```sh +# 1. Confirm mediamtx is pushing to SRS. +curl -s http://localhost:1985/api/v1/streams/ | jq + +# 2. SRS-side probe. +ffprobe http://localhost:18085/live/cam1.flv + +# 3. End-to-end via the web UI — open the team channel that owns the +# camera; the tile should show `live` within a few seconds. +``` + +### Browser fallback order + +The `CameraTile` component tries each advertised endpoint in turn: + +1. Local mediamtx WHEP (loopback / LAN) +2. Remote SRS WHEP +3. Remote SRS HLS (H.264, via native HLS or hls.js) +4. Local mediamtx HLS + +A teammate off the LAN fails step 1 fast (mediamtx port unreachable) +and falls through to step 2. If Electron/Chromium cannot establish the +SRS UDP media path, it falls through to step 3, which works over HTTP +and uses the H.264 SRS output before trying any local HEVC HLS path. +The same endpoint list is sent to owner and teammate — only the +browser's reach differs.