Skip to main content

videre_api/
images.rs

1//! Image-bytes operations shared by every videre-api caller (the axum
2//! `--faces` server in this repo): aligned face thumbnails and full original
3//! images.
4
5use crate::error::{Error, Result};
6use rusqlite::Connection;
7
8const FACE_THUMB_SIZE: u32 = 140;
9
10/// Square crop centered on bbox [x1,y1,x2,y2] with 25% padding, then resize to 140x140.
11fn crop_face_square(img: &image::DynamicImage, bbox: [f32; 4]) -> image::DynamicImage {
12    let w = img.width() as f32;
13    let h = img.height() as f32;
14    let bw = bbox[2] - bbox[0];
15    let bh = bbox[3] - bbox[1];
16    let pad = (bw.max(bh) * 0.25).max(4.0);
17    let half = bw.max(bh) * 0.5 + pad;
18    let cx = (bbox[0] + bbox[2]) * 0.5;
19    let cy = (bbox[1] + bbox[3]) * 0.5;
20    let x1 = (cx - half).max(0.0) as u32;
21    let y1 = (cy - half).max(0.0) as u32;
22    let x2 = (cx + half).min(w) as u32;
23    let y2 = (cy + half).min(h) as u32;
24    let side = (x2 - x1).min(y2 - y1).max(1);
25    img.crop_imm(x1, y1, side, side)
26        .resize_exact(140, 140, image::imageops::FilterType::Triangle)
27}
28
29/// Load, orientation-correct, and crop a face thumbnail. Face rows are
30/// detected on the display canvas, so the file is decoded upright first.
31///
32/// bbox coordinates are stored in terms of the *full-size* decoded image
33/// (videre faces rescales detections back to original width/height before
34/// writing to the DB), so the thumbnail must be cropped from an image of
35/// the same dimensions used at detection time.
36///
37/// For HEIC: decoding goes through
38/// `videre_core::heic::decode_fullres_cached`, which reads the library's
39/// cached full-resolution original when it exists and otherwise renders via
40/// QuickLook (see `videre_core::heic::decode_via_quicklook`) and publishes
41/// that render to the cache. Both give upright pixels with correct rotation
42/// applied, so no separate orientation step is needed.
43///
44/// `pub`: the static-page base64 thumbnail path (`face_thumb_b64` in
45/// `render`) also needs this exact crop+orientation logic, so it calls
46/// through here instead of keeping its own duplicate copy.
47pub fn make_face_thumb(
48    path: &str,
49    bbox: [f32; 4],
50    face_id: i64,
51    original: Option<(&videre_core::library::CachePaths, &str)>,
52) -> Option<image::DynamicImage> {
53    let ext = std::path::Path::new(path)
54        .extension()
55        .and_then(|e| e.to_str())
56        .unwrap_or("")
57        .to_lowercase();
58    if ext == "heic" {
59        // The cached-original read and the render's publish-back live in the
60        // core helper, shared with detection and the faces-original
61        // endpoint, so every HEIC render for this hash happens once.
62        // None: bbox is stored relative to a full-res decode. See the
63        // safety note on decode_via_quicklook.
64        let img = videre_core::heic::decode_fullres_cached(
65            std::path::Path::new(path),
66            &format!("thumb{face_id}"),
67            original,
68        )
69        .inspect_err(videre_core::heic::warn_if_timeout)
70        .ok()?;
71        return Some(crop_face_square(&img, bbox));
72    }
73    let timeout_path = path.to_string();
74    let decoded = match videre_core::io_timeout::run_with_timeout(
75        videre_core::io_timeout::DEFAULT_IO_TIMEOUT,
76        move || {
77            videre_core::image_decode::decode_oriented_file(std::path::Path::new(&timeout_path))
78        },
79    ) {
80        Ok(Ok(img)) => img,
81        Ok(Err(e)) => {
82            tracing::warn!("face thumbnail unavailable for {path}: {e}; skipping");
83            return None;
84        }
85        Err(_) => {
86            tracing::warn!(
87                "timed out reading {path} for face thumbnail \
88                 (file may be unreachable - is its drive connected?); skipping"
89            );
90            return None;
91        }
92    };
93    Some(crop_face_square(&decoded, bbox))
94}
95
96/// Bounds a plain (non-HEIC) file read against a stale/disconnected mount
97/// point the same way `videre_core::heic` bounds `qlmanage`, so a single
98/// unreachable file can't hang the caller (an axum request thread, or any
99/// other synchronous embedder) forever.
100fn read_with_timeout(path: &str) -> std::io::Result<Vec<u8>> {
101    let owned = path.to_string();
102    videre_core::io_timeout::run_with_timeout(
103        videre_core::io_timeout::DEFAULT_IO_TIMEOUT,
104        move || std::fs::read(&owned),
105    )
106    .unwrap_or_else(|_| {
107        Err(std::io::Error::new(
108            std::io::ErrorKind::TimedOut,
109            format!("timed out reading {path} (file may be unreachable - is its drive connected?)"),
110        ))
111    })
112}
113
114pub fn mime_for_ext(ext: &str) -> &'static str {
115    match ext {
116        "jpg" | "jpeg" => "image/jpeg",
117        "png" => "image/png",
118        "gif" => "image/gif",
119        "webp" => "image/webp",
120        "bmp" => "image/bmp",
121        "tiff" => "image/tiff",
122        "mov" => "video/quicktime",
123        "mp4" => "video/mp4",
124        _ => "application/octet-stream",
125    }
126}
127
128/// The single-row query `face_image_bytes` needs before it can do any image
129/// work, split out so a caller holding a shared/locked `Connection` (the
130/// axum server serializes every request on one `Mutex<Connection>`)
131/// can release that lock immediately after this cheap lookup, instead of
132/// holding it for the entire decode/crop/resize/encode/cache-write below,
133/// which otherwise fully serializes every thumbnail request behind the lock,
134/// turning a many-thousand-singleton library into one thumbnail at a time.
135pub struct FaceLookup {
136    pub bbox_json: String,
137    pub file_path: String,
138    pub hash: String,
139}
140
141/// The cheap part of `face_image_bytes`: just the DB row. No image I/O.
142pub fn face_lookup(conn: &Connection, face_id: i64) -> Result<FaceLookup> {
143    let (bbox_json, file_path, hash): (String, String, String) = conn
144        .query_row(
145            "SELECT f.bbox, fh.path, f.hash FROM faces f \
146             JOIN file_hashes fh ON f.hash = fh.hash WHERE f.id = ?1 LIMIT 1",
147            [face_id],
148            |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
149        )
150        .map_err(|_| Error::NotFound)?;
151    Ok(FaceLookup {
152        bbox_json,
153        file_path,
154        hash,
155    })
156}
157
158/// The expensive part of `face_image_bytes`: cache check, decode/crop/encode,
159/// write-through. Takes no `Connection`, so it can run without holding the
160/// shared DB lock.
161pub fn face_bytes_from_lookup(
162    lookup: &FaceLookup,
163    face_id: i64,
164    cache: &videre_core::library::CachePaths,
165) -> Result<Vec<u8>> {
166    let parts: Vec<f32> = lookup
167        .bbox_json
168        .split(',')
169        .filter_map(|s| s.trim().parse().ok())
170        .collect();
171    if parts.len() != 4 {
172        return Err(Error::NotFound);
173    }
174    let bbox = [parts[0], parts[1], parts[0] + parts[2], parts[1] + parts[3]];
175
176    // The crop's cache identity includes its full geometry, so the path is
177    // known only once the bbox is parsed.
178    let cache_path = videre_core::thumb_cache::face_thumb_path_in(
179        cache,
180        &lookup.hash,
181        face_id,
182        bbox,
183        FACE_THUMB_SIZE,
184    );
185    if videre_core::thumb_cache::face_thumb_exists_in(
186        cache,
187        &lookup.hash,
188        face_id,
189        bbox,
190        FACE_THUMB_SIZE,
191    ) {
192        if let Ok(bytes) = read_with_timeout(&cache_path.to_string_lossy()) {
193            return Ok(bytes);
194        }
195    }
196
197    let thumb = make_face_thumb(
198        &lookup.file_path,
199        bbox,
200        face_id,
201        Some((cache, &lookup.hash)),
202    )
203    .ok_or(Error::NotFound)?;
204    let mut buf = Vec::new();
205    thumb
206        .write_to(
207            &mut std::io::Cursor::new(&mut buf),
208            image::ImageFormat::Jpeg,
209        )
210        .map_err(|_| Error::NotFound)?;
211
212    // Best-effort write-through (a cache-write failure must not fail the read).
213    if let Some(parent) = cache_path.parent() {
214        let _ = std::fs::create_dir_all(parent);
215    }
216    let tmp = cache_path.with_extension(format!("tmp{}", std::process::id()));
217    if std::fs::write(&tmp, &buf).is_ok() {
218        let _ = std::fs::rename(&tmp, &cache_path);
219    }
220    Ok(buf)
221}
222
223/// JPEG bytes for a single aligned face thumbnail (140px), reading the disk
224/// cache first and converting from the source image (HEIC via QuickLook) on a
225/// miss, writing through to the cache. Returns `Error::NotFound` if the face id
226/// is unknown or the crop cannot be produced. Synchronous: callers that need
227/// async should run this on a blocking thread.
228///
229/// Holds `conn` only for the initial lookup (see `face_lookup`); callers that
230/// share `conn` behind a lock across many concurrent requests should call
231/// `face_lookup`/`face_bytes_from_lookup` directly instead, releasing the
232/// lock between the two.
233pub fn face_image_bytes(
234    conn: &Connection,
235    face_id: i64,
236    cache: &videre_core::library::CachePaths,
237) -> Result<Vec<u8>> {
238    let lookup = face_lookup(conn, face_id)?;
239    face_bytes_from_lookup(&lookup, face_id, cache)
240}
241
242/// The single-row query `original_image_bytes` needs before any image I/O.
243/// See `FaceLookup` for why this split matters for concurrency.
244pub struct OriginalLookup {
245    pub file_path: String,
246    pub hash: String,
247}
248
249/// The cheap part of `original_image_bytes`: just the DB row. No image I/O.
250pub fn original_lookup(conn: &Connection, face_id: i64) -> Result<OriginalLookup> {
251    let (file_path, hash): (String, String) = conn
252        .query_row(
253            "SELECT fh.path, f.hash FROM faces f \
254             JOIN file_hashes fh ON f.hash = fh.hash WHERE f.id = ?1 LIMIT 1",
255            [face_id],
256            |r| Ok((r.get(0)?, r.get(1)?)),
257        )
258        .map_err(|_| Error::NotFound)?;
259    Ok(OriginalLookup { file_path, hash })
260}
261
262/// The expensive part of `original_image_bytes`: read/convert/cache. Takes no
263/// `Connection`, so it can run without holding the shared DB lock.
264pub fn original_bytes_from_lookup(
265    lookup: &OriginalLookup,
266    face_id: i64,
267    cache: &videre_core::library::CachePaths,
268) -> Result<(&'static str, Vec<u8>)> {
269    let file_path = &lookup.file_path;
270    let hash = &lookup.hash;
271    let ext = std::path::Path::new(file_path)
272        .extension()
273        .and_then(|e| e.to_str())
274        .unwrap_or("")
275        .to_lowercase();
276
277    if ext == "heic" {
278        if let Ok(bytes) = read_with_timeout(
279            &videre_core::thumb_cache::original_path_in(cache, hash).to_string_lossy(),
280        ) {
281            return Ok(("image/jpeg", bytes));
282        }
283        // None: this serves the true original image, so it must stay at
284        // full resolution. The render is published atomically, shared with
285        // the crop path and detection, so a concurrent save can never tear
286        // the cached entry.
287        let img = videre_core::heic::decode_via_quicklook(
288            std::path::Path::new(file_path),
289            &format!("orig{face_id}"),
290            None,
291        )
292        .inspect_err(videre_core::heic::warn_if_timeout)
293        .map_err(|_| Error::NotFound)?;
294        let mut buf = Vec::new();
295        img.write_to(
296            &mut std::io::Cursor::new(&mut buf),
297            image::ImageFormat::Jpeg,
298        )
299        .map_err(|_| Error::NotFound)?;
300        videre_core::heic::publish_cached_original(cache, hash, &buf);
301        Ok(("image/jpeg", buf))
302    } else {
303        let bytes = read_with_timeout(file_path).map_err(|e| {
304            tracing::warn!("original image unavailable for {file_path}: {e}; skipping");
305            Error::NotFound
306        })?;
307        Ok((mime_for_ext(&ext), bytes))
308    }
309}
310
311/// Bytes for the full original image behind a face (raw for common formats,
312/// QuickLook-converted JPEG for HEIC, with the HEIC result cached). Returns the
313/// MIME type alongside the bytes. `Error::NotFound` if the id is unknown or the
314/// file cannot be read/converted. Synchronous.
315///
316/// Holds `conn` only for the initial lookup (see `original_lookup`); callers
317/// that share `conn` behind a lock across many concurrent requests should
318/// call `original_lookup`/`original_bytes_from_lookup` directly instead,
319/// releasing the lock between the two.
320pub fn original_image_bytes(
321    conn: &Connection,
322    face_id: i64,
323    cache: &videre_core::library::CachePaths,
324) -> Result<(&'static str, Vec<u8>)> {
325    let lookup = original_lookup(conn, face_id)?;
326    original_bytes_from_lookup(&lookup, face_id, cache)
327}
328
329#[cfg(test)]
330mod tests {
331    use super::*;
332
333    /// The crop comes from the upright image. The o6 fixture is the untagged
334    /// original plus EXIF Orientation = 6, so a display-canvas bbox must
335    /// render the same face as cropping the raw canvas and rotating the
336    /// square afterwards.
337    ///
338    /// Picking square bboxes centered on even coordinates makes the two
339    /// regions pixel-identical after the integer rotation, so the crops must
340    /// match exactly.
341    #[test]
342    fn a_face_thumbnail_is_cropped_from_the_upright_image() {
343        let base = concat!(env!("CARGO_MANIFEST_DIR"), "/../videre/tests/fixtures");
344        let tagged = format!("{base}/ai-generated-couple_o6.jpg");
345
346        // Raw canvas is 1200x1543 portrait; display canvas is 1543x1200.
347        // A 90 CW rotation maps raw (x, y) to display (H-1-y, x), which maps
348        // the half-open region [a, b) to [H-b, H-a): the bbox center moves
349        // from cy to H-cy, with no minus one, or the crop shifts by a pixel.
350        let raw_center = (600u32, 772u32);
351        let display_center = (1543 - raw_center.1, raw_center.0);
352        let raw_bbox = [
353            (raw_center.0 - 200) as f32,
354            (raw_center.1 - 200) as f32,
355            (raw_center.0 + 200) as f32,
356            (raw_center.1 + 200) as f32,
357        ];
358        let display_bbox = [
359            (display_center.0 - 200) as f32,
360            (display_center.1 - 200) as f32,
361            (display_center.0 + 200) as f32,
362            (display_center.1 + 200) as f32,
363        ];
364
365        let (raw, orientation) =
366            videre_core::image_decode::decode_raw_with_orientation(std::path::Path::new(&tagged))
367                .unwrap();
368        let mut expected =
369            image::DynamicImage::ImageRgba8(crop_face_square(&raw, raw_bbox).to_rgba8());
370        expected.apply_orientation(orientation);
371        let thumb = make_face_thumb(&tagged, display_bbox, 1, None).unwrap();
372        assert_eq!((thumb.width(), thumb.height()), (140, 140));
373        let a: Vec<u8> = expected.to_rgb8().pixels().map(|p| p.0[0]).collect();
374        let b: Vec<u8> = thumb.to_rgb8().pixels().map(|p| p.0[0]).collect();
375        let diff: u64 = a
376            .iter()
377            .zip(&b)
378            .map(|(x, y)| (*x as i32 - *y as i32).unsigned_abs() as u64)
379            .sum();
380        assert!(
381            diff < 1000,
382            "the thumbnail must show the upright face, sum |diff| = {diff}"
383        );
384    }
385
386    #[test]
387    fn a_face_crop_is_square_and_thumbnail_sized() {
388        let img = image::DynamicImage::ImageLuma8(image::GrayImage::new(200, 100));
389        let out = crop_face_square(&img, [80.0, 40.0, 120.0, 80.0]);
390        assert_eq!((out.width(), out.height()), (140, 140));
391    }
392
393    /// A bbox against the edge would give a negative origin, and one larger
394    /// than the image would run past it. Both are clamped rather than
395    /// panicking inside `crop_imm`.
396    #[test]
397    fn a_face_crop_clamps_to_the_image_bounds() {
398        let img = image::DynamicImage::ImageLuma8(image::GrayImage::new(50, 50));
399        for bbox in [
400            [0.0, 0.0, 10.0, 10.0],   // flush against the top-left
401            [45.0, 45.0, 60.0, 60.0], // runs past the bottom-right
402            [-20.0, -20.0, 5.0, 5.0], // negative origin
403            [0.0, 0.0, 500.0, 500.0], // larger than the whole image
404        ] {
405            let out = crop_face_square(&img, bbox);
406            assert_eq!((out.width(), out.height()), (140, 140), "bbox {bbox:?}");
407        }
408    }
409
410    /// A zero-area bbox still has to produce a thumbnail rather than a
411    /// zero-side crop: `crop_face_square` floors the side at 1.
412    #[test]
413    fn a_degenerate_bbox_still_produces_a_thumbnail() {
414        let img = image::DynamicImage::ImageLuma8(image::GrayImage::new(50, 50));
415        let out = crop_face_square(&img, [25.0, 25.0, 25.0, 25.0]);
416        assert_eq!((out.width(), out.height()), (140, 140));
417    }
418
419    #[test]
420    fn mime_types_cover_gallery_image_and_video_extensions() {
421        for (ext, expected) in [
422            ("jpg", "image/jpeg"),
423            ("jpeg", "image/jpeg"),
424            ("png", "image/png"),
425            ("gif", "image/gif"),
426            ("webp", "image/webp"),
427            ("bmp", "image/bmp"),
428            ("tiff", "image/tiff"),
429            ("mov", "video/quicktime"),
430            ("mp4", "video/mp4"),
431            ("unknown", "application/octet-stream"),
432        ] {
433            assert_eq!(mime_for_ext(ext), expected, "extension {ext}");
434        }
435    }
436
437    #[test]
438    fn face_thumbnail_cache_is_returned_without_reading_the_source() {
439        let temp = tempfile::tempdir().unwrap();
440        let ctx =
441            videre_core::library::LibraryContext::new(temp.path(), &temp.path().join("cache"))
442                .unwrap();
443        let lookup = FaceLookup {
444            bbox_json: "10,20,30,40".to_string(),
445            file_path: temp.path().join("missing.jpg").to_string_lossy().into(),
446            hash: "face-cache-hash".to_string(),
447        };
448        let bbox = [10.0, 20.0, 40.0, 60.0];
449        let cache_path = videre_core::thumb_cache::face_thumb_path_in(
450            &ctx.cache,
451            &lookup.hash,
452            42,
453            bbox,
454            FACE_THUMB_SIZE,
455        );
456        std::fs::create_dir_all(cache_path.parent().unwrap()).unwrap();
457        std::fs::write(&cache_path, b"cached thumbnail").unwrap();
458
459        assert_eq!(
460            face_bytes_from_lookup(&lookup, 42, &ctx.cache).unwrap(),
461            b"cached thumbnail"
462        );
463    }
464
465    #[test]
466    fn malformed_face_bbox_is_not_found_before_image_io() {
467        let temp = tempfile::tempdir().unwrap();
468        let ctx =
469            videre_core::library::LibraryContext::new(temp.path(), &temp.path().join("cache"))
470                .unwrap();
471        for bbox_json in ["", "1,2,3", "1,2,three,4", "1,2,3,4,5"] {
472            let lookup = FaceLookup {
473                bbox_json: bbox_json.to_string(),
474                file_path: temp.path().join("missing.jpg").to_string_lossy().into(),
475                hash: "bad-bbox-hash".to_string(),
476            };
477            assert!(matches!(
478                face_bytes_from_lookup(&lookup, 1, &ctx.cache),
479                Err(Error::NotFound)
480            ));
481        }
482    }
483
484    #[test]
485    fn original_bytes_preserve_plain_file_contents_and_choose_mime() {
486        let temp = tempfile::tempdir().unwrap();
487        let ctx =
488            videre_core::library::LibraryContext::new(temp.path(), &temp.path().join("cache"))
489                .unwrap();
490        let source = temp.path().join("original.JpEg");
491        std::fs::write(&source, b"original image bytes").unwrap();
492        let lookup = OriginalLookup {
493            file_path: source.to_string_lossy().into(),
494            hash: "original-hash".to_string(),
495        };
496
497        let (mime, bytes) = original_bytes_from_lookup(&lookup, 1, &ctx.cache).unwrap();
498        assert_eq!(mime, "image/jpeg");
499        assert_eq!(bytes, b"original image bytes");
500    }
501
502    #[test]
503    fn missing_original_file_is_not_found() {
504        let temp = tempfile::tempdir().unwrap();
505        let ctx =
506            videre_core::library::LibraryContext::new(temp.path(), &temp.path().join("cache"))
507                .unwrap();
508        let lookup = OriginalLookup {
509            file_path: temp.path().join("missing.jpg").to_string_lossy().into(),
510            hash: "missing-original-hash".to_string(),
511        };
512        assert!(matches!(
513            original_bytes_from_lookup(&lookup, 1, &ctx.cache),
514            Err(Error::NotFound)
515        ));
516    }
517
518    #[test]
519    fn unknown_face_id_is_not_found() {
520        let conn = Connection::open_in_memory().unwrap();
521        videre_core::face_db::create_faces_table(&conn).unwrap();
522        conn.execute_batch("CREATE TABLE file_hashes (hash TEXT PRIMARY KEY, path TEXT);")
523            .unwrap();
524        let temp = tempfile::tempdir().unwrap();
525        let ctx =
526            videre_core::library::LibraryContext::new(temp.path(), &temp.path().join("cache"))
527                .unwrap();
528        assert!(matches!(
529            face_image_bytes(&conn, 999, &ctx.cache),
530            Err(Error::NotFound)
531        ));
532        assert!(matches!(
533            original_image_bytes(&conn, 999, &ctx.cache),
534            Err(Error::NotFound)
535        ));
536    }
537
538    #[test]
539    fn face_lookup_unknown_id_is_not_found() {
540        let conn = Connection::open_in_memory().unwrap();
541        videre_core::face_db::create_faces_table(&conn).unwrap();
542        conn.execute_batch("CREATE TABLE file_hashes (hash TEXT PRIMARY KEY, path TEXT);")
543            .unwrap();
544        assert!(matches!(face_lookup(&conn, 999), Err(Error::NotFound)));
545    }
546
547    #[test]
548    fn original_lookup_unknown_id_is_not_found() {
549        let conn = Connection::open_in_memory().unwrap();
550        videre_core::face_db::create_faces_table(&conn).unwrap();
551        conn.execute_batch("CREATE TABLE file_hashes (hash TEXT PRIMARY KEY, path TEXT);")
552            .unwrap();
553        assert!(matches!(original_lookup(&conn, 999), Err(Error::NotFound)));
554    }
555
556    #[test]
557    fn face_lookup_does_not_touch_the_filesystem() {
558        // Regression test for the thumbnail-rendering serialization bug: the
559        // DB lookup must be a pure query with no image I/O, so callers can
560        // release the connection lock before doing the expensive part.
561        let conn = Connection::open_in_memory().unwrap();
562        videre_core::face_db::create_faces_table(&conn).unwrap();
563        conn.execute_batch("CREATE TABLE file_hashes (hash TEXT PRIMARY KEY, path TEXT);")
564            .unwrap();
565        conn.execute(
566            "INSERT INTO file_hashes (hash, path) VALUES ('h1', '/no/such/file.jpg')",
567            [],
568        )
569        .unwrap();
570        conn.execute(
571            "INSERT INTO faces (id, hash, bbox, embedding) VALUES (1, 'h1', '0,0,10,10', X'00')",
572            [],
573        )
574        .unwrap();
575        let lookup = face_lookup(&conn, 1).unwrap();
576        assert_eq!(lookup.file_path, "/no/such/file.jpg");
577        assert_eq!(lookup.hash, "h1");
578        assert_eq!(lookup.bbox_json, "0,0,10,10");
579    }
580
581    /// With the cached full-resolution original present, the crop is produced
582    /// from it without rendering the HEIC: the source path below does not
583    /// exist, so QuickLook could not have produced anything. Before the
584    /// cache-first read this failed on every platform (Linux bails inside
585    /// `decode_via_quicklook`; macOS `qlmanage` fails on the missing file).
586    #[test]
587    fn a_cached_original_feeds_the_face_crop_without_quicklook() {
588        let temp = tempfile::tempdir().unwrap();
589        let ctx =
590            videre_core::library::LibraryContext::new(temp.path(), &temp.path().join("cache"))
591                .unwrap();
592        let hash = format!("face-crop-original-{}", std::process::id());
593        let cached = videre_core::thumb_cache::original_path_in(&ctx.cache, &hash);
594        std::fs::create_dir_all(cached.parent().unwrap()).unwrap();
595        let img = image::RgbImage::from_pixel(2, 2, image::Rgb([255, 0, 0]));
596        image::DynamicImage::ImageRgb8(img).save(&cached).unwrap();
597
598        let thumb = make_face_thumb(
599            "/nonexistent/should-not-be-read.heic",
600            [0.0, 0.0, 2.0, 2.0],
601            1,
602            Some((&ctx.cache, &hash)),
603        )
604        .unwrap();
605        assert_eq!((thumb.width(), thumb.height()), (140, 140));
606        assert!(
607            thumb
608                .to_rgb8()
609                .pixels()
610                .all(|p| p.0[0] >= 200 && p.0[1] <= 64 && p.0[2] <= 64),
611            "the crop must show the cached original's pixels (JPEG round-trip shifts them a little)"
612        );
613    }
614
615    /// A corrupt cached original is skipped like a missing one: the fallback
616    /// decodes the (here nonexistent) source, fails, and the crop is `None`
617    /// with no panic. Linux bails in `decode_via_quicklook`; macOS `qlmanage`
618    /// fails on the missing file, but only after its own timeout, so this is
619    /// the slow test of the two there.
620    #[test]
621    fn a_corrupt_cached_original_is_skipped_not_fatal() {
622        let temp = tempfile::tempdir().unwrap();
623        let ctx =
624            videre_core::library::LibraryContext::new(temp.path(), &temp.path().join("cache"))
625                .unwrap();
626        let hash = format!("face-crop-corrupt-{}", std::process::id());
627        let cached = videre_core::thumb_cache::original_path_in(&ctx.cache, &hash);
628        std::fs::create_dir_all(cached.parent().unwrap()).unwrap();
629        std::fs::write(&cached, b"not a jpeg").unwrap();
630
631        assert!(make_face_thumb(
632            "/nonexistent/should-not-be-read.heic",
633            [0.0, 0.0, 2.0, 2.0],
634            1,
635            Some((&ctx.cache, &hash)),
636        )
637        .is_none());
638    }
639
640    /// After a fallback render the full-resolution original is in the cache:
641    /// the first crop pays for the QuickLook render and the photo's other
642    /// crops read it. macOS only: the fallback is a real QuickLook render of
643    /// the committed `tiny.heic` fixture.
644    #[test]
645    #[cfg(target_os = "macos")]
646    fn a_fallback_render_publishes_the_cached_original() {
647        let temp = tempfile::tempdir().unwrap();
648        let ctx =
649            videre_core::library::LibraryContext::new(temp.path(), &temp.path().join("cache"))
650                .unwrap();
651        let heic = concat!(
652            env!("CARGO_MANIFEST_DIR"),
653            "/../videre/tests/fixtures/content_key/tiny.heic"
654        );
655        let hash = format!("face-crop-publish-{}", std::process::id());
656
657        let thumb =
658            make_face_thumb(heic, [0.0, 0.0, 200.0, 200.0], 1, Some((&ctx.cache, &hash))).unwrap();
659        assert_eq!((thumb.width(), thumb.height()), (140, 140));
660
661        let cached = videre_core::thumb_cache::original_path_in(&ctx.cache, &hash);
662        assert!(cached.is_file(), "the render was not published: {cached:?}");
663        let opened = image::open(&cached).unwrap();
664        assert!(opened.width() > 0);
665    }
666}