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