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