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): `videre report`'s static-report
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(lookup: &FaceLookup, face_id: i64) -> Result<Vec<u8>> {
203    let cache = videre_core::thumb_cache::face_thumb_path(&lookup.hash, face_id, FACE_THUMB_SIZE);
204    if videre_core::thumb_cache::face_thumb_exists(&lookup.hash, face_id, FACE_THUMB_SIZE) {
205        if let Ok(bytes) = read_with_timeout(&cache.to_string_lossy()) {
206            return Ok(bytes);
207        }
208    }
209
210    let parts: Vec<f32> = lookup
211        .bbox_json
212        .split(',')
213        .filter_map(|s| s.trim().parse().ok())
214        .collect();
215    if parts.len() != 4 {
216        return Err(Error::NotFound);
217    }
218    let bbox = [parts[0], parts[1], parts[0] + parts[2], parts[1] + parts[3]];
219    let thumb = make_face_thumb(&lookup.file_path, bbox, face_id).ok_or(Error::NotFound)?;
220    let mut buf = Vec::new();
221    thumb
222        .write_to(
223            &mut std::io::Cursor::new(&mut buf),
224            image::ImageFormat::Jpeg,
225        )
226        .map_err(|_| Error::NotFound)?;
227
228    // Best-effort write-through (a cache-write failure must not fail the read).
229    if let Some(parent) = cache.parent() {
230        let _ = std::fs::create_dir_all(parent);
231    }
232    let tmp = cache.with_extension(format!("tmp{}", std::process::id()));
233    if std::fs::write(&tmp, &buf).is_ok() {
234        let _ = std::fs::rename(&tmp, &cache);
235    }
236    Ok(buf)
237}
238
239/// JPEG bytes for a single aligned face thumbnail (140px), reading the disk
240/// cache first and converting from the source image (HEIC via QuickLook) on a
241/// miss, writing through to the cache. Returns `Error::NotFound` if the face id
242/// is unknown or the crop cannot be produced. Synchronous: callers that need
243/// async should run this on a blocking thread.
244///
245/// Holds `conn` only for the initial lookup (see `face_lookup`); callers that
246/// share `conn` behind a lock across many concurrent requests should call
247/// `face_lookup`/`face_bytes_from_lookup` directly instead, releasing the
248/// lock between the two.
249pub fn face_image_bytes(conn: &Connection, face_id: i64) -> Result<Vec<u8>> {
250    let lookup = face_lookup(conn, face_id)?;
251    face_bytes_from_lookup(&lookup, face_id)
252}
253
254/// The single-row query `original_image_bytes` needs before any image I/O.
255/// See `FaceLookup` for why this split matters for concurrency.
256pub struct OriginalLookup {
257    pub file_path: String,
258    pub hash: String,
259}
260
261/// The cheap part of `original_image_bytes`: just the DB row. No image I/O.
262pub fn original_lookup(conn: &Connection, face_id: i64) -> Result<OriginalLookup> {
263    let (file_path, hash): (String, String) = conn
264        .query_row(
265            "SELECT fh.path, f.hash FROM faces f \
266             JOIN file_hashes fh ON f.hash = fh.hash WHERE f.id = ?1 LIMIT 1",
267            [face_id],
268            |r| Ok((r.get(0)?, r.get(1)?)),
269        )
270        .map_err(|_| Error::NotFound)?;
271    Ok(OriginalLookup { file_path, hash })
272}
273
274/// The expensive part of `original_image_bytes`: read/convert/cache. Takes no
275/// `Connection`, so it can run without holding the shared DB lock.
276pub fn original_bytes_from_lookup(
277    lookup: &OriginalLookup,
278    face_id: i64,
279) -> Result<(&'static str, Vec<u8>)> {
280    let file_path = &lookup.file_path;
281    let hash = &lookup.hash;
282    let ext = std::path::Path::new(file_path)
283        .extension()
284        .and_then(|e| e.to_str())
285        .unwrap_or("")
286        .to_lowercase();
287
288    if ext == "heic" {
289        if let Ok(bytes) =
290            read_with_timeout(&videre_core::thumb_cache::original_path(&hash).to_string_lossy())
291        {
292            return Ok(("image/jpeg", bytes));
293        }
294        // None: this serves the true original image, so it must stay at
295        // full resolution.
296        let img =
297            videre_core::heic::heic_via_quicklook(&file_path, &format!("orig{face_id}"), None)
298                .ok_or(Error::NotFound)?;
299        let mut buf = Vec::new();
300        img.write_to(
301            &mut std::io::Cursor::new(&mut buf),
302            image::ImageFormat::Jpeg,
303        )
304        .map_err(|_| Error::NotFound)?;
305        let final_path = videre_core::thumb_cache::original_path(&hash);
306        if let Some(parent) = final_path.parent() {
307            let _ = std::fs::create_dir_all(parent);
308        }
309        let tmp = final_path.with_extension(format!("tmp{}", std::process::id()));
310        if std::fs::write(&tmp, &buf).is_ok() {
311            let _ = std::fs::rename(&tmp, &final_path);
312        }
313        Ok(("image/jpeg", buf))
314    } else {
315        let bytes = read_with_timeout(file_path).map_err(|e| {
316            eprintln!("warning: original image unavailable for {file_path}: {e}; skipping");
317            Error::NotFound
318        })?;
319        Ok((mime_for_ext(&ext), bytes))
320    }
321}
322
323/// Bytes for the full original image behind a face (raw for common formats,
324/// QuickLook-converted JPEG for HEIC, with the HEIC result cached). Returns the
325/// MIME type alongside the bytes. `Error::NotFound` if the id is unknown or the
326/// file cannot be read/converted. Synchronous.
327///
328/// Holds `conn` only for the initial lookup (see `original_lookup`); callers
329/// that share `conn` behind a lock across many concurrent requests should
330/// call `original_lookup`/`original_bytes_from_lookup` directly instead,
331/// releasing the lock between the two.
332pub fn original_image_bytes(conn: &Connection, face_id: i64) -> Result<(&'static str, Vec<u8>)> {
333    let lookup = original_lookup(conn, face_id)?;
334    original_bytes_from_lookup(&lookup, face_id)
335}
336
337#[cfg(test)]
338mod tests {
339    use super::*;
340
341    /// A 2x3 image whose every pixel is distinguishable, so a transposed
342    /// rotation or a flip on the wrong axis actually fails. A square or
343    /// symmetric fixture would pass for several wrong mappings.
344    ///
345    /// Pixel values encode position as `10 * x + y`:
346    ///
347    /// ```text
348    ///   (0,0)=0   (1,0)=10
349    ///   (0,1)=1   (1,1)=11
350    ///   (0,2)=2   (1,2)=12
351    /// ```
352    fn asymmetric() -> image::DynamicImage {
353        let mut img = image::GrayImage::new(2, 3);
354        for y in 0..3u32 {
355            for x in 0..2u32 {
356                img.put_pixel(x, y, image::Luma([(x * 10 + y) as u8]));
357            }
358        }
359        image::DynamicImage::ImageLuma8(img)
360    }
361
362    fn pixels(img: &image::DynamicImage) -> (u32, u32, Vec<u8>) {
363        let g = img.to_luma8();
364        (g.width(), g.height(), g.pixels().map(|p| p.0[0]).collect())
365    }
366
367    #[test]
368    fn orientation_1_and_unknown_values_are_the_identity() {
369        let expected = pixels(&asymmetric());
370        // 1 is "normal", and is also what read_exif_orientation returns for a
371        // file with no EXIF, so this is the common path, not an edge case.
372        for o in [0u16, 1, 9, 42, u16::MAX] {
373            assert_eq!(
374                pixels(&apply_orientation(asymmetric(), o)),
375                expected,
376                "orientation {o} must not transform the image"
377            );
378        }
379    }
380
381    #[test]
382    fn orientation_2_mirrors_horizontally() {
383        let (w, h, px) = pixels(&apply_orientation(asymmetric(), 2));
384        assert_eq!((w, h), (2, 3));
385        // Rows reversed left-to-right: (0,y) and (1,y) swap.
386        assert_eq!(px, vec![10, 0, 11, 1, 12, 2]);
387    }
388
389    #[test]
390    fn orientation_3_rotates_180() {
391        let (w, h, px) = pixels(&apply_orientation(asymmetric(), 3));
392        assert_eq!((w, h), (2, 3));
393        assert_eq!(px, vec![12, 2, 11, 1, 10, 0]);
394    }
395
396    #[test]
397    fn orientation_4_mirrors_vertically() {
398        let (w, h, px) = pixels(&apply_orientation(asymmetric(), 4));
399        assert_eq!((w, h), (2, 3));
400        assert_eq!(px, vec![2, 12, 1, 11, 0, 10]);
401    }
402
403    /// 5 and 7 are the two that combine a rotation with a flip, and are the
404    /// pair most easily transposed. Their dimensions swap to 3x2.
405    #[test]
406    fn orientation_5_and_7_transpose_and_differ_from_each_other() {
407        let five = pixels(&apply_orientation(asymmetric(), 5));
408        let seven = pixels(&apply_orientation(asymmetric(), 7));
409        assert_eq!((five.0, five.1), (3, 2));
410        assert_eq!((seven.0, seven.1), (3, 2));
411        assert_ne!(five.2, seven.2, "5 and 7 must not be the same transform");
412        assert_eq!(five.2, vec![0, 1, 2, 10, 11, 12]);
413        assert_eq!(seven.2, vec![12, 11, 10, 2, 1, 0]);
414    }
415
416    #[test]
417    fn orientation_6_and_8_rotate_opposite_ways() {
418        let six = pixels(&apply_orientation(asymmetric(), 6));
419        let eight = pixels(&apply_orientation(asymmetric(), 8));
420        assert_eq!((six.0, six.1), (3, 2));
421        assert_eq!((eight.0, eight.1), (3, 2));
422        assert_ne!(six.2, eight.2, "90 and 270 must not be the same transform");
423        assert_eq!(six.2, vec![2, 1, 0, 12, 11, 10]);
424        assert_eq!(eight.2, vec![10, 11, 12, 0, 1, 2]);
425    }
426
427    /// A minimal JPEG carrying nothing but an EXIF APP1 segment declaring
428    /// `orientation`.
429    ///
430    /// Built by hand rather than shipping eight binary fixtures, and rather
431    /// than borrowing `crates/videre/tests/fixtures`, which would couple this
432    /// crate's unit tests to another crate's test data.
433    ///
434    /// Layout: SOI, then APP1 holding "Exif\0\0" and a little-endian TIFF
435    /// header whose IFD0 has exactly one entry, Orientation (tag 0x0112,
436    /// type SHORT), then EOI.
437    fn jpeg_with_orientation(orientation: u16) -> Vec<u8> {
438        jpeg_with_orientation_of_type(orientation, 3)
439    }
440
441    /// As above, but with the IFD entry's type field configurable, so a test
442    /// can declare Orientation as something other than SHORT.
443    fn jpeg_with_orientation_of_type(orientation: u16, tiff_type: u16) -> Vec<u8> {
444        let mut tiff = Vec::new();
445        tiff.extend_from_slice(b"II"); // little-endian
446        tiff.extend_from_slice(&42u16.to_le_bytes()); // TIFF magic
447        tiff.extend_from_slice(&8u32.to_le_bytes()); // offset of IFD0
448        tiff.extend_from_slice(&1u16.to_le_bytes()); // one entry
449        tiff.extend_from_slice(&0x0112u16.to_le_bytes()); // Orientation
450        tiff.extend_from_slice(&tiff_type.to_le_bytes()); // 3 = SHORT
451        tiff.extend_from_slice(&1u32.to_le_bytes()); // count
452        tiff.extend_from_slice(&orientation.to_le_bytes()); // value, inline
453        tiff.extend_from_slice(&[0, 0]); // pad to the 4-byte value field
454        tiff.extend_from_slice(&0u32.to_le_bytes()); // no next IFD
455
456        let mut app1 = Vec::from(*b"Exif\0\0");
457        app1.extend_from_slice(&tiff);
458
459        let mut jpeg = vec![0xFF, 0xD8]; // SOI
460        jpeg.extend_from_slice(&[0xFF, 0xE1]); // APP1
461        jpeg.extend_from_slice(&((app1.len() + 2) as u16).to_be_bytes());
462        jpeg.extend_from_slice(&app1);
463        jpeg.extend_from_slice(&[0xFF, 0xD9]); // EOI
464        jpeg
465    }
466
467    #[test]
468    fn every_exif_orientation_value_is_read_back() {
469        let dir = std::env::temp_dir().join(format!("videre-api-orient-{}", std::process::id()));
470        std::fs::create_dir_all(&dir).unwrap();
471        for o in 1..=8u16 {
472            let p = dir.join(format!("o{o}.jpg"));
473            std::fs::write(&p, jpeg_with_orientation(o)).unwrap();
474            assert_eq!(
475                read_exif_orientation(p.to_str().unwrap()),
476                o,
477                "orientation {o} did not round-trip"
478            );
479        }
480        let _ = std::fs::remove_dir_all(&dir);
481    }
482
483    /// The whole path together: a file whose EXIF says "rotate 90" must come
484    /// back rotated, with its dimensions swapped. Covers the join between
485    /// reading the tag and applying the transform, which the two halves tested
486    /// separately above cannot.
487    #[test]
488    fn a_jpeg_declaring_rotation_is_actually_rotated() {
489        let dir = std::env::temp_dir().join(format!("videre-api-rot-{}", std::process::id()));
490        std::fs::create_dir_all(&dir).unwrap();
491        let p = dir.join("rot90.jpg");
492        std::fs::write(&p, jpeg_with_orientation(6)).unwrap();
493
494        let out = apply_exif_orientation(asymmetric(), p.to_str().unwrap());
495        let (w, h, px) = pixels(&out);
496        assert_eq!((w, h), (3, 2), "orientation 6 must swap the dimensions");
497        assert_eq!(px, vec![2, 1, 0, 12, 11, 10]);
498        let _ = std::fs::remove_dir_all(&dir);
499    }
500
501    /// Orientation is a SHORT by spec. A file declaring it as some other type
502    /// is malformed, and must fall back to 1 rather than being coerced into a
503    /// rotation nobody asked for.
504    #[test]
505    fn an_orientation_of_the_wrong_exif_type_falls_back_to_1() {
506        let dir = std::env::temp_dir().join(format!("videre-api-badtype-{}", std::process::id()));
507        std::fs::create_dir_all(&dir).unwrap();
508        let p = dir.join("badtype.jpg");
509        // Type 4 is LONG, not SHORT.
510        std::fs::write(&p, jpeg_with_orientation_of_type(6, 4)).unwrap();
511        assert_eq!(read_exif_orientation(p.to_str().unwrap()), 1);
512        let _ = std::fs::remove_dir_all(&dir);
513    }
514
515    #[test]
516    fn exif_orientation_defaults_to_1_for_a_missing_or_non_exif_file() {
517        assert_eq!(read_exif_orientation("/nonexistent/path/nope.jpg"), 1);
518
519        let dir = std::env::temp_dir().join(format!("videre-api-exif-{}", std::process::id()));
520        std::fs::create_dir_all(&dir).unwrap();
521        let not_an_image = dir.join("plain.jpg");
522        std::fs::write(&not_an_image, b"definitely not a jpeg").unwrap();
523        assert_eq!(read_exif_orientation(not_an_image.to_str().unwrap()), 1);
524        let _ = std::fs::remove_dir_all(&dir);
525    }
526
527    /// Orientation is only consulted for formats that carry EXIF. A PNG named
528    /// with a non-EXIF extension must be returned untouched without the file
529    /// even being opened, which is why this passes a path that does not exist.
530    #[test]
531    fn non_exif_extensions_skip_orientation_entirely() {
532        let expected = pixels(&asymmetric());
533        for path in [
534            "/nonexistent/a.png",
535            "/nonexistent/b.heic",
536            "/nonexistent/c",
537        ] {
538            assert_eq!(
539                pixels(&apply_exif_orientation(asymmetric(), path)),
540                expected,
541                "{path} must be returned unchanged"
542            );
543        }
544    }
545
546    #[test]
547    fn a_face_crop_is_square_and_thumbnail_sized() {
548        let img = image::DynamicImage::ImageLuma8(image::GrayImage::new(200, 100));
549        let out = crop_face_square(&img, [80.0, 40.0, 120.0, 80.0]);
550        assert_eq!((out.width(), out.height()), (140, 140));
551    }
552
553    /// A bbox against the edge would give a negative origin, and one larger
554    /// than the image would run past it. Both are clamped rather than
555    /// panicking inside `crop_imm`.
556    #[test]
557    fn a_face_crop_clamps_to_the_image_bounds() {
558        let img = image::DynamicImage::ImageLuma8(image::GrayImage::new(50, 50));
559        for bbox in [
560            [0.0, 0.0, 10.0, 10.0],   // flush against the top-left
561            [45.0, 45.0, 60.0, 60.0], // runs past the bottom-right
562            [-20.0, -20.0, 5.0, 5.0], // negative origin
563            [0.0, 0.0, 500.0, 500.0], // larger than the whole image
564        ] {
565            let out = crop_face_square(&img, bbox);
566            assert_eq!((out.width(), out.height()), (140, 140), "bbox {bbox:?}");
567        }
568    }
569
570    /// A zero-area bbox still has to produce a thumbnail rather than a
571    /// zero-side crop: `crop_face_square` floors the side at 1.
572    #[test]
573    fn a_degenerate_bbox_still_produces_a_thumbnail() {
574        let img = image::DynamicImage::ImageLuma8(image::GrayImage::new(50, 50));
575        let out = crop_face_square(&img, [25.0, 25.0, 25.0, 25.0]);
576        assert_eq!((out.width(), out.height()), (140, 140));
577    }
578
579    #[test]
580    fn unknown_face_id_is_not_found() {
581        let conn = Connection::open_in_memory().unwrap();
582        videre_core::face_db::create_faces_table(&conn).unwrap();
583        conn.execute_batch("CREATE TABLE file_hashes (hash TEXT PRIMARY KEY, path TEXT);")
584            .unwrap();
585        assert!(matches!(face_image_bytes(&conn, 999), Err(Error::NotFound)));
586        assert!(matches!(
587            original_image_bytes(&conn, 999),
588            Err(Error::NotFound)
589        ));
590    }
591
592    #[test]
593    fn face_lookup_unknown_id_is_not_found() {
594        let conn = Connection::open_in_memory().unwrap();
595        videre_core::face_db::create_faces_table(&conn).unwrap();
596        conn.execute_batch("CREATE TABLE file_hashes (hash TEXT PRIMARY KEY, path TEXT);")
597            .unwrap();
598        assert!(matches!(face_lookup(&conn, 999), Err(Error::NotFound)));
599    }
600
601    #[test]
602    fn original_lookup_unknown_id_is_not_found() {
603        let conn = Connection::open_in_memory().unwrap();
604        videre_core::face_db::create_faces_table(&conn).unwrap();
605        conn.execute_batch("CREATE TABLE file_hashes (hash TEXT PRIMARY KEY, path TEXT);")
606            .unwrap();
607        assert!(matches!(original_lookup(&conn, 999), Err(Error::NotFound)));
608    }
609
610    #[test]
611    fn face_lookup_does_not_touch_the_filesystem() {
612        // Regression test for the thumbnail-rendering serialization bug: the
613        // DB lookup must be a pure query with no image I/O, so callers can
614        // release the connection lock before doing the expensive part.
615        let conn = Connection::open_in_memory().unwrap();
616        videre_core::face_db::create_faces_table(&conn).unwrap();
617        conn.execute_batch("CREATE TABLE file_hashes (hash TEXT PRIMARY KEY, path TEXT);")
618            .unwrap();
619        conn.execute(
620            "INSERT INTO file_hashes (hash, path) VALUES ('h1', '/no/such/file.jpg')",
621            [],
622        )
623        .unwrap();
624        conn.execute(
625            "INSERT INTO faces (id, hash, bbox, embedding) VALUES (1, 'h1', '0,0,10,10', X'00')",
626            [],
627        )
628        .unwrap();
629        let lookup = face_lookup(&conn, 1).unwrap();
630        assert_eq!(lookup.file_path, "/no/such/file.jpg");
631        assert_eq!(lookup.hash, "h1");
632        assert_eq!(lookup.bbox_json, "0,0,10,10");
633    }
634}