1pub mod raw;
6
7use anyhow::{Context, Result};
8use wall::Frame;
9use std::process::{Child, ChildStdout, Command, Stdio};
10use std::str::FromStr;
11
12pub trait FrameSource {
14 fn next_frame(&mut self, frame: &mut Frame) -> Result<bool>;
20}
21
22#[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#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
40#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(rename_all = "lowercase"))]
41pub enum Fit {
42 #[default]
44 Stretch,
45 Contain,
47 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
83pub struct VideoSource {
85 child: Child,
86 frames: raw::RawSource<ChildStdout>,
87}
88
89impl VideoSource {
90 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
144#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(rename_all = "lowercase"))]
145pub enum Pattern {
146 Rgb,
148 Border,
150 Rows,
152 Gradient,
154 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#[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 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 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 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}