studio_worker/
thumbnail.rs1use std::collections::VecDeque;
9use std::io::Cursor;
10use std::sync::Arc;
11
12use anyhow::Context as _;
13use parking_lot::Mutex;
14
15pub const THUMBNAIL_MAX_SIDE: u32 = 384;
19
20pub const THUMBNAILS_CAP: usize = 2 * crate::runtime::RECENT_JOBS_CAP;
23
24pub fn make_thumbnail(encoded: &[u8]) -> anyhow::Result<Vec<u8>> {
28 let image = image::load_from_memory(encoded).context("decoding the job's image")?;
29 let image = if image.width() > THUMBNAIL_MAX_SIDE || image.height() > THUMBNAIL_MAX_SIDE {
30 image.thumbnail(THUMBNAIL_MAX_SIDE, THUMBNAIL_MAX_SIDE)
31 } else {
32 image
33 };
34 let mut png = Vec::new();
35 image
36 .write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png)
37 .context("encoding the thumbnail")?;
38 Ok(png)
39}
40
41type Entry = (String, Arc<Vec<u8>>);
43
44#[derive(Clone, Default)]
47pub struct Thumbnails {
48 inner: Arc<Mutex<VecDeque<Entry>>>,
49}
50
51impl Thumbnails {
52 pub fn insert(&self, job_id: &str, png: Vec<u8>) {
54 let mut ring = self.inner.lock();
55 ring.retain(|(id, _)| id != job_id);
56 ring.push_back((job_id.to_string(), Arc::new(png)));
57 while ring.len() > THUMBNAILS_CAP {
58 ring.pop_front();
59 }
60 }
61
62 pub fn get(&self, job_id: &str) -> Option<Arc<Vec<u8>>> {
64 self.inner
65 .lock()
66 .iter()
67 .find(|(id, _)| id == job_id)
68 .map(|(_, png)| png.clone())
69 }
70
71 pub fn contains(&self, job_id: &str) -> bool {
73 self.inner.lock().iter().any(|(id, _)| id == job_id)
74 }
75
76 pub fn retain(&self, keep: impl Fn(&str) -> bool) {
78 self.inner.lock().retain(|(id, _)| keep(id));
79 }
80
81 pub fn clear(&self) {
83 self.inner.lock().clear();
84 }
85
86 pub fn len(&self) -> usize {
88 self.inner.lock().len()
89 }
90
91 pub fn is_empty(&self) -> bool {
93 self.len() == 0
94 }
95}
96
97#[cfg(test)]
98mod tests {
99 use super::*;
100
101 fn encoded(width: u32, height: u32, format: image::ImageFormat) -> Vec<u8> {
102 let image = image::RgbImage::from_pixel(width, height, image::Rgb([200, 40, 40]));
103 let mut out = Vec::new();
104 image::DynamicImage::ImageRgb8(image)
105 .write_to(&mut Cursor::new(&mut out), format)
106 .unwrap();
107 out
108 }
109
110 fn dimensions(png: &[u8]) -> (u32, u32) {
111 let image = image::load_from_memory_with_format(png, image::ImageFormat::Png).unwrap();
112 (image.width(), image.height())
113 }
114
115 #[test]
116 fn a_large_image_is_scaled_to_fit_keeping_its_aspect() {
117 let png = make_thumbnail(&encoded(1024, 512, image::ImageFormat::WebP)).unwrap();
118 assert_eq!(
119 dimensions(&png),
120 (THUMBNAIL_MAX_SIDE, THUMBNAIL_MAX_SIDE / 2)
121 );
122 }
123
124 #[test]
125 fn a_thumbnail_is_large_enough_to_show_larger() {
126 let png = make_thumbnail(&encoded(1024, 1024, image::ImageFormat::WebP)).unwrap();
128 assert_eq!(dimensions(&png), (384, 384));
129 }
130
131 #[test]
132 fn a_small_image_keeps_its_size() {
133 let png = make_thumbnail(&encoded(64, 48, image::ImageFormat::Png)).unwrap();
134 assert_eq!(dimensions(&png), (64, 48));
135 }
136
137 #[test]
138 fn bytes_that_are_not_an_image_are_refused() {
139 let err = make_thumbnail(b"not an image").unwrap_err();
140 assert!(err.to_string().contains("decoding"), "got {err:#}");
141 }
142
143 #[test]
144 fn the_ring_keeps_only_the_most_recent_thumbnails() {
145 let thumbnails = Thumbnails::default();
146 for i in 0..(THUMBNAILS_CAP + 2) {
147 thumbnails.insert(&format!("job-{i}"), vec![i as u8]);
148 }
149 assert_eq!(thumbnails.len(), THUMBNAILS_CAP);
150 assert!(!thumbnails.contains("job-0"));
151 assert!(!thumbnails.contains("job-1"));
152 assert_eq!(
153 thumbnails
154 .get(&format!("job-{}", THUMBNAILS_CAP + 1))
155 .unwrap()[0],
156 (THUMBNAILS_CAP + 1) as u8
157 );
158 }
159
160 #[test]
161 fn inserting_again_replaces_the_thumbnail() {
162 let thumbnails = Thumbnails::default();
163 thumbnails.insert("a", vec![1]);
164 thumbnails.insert("a", vec![2]);
165 assert_eq!(thumbnails.len(), 1);
166 assert_eq!(*thumbnails.get("a").unwrap(), vec![2]);
167 }
168
169 #[test]
170 fn retain_and_clear_drop_thumbnails() {
171 let thumbnails = Thumbnails::default();
172 thumbnails.insert("keep", vec![1]);
173 thumbnails.insert("drop", vec![2]);
174 thumbnails.retain(|id| id == "keep");
175 assert!(thumbnails.contains("keep") && !thumbnails.contains("drop"));
176 thumbnails.clear();
177 assert!(thumbnails.is_empty());
178 }
179}