Skip to main content

heldar_kernel/services/
mediamtx.rs

1//! Live-view gateway integration: registers a camera's stream as a MediaMTX path (server-side,
2//! credentials never exposed to the browser) and returns HLS / WebRTC / RTSP playback URLs.
3
4use serde::Serialize;
5use serde_json::json;
6
7use crate::camera_url;
8use crate::config::Config;
9use crate::error::{AppError, AppResult};
10use crate::models::Camera;
11use crate::state::AppState;
12
13/// Software (libx264) encoder args for the live HEVC->H.264 preview transcode (the default path).
14const SOFTWARE_CODEC_ARGS: &str =
15    "-c:v libx264 -preset ultrafast -tune zerolatency -profile:v baseline -pix_fmt yuv420p -g 30";
16
17/// FFmpeg encoder args for the live preview transcode, selected by `HELDAR_LIVE_TRANSCODE_ENGINE`.
18/// `software` uses libx264 (CPU); `vaapi` offloads to an Intel/AMD render node; `nvenc` to an NVIDIA
19/// GPU. An unknown engine warns and falls back to software so a typo never breaks live preview.
20pub fn transcode_codec_args(cfg: &Config) -> String {
21    select_codec_args(&cfg.live_transcode_engine, &cfg.vaapi_device)
22}
23
24fn select_codec_args(engine: &str, vaapi_device: &str) -> String {
25    match engine {
26        "software" => SOFTWARE_CODEC_ARGS.to_string(),
27        // VAAPI: upload the decoded frames to the render node and encode with h264_vaapi.
28        "vaapi" => {
29            format!("-vaapi_device {vaapi_device} -vf format=nv12,hwupload -c:v h264_vaapi -g 30")
30        }
31        // NVENC: low-latency NVIDIA hardware encoder.
32        "nvenc" => "-c:v h264_nvenc -preset p1 -tune ll -profile:v baseline -pix_fmt yuv420p -g 30"
33            .to_string(),
34        other => {
35            tracing::warn!(
36                engine = %other,
37                "unknown HELDAR_LIVE_TRANSCODE_ENGINE; falling back to software (libx264)"
38            );
39            SOFTWARE_CODEC_ARGS.to_string()
40        }
41    }
42}
43
44#[derive(Debug, Serialize)]
45pub struct LiveUrls {
46    pub name: String,
47    pub hls_url: String,
48    pub webrtc_url: String,
49    pub rtsp_url: String,
50}
51
52/// Ensure a MediaMTX path exists for this camera and return its playback URLs.
53pub async fn ensure_live(state: &AppState, camera_id: &str) -> AppResult<LiveUrls> {
54    let cam: Option<Camera> = sqlx::query_as::<_, Camera>("SELECT * FROM cameras WHERE id = ?")
55        .bind(camera_id)
56        .fetch_optional(&state.pool)
57        .await?;
58    let cam = cam.ok_or_else(|| AppError::NotFound(format!("camera {camera_id} not found")))?;
59
60    let source = camera_url::stream_url(&cam, "sub")
61        .or_else(|| camera_url::record_url(&cam))
62        .ok_or_else(|| AppError::BadRequest("camera has no stream URL".into()))?;
63
64    let name = format!("cam_{camera_id}");
65    let api = state.cfg.mediamtx_api_url.trim_end_matches('/');
66
67    let existing = state
68        .http
69        .get(format!("{api}/v3/config/paths/get/{name}"))
70        .send()
71        .await;
72    let already = matches!(existing, Ok(ref r) if r.status().is_success());
73
74    if !already {
75        // Transcode to H.264 on demand: many cameras (e.g. these HikVision units) emit HEVC, which
76        // browsers can't play over HLS/WebRTC. FFmpeg decodes the camera stream and republishes
77        // H.264 to this path, but only while someone is actually watching (runOnDemand). The raw
78        // stream is still recorded untouched by the recorder; this decode is preview-only.
79        // $MTX_PATH / $RTSP_PORT are substituted by MediaMTX; credentials stay server-side. The
80        // video encoder args are selected by HELDAR_LIVE_TRANSCODE_ENGINE (software | vaapi | nvenc).
81        let codec_args = transcode_codec_args(&state.cfg);
82        // Live audio is opt-in per camera, reusing the same `record_audio` intent as the recorder:
83        // a camera you record audio for can also be listened to live (re-encoded to AAC for HLS; a
84        // no-op when the source has no audio track). Cameras without it stay video-only (`-an`).
85        let audio_args = if cam.record_audio {
86            "-c:a aac -b:a 96k"
87        } else {
88            "-an"
89        };
90        let run_on_demand = format!(
91            "ffmpeg -nostdin -rtsp_transport tcp -timeout 10000000 -i {source} {audio_args} \
92{codec_args} \
93-f rtsp rtsp://localhost:$RTSP_PORT/$MTX_PATH"
94        );
95        let body = json!({
96            "runOnDemand": run_on_demand,
97            "runOnDemandRestart": true,
98            "runOnDemandCloseAfter": "10s",
99        });
100        let resp = state
101            .http
102            .post(format!("{api}/v3/config/paths/add/{name}"))
103            .json(&body)
104            .send()
105            .await
106            .map_err(|e| AppError::Other(anyhow::anyhow!("MediaMTX unreachable at {api}: {e}")))?;
107        let code = resp.status();
108        if !code.is_success() && code.as_u16() != 400 {
109            let txt = resp.text().await.unwrap_or_default();
110            return Err(AppError::Other(anyhow::anyhow!(
111                "MediaMTX add-path failed ({code}): {txt}"
112            )));
113        }
114    }
115
116    let hls = state.cfg.mediamtx_hls_base.trim_end_matches('/');
117    let webrtc = state.cfg.mediamtx_webrtc_base.trim_end_matches('/');
118    let rtsp = state.cfg.mediamtx_rtsp_base.trim_end_matches('/');
119    Ok(LiveUrls {
120        hls_url: format!("{hls}/{name}/index.m3u8"),
121        webrtc_url: format!("{webrtc}/{name}"),
122        rtsp_url: format!("{rtsp}/{name}"),
123        name,
124    })
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130
131    #[test]
132    fn codec_args_select_by_engine() {
133        assert_eq!(
134            select_codec_args("software", "/dev/dri/renderD128"),
135            SOFTWARE_CODEC_ARGS
136        );
137        let vaapi = select_codec_args("vaapi", "/dev/dri/renderD129");
138        assert!(vaapi.contains("h264_vaapi"));
139        assert!(vaapi.contains("/dev/dri/renderD129"));
140        assert!(select_codec_args("nvenc", "/dev/dri/renderD128").contains("h264_nvenc"));
141        // Unknown engine falls back to software (libx264).
142        assert_eq!(
143            select_codec_args("bogus", "/dev/dri/renderD128"),
144            SOFTWARE_CODEC_ARGS
145        );
146    }
147}