Skip to main content

seydd/input/
mod.rs

1// Copyright 2026 Anton Gravestam
2// SPDX-License-Identifier: Apache-2.0
3//! Media and sensor inputs. Everything here produces encoded bytes; nothing
4//! decodes. The daemon depacketizes (RTP → NAL units) — that is framing, not
5//! transcoding.
6
7pub mod rtp;
8pub mod rtsp;
9pub mod udp;
10
11use bytes::Bytes;
12
13/// One encoded picture (Annex B, parameter sets inline on keyframes).
14#[derive(Debug, Clone)]
15pub struct VideoAu {
16    pub data: Bytes,
17    pub keyframe: bool,
18    /// Microseconds on the *source's* sampling clock (the RTP timestamp),
19    /// relative to the first picture of the stream — not an agent-clock
20    /// arrival time. The pilot paces presentation on this timeline, so it must
21    /// carry the encoder's cadence and none of the transport's jitter.
22    pub capture_ts_us: u64,
23    /// RTP packets lost before this picture (input-side loss, not Seyd's).
24    pub input_loss: u16,
25}
26
27/// Strip userinfo from a URL for logging. libavformat taught us that the
28/// password leaks on the *error* path if this is not applied everywhere.
29pub fn redact(url: &str) -> String {
30    match url::Url::parse(url) {
31        Ok(mut u) if u.username() != "" || u.password().is_some() => {
32            let _ = u.set_username("");
33            let _ = u.set_password(None);
34            u.to_string()
35        }
36        _ => url.to_string(),
37    }
38}
39
40/// Which elementary stream the input carries.
41///
42/// Seyd never decodes video, so this exists for exactly two reasons: to find
43/// frame boundaries in an RTP stream, and to know which frames are keyframes.
44/// Everything downstream — chunking, FEC, transport, pacing — is bytes.
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
46pub enum Codec {
47    #[default]
48    H264,
49    H265,
50    /// Motion JPEG: a sequence of complete JPEG images, one per frame.
51    ///
52    /// The only codec ONVIF Profile S makes *mandatory*, so every conformant
53    /// camera can emit it while none is obliged to emit H.265 — which makes it
54    /// the compatibility path, not a preference. It costs roughly an order of
55    /// magnitude more bandwidth than H.264.
56    Mjpeg,
57}
58
59impl Codec {
60    /// From the channel's `codec` string, which is a WebCodecs identifier
61    /// (`avc1.42001f`, `hev1.1.6.L93.B0`) because it also travels to the
62    /// browser's decoder untouched. Bare `h264` / `h265` are accepted too.
63    pub fn from_codec_string(s: &str) -> anyhow::Result<Self> {
64        let head = s.split('.').next().unwrap_or(s).to_ascii_lowercase();
65        match head.as_str() {
66            "avc1" | "avc3" | "h264" => Ok(Codec::H264),
67            "hev1" | "hvc1" | "h265" | "hevc" => Ok(Codec::H265),
68            "mjpeg" | "jpeg" => Ok(Codec::Mjpeg),
69            _ => anyhow::bail!(
70                "codec {s:?} is not a video codec Seyd can frame. Use an H.264 \
71                 (avc1.…), H.265 (hev1.…) or Motion JPEG (mjpeg) identifier."
72            ),
73        }
74    }
75
76    /// The RTP `encoding_name` an RTSP server advertises for this codec.
77    pub fn rtp_encoding_name(self) -> &'static str {
78        match self {
79            Codec::H264 => "h264",
80            Codec::H265 => "h265",
81            Codec::Mjpeg => "jpeg",
82        }
83    }
84}
85
86/// Start the video input matching the URL scheme; frames flow to `tx`.
87/// Reconnects forever with backoff — a robot whose camera reboots keeps its
88/// agent alive.
89pub fn spawn_video(
90    url: String,
91    codec: Codec,
92    tx: tokio::sync::mpsc::Sender<VideoAu>,
93) -> anyhow::Result<()> {
94    if codec == Codec::Mjpeg {
95        // Measured on a 640x480@15 test source: 3478 kbps, against 3431 kbps
96        // for H.264 at 1280x720@30 — the same bandwidth for an eighth of the
97        // pixel throughput. Worth saying out loud, because a camera left on
98        // its ONVIF-mandated default will quietly cost eight times the link.
99        tracing::warn!(
100            "channel is Motion JPEG: every frame is a full JPEG, so expect roughly \
101             an order of magnitude more bandwidth than H.264 at the same picture. \
102             Use it for cameras that offer nothing better, and prefer H.264 or \
103             H.265 where the camera supports it."
104        );
105    }
106    let parsed = url::Url::parse(&url)?;
107    match parsed.scheme() {
108        "rtsp" | "rtsps" => {
109            tokio::spawn(rtsp::run(url, codec, tx));
110        }
111        // RFC 2435 strips the JPEG headers from every packet and the receiver
112        // has to rebuild a JFIF header from the type, Q and dimensions. Retina
113        // does that for us on the RTSP path; doing it again here for bare RTP
114        // would be a second implementation of a fiddly reconstruction for a
115        // transport no ONVIF camera uses. Say so rather than half-support it.
116        "rtp" if codec == Codec::Mjpeg => {
117            anyhow::bail!(
118                "Motion JPEG is supported over rtsp:// only. Bare rtp:// carries \
119                 JPEG without its headers (RFC 2435) and Seyd does not rebuild them; \
120                 point this channel at the camera's RTSP URL, or have the source \
121                 send H.264."
122            )
123        }
124        "rtp" => {
125            let addr = format!(
126                "{}:{}",
127                parsed.host_str().unwrap_or("0.0.0.0"),
128                parsed
129                    .port()
130                    .ok_or_else(|| anyhow::anyhow!("rtp:// needs a port"))?
131            );
132            tokio::spawn(rtp::run(addr, codec, tx));
133        }
134        other => anyhow::bail!("unsupported video input scheme {other:?}"),
135    }
136    Ok(())
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142
143    #[test]
144    fn codec_strings_map_to_the_right_framing() {
145        // WebCodecs identifiers, because the same string travels to the
146        // browser's decoder untouched.
147        assert_eq!(
148            Codec::from_codec_string("avc1.42001f").unwrap(),
149            Codec::H264
150        );
151        assert_eq!(
152            Codec::from_codec_string("hev1.1.6.L93.B0").unwrap(),
153            Codec::H265
154        );
155        assert_eq!(
156            Codec::from_codec_string("hvc1.1.6.L93.B0").unwrap(),
157            Codec::H265
158        );
159        assert_eq!(Codec::from_codec_string("mjpeg").unwrap(), Codec::Mjpeg);
160        assert_eq!(Codec::from_codec_string("JPEG").unwrap(), Codec::Mjpeg);
161        assert!(Codec::from_codec_string("vp09.00.10.08").is_err());
162    }
163
164    #[test]
165    fn rtp_encoding_names_match_what_an_rtsp_server_advertises() {
166        assert_eq!(Codec::H264.rtp_encoding_name(), "h264");
167        assert_eq!(Codec::H265.rtp_encoding_name(), "h265");
168        // retina matches ("image" | "video", "jpeg").
169        assert_eq!(Codec::Mjpeg.rtp_encoding_name(), "jpeg");
170    }
171
172    #[test]
173    fn bare_rtp_mjpeg_is_refused_with_an_explanation_rather_than_silence() {
174        // RFC 2435 strips the JPEG headers; rebuilding them is retina's job on
175        // the RTSP path and Seyd does not duplicate it. Failing loudly beats
176        // accepting the config and delivering no frames.
177        let (tx, _rx) = tokio::sync::mpsc::channel(1);
178        let err = spawn_video("rtp://127.0.0.1:5000".into(), Codec::Mjpeg, tx)
179            .expect_err("bare rtp:// MJPEG must be refused");
180        let msg = err.to_string();
181        assert!(
182            msg.contains("rtsp://"),
183            "the error should say what to do instead: {msg}"
184        );
185    }
186
187    // Needs a runtime: unlike the MJPEG case this reaches `tokio::spawn`.
188    #[tokio::test]
189    async fn bare_rtp_still_works_for_the_codecs_that_carry_their_own_headers() {
190        let (tx, _rx) = tokio::sync::mpsc::channel(1);
191        assert!(spawn_video("rtp://127.0.0.1:5000".into(), Codec::H264, tx).is_ok());
192    }
193}