Skip to main content

sources/
lib.rs

1//! Frame sources: ffmpeg video, raw rgb24 streams from other processes, and
2//! built-in test patterns. ffmpeg is asked for rgb24 already scaled to the
3//! wall size, so nothing here parses containers or codecs.
4
5pub mod raw;
6
7use anyhow::{Context, Result};
8use wall::Frame;
9use std::process::{Child, ChildStdout, Command, Stdio};
10use std::str::FromStr;
11
12/// Anything that refills a caller-owned frame, one image per call.
13pub trait FrameSource {
14    /// Fill `frame` with the next image, resizing it to the source's size if
15    /// needed. `Ok(false)` at the end of the stream.
16    ///
17    /// # Errors
18    /// Fails if the underlying stream cannot be read.
19    fn next_frame(&mut self, frame: &mut Frame) -> Result<bool>;
20}
21
22/// A name that is not one of the allowed spellings for a type.
23#[derive(Clone, Debug, PartialEq, Eq)]
24pub struct UnknownName {
25    what: &'static str,
26    got: String,
27    allowed: &'static str,
28}
29
30impl std::fmt::Display for UnknownName {
31    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
32        write!(f, "unknown {} {:?} ({})", self.what, self.got, self.allowed)
33    }
34}
35
36impl std::error::Error for UnknownName {}
37
38/// How a source should fit its images to the wall.
39#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
40#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(rename_all = "lowercase"))]
41pub enum Fit {
42    /// Fill the wall exactly, ignoring the source's aspect ratio.
43    #[default]
44    Stretch,
45    /// Preserve aspect ratio, padding with black.
46    Contain,
47    /// Preserve aspect ratio, cropping the overflow.
48    Cover,
49}
50
51impl FromStr for Fit {
52    type Err = UnknownName;
53    fn from_str(s: &str) -> Result<Self, Self::Err> {
54        match s {
55            "stretch" => Ok(Self::Stretch),
56            "contain" => Ok(Self::Contain),
57            "cover" => Ok(Self::Cover),
58            _ => Err(UnknownName {
59                what: "fit",
60                got: s.to_owned(),
61                allowed: "stretch|contain|cover",
62            }),
63        }
64    }
65}
66
67impl Fit {
68    fn filter(self, w: u32, h: u32) -> String {
69        match self {
70            Self::Stretch => format!("scale={w}:{h}:flags=lanczos"),
71            Self::Contain => format!(
72                "scale={w}:{h}:flags=lanczos:force_original_aspect_ratio=decrease,\
73                 pad={w}:{h}:(ow-iw)/2:(oh-ih)/2:color=black"
74            ),
75            Self::Cover => format!(
76                "scale={w}:{h}:flags=lanczos:force_original_aspect_ratio=increase,\
77                 crop={w}:{h}"
78            ),
79        }
80    }
81}
82
83/// Decodes a video file (or any URL ffmpeg understands) into wall-sized frames.
84pub struct VideoSource {
85    child: Child,
86    frames: raw::RawSource<ChildStdout>,
87}
88
89impl VideoSource {
90    /// Start decoding `input`, scaled to `width` x `height` at `fps`.
91    ///
92    /// # Errors
93    /// Fails if ffmpeg is not installed or cannot start.
94    pub fn open(
95        input: &str,
96        width: u32,
97        height: u32,
98        fps: u32,
99        fit: Fit,
100        repeat: bool,
101    ) -> Result<Self> {
102        let mut cmd = Command::new("ffmpeg");
103        cmd.arg("-hide_banner").args(["-loglevel", "error"]);
104        if repeat {
105            cmd.args(["-stream_loop", "-1"]);
106        }
107        cmd.args(["-i", input])
108            .args(["-vf", &format!("{},fps={fps}", fit.filter(width, height))])
109            .args(["-f", "rawvideo"])
110            .args(["-pix_fmt", "rgb24"])
111            .arg("-")
112            .stdout(Stdio::piped())
113            .stderr(Stdio::inherit())
114            .stdin(Stdio::null());
115
116        let mut child = cmd
117            .spawn()
118            .context("could not start ffmpeg (is it installed?)")?;
119        let stdout = child.stdout.take().context("ffmpeg stdout not piped")?;
120        Ok(Self {
121            child,
122            frames: raw::RawSource::new(stdout, width, height),
123        })
124    }
125}
126
127impl FrameSource for VideoSource {
128    fn next_frame(&mut self, frame: &mut Frame) -> Result<bool> {
129        self.frames
130            .read_frame(frame)
131            .context("read frame from ffmpeg")
132    }
133}
134
135impl Drop for VideoSource {
136    fn drop(&mut self) {
137        let _ = self.child.kill();
138        let _ = self.child.wait();
139    }
140}
141
142/// Built-in patterns, for checking wiring and colour order without any media.
143#[derive(Clone, Copy, Debug, PartialEq, Eq)]
144#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(rename_all = "lowercase"))]
145pub enum Pattern {
146    /// Vertical red, green and blue bands: confirms colour order.
147    Rgb,
148    /// A one-pixel white border with coloured corners: confirms geometry.
149    Border,
150    /// Horizontal red/green/blue stripes: confirms row mapping.
151    Rows,
152    /// A two-axis gradient.
153    Gradient,
154    /// Solid white: maximum current draw, useful for power checks.
155    White,
156}
157
158impl FromStr for Pattern {
159    type Err = UnknownName;
160    fn from_str(s: &str) -> Result<Self, Self::Err> {
161        match s.to_ascii_lowercase().as_str() {
162            "rgb" => Ok(Self::Rgb),
163            "border" => Ok(Self::Border),
164            "rows" => Ok(Self::Rows),
165            "gradient" => Ok(Self::Gradient),
166            "white" => Ok(Self::White),
167            _ => Err(UnknownName {
168                what: "pattern",
169                got: s.to_owned(),
170                allowed: "rgb|border|rows|gradient|white",
171            }),
172        }
173    }
174}
175
176const RED: [u8; 3] = [255, 0, 0];
177const GREEN: [u8; 3] = [0, 255, 0];
178const BLUE: [u8; 3] = [0, 0, 255];
179const WHITE: [u8; 3] = [255; 3];
180
181/// Draw a built-in pattern at the given size.
182#[must_use]
183pub fn pattern(p: Pattern, width: u32, height: u32) -> Frame {
184    let mut f = Frame::black(width, height);
185    match p {
186        Pattern::Rgb => {
187            if width > 0 && height > 0 {
188                // Bands at w/3 and 2w/3, not 2*(w/3); the boundary column is
189                // pinned by rgb_pattern_puts_red_left_and_blue_right.
190                for (x, px) in f.row_mut(0).iter_mut().enumerate() {
191                    let x = x as u32;
192                    *px = if x < width / 3 {
193                        RED
194                    } else if x < 2 * width / 3 {
195                        GREEN
196                    } else {
197                        BLUE
198                    };
199                }
200                let stride = (width as usize) * 3;
201                let (first, rest) = f.as_bytes_mut().split_at_mut(stride);
202                for row in rest.chunks_exact_mut(stride) {
203                    row.copy_from_slice(first);
204                }
205            }
206        }
207        Pattern::Border => {
208            let (right, bottom) = (width.saturating_sub(1), height.saturating_sub(1));
209            for y in 0..height {
210                let row = f.row_mut(y);
211                if y == 0 || y == bottom {
212                    row.fill(WHITE);
213                } else if let Some((l, rest)) = row.split_first_mut() {
214                    *l = WHITE;
215                    if let Some(r) = rest.last_mut() {
216                        *r = WHITE;
217                    }
218                }
219            }
220            f.set_pixel(0, 0, RED);
221            f.set_pixel(right, 0, GREEN);
222            f.set_pixel(0, bottom, BLUE);
223        }
224        Pattern::Rows => {
225            for y in 0..height {
226                let c = match y % 3 {
227                    0 => RED,
228                    1 => GREEN,
229                    _ => BLUE,
230                };
231                f.row_mut(y).fill(c);
232            }
233        }
234        Pattern::Gradient => {
235            for y in 0..height {
236                let g = (y * 255 / height.max(1)) as u8;
237                for (x, px) in f.row_mut(y).iter_mut().enumerate() {
238                    let r = (x as u32 * 255 / width.max(1)) as u8;
239                    *px = [r, g, 128];
240                }
241            }
242        }
243        Pattern::White => {
244            f.as_bytes_mut().fill(255);
245        }
246    }
247    f
248}
249
250#[cfg(test)]
251mod tests {
252    use super::*;
253
254    #[test]
255    fn patterns_are_the_requested_size() {
256        let f = pattern(Pattern::Gradient, 16, 8);
257        assert_eq!((f.width, f.height), (16, 8));
258        assert_eq!(f.as_bytes().len(), 16 * 8 * 3);
259    }
260
261    #[test]
262    fn rgb_pattern_puts_red_left_and_blue_right() {
263        let f = pattern(Pattern::Rgb, 30, 2);
264        assert_eq!(f.pixel(0, 0), [255, 0, 0]);
265        assert_eq!(f.pixel(29, 0), [0, 0, 255]);
266        // 2w/3 = 85 on the 128-wide panel; 2*(w/3) would give 84.
267        let f = pattern(Pattern::Rgb, 128, 1);
268        assert_eq!(f.pixel(84, 0), [0, 255, 0]);
269        assert_eq!(f.pixel(85, 0), [0, 0, 255]);
270    }
271
272    #[test]
273    fn border_pattern_marks_the_corners() {
274        let f = pattern(Pattern::Border, 8, 4);
275        assert_eq!(f.pixel(0, 0), [255, 0, 0]);
276        assert_eq!(f.pixel(7, 0), [0, 255, 0]);
277        assert_eq!(f.pixel(0, 3), [0, 0, 255]);
278        assert_eq!(f.pixel(3, 2), [0, 0, 0]);
279    }
280
281    #[test]
282    fn white_pattern_is_fully_lit() {
283        let f = pattern(Pattern::White, 4, 4);
284        assert!(f.as_bytes().iter().all(|&b| b == 255));
285    }
286
287    /// The per-pixel drawing this crate used before the row-wise version.
288    fn pattern_per_pixel(p: Pattern, width: u32, height: u32) -> Frame {
289        let mut f = Frame::black(width, height);
290        for y in 0..height {
291            for x in 0..width {
292                let c = match p {
293                    Pattern::Rgb if x < width / 3 => RED,
294                    Pattern::Rgb if x < 2 * width / 3 => GREEN,
295                    Pattern::Rgb => BLUE,
296                    Pattern::Border if x == 0 || y == 0 || x == width - 1 || y == height - 1 => {
297                        WHITE
298                    }
299                    Pattern::Border => continue,
300                    Pattern::Rows => [RED, GREEN, BLUE][(y % 3) as usize],
301                    Pattern::Gradient => [
302                        (x * 255 / width.max(1)) as u8,
303                        (y * 255 / height.max(1)) as u8,
304                        128,
305                    ],
306                    Pattern::White => WHITE,
307                };
308                f.set_pixel(x, y, c);
309            }
310        }
311        if p == Pattern::Border && width > 0 && height > 0 {
312            f.set_pixel(0, 0, RED);
313            f.set_pixel(width - 1, 0, GREEN);
314            f.set_pixel(0, height - 1, BLUE);
315        }
316        f
317    }
318
319    const ALL: [Pattern; 5] = [
320        Pattern::Rgb,
321        Pattern::Border,
322        Pattern::Rows,
323        Pattern::Gradient,
324        Pattern::White,
325    ];
326
327    #[test]
328    fn every_pattern_matches_the_per_pixel_drawing() {
329        for p in ALL {
330            for (w, h) in [(128, 64), (30, 2), (1, 1), (7, 5), (1, 9), (0, 4), (4, 0), (0, 0)] {
331                assert_eq!(pattern(p, w, h), pattern_per_pixel(p, w, h), "{p:?} {w}x{h}");
332            }
333        }
334    }
335
336    #[test]
337    fn names_parse_case_insensitively_and_reject_strangers() {
338        assert_eq!("RGB".parse::<Pattern>(), Ok(Pattern::Rgb));
339        assert_eq!("contain".parse::<Fit>(), Ok(Fit::Contain));
340        assert_eq!(
341            "Blob".parse::<Pattern>().unwrap_err().to_string(),
342            "unknown pattern \"Blob\" (rgb|border|rows|gradient|white)"
343        );
344        assert_eq!(
345            "fill".parse::<Fit>().unwrap_err().to_string(),
346            "unknown fit \"fill\" (stretch|contain|cover)"
347        );
348    }
349
350    #[test]
351    fn fit_filters_mention_the_target_size() {
352        assert!(Fit::Stretch.filter(128, 64).contains("128:64"));
353        assert!(Fit::Contain.filter(128, 64).contains("pad=128:64"));
354        assert!(Fit::Cover.filter(128, 64).contains("crop=128:64"));
355    }
356}