Skip to main content

falcon_library/
thumbnail.rs

1//! Thumbnail rules and rendering, following Eagle's `thumbs/` modules.
2//!
3//! Eagle stores thumbnails as lossy WebP (quality 75) under the name
4//! `<name>_thumbnail.png`; sides above 16383 px fall back to JPEG at 0.7.
5//! See the thumbnails chapter of the docs for what is observed and inferred.
6
7use std::fs;
8use std::path::Path;
9
10use image::imageops::FilterType;
11use image::{DynamicImage, ImageDecoder, ImageReader, RgbaImage};
12
13/// Short-side limit for ordinary images.
14pub const DEFAULT_MAX_SIDE: u32 = 320;
15/// An image up to this many pixels per side may be shown without a thumbnail.
16pub const NO_THUMBNAIL_SIZE: u32 = 960;
17/// ... as long as its file is no larger than this many bytes.
18pub const NO_THUMBNAIL_FILE_SIZE: u64 = 15_000_000;
19/// A PNG this large always gets a thumbnail, whatever its dimensions.
20pub const PNG_FORCE_THUMBNAIL_SIZE: u64 = 1024 * 1024;
21/// SVGs smaller than this (bytes) are displayed directly.
22pub const SVG_INLINE_LIMIT: u64 = 100_000;
23/// Largest side WebP can hold; beyond it Eagle writes JPEG.
24pub const MAX_WEBP_SIDE: u32 = 16383;
25/// WebP quality used by Eagle.
26pub const WEBP_QUALITY: f32 = 75.0;
27/// JPEG quality (0.7) used past [`MAX_WEBP_SIDE`].
28pub const JPEG_QUALITY: u8 = 70;
29
30/// Failure while decoding, rendering or encoding an image.
31#[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
41/// Whether falcon can decode and thumbnail this extension (lowercase, no dot).
42pub fn is_supported(ext: &str) -> bool {
43    matches!(ext, "png" | "jpg" | "jpeg" | "jfif" | "jpe" | "insp" | "webp" | "gif" | "bmp" | "svg")
44}
45
46/// The short-side limit for `ext`: 320 for ordinary images, 512 for icon
47/// formats, 480 for documents, design files and other large-canvas formats.
48/// 3D models are not scaled down: Eagle keeps its 1080 render (plus the
49/// padding) as is.
50pub 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/// What to do about an item's thumbnail.
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub struct Plan {
63    /// Whether a thumbnail file is generated at all.
64    pub thumbnail: bool,
65    /// Value for the item's `noThumbnail` field (`None` = field absent).
66    pub no_thumbnail: Option<bool>,
67    /// Value for the item's `removeThumbnail` field (`None` = field absent).
68    pub remove_thumbnail: Option<bool>,
69}
70
71impl Plan {
72    /// Whether the palette is computed from the original file rather than the
73    /// thumbnail (`noThumbnail && !removeThumbnail`).
74    pub fn analyse_original(&self) -> bool {
75        self.no_thumbnail == Some(true) && self.remove_thumbnail != Some(true)
76    }
77}
78
79/// Plan for a raster image of `w` x `h` pixels and `file_size` bytes.
80///
81/// The original is shown directly when it is at most 960 px on both sides and
82/// at most 15 MB; a PNG over 1 MiB is the exception and still gets a thumbnail.
83pub 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
93/// Plan for an SVG of `file_size` bytes whose render succeeded. A thumbnail is
94/// always rendered (it feeds the palette); small SVGs delete it afterwards.
95pub 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
103/// Plan for an SVG that failed to render: no thumbnail, `noThumbnail` only.
104pub fn plan_svg_failed() -> Plan {
105    Plan { thumbnail: false, no_thumbnail: Some(true), remove_thumbnail: None }
106}
107
108/// Target size for a `w` x `h` image with short-side limit `max`.
109///
110/// Nothing changes unless the long side exceeds `max`. Then the short side
111/// becomes `max` (landscape fixes the height, everything else the width) and
112/// the other side follows with truncation. A wide image whose short side is
113/// already under `max` is therefore scaled up, as in Eagle.
114pub 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
122/// Decodes a raster image, applying its EXIF orientation. Sniffs the format
123/// from content, so it also reads Eagle's WebP-in-`.png` thumbnails.
124pub fn decode(path: &Path) -> Result<DynamicImage, ThumbError> {
125    decode_with_profile(path).map(|(img, _)| img)
126}
127
128/// [`decode`] for showing: the pixels are turned into sRGB through the file's
129/// colour profile (see [`crate::color`]). Whatever rewrites the file uses
130/// [`decode`] instead.
131pub 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    // A format with no way to carry a profile is the same as a file without one.
147    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
153/// The largest JPEG preview embedded in a RAW photo, turned by the file's
154/// EXIF orientation when it has one (from its TIFF IFDs, RAF's preview
155/// EXIF, CR3's `CMT1`, MRW's TTW block, X3F's preview or CRW's rotation record; see [`crate::exif::orientation`]). Eagle's RAW
156/// viewer shows the preview dcraw extracts; falcon finds the same kind of picture by scanning for
157/// baseline or progressive 8-bit JPEG streams and keeping the one with the
158/// most pixels (the camera's full-size preview rather than its thumbnail),
159/// in sRGB when the preview has a colour profile.
160pub 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
177/// Start offsets and pixel counts of the JPEG streams in `data` whose
178/// headers parse up to a baseline or progressive 8-bit frame.
179fn 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
187/// Walks a JPEG's marker segments from its SOI to the frame header and
188/// returns width x height for an 8-bit SOF0, SOF1 or SOF2 frame.
189fn 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            // Lossless, hierarchical or arithmetic frames, or the image data
205            // before any frame: not a preview picture.
206            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
219/// Long side of QuickLook renders, matching the size Eagle reports for
220/// documents such as PDFs.
221pub const QUICKLOOK_SIZE: u32 = 1024;
222/// How long a QuickLook render may take.
223pub const QUICKLOOK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
224
225/// Why [`quicklook_until`] gave no picture.
226#[derive(Debug)]
227pub enum QuickLookStop {
228    /// `cancel` was set; `qlmanage` has been killed.
229    Cancelled,
230    /// The time ran out; `qlmanage` has been killed. Without a generator for
231    /// the format `qlmanage` hangs here rather than failing.
232    TimedOut,
233    Failed(ThumbError),
234}
235
236/// Renders a preview of any file macOS QuickLook understands, with its long
237/// side at most `size`.
238pub 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
247/// [`quicklook`], giving up after `timeout` or once `cancel` is set, either
248/// of which kills `qlmanage`.
249pub 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    // macOS has no type for some alias names (.jfif, .psdt); QuickLook gets
255    // the file under the extension the alias stands for.
256    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/// What Spotlight knows about a file's pixel size and duration.
299#[derive(Debug, Default, Clone, Copy, PartialEq)]
300pub struct Spotlight {
301    pub size: Option<(u32, u32)>,
302    pub duration: Option<f64>,
303}
304
305/// Reads pixel size and duration from Spotlight (`mdls`). Files Spotlight
306/// hasn't indexed, such as ones in temporary folders, give nothing.
307pub 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
325/// Natural pixel size of an SVG file's contents.
326pub 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
341/// Renders an SVG. With `max`, it is drawn straight at [`target_size`];
342/// without, at its natural size.
343pub 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
365/// Renders part of an SVG at `size` pixels: `region` is the part to draw,
366/// as fractions of the drawing (`[x0, y0, x1, y1]`, 0..1). The viewer uses
367/// it to stay sharp when zoomed in, drawing only what is on screen.
368pub 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
388/// Scales `img` per [`target_size`]; returns it unchanged when it fits.
389pub fn resize(img: DynamicImage, max: u32) -> DynamicImage {
390    resize_with(img, max, RESIZE_FILTER)
391}
392
393/// Filter for each resampling step. Chromium draws with
394/// `imageSmoothingQuality = 'high'`, a cubic filter in Skia.
395const RESIZE_FILTER: FilterType = FilterType::CatmullRom;
396
397/// [`resize`] with an explicit filter.
398///
399/// Like Eagle, an image more than twice the target size is first halved
400/// repeatedly (never below the target) while it is over 1.5 times the target,
401/// and only then drawn at the final size.
402pub 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
421/// Encodes `img` as Eagle does: lossy WebP at quality 75, or JPEG at 0.7 when
422/// a side exceeds 16383 px.
423pub 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
437/// Resizes `img` to the limit `max` and writes the encoded thumbnail to `dest`.
438pub fn write_thumbnail(img: DynamicImage, max: u32, dest: &Path) -> Result<(), ThumbError> {
439    write_encoded(&resize(img, max), dest)
440}
441
442/// Encodes `img` as is and writes it to `dest`.
443pub 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
448/// Largest file Eagle accepts as a custom thumbnail, in bytes.
449pub const CUSTOM_THUMBNAIL_MAX_BYTES: u64 = 10_000_000;
450/// Item key Eagle sets when the thumbnail was chosen by the user.
451pub const CUSTOM_THUMBNAIL_KEY: &str = "customThumbnail";
452
453/// Whether an item of this extension may get a custom thumbnail. Eagle hides
454/// the command for the image formats it shows as themselves (tif, jpg, png,
455/// bmp, webp); jpeg and tiff are falcon's addition, as the same formats.
456pub fn custom_thumbnail_allowed(ext: &str) -> bool {
457    !matches!(crate::alias::canonical(ext).as_str(), "tiff" | "jpg" | "jpeg" | "png" | "bmp" | "webp")
458}
459
460/// Whether the item's thumbnail was set by hand (`customThumbnail: true`).
461pub 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
465/// Short side a frame chosen in the GIF player is brought down to.
466pub const FRAME_THUMBNAIL_SIDE: u32 = 480;
467
468/// The size Eagle draws a GIF frame thumbnail at: the short side brought
469/// down to [`FRAME_THUMBNAIL_SIDE`], never enlarged.
470pub 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
479/// Width Eagle draws a video's thumbnail at, whatever the video's size.
480pub const VIDEO_THUMBNAIL_WIDTH: u32 = 480;
481/// Item key Eagle sets to the second a video's thumbnail was taken at.
482pub const THUMBNAIL_AT_KEY: &str = "thumbnailAt";
483
484/// The size of Eagle's video thumbnail for a `w` × `h` frame: 480 wide,
485/// the height scaled to match and cut to whole pixels (a canvas size).
486pub 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
491/// Encodes a video frame the way Eagle's thumbnailer does: drawn at
492/// [`video_thumbnail_size`] and saved as a JPEG at full quality. Eagle
493/// writes those JPEG bytes under the usual `_thumbnail.png` name.
494pub 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
501/// `img` as a JPEG at `quality` (1..=100), alpha dropped.
502pub 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
508/// A page screenshot made ready to save: encoded and its palette taken.
509/// Slow, and needs no library.
510pub struct PageThumbnail {
511    bytes: Vec<u8>,
512    palettes: Vec<crate::model::Palette>,
513}
514
515impl PageThumbnail {
516    /// `img` scaled by [`resize`] to the `url` limit and encoded by
517    /// [`encode`], the encoder [`write_thumbnail`] uses; the palette is taken
518    /// from the scaled image.
519    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    /// Makes the video frame shown at `at` seconds the item's thumbnail, as
529    /// Eagle's "Set Current Frame as Thumbnail" does: Eagle regenerates the
530    /// video thumbnail from that time, so the frame is written per
531    /// [`video_thumbnail_jpeg`] to `<name>_thumbnail.png`, the palette is
532    /// computed from it and `thumbnailAt` records the time (Eagle keeps it
533    /// and draws later thumbnails from there). The item is not marked as a
534    /// custom thumbnail and keeps its size.
535    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    /// Makes a frame of an animated item its thumbnail, as Eagle's GIF
548    /// player does: the frame is scaled per [`frame_thumbnail_size`],
549    /// written as a PNG to `<name>_thumbnail.png`, and the palette is
550    /// computed from it. Nothing else changes: the item keeps its size and
551    /// is not marked as a custom thumbnail, so Refresh Thumbnail draws the
552    /// first frame again.
553    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    /// Saves a page screenshot as a bookmark's thumbnail, through the normal
567    /// thumbnail path: `<name>_thumbnail.png` is written to a temporary
568    /// sibling and renamed into place, `width` and `height` become the page's
569    /// size, the palette is set and `noThumbnail` and `removeThumbnail` are
570    /// cleared. Size, URL, times and tags are untouched, and the item is not
571    /// marked as a custom thumbnail. Returns false, writing nothing, when the
572    /// item is gone or has a custom thumbnail.
573    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    /// Makes the image at `image` the item's thumbnail, as Eagle's
596    /// "Custom Thumbnail" does: the file is copied byte for byte to
597    /// `<name>_thumbnail.png` (not resized or re-encoded), `width` and
598    /// `height` become the picture's size, the palette is computed from it,
599    /// `noThumbnail` and `noPreview` are removed and `customThumbnail: true`
600    /// is set. Refuses files over 10 MB, pictures falcon can't decode, and
601    /// items whose format is its own thumbnail.
602    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    /// Drops a custom thumbnail and draws the item's own again (Eagle's
609    /// "Reset Thumbnail"). Returns false when the item had none.
610    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    /// Redraws an item's thumbnail from its original and recomputes the
620    /// palette (Eagle's "Refresh Thumbnail"). Size, birth time and mtime are
621    /// read from the file again. As in Eagle, an item with a custom thumbnail
622    /// is left alone; the result is then false.
623    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    /// Re-reads the original's file times and redraws thumbnail and palette,
632    /// then saves the item.
633    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
643/// The file work of [`crate::Library::set_custom_thumbnail`], without the
644/// library: checks `image`, copies it over the thumbnail of `item` (in the
645/// library at `root`) and returns the item as it should be saved. Lets a
646/// caller that shares the library read and write files without holding
647/// its lock.
648pub 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
668/// The file work of Refresh Thumbnail, without the library: re-reads the
669/// original's file times, redraws thumbnail and palette, and returns the
670/// item as it should be saved.
671pub 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    /// `qlmanage` hangs on a format with no generator; elsewhere it answers,
686    /// which these accept too. Neither takes long or leaves a process.
687    #[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        // A stand-in RAW: header bytes, a small thumbnail, sensor data with a
759        // stray FF D8 FF, then the full-size preview.
760        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    /// RAF and CR3 previews are turned by the orientation in their own
773    /// containers (the preview's EXIF, the Canon `CMT1` block).
774    #[test]
775    fn raw_previews_follow_raf_and_cr3_orientation() {
776        // A little-endian TIFF whose one IFD entry is Orientation = `o`.
777        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        // Left half red, right half blue.
801        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        // A strip across the middle shows both halves, magnified 20 times.
807        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}