Krolyxon
Branch master

Branches

Tags

Refs →

remagesrcthumbs.rs

645 lines · 21.1 KB · Rust

1//! freedesktop thumbnail cache: lookup, validation, generation and atomic writes.
2//!
3//! Entries live in `$XDG_CACHE_HOME/thumbnails/<bucket>/<md5(uri)>.png`, validated by their
4//! `Thumb::URI` and `Thumb::MTime` text chunks. The cache is shared with file managers, so
5//! the URI must be spelled exactly as GLib spells it.
6
7use std::fs::{self, File};
8use std::io::{BufReader, BufWriter, Write};
9use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt};
10use std::path::{Component, Path, PathBuf};
11use std::sync::RwLock;
12use std::time::UNIX_EPOCH;
13
14use anyhow::{Context, anyhow, bail};
15use fast_image_resize as fir;
16use image::metadata::Orientation;
17use md5::{Digest, Md5};
18
19use crate::decode::{self, DecodedImage};
20
21/// The freedesktop size buckets, smallest first.
22pub const BUCKETS: [(&str, u32); 4] = [
23 ("normal", 128),
24 ("large", 256),
25 ("x-large", 512),
26 ("xx-large", 1024),
27];
28
29/// The folder failed entries go in, under `fail/`.
30const APP: &str = "remage";
31
32/// A thumbnail ready for the atlas: straight-alpha RGBA8, at most `px` on its longer side.
33pub struct Thumb {
34 pub size: [u32; 2],
35 pub pixels: Vec<u8>,
36}
37
38impl std::fmt::Debug for Thumb {
39 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40 f.debug_struct("Thumb").field("size", &self.size).finish()
41 }
42}
43
44#[derive(Debug, thiserror::Error)]
45pub enum ThumbError {
46 /// A `fail/remage` entry says this file can't be thumbnailed; don't retry.
47 #[error("thumbnailing failed before")]
48 KnownFailure,
49 #[error("{0:#}")]
50 Failed(anyhow::Error),
51}
52
53/// How a job uses the shared cache.
54#[derive(Clone, Debug)]
55pub struct CacheOptions {
56 /// `$XDG_CACHE_HOME/thumbnails`; `None` disables the cache entirely.
57 pub root: Option<PathBuf>,
58 /// Read but never write (`-p`).
59 pub private: bool,
60}
61
62impl CacheOptions {
63 pub fn new(enabled: bool, private: bool) -> Self {
64 Self {
65 root: enabled
66 .then(|| dirs::cache_dir().map(|d| d.join("thumbnails")))
67 .flatten(),
68 private,
69 }
70 }
71}
72
73/// The smallest bucket at least `px` wide, else the largest.
74pub fn bucket_for(px: u32) -> (&'static str, u32) {
75 BUCKETS
76 .iter()
77 .copied()
78 .find(|(_, s)| *s >= px)
79 .unwrap_or(BUCKETS[BUCKETS.len() - 1])
80}
81
82/// Make `path` absolute and drop `.` and `..` without resolving symlinks, as GLib does.
83pub fn normalize(path: &Path) -> PathBuf {
84 let abs = std::path::absolute(path).unwrap_or_else(|_| path.to_path_buf());
85 let mut out = PathBuf::new();
86 for c in abs.components() {
87 match c {
88 Component::CurDir => {}
89 Component::ParentDir => {
90 out.pop();
91 }
92 other => out.push(other),
93 }
94 }
95 out
96}
97
98/// `file://` URI of an absolute path, escaped like GLib's `g_filename_to_uri`.
99pub fn file_uri(path: &Path) -> String {
100 use std::os::unix::ffi::OsStrExt;
101 const HEX: &[u8; 16] = b"0123456789ABCDEF";
102 let mut uri = String::from("file://");
103 for &b in path.as_os_str().as_bytes() {
104 let keep = b.is_ascii_alphanumeric() || b"-._~!$&'()*+,;=:@/".contains(&b);
105 if keep {
106 uri.push(b as char);
107 } else {
108 uri.push('%');
109 uri.push(HEX[(b >> 4) as usize] as char);
110 uri.push(HEX[(b & 15) as usize] as char);
111 }
112 }
113 uri
114}
115
116/// The cache file name for a URI: its MD5 in lowercase hex, plus `.png`.
117pub fn cache_name(uri: &str) -> String {
118 let digest = Md5::digest(uri.as_bytes());
119 let mut s = String::with_capacity(36);
120 for b in digest {
121 s.push_str(&format!("{b:02x}"));
122 }
123 s.push_str(".png");
124 s
125}
126
127/// Seconds since the epoch, as `Thumb::MTime` stores it.
128fn mtime_secs(meta: &fs::Metadata) -> Option<u64> {
129 meta.modified()
130 .ok()?
131 .duration_since(UNIX_EPOCH)
132 .ok()
133 .map(|d| d.as_secs())
134}
135
136/// PNG text chunks as keyword–text pairs.
137type Text = Vec<(String, String)>;
138
139/// Text chunks of a PNG, and its pixels as RGBA8 if `pixels` is set.
140fn read_png(path: &Path, pixels: bool) -> anyhow::Result<(Text, Option<Thumb>)> {
141 let file = File::open(path)?;
142 let mut dec = png::Decoder::new(BufReader::new(file));
143 dec.set_transformations(png::Transformations::normalize_to_color8());
144 let mut reader = dec.read_info()?;
145 let mut thumb = None;
146 if pixels {
147 let mut buf = vec![0; reader.output_buffer_size().context("thumbnail too large")?];
148 let out = reader.next_frame(&mut buf)?;
149 buf.truncate(out.buffer_size());
150 let rgba = to_rgba(buf, out.color_type).context("unsupported thumbnail colour type")?;
151 thumb = Some(Thumb {
152 size: [out.width, out.height],
153 pixels: rgba,
154 });
155 // Text chunks after the image data.
156 let _ = reader.finish();
157 }
158 let info = reader.info();
159 let mut text: Text = info
160 .uncompressed_latin1_text
161 .iter()
162 .map(|c| (c.keyword.clone(), c.text.clone()))
163 .collect();
164 text.extend(
165 info.utf8_text
166 .iter()
167 .filter_map(|c| Some((c.keyword.clone(), c.get_text().ok()?))),
168 );
169 text.extend(
170 info.compressed_latin1_text
171 .iter()
172 .filter_map(|c| Some((c.keyword.clone(), c.get_text().ok()?))),
173 );
174 Ok((text, thumb))
175}
176
177fn to_rgba(buf: Vec<u8>, color: png::ColorType) -> Option<Vec<u8>> {
178 use png::ColorType::*;
179 Some(match color {
180 Rgba => buf,
181 Rgb => buf
182 .as_chunks::<3>()
183 .0
184 .iter()
185 .flat_map(|p| [p[0], p[1], p[2], 255])
186 .collect(),
187 Grayscale => buf.iter().flat_map(|&g| [g, g, g, 255]).collect(),
188 GrayscaleAlpha => buf
189 .as_chunks::<2>()
190 .0
191 .iter()
192 .flat_map(|p| [p[0], p[0], p[0], p[1]])
193 .collect(),
194 Indexed => return None,
195 })
196}
197
198/// Whether the text chunks describe `uri` at `mtime`. Some writers (ffmpegthumbnailer) store
199/// a bare path as the URI; that counts too.
200fn is_valid(text: &[(String, String)], uri: &str, mtime: u64) -> bool {
201 let get = |k: &str| text.iter().find(|(key, _)| key == k).map(|(_, v)| v.trim());
202 // Some writers store fractional seconds.
203 let mtime_ok = get("Thumb::MTime")
204 .and_then(|m| m.parse::<f64>().ok())
205 .is_some_and(|m| m.trunc() as u64 == mtime);
206 let path = uri.strip_prefix("file://").map(|p| {
207 let bytes = percent_decode(p);
208 String::from_utf8_lossy(&bytes).into_owned()
209 });
210 let uri_ok = get("Thumb::URI").is_some_and(|u| u == uri || Some(u) == path.as_deref());
211 uri_ok && mtime_ok
212}
213
214fn percent_decode(s: &str) -> Vec<u8> {
215 let b = s.as_bytes();
216 let mut out = Vec::with_capacity(b.len());
217 let mut i = 0;
218 while i < b.len() {
219 let hex = |c: u8| (c as char).to_digit(16);
220 if b[i] == b'%'
221 && let (Some(h), Some(l)) = (
222 b.get(i + 1).and_then(|&c| hex(c)),
223 b.get(i + 2).and_then(|&c| hex(c)),
224 )
225 {
226 out.push((h * 16 + l) as u8);
227 i += 3;
228 } else {
229 out.push(b[i]);
230 i += 1;
231 }
232 }
233 out
234}
235
236/// Set once the process is on its way out.
237static WRITES_CLOSED: RwLock<bool> = RwLock::new(false);
238
239/// Wait for thumbnails being written and refuse new ones. Workers aren't joined on exit, so
240/// this is what keeps temporary files from being left in the shared cache.
241pub fn finish_writes() {
242 *WRITES_CLOSED.write().unwrap_or_else(|e| e.into_inner()) = true;
243}
244
245/// Write a PNG atomically (temporary file, then rename) with mode 0600.
246fn write_png(path: &Path, thumb: &Thumb, text: &[(&str, String)]) -> anyhow::Result<()> {
247 let dir = path.parent().context("cache path has no parent")?;
248 fs::DirBuilder::new()
249 .recursive(true)
250 .mode(0o700)
251 .create(dir)?;
252 let name = path.file_name().context("cache path has no name")?;
253 let tmp = dir.join(format!(
254 ".{}.remage-{}.tmp",
255 name.to_string_lossy(),
256 std::process::id()
257 ));
258 // Held to the end, so quitting never kills a write half-way (see `finish_writes`).
259 let closed = WRITES_CLOSED.read().unwrap_or_else(|e| e.into_inner());
260 if *closed {
261 bail!("shutting down");
262 }
263 let result = (|| -> anyhow::Result<()> {
264 let file = fs::OpenOptions::new()
265 .write(true)
266 .create(true)
267 .truncate(true)
268 .mode(0o600)
269 .open(&tmp)?;
270 let mut w = BufWriter::new(file);
271 let mut enc = png::Encoder::new(&mut w, thumb.size[0], thumb.size[1]);
272 enc.set_color(png::ColorType::Rgba);
273 enc.set_depth(png::BitDepth::Eight);
274 enc.set_compression(png::Compression::Fast);
275 for (k, v) in text {
276 enc.add_text_chunk(k.to_string(), v.clone())?;
277 }
278 let mut writer = enc.write_header()?;
279 writer.write_image_data(&thumb.pixels)?;
280 writer.finish()?;
281 w.flush()?;
282 drop(w);
283 fs::rename(&tmp, path)?;
284 Ok(())
285 })();
286 if result.is_err() {
287 let _ = fs::remove_file(&tmp);
288 }
289 result
290}
291
292/// Shrink `src` so its longer side is at most `max` (never enlarging).
293pub fn resize(src: Thumb, max: u32) -> anyhow::Result<Thumb> {
294 let [w, h] = src.size;
295 if w <= max && h <= max {
296 return Ok(src);
297 }
298 let k = max as f64 / w.max(h) as f64;
299 let nw = ((w as f64 * k).round() as u32).clamp(1, max);
300 let nh = ((h as f64 * k).round() as u32).clamp(1, max);
301 let mut img = fir::images::Image::from_vec_u8(w, h, src.pixels, fir::PixelType::U8x4)
302 .context("resize source")?;
303 // Resample in premultiplied alpha so transparent edges don't bleed dark fringes.
304 let mul = fir::MulDiv::default();
305 mul.multiply_alpha_inplace(&mut img)
306 .context("premultiply")?;
307 let mut dst = fir::images::Image::new(nw, nh, fir::PixelType::U8x4);
308 let opts = fir::ResizeOptions::new()
309 .resize_alg(fir::ResizeAlg::Convolution(fir::FilterType::Lanczos3))
310 .use_alpha(false);
311 fir::Resizer::new()
312 .resize(&img, &mut dst, &opts)
313 .context("resize")?;
314 mul.divide_alpha_inplace(&mut dst)
315 .context("unpremultiply")?;
316 Ok(Thumb {
317 size: [nw, nh],
318 pixels: dst.into_vec(),
319 })
320}
321
322/// Load a thumbnail of `path` at most `px` on its longer side: from the shared cache when a
323/// valid entry is big enough, otherwise generated (and written back unless private).
324pub fn load(path: &Path, px: u32, cache: &CacheOptions) -> Result<Thumb, ThumbError> {
325 let fail = |e: anyhow::Error| ThumbError::Failed(e);
326 let meta = fs::metadata(path).map_err(|e| fail(e.into()))?;
327 let mtime = mtime_secs(&meta).unwrap_or(0);
328 let abs = normalize(path);
329 let uri = file_uri(&abs);
330 let name = cache_name(&uri);
331 let (bucket, bucket_px) = bucket_for(px);
332
333 if let Some(root) = &cache.root {
334 // Our bucket, or any larger one.
335 for (dir, _) in BUCKETS.iter().filter(|(_, s)| *s >= bucket_px) {
336 let p = root.join(dir).join(&name);
337 if let Ok((text, Some(t))) = read_png(&p, true)
338 && is_valid(&text, &uri, mtime)
339 {
340 return resize(t, px).map_err(fail);
341 }
342 }
343 // Only trust failures this version recorded: a later one may decode more formats.
344 let failed = root.join("fail").join(APP).join(&name);
345 if let Ok((text, _)) = read_png(&failed, false)
346 && is_valid(&text, &uri, mtime)
347 && text
348 .iter()
349 .any(|(k, v)| k == "Software" && *v == software())
350 {
351 return Err(ThumbError::KnownFailure);
352 }
353 }
354
355 let generated = match generate(path, bucket_px) {
356 Ok(t) => t,
357 Err(e) => {
358 if let Some(root) = cache.root.as_ref().filter(|_| !cache.private) {
359 let failed = root.join("fail").join(APP).join(&name);
360 let empty = Thumb {
361 size: [1, 1],
362 pixels: vec![0; 4],
363 };
364 let text = [
365 ("Thumb::URI", uri.clone()),
366 ("Thumb::MTime", mtime.to_string()),
367 ("Software", software()),
368 ];
369 if let Err(e) = write_png(&failed, &empty, &text) {
370 log::debug!("{}: {e:#}", failed.display());
371 }
372 }
373 return Err(fail(e));
374 }
375 };
376
377 // Images no bigger than the bucket gain nothing from a cache entry.
378 let worth_caching = generated.source_size.iter().any(|&s| s > bucket_px);
379 let thumb = Thumb {
380 size: generated.size,
381 pixels: generated.pixels,
382 };
383 if let Some(root) = cache.root.as_ref().filter(|_| !cache.private)
384 && worth_caching
385 && !abs.starts_with(root)
386 {
387 let dest = root.join(bucket).join(&name);
388 let [sw, sh] = generated.source_size;
389 let text = [
390 ("Thumb::URI", uri.clone()),
391 ("Thumb::MTime", mtime.to_string()),
392 ("Thumb::Size", meta.len().to_string()),
393 ("Thumb::Image::Width", sw.to_string()),
394 ("Thumb::Image::Height", sh.to_string()),
395 ("Software", software()),
396 ];
397 if let Err(e) = write_png(&dest, &thumb, &text) {
398 log::debug!("{}: {e:#}", dest.display());
399 }
400 }
401 resize(thumb, px).map_err(fail)
402}
403
404fn software() -> String {
405 format!("remage {}", env!("CARGO_PKG_VERSION"))
406}
407
408/// A freshly made thumbnail at most `max` on its longer side.
409struct Generated {
410 size: [u32; 2],
411 pixels: Vec<u8>,
412 /// The original's upright size.
413 source_size: [u32; 2],
414}
415
416fn generate(path: &Path, max: u32) -> anyhow::Result<Generated> {
417 // JPEG: decode at 1/2, 1/4 or 1/8 straight from the DCT.
418 if let Some(g) = jpeg_scaled(path, max)? {
419 return Ok(g);
420 }
421 // Scalable and previewed sources (SVG, RAW) only need the bucket's size.
422 let opts = decode::Options {
423 target: max,
424 ..decode::Options::default()
425 };
426 let img = decode::decode(path, &opts)?;
427 let source_size = img.source_size;
428 let DecodedImage {
429 size,
430 mut frames,
431 orientation,
432 ..
433 } = img;
434 let pixels = frames.swap_remove(0).pixels;
435 let t = resize(Thumb { size, pixels }, max)?;
436 // Turning the thumbnail is much cheaper than turning the image.
437 let (size, pixels) = decode::color::orient(t.size, t.pixels, orientation);
438 Ok(Generated {
439 size,
440 pixels,
441 source_size,
442 })
443}
444
445/// DCT-scaled JPEG decode, or `None` when the file isn't a plain 8-bit RGB/grey JPEG.
446fn jpeg_scaled(path: &Path, max: u32) -> anyhow::Result<Option<Generated>> {
447 let mut magic = [0u8; 3];
448 {
449 use std::io::Read;
450 let mut f = File::open(path)?;
451 if f.read_exact(&mut magic).is_err() || magic != [0xFF, 0xD8, 0xFF] {
452 return Ok(None);
453 }
454 }
455 let mut dec = jpeg_decoder::Decoder::new(BufReader::new(File::open(path)?));
456 dec.read_info()?;
457 let info = dec.info().ok_or_else(|| anyhow!("no JPEG header"))?;
458 use jpeg_decoder::PixelFormat::*;
459 if !matches!(info.pixel_format, L8 | RGB24) {
460 return Ok(None);
461 }
462 let (w, h) = (info.width as u32, info.height as u32);
463 let k = (max as f64 / w.max(h) as f64).min(1.0);
464 let req = |v: u32| ((v as f64 * k).ceil() as u32).clamp(1, u16::MAX as u32) as u16;
465 let (sw, sh) = dec.scale(req(w), req(h))?;
466 let data = dec.decode()?;
467 let pixels: Vec<u8> = match info.pixel_format {
468 L8 => data.iter().flat_map(|&g| [g, g, g, 255]).collect(),
469 _ => data
470 .as_chunks::<3>()
471 .0
472 .iter()
473 .flat_map(|p| [p[0], p[1], p[2], 255])
474 .collect(),
475 };
476 let (sw, sh) = (sw as u32, sh as u32);
477 if pixels.len() != (sw * sh * 4) as usize {
478 bail!("JPEG decoded to an unexpected size");
479 }
480 let orientation = dec
481 .exif_data()
482 .and_then(Orientation::from_exif_chunk)
483 .unwrap_or(Orientation::NoTransforms);
484 let mut img = image::DynamicImage::ImageRgba8(
485 image::RgbaImage::from_raw(sw, sh, pixels).context("JPEG buffer size")?,
486 );
487 img.apply_orientation(orientation);
488 let source_size = if img.width() == sw { [w, h] } else { [h, w] };
489 let rgba = img.into_rgba8();
490 let size = [rgba.width(), rgba.height()];
491 let t = resize(
492 Thumb {
493 size,
494 pixels: rgba.into_raw(),
495 },
496 max,
497 )?;
498 Ok(Some(Generated {
499 size: t.size,
500 pixels: t.pixels,
501 source_size,
502 }))
503}
504
505#[cfg(test)]
506mod tests {
507 use super::*;
508
509 fn tmp(name: &str) -> PathBuf {
510 let dir = std::env::temp_dir().join(format!("remage-thumbs-{}-{name}", std::process::id()));
511 let _ = fs::remove_dir_all(&dir);
512 fs::create_dir_all(&dir).unwrap();
513 dir
514 }
515
516 #[test]
517 fn uris_are_escaped_like_glib() {
518 assert_eq!(
519 file_uri(Path::new("/home/u/a b/ü#?%.png")),
520 "file:///home/u/a%20b/%C3%BC%23%3F%25.png"
521 );
522 assert_eq!(
523 file_uri(Path::new("/x/(1)!$&'*+,;=:@~-_.png")),
524 "file:///x/(1)!$&'*+,;=:@~-_.png"
525 );
526 }
527
528 #[test]
529 fn names_are_md5_of_the_uri() {
530 // The spec's own example.
531 assert_eq!(
532 cache_name("file:///home/jens/photos/me.png"),
533 "c6ee772d9e49320e97ec29a7eb5b1697.png"
534 );
535 }
536
537 #[test]
538 fn normalize_is_lexical() {
539 assert_eq!(
540 normalize(Path::new("/a/./b/../c.png")),
541 Path::new("/a/c.png")
542 );
543 assert!(normalize(Path::new("x.png")).is_absolute());
544 }
545
546 #[test]
547 fn buckets() {
548 assert_eq!(bucket_for(96), ("normal", 128));
549 assert_eq!(bucket_for(128), ("normal", 128));
550 assert_eq!(bucket_for(160), ("large", 256));
551 assert_eq!(bucket_for(320), ("x-large", 512));
552 assert_eq!(bucket_for(2000), ("xx-large", 1024));
553 }
554
555 #[test]
556 fn generates_writes_and_rereads() {
557 let dir = tmp("cache");
558 let img = dir.join("pic.png");
559 image::RgbaImage::from_fn(600, 300, |x, _| image::Rgba([(x % 256) as u8, 0, 0, 255]))
560 .save(&img)
561 .unwrap();
562 let cache = CacheOptions {
563 root: Some(dir.join("thumbnails")),
564 private: false,
565 };
566 let t = load(&img, 160, &cache).unwrap();
567 assert_eq!(t.size, [160, 80]);
568 let entry = dir
569 .join("thumbnails/large")
570 .join(cache_name(&file_uri(&normalize(&img))));
571 let (text, stored) = read_png(&entry, true).unwrap();
572 assert_eq!(stored.unwrap().size, [256, 128]);
573 assert!(
574 text.iter()
575 .any(|(k, v)| k == "Thumb::Image::Width" && v == "600")
576 );
577 {
578 use std::os::unix::fs::PermissionsExt;
579 let mode = fs::metadata(&entry).unwrap().permissions().mode();
580 assert_eq!(mode & 0o777, 0o600);
581 }
582 // A second load reads the entry (same result), a smaller size downscales it.
583 assert_eq!(load(&img, 160, &cache).unwrap().size, [160, 80]);
584 assert_eq!(load(&img, 96, &cache).unwrap().size, [96, 48]);
585 // A stale entry (different mtime) is ignored and replaced.
586 let text = vec![
587 ("Thumb::URI".to_string(), file_uri(&normalize(&img))),
588 ("Thumb::MTime".to_string(), "1".to_string()),
589 ];
590 let meta = fs::metadata(&img).unwrap();
591 assert!(!is_valid(&text, &text[0].1, mtime_secs(&meta).unwrap()));
592 assert!(is_valid(&text, &text[0].1, 1));
593 // A bare path as the URI is accepted too.
594 let bare = vec![
595 ("Thumb::URI".to_string(), "/a b/c.png".to_string()),
596 ("Thumb::MTime".to_string(), "1".to_string()),
597 ];
598 assert!(is_valid(&bare, "file:///a%20b/c.png", 1));
599 fs::remove_dir_all(&dir).unwrap();
600 }
601
602 #[test]
603 fn failures_are_remembered_and_private_writes_nothing() {
604 let dir = tmp("fail");
605 let bad = dir.join("bad.png");
606 fs::write(&bad, b"not a png").unwrap();
607 let cache = CacheOptions {
608 root: Some(dir.join("thumbnails")),
609 private: true,
610 };
611 assert!(matches!(
612 load(&bad, 128, &cache),
613 Err(ThumbError::Failed(_))
614 ));
615 assert!(!dir.join("thumbnails").exists());
616 let cache = CacheOptions {
617 private: false,
618 ..cache
619 };
620 assert!(matches!(
621 load(&bad, 128, &cache),
622 Err(ThumbError::Failed(_))
623 ));
624 assert!(matches!(
625 load(&bad, 128, &cache),
626 Err(ThumbError::KnownFailure)
627 ));
628 fs::remove_dir_all(&dir).unwrap();
629 }
630
631 #[test]
632 fn jpeg_is_scaled_and_oriented() {
633 let dir = tmp("jpeg");
634 let p = dir.join("a.jpg");
635 image::RgbImage::from_pixel(800, 400, image::Rgb([10, 200, 30]))
636 .save(&p)
637 .unwrap();
638 let g = jpeg_scaled(&p, 128).unwrap().unwrap();
639 assert_eq!(g.source_size, [800, 400]);
640 assert_eq!(g.size, [128, 64]);
641 let px = &g.pixels[..4];
642 assert!(px[1] > 150 && px[0] < 60, "{px:?}");
643 fs::remove_dir_all(&dir).unwrap();
644 }
645}