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
176impl Pattern {
177    /// The spelling [`FromStr`] reads back.
178    #[must_use]
179    pub const fn as_str(self) -> &'static str {
180        match self {
181            Self::Rgb => "rgb",
182            Self::Border => "border",
183            Self::Rows => "rows",
184            Self::Gradient => "gradient",
185            Self::White => "white",
186        }
187    }
188}
189
190impl std::fmt::Display for Pattern {
191    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
192        f.write_str(self.as_str())
193    }
194}
195
196const RED: [u8; 3] = [255, 0, 0];
197const GREEN: [u8; 3] = [0, 255, 0];
198const BLUE: [u8; 3] = [0, 0, 255];
199const WHITE: [u8; 3] = [255; 3];
200
201/// Draw a built-in pattern at the given size.
202#[must_use]
203pub fn pattern(p: Pattern, width: u32, height: u32) -> Frame {
204    let mut f = Frame::black(width, height);
205    match p {
206        Pattern::Rgb => {
207            if width > 0 && height > 0 {
208                // Bands at w/3 and 2w/3, not 2*(w/3); the boundary column is
209                // pinned by rgb_pattern_puts_red_left_and_blue_right.
210                for (x, px) in f.row_mut(0).iter_mut().enumerate() {
211                    let x = x as u32;
212                    *px = if x < width / 3 {
213                        RED
214                    } else if x < 2 * width / 3 {
215                        GREEN
216                    } else {
217                        BLUE
218                    };
219                }
220                let stride = (width as usize) * 3;
221                let (first, rest) = f.as_bytes_mut().split_at_mut(stride);
222                for row in rest.chunks_exact_mut(stride) {
223                    row.copy_from_slice(first);
224                }
225            }
226        }
227        Pattern::Border => {
228            let (right, bottom) = (width.saturating_sub(1), height.saturating_sub(1));
229            for y in 0..height {
230                let row = f.row_mut(y);
231                if y == 0 || y == bottom {
232                    row.fill(WHITE);
233                } else if let Some((l, rest)) = row.split_first_mut() {
234                    *l = WHITE;
235                    if let Some(r) = rest.last_mut() {
236                        *r = WHITE;
237                    }
238                }
239            }
240            f.set_pixel(0, 0, RED);
241            f.set_pixel(right, 0, GREEN);
242            f.set_pixel(0, bottom, BLUE);
243        }
244        Pattern::Rows => {
245            for y in 0..height {
246                let c = match y % 3 {
247                    0 => RED,
248                    1 => GREEN,
249                    _ => BLUE,
250                };
251                f.row_mut(y).fill(c);
252            }
253        }
254        Pattern::Gradient => {
255            for y in 0..height {
256                let g = (y * 255 / height.max(1)) as u8;
257                for (x, px) in f.row_mut(y).iter_mut().enumerate() {
258                    let r = (x as u32 * 255 / width.max(1)) as u8;
259                    *px = [r, g, 128];
260                }
261            }
262        }
263        Pattern::White => {
264            f.as_bytes_mut().fill(255);
265        }
266    }
267    f
268}
269
270#[cfg(test)]
271mod tests {
272    use super::*;
273
274    #[test]
275    fn patterns_are_the_requested_size() {
276        let f = pattern(Pattern::Gradient, 16, 8);
277        assert_eq!((f.width, f.height), (16, 8));
278        assert_eq!(f.as_bytes().len(), 16 * 8 * 3);
279    }
280
281    #[test]
282    fn rgb_pattern_puts_red_left_and_blue_right() {
283        let f = pattern(Pattern::Rgb, 30, 2);
284        assert_eq!(f.pixel(0, 0), [255, 0, 0]);
285        assert_eq!(f.pixel(29, 0), [0, 0, 255]);
286        // 2w/3 = 85 on the 128-wide panel; 2*(w/3) would give 84.
287        let f = pattern(Pattern::Rgb, 128, 1);
288        assert_eq!(f.pixel(84, 0), [0, 255, 0]);
289        assert_eq!(f.pixel(85, 0), [0, 0, 255]);
290    }
291
292    #[test]
293    fn border_pattern_marks_the_corners() {
294        let f = pattern(Pattern::Border, 8, 4);
295        assert_eq!(f.pixel(0, 0), [255, 0, 0]);
296        assert_eq!(f.pixel(7, 0), [0, 255, 0]);
297        assert_eq!(f.pixel(0, 3), [0, 0, 255]);
298        assert_eq!(f.pixel(3, 2), [0, 0, 0]);
299    }
300
301    #[test]
302    fn white_pattern_is_fully_lit() {
303        let f = pattern(Pattern::White, 4, 4);
304        assert!(f.as_bytes().iter().all(|&b| b == 255));
305    }
306
307    /// The per-pixel drawing this crate used before the row-wise version.
308    fn pattern_per_pixel(p: Pattern, width: u32, height: u32) -> Frame {
309        let mut f = Frame::black(width, height);
310        for y in 0..height {
311            for x in 0..width {
312                let c = match p {
313                    Pattern::Rgb if x < width / 3 => RED,
314                    Pattern::Rgb if x < 2 * width / 3 => GREEN,
315                    Pattern::Rgb => BLUE,
316                    Pattern::Border if x == 0 || y == 0 || x == width - 1 || y == height - 1 => {
317                        WHITE
318                    }
319                    Pattern::Border => continue,
320                    Pattern::Rows => [RED, GREEN, BLUE][(y % 3) as usize],
321                    Pattern::Gradient => [
322                        (x * 255 / width.max(1)) as u8,
323                        (y * 255 / height.max(1)) as u8,
324                        128,
325                    ],
326                    Pattern::White => WHITE,
327                };
328                f.set_pixel(x, y, c);
329            }
330        }
331        if p == Pattern::Border && width > 0 && height > 0 {
332            f.set_pixel(0, 0, RED);
333            f.set_pixel(width - 1, 0, GREEN);
334            f.set_pixel(0, height - 1, BLUE);
335        }
336        f
337    }
338
339    const ALL: [Pattern; 5] = [
340        Pattern::Rgb,
341        Pattern::Border,
342        Pattern::Rows,
343        Pattern::Gradient,
344        Pattern::White,
345    ];
346
347    #[test]
348    fn every_pattern_matches_the_per_pixel_drawing() {
349        for p in ALL {
350            for (w, h) in [(128, 64), (30, 2), (1, 1), (7, 5), (1, 9), (0, 4), (4, 0), (0, 0)] {
351                assert_eq!(pattern(p, w, h), pattern_per_pixel(p, w, h), "{p:?} {w}x{h}");
352            }
353        }
354    }
355
356    #[test]
357    fn names_parse_case_insensitively_and_reject_strangers() {
358        assert_eq!("RGB".parse::<Pattern>(), Ok(Pattern::Rgb));
359        assert_eq!("contain".parse::<Fit>(), Ok(Fit::Contain));
360        assert_eq!(
361            "Blob".parse::<Pattern>().unwrap_err().to_string(),
362            "unknown pattern \"Blob\" (rgb|border|rows|gradient|white)"
363        );
364        assert_eq!(
365            "fill".parse::<Fit>().unwrap_err().to_string(),
366            "unknown fit \"fill\" (stretch|contain|cover)"
367        );
368    }
369
370    #[test]
371    fn fit_filters_mention_the_target_size() {
372        assert!(Fit::Stretch.filter(128, 64).contains("128:64"));
373        assert!(Fit::Contain.filter(128, 64).contains("pad=128:64"));
374        assert!(Fit::Cover.filter(128, 64).contains("crop=128:64"));
375    }
376}