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