Skip to main content

studio_worker/
thumbnail.rs

1//! Thumbnails of image jobs, so the tray UI can show what a job made.
2//!
3//! The daemon decodes an image result, scales it to fit
4//! [`THUMBNAIL_MAX_SIDE`] and keeps it as PNG in a bounded [`Thumbnails`]
5//! ring keyed by job id.  The tray UI keeps its own copy (fetched from
6//! `GET /jobs/:id/thumbnail`) in the same type.
7
8use std::collections::VecDeque;
9use std::io::Cursor;
10use std::sync::Arc;
11
12use anyhow::Context as _;
13use parking_lot::Mutex;
14
15/// Longer side of a thumbnail, in pixels: crisp on the UI's 88 pt card tile
16/// on a 2x display, and large enough to recognise when shown larger.  About
17/// 0.1 to 0.4 MB of PNG each, so the 100 kept stay within tens of MB.
18pub const THUMBNAIL_MAX_SIDE: u32 = 384;
19
20/// Thumbnails of this many most recent image jobs are kept: the size of
21/// the studio and local job rings together.
22pub const THUMBNAILS_CAP: usize = 2 * crate::runtime::RECENT_JOBS_CAP;
23
24/// Scale an encoded image (WEBP / PNG / JPEG) to fit
25/// [`THUMBNAIL_MAX_SIDE`] and encode it as PNG.  Smaller images keep their
26/// size.
27pub 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
41/// A job id and its PNG thumbnail.
42type Entry = (String, Arc<Vec<u8>>);
43
44/// Bounded ring of PNG thumbnails keyed by job id, oldest evicted first.
45/// Cheap to clone.
46#[derive(Clone, Default)]
47pub struct Thumbnails {
48    inner: Arc<Mutex<VecDeque<Entry>>>,
49}
50
51impl Thumbnails {
52    /// Keep `png` as `job_id`'s thumbnail, replacing an older one.
53    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    /// The thumbnail of `job_id`.
63    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    /// Whether `job_id` has a thumbnail.
72    pub fn contains(&self, job_id: &str) -> bool {
73        self.inner.lock().iter().any(|(id, _)| id == job_id)
74    }
75
76    /// Drop every thumbnail whose job id fails `keep`.
77    pub fn retain(&self, keep: impl Fn(&str) -> bool) {
78        self.inner.lock().retain(|(id, _)| keep(id));
79    }
80
81    /// Drop every thumbnail.
82    pub fn clear(&self) {
83        self.inner.lock().clear();
84    }
85
86    /// Number of thumbnails kept.
87    pub fn len(&self) -> usize {
88        self.inner.lock().len()
89    }
90
91    /// True when no thumbnail is kept.
92    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        // The UI shows it on a 88 pt card tile and larger on request.
127        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}