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