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;
7use std::io::BufReader;
8
9const FACE_THUMB_SIZE: u32 = 140;
10
11fn read_exif_orientation(path: &str) -> u16 {
12    let Ok(f) = std::fs::File::open(path) else {
13        return 1;
14    };
15    let Ok(exif_data) = exif::Reader::new().read_from_container(&mut BufReader::new(f)) else {
16        return 1;
17    };
18    exif_data
19        .get_field(exif::Tag::Orientation, exif::In::PRIMARY)
20        .and_then(|field| {
21            if let exif::Value::Short(ref v) = field.value {
22                v.first().copied()
23            } else {
24                None
25            }
26        })
27        .unwrap_or(1)
28}
29
30/// The eight EXIF orientation transforms, as pure image maths.
31///
32/// Split from `apply_exif_orientation` so it can be tested exhaustively: the
33/// alternative is hand-crafting a JPEG with an EXIF APP1 segment per case,
34/// which would test the `exif` crate's parser far more than this mapping. The
35/// mapping is where the bugs actually live, since 5 and 7 combine a rotation
36/// with a flip and are easy to transpose.
37///
38/// Anything outside 1..=8, including the 1 that `read_exif_orientation`
39/// returns when a file has no EXIF at all, is the identity.
40fn apply_orientation(img: image::DynamicImage, orientation: u16) -> image::DynamicImage {
41    match orientation {
42        2 => img.fliph(),
43        3 => img.rotate180(),
44        4 => img.flipv(),
45        5 => img.rotate90().fliph(),
46        6 => img.rotate90(),
47        7 => img.rotate270().fliph(),
48        8 => img.rotate270(),
49        _ => img,
50    }
51}
52
53/// Rotate/flip `img` to match its EXIF orientation (read from `path`).
54fn apply_exif_orientation(img: image::DynamicImage, path: &str) -> image::DynamicImage {
55    let ext = std::path::Path::new(path)
56        .extension()
57        .and_then(|e| e.to_str())
58        .unwrap_or("")
59        .to_lowercase();
60    if !matches!(ext.as_str(), "jpg" | "jpeg" | "tiff" | "dng") {
61        return img;
62    }
63    apply_orientation(img, read_exif_orientation(path))
64}
65
66/// Square crop centered on bbox [x1,y1,x2,y2] with 25% padding, then resize to 140x140.
67fn crop_face_square(img: &image::DynamicImage, bbox: [f32; 4]) -> image::DynamicImage {
68    let w = img.width() as f32;
69    let h = img.height() as f32;
70    let bw = bbox[2] - bbox[0];
71    let bh = bbox[3] - bbox[1];
72    let pad = (bw.max(bh) * 0.25).max(4.0);
73    let half = bw.max(bh) * 0.5 + pad;
74    let cx = (bbox[0] + bbox[2]) * 0.5;
75    let cy = (bbox[1] + bbox[3]) * 0.5;
76    let x1 = (cx - half).max(0.0) as u32;
77    let y1 = (cy - half).max(0.0) as u32;
78    let x2 = (cx + half).min(w) as u32;
79    let y2 = (cy + half).min(h) as u32;
80    let side = (x2 - x1).min(y2 - y1).max(1);
81    img.crop_imm(x1, y1, side, side)
82        .resize_exact(140, 140, image::imageops::FilterType::Triangle)
83}
84
85/// Load, crop, and orientation-correct a face thumbnail.
86///
87/// bbox coordinates are stored in terms of the *full-size* decoded image
88/// (videre faces rescales detections back to original width/height before
89/// writing to the DB), so the thumbnail must be cropped from an image of
90/// the same dimensions used at detection time.
91///
92/// For HEIC: videre faces converts via QuickLook (see
93/// `videre_core::heic::heic_via_quicklook`), which already applies correct
94/// rotation, so no separate orientation step is needed. For JPEG/PNG/etc:
95/// detection ran on raw pixels; apply EXIF orientation after crop.
96///
97/// `pub` (unlike the three helpers above it): the static-page
98/// base64 thumbnail path (`face_thumb_b64` in `report.rs`) also needs this
99/// exact crop+orientation logic, so it calls through here instead of keeping
100/// its own duplicate copy.
101pub fn make_face_thumb(path: &str, bbox: [f32; 4], face_id: i64) -> Option<image::DynamicImage> {
102    let ext = std::path::Path::new(path)
103        .extension()
104        .and_then(|e| e.to_str())
105        .unwrap_or("")
106        .to_lowercase();
107    if ext == "heic" {
108        // None: bbox is stored relative to a full-res decode. See the
109        // safety note on heic_via_quicklook.
110        let img = videre_core::heic::heic_via_quicklook(path, &format!("thumb{face_id}"), None)?;
111        Some(crop_face_square(&img, bbox))
112    } else {
113        // Detection ran on raw pixels; crop first, then correct orientation
114        let timeout_path = path.to_string();
115        let img = match videre_core::io_timeout::run_with_timeout(
116            videre_core::io_timeout::DEFAULT_IO_TIMEOUT,
117            move || image::open(&timeout_path),
118        ) {
119            Ok(Ok(img)) => img,
120            Ok(Err(e)) => {
121                eprintln!("warning: face thumbnail unavailable for {path}: {e}; skipping");
122                return None;
123            }
124            Err(_) => {
125                eprintln!(
126                    "warning: timed out reading {path} for face thumbnail \
127                     (file may be unreachable - is its drive connected?); skipping"
128                );
129                return None;
130            }
131        };
132        let cropped = crop_face_square(&img, bbox);
133        Some(apply_exif_orientation(cropped, path))
134    }
135}
136
137/// Bounds a plain (non-HEIC) file read against a stale/disconnected mount
138/// point the same way `videre_core::heic` bounds `qlmanage`, so a single
139/// unreachable file can't hang the caller (an axum request thread, or any
140/// other synchronous embedder) forever.
141fn read_with_timeout(path: &str) -> std::io::Result<Vec<u8>> {
142    let owned = path.to_string();
143    videre_core::io_timeout::run_with_timeout(
144        videre_core::io_timeout::DEFAULT_IO_TIMEOUT,
145        move || std::fs::read(&owned),
146    )
147    .unwrap_or_else(|_| {
148        Err(std::io::Error::new(
149            std::io::ErrorKind::TimedOut,
150            format!("timed out reading {path} (file may be unreachable - is its drive connected?)"),
151        ))
152    })
153}
154
155pub fn mime_for_ext(ext: &str) -> &'static str {
156    match ext {
157        "jpg" | "jpeg" => "image/jpeg",
158        "png" => "image/png",
159        "gif" => "image/gif",
160        "webp" => "image/webp",
161        "bmp" => "image/bmp",
162        "tiff" => "image/tiff",
163        "mov" => "video/quicktime",
164        "mp4" => "video/mp4",
165        _ => "application/octet-stream",
166    }
167}
168
169/// The single-row query `face_image_bytes` needs before it can do any image
170/// work, split out so a caller holding a shared/locked `Connection` (the
171/// axum server serializes every request on one `Mutex<Connection>`)
172/// can release that lock immediately after this cheap lookup, instead of
173/// holding it for the entire decode/crop/resize/encode/cache-write below,
174/// which otherwise fully serializes every thumbnail request behind the lock,
175/// turning a many-thousand-singleton library into one thumbnail at a time.
176pub struct FaceLookup {
177    pub bbox_json: String,
178    pub file_path: String,
179    pub hash: String,
180}
181
182/// The cheap part of `face_image_bytes`: just the DB row. No image I/O.
183pub fn face_lookup(conn: &Connection, face_id: i64) -> Result<FaceLookup> {
184    let (bbox_json, file_path, hash): (String, String, String) = conn
185        .query_row(
186            "SELECT f.bbox, fh.path, f.hash FROM faces f \
187             JOIN file_hashes fh ON f.hash = fh.hash WHERE f.id = ?1 LIMIT 1",
188            [face_id],
189            |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
190        )
191        .map_err(|_| Error::NotFound)?;
192    Ok(FaceLookup {
193        bbox_json,
194        file_path,
195        hash,
196    })
197}
198
199/// The expensive part of `face_image_bytes`: cache check, decode/crop/encode,
200/// write-through. Takes no `Connection`, so it can run without holding the
201/// shared DB lock.
202pub fn face_bytes_from_lookup(
203    lookup: &FaceLookup,
204    face_id: i64,
205    cache: &videre_core::library::CachePaths,
206) -> Result<Vec<u8>> {
207    let parts: Vec<f32> = lookup
208        .bbox_json
209        .split(',')
210        .filter_map(|s| s.trim().parse().ok())
211        .collect();
212    if parts.len() != 4 {
213        return Err(Error::NotFound);
214    }
215    let bbox = [parts[0], parts[1], parts[0] + parts[2], parts[1] + parts[3]];
216
217    // The crop's cache identity includes its full geometry, so the path is
218    // known only once the bbox is parsed.
219    let cache_path = videre_core::thumb_cache::face_thumb_path_in(
220        cache,
221        &lookup.hash,
222        face_id,
223        bbox,
224        FACE_THUMB_SIZE,
225    );
226    if videre_core::thumb_cache::face_thumb_exists_in(
227        cache,
228        &lookup.hash,
229        face_id,
230        bbox,
231        FACE_THUMB_SIZE,
232    ) {
233        if let Ok(bytes) = read_with_timeout(&cache_path.to_string_lossy()) {
234            return Ok(bytes);
235        }
236    }
237
238    let thumb = make_face_thumb(&lookup.file_path, bbox, face_id).ok_or(Error::NotFound)?;
239    let mut buf = Vec::new();
240    thumb
241        .write_to(
242            &mut std::io::Cursor::new(&mut buf),
243            image::ImageFormat::Jpeg,
244        )
245        .map_err(|_| Error::NotFound)?;
246
247    // Best-effort write-through (a cache-write failure must not fail the read).
248    if let Some(parent) = cache_path.parent() {
249        let _ = std::fs::create_dir_all(parent);
250    }
251    let tmp = cache_path.with_extension(format!("tmp{}", std::process::id()));
252    if std::fs::write(&tmp, &buf).is_ok() {
253        let _ = std::fs::rename(&tmp, &cache_path);
254    }
255    Ok(buf)
256}
257
258/// JPEG bytes for a single aligned face thumbnail (140px), reading the disk
259/// cache first and converting from the source image (HEIC via QuickLook) on a
260/// miss, writing through to the cache. Returns `Error::NotFound` if the face id
261/// is unknown or the crop cannot be produced. Synchronous: callers that need
262/// async should run this on a blocking thread.
263///
264/// Holds `conn` only for the initial lookup (see `face_lookup`); callers that
265/// share `conn` behind a lock across many concurrent requests should call
266/// `face_lookup`/`face_bytes_from_lookup` directly instead, releasing the
267/// lock between the two.
268pub fn face_image_bytes(
269    conn: &Connection,
270    face_id: i64,
271    cache: &videre_core::library::CachePaths,
272) -> Result<Vec<u8>> {
273    let lookup = face_lookup(conn, face_id)?;
274    face_bytes_from_lookup(&lookup, face_id, cache)
275}
276
277/// The single-row query `original_image_bytes` needs before any image I/O.
278/// See `FaceLookup` for why this split matters for concurrency.
279pub struct OriginalLookup {
280    pub file_path: String,
281    pub hash: String,
282}
283
284/// The cheap part of `original_image_bytes`: just the DB row. No image I/O.
285pub fn original_lookup(conn: &Connection, face_id: i64) -> Result<OriginalLookup> {
286    let (file_path, hash): (String, String) = conn
287        .query_row(
288            "SELECT fh.path, f.hash FROM faces f \
289             JOIN file_hashes fh ON f.hash = fh.hash WHERE f.id = ?1 LIMIT 1",
290            [face_id],
291            |r| Ok((r.get(0)?, r.get(1)?)),
292        )
293        .map_err(|_| Error::NotFound)?;
294    Ok(OriginalLookup { file_path, hash })
295}
296
297/// The expensive part of `original_image_bytes`: read/convert/cache. Takes no
298/// `Connection`, so it can run without holding the shared DB lock.
299pub fn original_bytes_from_lookup(
300    lookup: &OriginalLookup,
301    face_id: i64,
302    cache: &videre_core::library::CachePaths,
303) -> Result<(&'static str, Vec<u8>)> {
304    let file_path = &lookup.file_path;
305    let hash = &lookup.hash;
306    let ext = std::path::Path::new(file_path)
307        .extension()
308        .and_then(|e| e.to_str())
309        .unwrap_or("")
310        .to_lowercase();
311
312    if ext == "heic" {
313        if let Ok(bytes) = read_with_timeout(
314            &videre_core::thumb_cache::original_path_in(cache, hash).to_string_lossy(),
315        ) {
316            return Ok(("image/jpeg", bytes));
317        }
318        // None: this serves the true original image, so it must stay at
319        // full resolution.
320        let img =
321            videre_core::heic::heic_via_quicklook(&file_path, &format!("orig{face_id}"), None)
322                .ok_or(Error::NotFound)?;
323        let mut buf = Vec::new();
324        img.write_to(
325            &mut std::io::Cursor::new(&mut buf),
326            image::ImageFormat::Jpeg,
327        )
328        .map_err(|_| Error::NotFound)?;
329        let final_path = videre_core::thumb_cache::original_path_in(cache, hash);
330        if let Some(parent) = final_path.parent() {
331            let _ = std::fs::create_dir_all(parent);
332        }
333        let tmp = final_path.with_extension(format!("tmp{}", std::process::id()));
334        if std::fs::write(&tmp, &buf).is_ok() {
335            let _ = std::fs::rename(&tmp, &final_path);
336        }
337        Ok(("image/jpeg", buf))
338    } else {
339        let bytes = read_with_timeout(file_path).map_err(|e| {
340            eprintln!("warning: original image unavailable for {file_path}: {e}; skipping");
341            Error::NotFound
342        })?;
343        Ok((mime_for_ext(&ext), bytes))
344    }
345}
346
347/// Bytes for the full original image behind a face (raw for common formats,
348/// QuickLook-converted JPEG for HEIC, with the HEIC result cached). Returns the
349/// MIME type alongside the bytes. `Error::NotFound` if the id is unknown or the
350/// file cannot be read/converted. Synchronous.
351///
352/// Holds `conn` only for the initial lookup (see `original_lookup`); callers
353/// that share `conn` behind a lock across many concurrent requests should
354/// call `original_lookup`/`original_bytes_from_lookup` directly instead,
355/// releasing the lock between the two.
356pub fn original_image_bytes(
357    conn: &Connection,
358    face_id: i64,
359    cache: &videre_core::library::CachePaths,
360) -> Result<(&'static str, Vec<u8>)> {
361    let lookup = original_lookup(conn, face_id)?;
362    original_bytes_from_lookup(&lookup, face_id, cache)
363}
364
365#[cfg(test)]
366mod tests {
367    use super::*;
368
369    /// A 2x3 image whose every pixel is distinguishable, so a transposed
370    /// rotation or a flip on the wrong axis actually fails. A square or
371    /// symmetric fixture would pass for several wrong mappings.
372    ///
373    /// Pixel values encode position as `10 * x + y`:
374    ///
375    /// ```text
376    ///   (0,0)=0   (1,0)=10
377    ///   (0,1)=1   (1,1)=11
378    ///   (0,2)=2   (1,2)=12
379    /// ```
380    fn asymmetric() -> image::DynamicImage {
381        let mut img = image::GrayImage::new(2, 3);
382        for y in 0..3u32 {
383            for x in 0..2u32 {
384                img.put_pixel(x, y, image::Luma([(x * 10 + y) as u8]));
385            }
386        }
387        image::DynamicImage::ImageLuma8(img)
388    }
389
390    fn pixels(img: &image::DynamicImage) -> (u32, u32, Vec<u8>) {
391        let g = img.to_luma8();
392        (g.width(), g.height(), g.pixels().map(|p| p.0[0]).collect())
393    }
394
395    #[test]
396    fn orientation_1_and_unknown_values_are_the_identity() {
397        let expected = pixels(&asymmetric());
398        // 1 is "normal", and is also what read_exif_orientation returns for a
399        // file with no EXIF, so this is the common path, not an edge case.
400        for o in [0u16, 1, 9, 42, u16::MAX] {
401            assert_eq!(
402                pixels(&apply_orientation(asymmetric(), o)),
403                expected,
404                "orientation {o} must not transform the image"
405            );
406        }
407    }
408
409    #[test]
410    fn orientation_2_mirrors_horizontally() {
411        let (w, h, px) = pixels(&apply_orientation(asymmetric(), 2));
412        assert_eq!((w, h), (2, 3));
413        // Rows reversed left-to-right: (0,y) and (1,y) swap.
414        assert_eq!(px, vec![10, 0, 11, 1, 12, 2]);
415    }
416
417    #[test]
418    fn orientation_3_rotates_180() {
419        let (w, h, px) = pixels(&apply_orientation(asymmetric(), 3));
420        assert_eq!((w, h), (2, 3));
421        assert_eq!(px, vec![12, 2, 11, 1, 10, 0]);
422    }
423
424    #[test]
425    fn orientation_4_mirrors_vertically() {
426        let (w, h, px) = pixels(&apply_orientation(asymmetric(), 4));
427        assert_eq!((w, h), (2, 3));
428        assert_eq!(px, vec![2, 12, 1, 11, 0, 10]);
429    }
430
431    /// 5 and 7 are the two that combine a rotation with a flip, and are the
432    /// pair most easily transposed. Their dimensions swap to 3x2.
433    #[test]
434    fn orientation_5_and_7_transpose_and_differ_from_each_other() {
435        let five = pixels(&apply_orientation(asymmetric(), 5));
436        let seven = pixels(&apply_orientation(asymmetric(), 7));
437        assert_eq!((five.0, five.1), (3, 2));
438        assert_eq!((seven.0, seven.1), (3, 2));
439        assert_ne!(five.2, seven.2, "5 and 7 must not be the same transform");
440        assert_eq!(five.2, vec![0, 1, 2, 10, 11, 12]);
441        assert_eq!(seven.2, vec![12, 11, 10, 2, 1, 0]);
442    }
443
444    #[test]
445    fn orientation_6_and_8_rotate_opposite_ways() {
446        let six = pixels(&apply_orientation(asymmetric(), 6));
447        let eight = pixels(&apply_orientation(asymmetric(), 8));
448        assert_eq!((six.0, six.1), (3, 2));
449        assert_eq!((eight.0, eight.1), (3, 2));
450        assert_ne!(six.2, eight.2, "90 and 270 must not be the same transform");
451        assert_eq!(six.2, vec![2, 1, 0, 12, 11, 10]);
452        assert_eq!(eight.2, vec![10, 11, 12, 0, 1, 2]);
453    }
454
455    /// A minimal JPEG carrying nothing but an EXIF APP1 segment declaring
456    /// `orientation`.
457    ///
458    /// Built by hand rather than shipping eight binary fixtures, and rather
459    /// than borrowing `crates/videre/tests/fixtures`, which would couple this
460    /// crate's unit tests to another crate's test data.
461    ///
462    /// Layout: SOI, then APP1 holding "Exif\0\0" and a little-endian TIFF
463    /// header whose IFD0 has exactly one entry, Orientation (tag 0x0112,
464    /// type SHORT), then EOI.
465    fn jpeg_with_orientation(orientation: u16) -> Vec<u8> {
466        jpeg_with_orientation_of_type(orientation, 3)
467    }
468
469    /// As above, but with the IFD entry's type field configurable, so a test
470    /// can declare Orientation as something other than SHORT.
471    fn jpeg_with_orientation_of_type(orientation: u16, tiff_type: u16) -> Vec<u8> {
472        let mut tiff = Vec::new();
473        tiff.extend_from_slice(b"II"); // little-endian
474        tiff.extend_from_slice(&42u16.to_le_bytes()); // TIFF magic
475        tiff.extend_from_slice(&8u32.to_le_bytes()); // offset of IFD0
476        tiff.extend_from_slice(&1u16.to_le_bytes()); // one entry
477        tiff.extend_from_slice(&0x0112u16.to_le_bytes()); // Orientation
478        tiff.extend_from_slice(&tiff_type.to_le_bytes()); // 3 = SHORT
479        tiff.extend_from_slice(&1u32.to_le_bytes()); // count
480        tiff.extend_from_slice(&orientation.to_le_bytes()); // value, inline
481        tiff.extend_from_slice(&[0, 0]); // pad to the 4-byte value field
482        tiff.extend_from_slice(&0u32.to_le_bytes()); // no next IFD
483
484        let mut app1 = Vec::from(*b"Exif\0\0");
485        app1.extend_from_slice(&tiff);
486
487        let mut jpeg = vec![0xFF, 0xD8]; // SOI
488        jpeg.extend_from_slice(&[0xFF, 0xE1]); // APP1
489        jpeg.extend_from_slice(&((app1.len() + 2) as u16).to_be_bytes());
490        jpeg.extend_from_slice(&app1);
491        jpeg.extend_from_slice(&[0xFF, 0xD9]); // EOI
492        jpeg
493    }
494
495    #[test]
496    fn every_exif_orientation_value_is_read_back() {
497        let dir = std::env::temp_dir().join(format!("videre-api-orient-{}", std::process::id()));
498        std::fs::create_dir_all(&dir).unwrap();
499        for o in 1..=8u16 {
500            let p = dir.join(format!("o{o}.jpg"));
501            std::fs::write(&p, jpeg_with_orientation(o)).unwrap();
502            assert_eq!(
503                read_exif_orientation(p.to_str().unwrap()),
504                o,
505                "orientation {o} did not round-trip"
506            );
507        }
508        let _ = std::fs::remove_dir_all(&dir);
509    }
510
511    /// The whole path together: a file whose EXIF says "rotate 90" must come
512    /// back rotated, with its dimensions swapped. Covers the join between
513    /// reading the tag and applying the transform, which the two halves tested
514    /// separately above cannot.
515    #[test]
516    fn a_jpeg_declaring_rotation_is_actually_rotated() {
517        let dir = std::env::temp_dir().join(format!("videre-api-rot-{}", std::process::id()));
518        std::fs::create_dir_all(&dir).unwrap();
519        let p = dir.join("rot90.jpg");
520        std::fs::write(&p, jpeg_with_orientation(6)).unwrap();
521
522        let out = apply_exif_orientation(asymmetric(), p.to_str().unwrap());
523        let (w, h, px) = pixels(&out);
524        assert_eq!((w, h), (3, 2), "orientation 6 must swap the dimensions");
525        assert_eq!(px, vec![2, 1, 0, 12, 11, 10]);
526        let _ = std::fs::remove_dir_all(&dir);
527    }
528
529    /// Orientation is a SHORT by spec. A file declaring it as some other type
530    /// is malformed, and must fall back to 1 rather than being coerced into a
531    /// rotation nobody asked for.
532    #[test]
533    fn an_orientation_of_the_wrong_exif_type_falls_back_to_1() {
534        let dir = std::env::temp_dir().join(format!("videre-api-badtype-{}", std::process::id()));
535        std::fs::create_dir_all(&dir).unwrap();
536        let p = dir.join("badtype.jpg");
537        // Type 4 is LONG, not SHORT.
538        std::fs::write(&p, jpeg_with_orientation_of_type(6, 4)).unwrap();
539        assert_eq!(read_exif_orientation(p.to_str().unwrap()), 1);
540        let _ = std::fs::remove_dir_all(&dir);
541    }
542
543    #[test]
544    fn exif_orientation_defaults_to_1_for_a_missing_or_non_exif_file() {
545        assert_eq!(read_exif_orientation("/nonexistent/path/nope.jpg"), 1);
546
547        let dir = std::env::temp_dir().join(format!("videre-api-exif-{}", std::process::id()));
548        std::fs::create_dir_all(&dir).unwrap();
549        let not_an_image = dir.join("plain.jpg");
550        std::fs::write(&not_an_image, b"definitely not a jpeg").unwrap();
551        assert_eq!(read_exif_orientation(not_an_image.to_str().unwrap()), 1);
552        let _ = std::fs::remove_dir_all(&dir);
553    }
554
555    /// Orientation is only consulted for formats that carry EXIF. A PNG named
556    /// with a non-EXIF extension must be returned untouched without the file
557    /// even being opened, which is why this passes a path that does not exist.
558    #[test]
559    fn non_exif_extensions_skip_orientation_entirely() {
560        let expected = pixels(&asymmetric());
561        for path in [
562            "/nonexistent/a.png",
563            "/nonexistent/b.heic",
564            "/nonexistent/c",
565        ] {
566            assert_eq!(
567                pixels(&apply_exif_orientation(asymmetric(), path)),
568                expected,
569                "{path} must be returned unchanged"
570            );
571        }
572    }
573
574    #[test]
575    fn a_face_crop_is_square_and_thumbnail_sized() {
576        let img = image::DynamicImage::ImageLuma8(image::GrayImage::new(200, 100));
577        let out = crop_face_square(&img, [80.0, 40.0, 120.0, 80.0]);
578        assert_eq!((out.width(), out.height()), (140, 140));
579    }
580
581    /// A bbox against the edge would give a negative origin, and one larger
582    /// than the image would run past it. Both are clamped rather than
583    /// panicking inside `crop_imm`.
584    #[test]
585    fn a_face_crop_clamps_to_the_image_bounds() {
586        let img = image::DynamicImage::ImageLuma8(image::GrayImage::new(50, 50));
587        for bbox in [
588            [0.0, 0.0, 10.0, 10.0],   // flush against the top-left
589            [45.0, 45.0, 60.0, 60.0], // runs past the bottom-right
590            [-20.0, -20.0, 5.0, 5.0], // negative origin
591            [0.0, 0.0, 500.0, 500.0], // larger than the whole image
592        ] {
593            let out = crop_face_square(&img, bbox);
594            assert_eq!((out.width(), out.height()), (140, 140), "bbox {bbox:?}");
595        }
596    }
597
598    /// A zero-area bbox still has to produce a thumbnail rather than a
599    /// zero-side crop: `crop_face_square` floors the side at 1.
600    #[test]
601    fn a_degenerate_bbox_still_produces_a_thumbnail() {
602        let img = image::DynamicImage::ImageLuma8(image::GrayImage::new(50, 50));
603        let out = crop_face_square(&img, [25.0, 25.0, 25.0, 25.0]);
604        assert_eq!((out.width(), out.height()), (140, 140));
605    }
606
607    #[test]
608    fn unknown_face_id_is_not_found() {
609        let conn = Connection::open_in_memory().unwrap();
610        videre_core::face_db::create_faces_table(&conn).unwrap();
611        conn.execute_batch("CREATE TABLE file_hashes (hash TEXT PRIMARY KEY, path TEXT);")
612            .unwrap();
613        let temp = tempfile::tempdir().unwrap();
614        let ctx =
615            videre_core::library::LibraryContext::new(temp.path(), &temp.path().join("cache"))
616                .unwrap();
617        assert!(matches!(
618            face_image_bytes(&conn, 999, &ctx.cache),
619            Err(Error::NotFound)
620        ));
621        assert!(matches!(
622            original_image_bytes(&conn, 999, &ctx.cache),
623            Err(Error::NotFound)
624        ));
625    }
626
627    #[test]
628    fn face_lookup_unknown_id_is_not_found() {
629        let conn = Connection::open_in_memory().unwrap();
630        videre_core::face_db::create_faces_table(&conn).unwrap();
631        conn.execute_batch("CREATE TABLE file_hashes (hash TEXT PRIMARY KEY, path TEXT);")
632            .unwrap();
633        assert!(matches!(face_lookup(&conn, 999), Err(Error::NotFound)));
634    }
635
636    #[test]
637    fn original_lookup_unknown_id_is_not_found() {
638        let conn = Connection::open_in_memory().unwrap();
639        videre_core::face_db::create_faces_table(&conn).unwrap();
640        conn.execute_batch("CREATE TABLE file_hashes (hash TEXT PRIMARY KEY, path TEXT);")
641            .unwrap();
642        assert!(matches!(original_lookup(&conn, 999), Err(Error::NotFound)));
643    }
644
645    #[test]
646    fn face_lookup_does_not_touch_the_filesystem() {
647        // Regression test for the thumbnail-rendering serialization bug: the
648        // DB lookup must be a pure query with no image I/O, so callers can
649        // release the connection lock before doing the expensive part.
650        let conn = Connection::open_in_memory().unwrap();
651        videre_core::face_db::create_faces_table(&conn).unwrap();
652        conn.execute_batch("CREATE TABLE file_hashes (hash TEXT PRIMARY KEY, path TEXT);")
653            .unwrap();
654        conn.execute(
655            "INSERT INTO file_hashes (hash, path) VALUES ('h1', '/no/such/file.jpg')",
656            [],
657        )
658        .unwrap();
659        conn.execute(
660            "INSERT INTO faces (id, hash, bbox, embedding) VALUES (1, 'h1', '0,0,10,10', X'00')",
661            [],
662        )
663        .unwrap();
664        let lookup = face_lookup(&conn, 1).unwrap();
665        assert_eq!(lookup.file_path, "/no/such/file.jpg");
666        assert_eq!(lookup.hash, "h1");
667        assert_eq!(lookup.bbox_json, "0,0,10,10");
668    }
669}