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}