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
176impl Pattern {
177 #[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#[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 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 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 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}