1use std::fs;
8use std::path::Path;
9
10use image::imageops::FilterType;
11use image::{DynamicImage, ImageDecoder, ImageReader, RgbaImage};
12
13pub const DEFAULT_MAX_SIDE: u32 = 320;
15pub const NO_THUMBNAIL_SIZE: u32 = 960;
17pub const NO_THUMBNAIL_FILE_SIZE: u64 = 15_000_000;
19pub const PNG_FORCE_THUMBNAIL_SIZE: u64 = 1024 * 1024;
21pub const SVG_INLINE_LIMIT: u64 = 100_000;
23pub const MAX_WEBP_SIDE: u32 = 16383;
25pub const WEBP_QUALITY: f32 = 75.0;
27pub const JPEG_QUALITY: u8 = 70;
29
30#[derive(Debug, thiserror::Error)]
32#[error("{0}")]
33pub struct ThumbError(pub String);
34
35impl ThumbError {
36 fn new(e: impl std::fmt::Display) -> Self {
37 ThumbError(e.to_string())
38 }
39}
40
41pub fn is_supported(ext: &str) -> bool {
43 matches!(ext, "png" | "jpg" | "jpeg" | "jfif" | "jpe" | "insp" | "webp" | "gif" | "bmp" | "svg")
44}
45
46pub fn max_side(ext: &str) -> u32 {
51 match ext {
52 e if crate::model3d::EXTENSIONS.contains(&e) => crate::model3d::raster::CANVAS + 2 * crate::model3d::raster::PADDING,
53 "ico" | "icns" => 512,
54 "pdf" | "doc" | "docx" | "xls" | "xlsx" | "ppt" | "pptx" | "psd" | "psdt" | "ai" | "ait" | "eps" | "hdr" | "exr" | "dds"
55 | "cr3" => 480,
56 _ => DEFAULT_MAX_SIDE,
57 }
58}
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub struct Plan {
63 pub thumbnail: bool,
65 pub no_thumbnail: Option<bool>,
67 pub remove_thumbnail: Option<bool>,
69}
70
71impl Plan {
72 pub fn analyse_original(&self) -> bool {
75 self.no_thumbnail == Some(true) && self.remove_thumbnail != Some(true)
76 }
77}
78
79pub fn plan_raster(ext: &str, w: u32, h: u32, file_size: u64) -> Plan {
84 let small = w <= NO_THUMBNAIL_SIZE && h <= NO_THUMBNAIL_SIZE && file_size <= NO_THUMBNAIL_FILE_SIZE;
85 let forced = ext == "png" && file_size > PNG_FORCE_THUMBNAIL_SIZE;
86 if small && !forced {
87 Plan { thumbnail: false, no_thumbnail: Some(true), remove_thumbnail: None }
88 } else {
89 Plan { thumbnail: true, no_thumbnail: None, remove_thumbnail: None }
90 }
91}
92
93pub fn plan_svg(file_size: u64) -> Plan {
96 if file_size < SVG_INLINE_LIMIT {
97 Plan { thumbnail: true, no_thumbnail: Some(true), remove_thumbnail: Some(true) }
98 } else {
99 Plan { thumbnail: true, no_thumbnail: None, remove_thumbnail: None }
100 }
101}
102
103pub fn plan_svg_failed() -> Plan {
105 Plan { thumbnail: false, no_thumbnail: Some(true), remove_thumbnail: None }
106}
107
108pub fn target_size(w: u32, h: u32, max: u32) -> (u32, u32) {
115 if w.max(h) <= max || w == 0 || h == 0 {
116 return (w, h);
117 }
118 let scale = |long: u32, short: u32| (u64::from(max) * u64::from(long) / u64::from(short)) as u32;
119 if w > h { (scale(w, h).max(1), max) } else { (max, scale(h, w).max(1)) }
120}
121
122pub fn decode(path: &Path) -> Result<DynamicImage, ThumbError> {
125 decode_with_profile(path).map(|(img, _)| img)
126}
127
128pub fn decode_for_display(path: &Path) -> Result<DynamicImage, ThumbError> {
132 let (img, icc) = decode_with_profile(path)?;
133 Ok(match icc {
134 Some(icc) => crate::color::to_srgb(img, &icc),
135 None => img,
136 })
137}
138
139fn decode_with_profile(path: &Path) -> Result<(DynamicImage, Option<Vec<u8>>), ThumbError> {
140 let reader = ImageReader::open(path)
141 .map_err(ThumbError::new)?
142 .with_guessed_format()
143 .map_err(ThumbError::new)?;
144 let mut decoder = reader.into_decoder().map_err(ThumbError::new)?;
145 let orientation = decoder.orientation().map_err(ThumbError::new)?;
146 let icc = decoder.icc_profile().ok().flatten();
148 let mut img = DynamicImage::from_decoder(decoder).map_err(ThumbError::new)?;
149 img.apply_orientation(orientation);
150 Ok((img, icc))
151}
152
153pub fn raw_preview(data: &[u8]) -> Result<DynamicImage, ThumbError> {
161 let (start, _) = embedded_jpegs(data).into_iter().max_by_key(|&(_, px)| px).ok_or_else(|| ThumbError("no embedded preview".into()))?;
162 let mut decoder = ImageReader::with_format(std::io::Cursor::new(&data[start..]), image::ImageFormat::Jpeg)
163 .into_decoder()
164 .map_err(ThumbError::new)?;
165 let icc = decoder.icc_profile().ok().flatten();
166 let mut img = DynamicImage::from_decoder(decoder).map_err(ThumbError::new)?;
167 if let Some(icc) = icc {
168 img = crate::color::to_srgb(img, &icc);
169 }
170 let orientation = crate::exif::orientation(data).and_then(|v| image::metadata::Orientation::from_exif(v as u8));
171 if let Some(o) = orientation {
172 img.apply_orientation(o);
173 }
174 Ok(img)
175}
176
177fn embedded_jpegs(data: &[u8]) -> Vec<(usize, u64)> {
180 data.windows(3)
181 .enumerate()
182 .filter(|(_, w)| *w == [0xFF, 0xD8, 0xFF])
183 .filter_map(|(i, _)| jpeg_frame_pixels(&data[i..]).map(|px| (i, px)))
184 .collect()
185}
186
187fn jpeg_frame_pixels(jpeg: &[u8]) -> Option<u64> {
190 let mut at = 2;
191 for _ in 0..64 {
192 if *jpeg.get(at)? != 0xFF {
193 return None;
194 }
195 let marker = *jpeg.get(at + 1)?;
196 match marker {
197 0xFF => at += 1,
198 0x01 | 0xD0..=0xD7 => at += 2,
199 0xC0..=0xC2 => {
200 let f = jpeg.get(at + 4..at + 9)?;
201 let (h, w) = (u16::from_be_bytes([f[1], f[2]]), u16::from_be_bytes([f[3], f[4]]));
202 return (f[0] == 8 && w > 0 && h > 0).then_some(u64::from(w) * u64::from(h));
203 }
204 0xC3 | 0xC5..=0xC7 | 0xC9..=0xCB | 0xCD..=0xCF | 0xD8 | 0xD9 | 0xDA => return None,
207 _ => {
208 let len = u16::from_be_bytes([*jpeg.get(at + 2)?, *jpeg.get(at + 3)?]) as usize;
209 if len < 2 {
210 return None;
211 }
212 at += 2 + len;
213 }
214 }
215 }
216 None
217}
218
219pub const QUICKLOOK_SIZE: u32 = 1024;
222pub const QUICKLOOK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
224
225#[derive(Debug)]
227pub enum QuickLookStop {
228 Cancelled,
230 TimedOut,
233 Failed(ThumbError),
234}
235
236pub fn quicklook(path: &Path, size: u32) -> Result<DynamicImage, ThumbError> {
239 let never = std::sync::atomic::AtomicBool::new(false);
240 quicklook_until(path, size, QUICKLOOK_TIMEOUT, &never).map_err(|stop| match stop {
241 QuickLookStop::Failed(e) => e,
242 QuickLookStop::TimedOut => ThumbError("QuickLook timed out".into()),
243 QuickLookStop::Cancelled => ThumbError("QuickLook was cancelled".into()),
244 })
245}
246
247pub fn quicklook_until(path: &Path, size: u32, timeout: std::time::Duration, cancel: &std::sync::atomic::AtomicBool) -> Result<DynamicImage, QuickLookStop> {
250 let failed = |e: ThumbError| QuickLookStop::Failed(e);
251 if !cfg!(target_os = "macos") {
252 return Err(failed(ThumbError("QuickLook is only available on macOS".into())));
253 }
254 let renamed = crate::alias::renamed(path);
257 let path = renamed.as_ref().map_or(path, |r| r.path());
258 let out = tempfile::tempdir().map_err(ThumbError::new).map_err(failed)?;
259 let mut child = std::process::Command::new("qlmanage")
260 .arg("-t")
261 .arg("-s")
262 .arg(size.to_string())
263 .arg("-o")
264 .arg(out.path())
265 .arg(path)
266 .stdout(std::process::Stdio::null())
267 .stderr(std::process::Stdio::null())
268 .spawn()
269 .map_err(ThumbError::new)
270 .map_err(failed)?;
271 let deadline = std::time::Instant::now() + timeout;
272 let status = loop {
273 if let Some(s) = child.try_wait().map_err(ThumbError::new).map_err(failed)? {
274 break s;
275 }
276 let stop = if cancel.load(std::sync::atomic::Ordering::Relaxed) {
277 Some(QuickLookStop::Cancelled)
278 } else if std::time::Instant::now() > deadline {
279 Some(QuickLookStop::TimedOut)
280 } else {
281 None
282 };
283 if let Some(stop) = stop {
284 let _ = child.kill();
285 let _ = child.wait();
286 return Err(stop);
287 }
288 std::thread::sleep(std::time::Duration::from_millis(20));
289 };
290 let name = format!("{}.png", path.file_name().map(|n| n.to_string_lossy()).unwrap_or_default());
291 let png = out.path().join(name);
292 if !status.success() || !png.is_file() {
293 return Err(failed(ThumbError("QuickLook made no preview".into())));
294 }
295 decode(&png).map_err(failed)
296}
297
298#[derive(Debug, Default, Clone, Copy, PartialEq)]
300pub struct Spotlight {
301 pub size: Option<(u32, u32)>,
302 pub duration: Option<f64>,
303}
304
305pub fn spotlight(path: &Path) -> Spotlight {
308 let out = std::process::Command::new("mdls")
309 .args(["-raw", "-name", "kMDItemPixelWidth", "-name", "kMDItemPixelHeight", "-name", "kMDItemDurationSeconds"])
310 .arg(path)
311 .output();
312 let Ok(out) = out else { return Spotlight::default() };
313 let text = String::from_utf8_lossy(&out.stdout);
314 let vals: Vec<Option<f64>> = text.split('\0').map(|v| v.trim().parse().ok()).collect();
315 let get = |i: usize| vals.get(i).copied().flatten();
316 Spotlight {
317 size: match (get(0), get(1)) {
318 (Some(w), Some(h)) if w > 0.0 && h > 0.0 => Some((w as u32, h as u32)),
319 _ => None,
320 },
321 duration: get(2),
322 }
323}
324
325pub fn svg_size(data: &[u8]) -> Result<(u32, u32), ThumbError> {
327 let tree = parse_svg(data)?;
328 let s = tree.size().to_int_size();
329 Ok((s.width(), s.height()))
330}
331
332fn parse_svg(data: &[u8]) -> Result<resvg::usvg::Tree, ThumbError> {
333 let tree = resvg::usvg::Tree::from_data(data, &resvg::usvg::Options::default()).map_err(ThumbError::new)?;
334 let s = tree.size().to_int_size();
335 if s.width() == 0 || s.height() == 0 {
336 return Err(ThumbError("SVG has no size".into()));
337 }
338 Ok(tree)
339}
340
341pub fn render_svg(data: &[u8], max: Option<u32>) -> Result<DynamicImage, ThumbError> {
344 use resvg::tiny_skia::{Pixmap, Transform};
345 let tree = parse_svg(data)?;
346 let natural = tree.size().to_int_size();
347 let (w, h) = match max {
348 Some(m) => target_size(natural.width(), natural.height(), m),
349 None => (natural.width(), natural.height()),
350 };
351 if w > MAX_WEBP_SIDE * 2 || h > MAX_WEBP_SIDE * 2 {
352 return Err(ThumbError("SVG too large to render".into()));
353 }
354 let mut pixmap = Pixmap::new(w, h).ok_or_else(|| ThumbError("cannot allocate pixmap".into()))?;
355 let transform = Transform::from_scale(w as f32 / tree.size().width(), h as f32 / tree.size().height());
356 resvg::render(&tree, transform, &mut pixmap.as_mut());
357 let mut raw = Vec::with_capacity(pixmap.pixels().len() * 4);
358 for p in pixmap.pixels() {
359 let c = p.demultiply();
360 raw.extend_from_slice(&[c.red(), c.green(), c.blue(), c.alpha()]);
361 }
362 RgbaImage::from_raw(w, h, raw).map(DynamicImage::ImageRgba8).ok_or_else(|| ThumbError("bad pixmap".into()))
363}
364
365pub fn render_svg_region(data: &[u8], region: [f32; 4], size: (u32, u32)) -> Result<DynamicImage, ThumbError> {
369 use resvg::tiny_skia::{Pixmap, Transform};
370 let tree = parse_svg(data)?;
371 let (w, h) = size;
372 let [x0, y0, x1, y1] = region;
373 if w == 0 || h == 0 || w > MAX_WEBP_SIDE || h > MAX_WEBP_SIDE || x1 <= x0 || y1 <= y0 {
374 return Err(ThumbError("bad SVG region".into()));
375 }
376 let (tw, th) = (tree.size().width(), tree.size().height());
377 let (sx, sy) = (w as f32 / ((x1 - x0) * tw), h as f32 / ((y1 - y0) * th));
378 let mut pixmap = Pixmap::new(w, h).ok_or_else(|| ThumbError("cannot allocate pixmap".into()))?;
379 resvg::render(&tree, Transform::from_row(sx, 0.0, 0.0, sy, -x0 * tw * sx, -y0 * th * sy), &mut pixmap.as_mut());
380 let mut raw = Vec::with_capacity(pixmap.pixels().len() * 4);
381 for p in pixmap.pixels() {
382 let c = p.demultiply();
383 raw.extend_from_slice(&[c.red(), c.green(), c.blue(), c.alpha()]);
384 }
385 RgbaImage::from_raw(w, h, raw).map(DynamicImage::ImageRgba8).ok_or_else(|| ThumbError("bad pixmap".into()))
386}
387
388pub fn resize(img: DynamicImage, max: u32) -> DynamicImage {
390 resize_with(img, max, RESIZE_FILTER)
391}
392
393const RESIZE_FILTER: FilterType = FilterType::CatmullRom;
396
397pub fn resize_with(img: DynamicImage, max: u32, filter: FilterType) -> DynamicImage {
403 let (tw, th) = target_size(img.width(), img.height(), max);
404 let (w, h) = (img.width(), img.height());
405 if (tw, th) == (w, h) {
406 return img;
407 }
408 if w <= tw * 2 && h <= th * 2 {
409 return img.resize_exact(tw, th, filter);
410 }
411 let (mut cw, mut ch) = (w, h);
412 let mut cur = img;
413 while f64::from(cw) > f64::from(tw) * 1.5 || f64::from(ch) > f64::from(th) * 1.5 {
414 cw = tw.max(cw / 2);
415 ch = th.max(ch / 2);
416 cur = cur.resize_exact(cw, ch, filter);
417 }
418 cur.resize_exact(tw, th, filter)
419}
420
421pub fn encode(img: &DynamicImage) -> Result<Vec<u8>, ThumbError> {
424 if img.width() > MAX_WEBP_SIDE || img.height() > MAX_WEBP_SIDE {
425 let mut out = Vec::new();
426 let rgb = img.to_rgb8();
427 image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, JPEG_QUALITY)
428 .encode_image(&rgb)
429 .map_err(ThumbError::new)?;
430 return Ok(out);
431 }
432 let rgba = img.to_rgba8();
433 let mem = webp::Encoder::from_rgba(rgba.as_raw(), rgba.width(), rgba.height()).encode(WEBP_QUALITY);
434 Ok(mem.to_vec())
435}
436
437pub fn write_thumbnail(img: DynamicImage, max: u32, dest: &Path) -> Result<(), ThumbError> {
439 write_encoded(&resize(img, max), dest)
440}
441
442pub fn write_encoded(img: &DynamicImage, dest: &Path) -> Result<(), ThumbError> {
444 let bytes = encode(img)?;
445 fs::write(dest, bytes).map_err(ThumbError::new)
446}
447
448pub const CUSTOM_THUMBNAIL_MAX_BYTES: u64 = 10_000_000;
450pub const CUSTOM_THUMBNAIL_KEY: &str = "customThumbnail";
452
453pub fn custom_thumbnail_allowed(ext: &str) -> bool {
457 !matches!(crate::alias::canonical(ext).as_str(), "tiff" | "jpg" | "jpeg" | "png" | "bmp" | "webp")
458}
459
460pub fn has_custom_thumbnail(item: &crate::model::Item) -> bool {
462 item.extra.get(CUSTOM_THUMBNAIL_KEY).and_then(|v| v.as_bool()) == Some(true)
463}
464
465pub const FRAME_THUMBNAIL_SIDE: u32 = 480;
467
468pub fn frame_thumbnail_size(w: u32, h: u32) -> (u32, u32) {
471 let short = w.min(h);
472 if short <= FRAME_THUMBNAIL_SIDE {
473 return (w, h);
474 }
475 let scale = |side: u32| ((u64::from(side) * u64::from(FRAME_THUMBNAIL_SIDE) / u64::from(short)) as u32).max(1);
476 (scale(w), scale(h))
477}
478
479pub const VIDEO_THUMBNAIL_WIDTH: u32 = 480;
481pub const THUMBNAIL_AT_KEY: &str = "thumbnailAt";
483
484pub fn video_thumbnail_size(w: u32, h: u32) -> (u32, u32) {
487 let h = (u64::from(VIDEO_THUMBNAIL_WIDTH) * u64::from(h) / u64::from(w.max(1))) as u32;
488 (VIDEO_THUMBNAIL_WIDTH, h.max(1))
489}
490
491pub fn video_thumbnail_jpeg(frame: &RgbaImage) -> Result<Vec<u8>, ThumbError> {
495 let (w, h) = video_thumbnail_size(frame.width(), frame.height());
496 let img = DynamicImage::ImageRgba8(frame.clone());
497 let img = if (w, h) == (frame.width(), frame.height()) { img } else { img.resize_exact(w, h, RESIZE_FILTER) };
498 jpeg(&img, 100)
499}
500
501pub fn jpeg(img: &DynamicImage, quality: u8) -> Result<Vec<u8>, ThumbError> {
503 let mut out = Vec::new();
504 image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, quality).encode_image(&img.to_rgb8()).map_err(ThumbError::new)?;
505 Ok(out)
506}
507
508pub struct PageThumbnail {
511 bytes: Vec<u8>,
512 palettes: Vec<crate::model::Palette>,
513}
514
515impl PageThumbnail {
516 pub fn draw(img: DynamicImage) -> Result<PageThumbnail, ThumbError> {
520 let img = resize(img, max_side("url"));
521 let bytes = encode(&img)?;
522 let palettes = crate::palette::extract(&img);
523 Ok(PageThumbnail { bytes, palettes })
524 }
525}
526
527impl crate::Library {
528 pub fn set_video_thumbnail(&mut self, id: &str, frame: &RgbaImage, at: f64) -> crate::Result<()> {
536 let mut item = self.item(id).cloned().ok_or_else(|| self.invalid(format!("no item {id}")))?;
537 let bytes = video_thumbnail_jpeg(frame).map_err(|e| self.invalid(e.0))?;
538 let dest = self.info_dir(&item).join(item.thumbnail_name());
539 let img = image::load_from_memory_with_format(&bytes, image::ImageFormat::Jpeg).map_err(|e| self.invalid(e.to_string()))?;
540 fs::write(&dest, &bytes).map_err(|e| crate::Error::io(&dest, e))?;
541 item.palettes = crate::palette::extract(&img);
542 let at = serde_json::Number::from_f64(at.max(0.0)).unwrap_or_else(|| 0.into());
543 item.extra.insert(THUMBNAIL_AT_KEY.into(), serde_json::Value::Number(at));
544 self.update_item(item)
545 }
546
547 pub fn set_frame_thumbnail(&mut self, id: &str, frame: &RgbaImage) -> crate::Result<()> {
554 let mut item = self.item(id).cloned().ok_or_else(|| self.invalid(format!("no item {id}")))?;
555 let (w, h) = frame_thumbnail_size(frame.width(), frame.height());
556 let img = DynamicImage::ImageRgba8(frame.clone());
557 let img = if (w, h) == (frame.width(), frame.height()) { img } else { img.resize_exact(w, h, RESIZE_FILTER) };
558 let mut png = Vec::new();
559 img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).map_err(|e| self.invalid(e.to_string()))?;
560 let dest = self.info_dir(&item).join(item.thumbnail_name());
561 fs::write(&dest, png).map_err(|e| crate::Error::io(&dest, e))?;
562 item.palettes = crate::palette::extract(&img);
563 self.update_item(item)
564 }
565
566 pub fn set_page_thumbnail(&mut self, id: &str, page: &PageThumbnail, width: u32, height: u32) -> crate::Result<bool> {
574 let Some(mut item) = self.item(id).cloned() else {
575 return Ok(false);
576 };
577 if has_custom_thumbnail(&item) {
578 return Ok(false);
579 }
580 let dest = self.info_dir(&item).join(item.thumbnail_name());
581 let tmp = dest.with_file_name(".falcon-page_thumbnail.png");
582 fs::write(&tmp, &page.bytes).map_err(|e| crate::Error::io(&tmp, e))?;
583 if let Err(e) = fs::rename(&tmp, &dest) {
584 let _ = fs::remove_file(&tmp);
585 return Err(crate::Error::io(&dest, e));
586 }
587 item.width = u64::from(width);
588 item.height = u64::from(height);
589 item.palettes = page.palettes.clone();
590 item.no_thumbnail = None;
591 item.remove_thumbnail = None;
592 self.update_item(item).map(|()| true)
593 }
594
595 pub fn set_custom_thumbnail(&mut self, id: &str, image: &Path) -> crate::Result<()> {
603 let item = self.item(id).cloned().ok_or_else(|| self.invalid(format!("no item {id}")))?;
604 let item = custom_thumbnailed(self.root(), item, image)?;
605 self.update_item(item)
606 }
607
608 pub fn reset_custom_thumbnail(&mut self, id: &str) -> crate::Result<bool> {
611 let mut item = self.item(id).cloned().ok_or_else(|| self.invalid(format!("no item {id}")))?;
612 if !has_custom_thumbnail(&item) {
613 return Ok(false);
614 }
615 item.extra.shift_remove(CUSTOM_THUMBNAIL_KEY);
616 self.redraw_item(item).map(|()| true)
617 }
618
619 pub fn refresh_thumbnail(&mut self, id: &str) -> crate::Result<bool> {
624 let item = self.item(id).cloned().ok_or_else(|| self.invalid(format!("no item {id}")))?;
625 if has_custom_thumbnail(&item) {
626 return Ok(false);
627 }
628 self.redraw_item(item).map(|()| true)
629 }
630
631 pub(crate) fn redraw_item(&mut self, item: crate::model::Item) -> crate::Result<()> {
634 let item = redrawn(self.root(), item)?;
635 self.update_item(item)
636 }
637}
638
639fn refused(root: &Path, reason: impl Into<String>) -> crate::Error {
640 crate::Error::Import { path: root.to_path_buf(), reason: reason.into() }
641}
642
643pub fn custom_thumbnailed(root: &Path, mut item: crate::model::Item, image: &Path) -> crate::Result<crate::model::Item> {
649 if !custom_thumbnail_allowed(&item.ext) {
650 return Err(refused(root, format!("a .{} file is its own thumbnail", item.ext)));
651 }
652 let size = fs::metadata(image).map_err(|e| crate::Error::io(image, e))?.len();
653 if size > CUSTOM_THUMBNAIL_MAX_BYTES {
654 return Err(refused(root, "a custom thumbnail can be at most 10 MB"));
655 }
656 let img = decode(image).map_err(|e| refused(root, format!("cannot read {}: {e}", image.display())))?;
657 let dest = item.info_dir(root).join(item.thumbnail_name());
658 fs::copy(image, &dest).map_err(|e| crate::Error::io(&dest, e))?;
659 item.width = img.width().into();
660 item.height = img.height().into();
661 item.palettes = crate::palette::extract(&img);
662 item.no_thumbnail = None;
663 item.extra.shift_remove("noPreview");
664 item.extra.insert(CUSTOM_THUMBNAIL_KEY.into(), true.into());
665 Ok(item)
666}
667
668pub fn redrawn(root: &Path, mut item: crate::model::Item) -> crate::Result<crate::model::Item> {
672 let raw = item.original_path(root);
673 crate::import::restat(&mut item, &raw)?;
674 let thumb = item.info_dir(root).join(item.thumbnail_name());
675 if !crate::import::redraw(&mut item, &raw, &thumb) {
676 return Err(refused(root, format!("cannot draw a thumbnail for {}.{}", item.name, item.ext)));
677 }
678 Ok(item)
679}
680
681#[cfg(test)]
682mod tests {
683 use super::*;
684
685 #[cfg(target_os = "macos")]
688 fn quicklook_stops(name: &str, timeout: std::time::Duration, cancel_after: Option<std::time::Duration>, stop: fn(&QuickLookStop) -> bool) {
689 use std::sync::atomic::{AtomicBool, Ordering};
690 let dir = tempfile::tempdir().unwrap();
691 let path = dir.path().join(name);
692 std::fs::write(&path, b"not really one").unwrap();
693 let cancel = AtomicBool::new(false);
694 let begun = std::time::Instant::now();
695 let out = std::thread::scope(|s| {
696 s.spawn(|| {
697 if let Some(after) = cancel_after {
698 std::thread::sleep(after);
699 cancel.store(true, Ordering::Relaxed);
700 }
701 });
702 quicklook_until(&path, 64, timeout, &cancel)
703 });
704 assert!(begun.elapsed() < std::time::Duration::from_secs(5));
705 if let Err(e) = &out {
706 assert!(stop(e) || matches!(e, QuickLookStop::Failed(_)), "{e:?}");
707 }
708 let left = std::process::Command::new("pgrep").arg("-f").arg(&path).output().unwrap();
709 assert!(left.stdout.is_empty(), "qlmanage left running");
710 }
711
712 #[cfg(target_os = "macos")]
713 #[test]
714 fn cancelling_quicklook_kills_it() {
715 let long = std::time::Duration::from_secs(30);
716 quicklook_stops("x.blend", long, Some(std::time::Duration::from_millis(150)), |e| matches!(e, QuickLookStop::Cancelled));
717 }
718
719 #[cfg(target_os = "macos")]
720 #[test]
721 fn quicklook_out_of_time_is_killed() {
722 quicklook_stops("x.dwg", std::time::Duration::from_millis(200), None, |e| matches!(e, QuickLookStop::TimedOut));
723 }
724
725 #[test]
726 fn only_display_decoding_applies_a_colour_profile() {
727 use image::ImageEncoder;
728 let dir = tempfile::tempdir().unwrap();
729 let path = dir.path().join("p3.png");
730 let mut encoder = image::codecs::png::PngEncoder::new(fs::File::create(&path).unwrap());
731 encoder.set_icc_profile(moxcms::ColorProfile::new_display_p3().encode().unwrap()).unwrap();
732 encoder.write_image(&[200, 100, 50], 1, 1, image::ExtendedColorType::Rgb8).unwrap();
733 assert_eq!(decode(&path).unwrap().to_rgb8().get_pixel(0, 0).0, [200, 100, 50]);
734 let [r, g, _] = decode_for_display(&path).unwrap().to_rgb8().get_pixel(0, 0).0;
735 assert!(r > 200 && g < 100, "{r} {g}");
736 }
737
738 #[test]
739 fn tga_decodes_by_extension() {
740 let dir = tempfile::tempdir().unwrap();
741 let path = dir.path().join("x.tga");
742 let img = image::RgbaImage::from_fn(5, 3, |x, y| image::Rgba([x as u8 * 40, y as u8 * 80, 7, 200]));
743 img.save(&path).unwrap();
744 let back = decode(&path).unwrap().into_rgba8();
745 assert_eq!(back.dimensions(), (5, 3));
746 assert_eq!(*back.get_pixel(3, 2), image::Rgba([120, 160, 7, 200]));
747 }
748
749 fn jpeg(w: u32, h: u32) -> Vec<u8> {
750 let mut out = Vec::new();
751 let img = image::RgbImage::from_pixel(w, h, image::Rgb([200, 30, 30]));
752 image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, 80).encode_image(&img).unwrap();
753 out
754 }
755
756 #[test]
757 fn raw_previews_are_the_largest_embedded_jpeg() {
758 let mut raw = b"II*\0junk".to_vec();
761 raw.extend(jpeg(16, 8));
762 raw.extend([0x12, 0xFF, 0xD8, 0xFF, 0xC3, 0x00, 0x0B, 0x10, 0x00, 0x40, 0x00, 0x40]);
763 raw.extend(vec![7u8; 1000]);
764 raw.extend(jpeg(64, 32));
765 raw.extend(vec![9u8; 100]);
766 assert_eq!(embedded_jpegs(&raw).len(), 2, "the lossless frame is skipped");
767 let img = raw_preview(&raw).unwrap();
768 assert_eq!((img.width(), img.height()), (64, 32));
769 assert!(raw_preview(&[0u8; 100]).is_err());
770 }
771
772 #[test]
775 fn raw_previews_follow_raf_and_cr3_orientation() {
776 let tiff = |o: u16| [&b"II*\0\x08\0\0\0\x01\0\x12\x01\x03\0\x01\0\0\0"[..], &o.to_le_bytes(), &[0; 6]].concat();
778 let mut preview = jpeg(64, 32);
779 let t = tiff(6);
780 let app1 = [&[0xFF, 0xE1][..], &((t.len() + 8) as u16).to_be_bytes(), b"Exif\0\0", &t].concat();
781 preview.splice(2..2, app1);
782 let mut raf = b"FUJIFILMCCD-RAW 0201FF383501".to_vec();
783 raf.resize(100, 0);
784 raf[84..88].copy_from_slice(&100u32.to_be_bytes());
785 raf[88..92].copy_from_slice(&(preview.len() as u32).to_be_bytes());
786 raf.extend_from_slice(&preview);
787 let img = raw_preview(&raf).unwrap();
788 assert_eq!((img.width(), img.height()), (32, 64));
789
790 let mp4 = |kind: &[u8], payload: &[u8]| [&((payload.len() + 8) as u32).to_be_bytes()[..], kind, payload].concat();
791 let uuid = [0x85, 0xC0, 0xB6, 0x87, 0x82, 0x0F, 0x11, 0xE0, 0x81, 0x11, 0xF4, 0xCE, 0x46, 0x2B, 0x6A, 0x48];
792 let canon = [&uuid[..], &mp4(b"CMT1", &tiff(8))].concat();
793 let cr3 = [mp4(b"ftyp", b"crx \0\0\0\x01crx isom"), mp4(b"moov", &mp4(b"uuid", &canon)), mp4(b"mdat", &jpeg(64, 32))].concat();
794 let img = raw_preview(&cr3).unwrap();
795 assert_eq!((img.width(), img.height()), (32, 64));
796 }
797
798 #[test]
799 fn svg_regions_render_only_the_part_asked_for() {
800 let svg = br##"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="50"><rect width="50" height="50" fill="#f00"/><rect x="50" width="50" height="50" fill="#00f"/></svg>"##;
802 let right = render_svg_region(svg, [0.5, 0.0, 1.0, 1.0], (400, 400)).unwrap().to_rgba8();
803 assert_eq!(right.dimensions(), (400, 400));
804 assert_eq!(right.get_pixel(0, 0).0, [0, 0, 255, 255]);
805 assert_eq!(right.get_pixel(399, 399).0, [0, 0, 255, 255]);
806 let middle = render_svg_region(svg, [0.45, 0.0, 0.55, 0.1], (200, 100)).unwrap().to_rgba8();
808 assert_eq!(middle.get_pixel(10, 50).0, [255, 0, 0, 255]);
809 assert_eq!(middle.get_pixel(190, 50).0, [0, 0, 255, 255]);
810 assert!(render_svg_region(svg, [0.5, 0.0, 0.5, 1.0], (10, 10)).is_err());
811 }
812
813 #[test]
814 fn sizes() {
815 assert_eq!(target_size(2000, 1000, 320), (640, 320));
816 assert_eq!(target_size(1000, 2000, 320), (320, 640));
817 assert_eq!(target_size(300, 200, 320), (300, 200));
818 assert_eq!(target_size(1000, 200, 320), (1600, 320));
819 assert_eq!(target_size(400, 400, 320), (320, 320));
820 assert_eq!(target_size(330, 100, 320), (1056, 320));
821 }
822
823 #[test]
824 fn frame_thumbnail_sizes() {
825 assert_eq!(frame_thumbnail_size(1200, 900), (640, 480));
826 assert_eq!(frame_thumbnail_size(900, 1200), (480, 640));
827 assert_eq!(frame_thumbnail_size(2000, 480), (2000, 480), "a short side of 480 is left alone");
828 assert_eq!(frame_thumbnail_size(300, 200), (300, 200));
829 }
830
831 #[test]
832 fn video_thumbnails_are_480_wide_jpegs() {
833 assert_eq!(video_thumbnail_size(1920, 1080), (480, 270));
834 assert_eq!(video_thumbnail_size(1080, 1920), (480, 853), "cut to whole pixels");
835 assert_eq!(video_thumbnail_size(160, 120), (480, 360), "small videos are drawn larger");
836 assert_eq!(video_thumbnail_size(0, 0), (480, 1));
837 let frame = RgbaImage::from_pixel(640, 360, image::Rgba([200, 30, 40, 255]));
838 let bytes = video_thumbnail_jpeg(&frame).unwrap();
839 assert_eq!(&bytes[..3], &[0xFF, 0xD8, 0xFF]);
840 let back = image::load_from_memory(&bytes).unwrap().to_rgb8();
841 assert_eq!(back.dimensions(), (480, 270));
842 let px = back.get_pixel(240, 135).0;
843 assert!(px[0].abs_diff(200) < 6 && px[1].abs_diff(30) < 6 && px[2].abs_diff(40) < 6, "{px:?}");
844 }
845
846 #[test]
847 fn plans() {
848 assert_eq!(plan_raster("jpg", 960, 960, 15_000_000).no_thumbnail, Some(true));
849 assert!(plan_raster("jpg", 961, 10, 10).thumbnail);
850 assert!(plan_raster("png", 100, 100, 2 << 20).thumbnail);
851 assert!(!plan_raster("jpg", 100, 100, 2 << 20).thumbnail);
852 assert!(plan_svg(99_999).remove_thumbnail == Some(true));
853 assert!(plan_svg(100_000).no_thumbnail.is_none());
854 }
855}