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 { return 1 };
13    let Ok(exif_data) = exif::Reader::new().read_from_container(&mut BufReader::new(f)) else {
14        return 1;
15    };
16    exif_data
17        .get_field(exif::Tag::Orientation, exif::In::PRIMARY)
18        .and_then(|field| {
19            if let exif::Value::Short(ref v) = field.value {
20                v.first().copied()
21            } else {
22                None
23            }
24        })
25        .unwrap_or(1)
26}
27
28/// Rotate/flip `img` to match its EXIF orientation (read from `path`).
29fn apply_exif_orientation(img: image::DynamicImage, path: &str) -> image::DynamicImage {
30    let ext = std::path::Path::new(path)
31        .extension()
32        .and_then(|e| e.to_str())
33        .unwrap_or("")
34        .to_lowercase();
35    if !matches!(ext.as_str(), "jpg" | "jpeg" | "tiff" | "dng") {
36        return img;
37    }
38    match read_exif_orientation(path) {
39        2 => img.fliph(),
40        3 => img.rotate180(),
41        4 => img.flipv(),
42        5 => img.rotate90().fliph(),
43        6 => img.rotate90(),
44        7 => img.rotate270().fliph(),
45        8 => img.rotate270(),
46        _ => img,
47    }
48}
49
50/// Square crop centered on bbox [x1,y1,x2,y2] with 25% padding, then resize to 140x140.
51fn crop_face_square(img: &image::DynamicImage, bbox: [f32; 4]) -> image::DynamicImage {
52    let w = img.width() as f32;
53    let h = img.height() as f32;
54    let bw = bbox[2] - bbox[0];
55    let bh = bbox[3] - bbox[1];
56    let pad = (bw.max(bh) * 0.25).max(4.0);
57    let half = bw.max(bh) * 0.5 + pad;
58    let cx = (bbox[0] + bbox[2]) * 0.5;
59    let cy = (bbox[1] + bbox[3]) * 0.5;
60    let x1 = (cx - half).max(0.0) as u32;
61    let y1 = (cy - half).max(0.0) as u32;
62    let x2 = (cx + half).min(w) as u32;
63    let y2 = (cy + half).min(h) as u32;
64    let side = (x2 - x1).min(y2 - y1).max(1);
65    img.crop_imm(x1, y1, side, side)
66        .resize_exact(140, 140, image::imageops::FilterType::Triangle)
67}
68
69/// Load, crop, and orientation-correct a face thumbnail.
70///
71/// bbox coordinates are stored in terms of the *full-size* decoded image
72/// (videre faces rescales detections back to original width/height before
73/// writing to the DB), so the thumbnail must be cropped from an image of
74/// the same dimensions used at detection time.
75///
76/// For HEIC: videre faces converts via QuickLook (see
77/// `videre_core::heic::heic_via_quicklook`), which already applies correct
78/// rotation, so no separate orientation step is needed. For JPEG/PNG/etc:
79/// detection ran on raw pixels; apply EXIF orientation after crop.
80///
81/// `pub` (unlike the three helpers above it): `videre report`'s static-report
82/// base64 thumbnail path (`face_thumb_b64` in `report.rs`) also needs this
83/// exact crop+orientation logic, so it calls through here instead of keeping
84/// its own duplicate copy.
85pub fn make_face_thumb(path: &str, bbox: [f32; 4], face_id: i64) -> Option<image::DynamicImage> {
86    let ext = std::path::Path::new(path)
87        .extension()
88        .and_then(|e| e.to_str())
89        .unwrap_or("")
90        .to_lowercase();
91    if ext == "heic" {
92        // None: bbox is stored relative to a full-res decode. See the
93        // safety note on heic_via_quicklook.
94        let img = videre_core::heic::heic_via_quicklook(path, &format!("thumb{face_id}"), None)?;
95        Some(crop_face_square(&img, bbox))
96    } else {
97        // Detection ran on raw pixels; crop first, then correct orientation
98        let timeout_path = path.to_string();
99        let img = match videre_core::io_timeout::run_with_timeout(
100            videre_core::io_timeout::DEFAULT_IO_TIMEOUT,
101            move || image::open(&timeout_path),
102        ) {
103            Ok(Ok(img)) => img,
104            Ok(Err(e)) => {
105                eprintln!("warning: face thumbnail unavailable for {path}: {e}; skipping");
106                return None;
107            }
108            Err(_) => {
109                eprintln!(
110                    "warning: timed out reading {path} for face thumbnail \
111                     (file may be unreachable - is its drive connected?); skipping"
112                );
113                return None;
114            }
115        };
116        let cropped = crop_face_square(&img, bbox);
117        Some(apply_exif_orientation(cropped, path))
118    }
119}
120
121/// Bounds a plain (non-HEIC) file read against a stale/disconnected mount
122/// point the same way `videre_core::heic` bounds `qlmanage`, so a single
123/// unreachable file can't hang the caller (an axum request thread, or any
124/// other synchronous embedder) forever.
125fn read_with_timeout(path: &str) -> std::io::Result<Vec<u8>> {
126    let owned = path.to_string();
127    videre_core::io_timeout::run_with_timeout(videre_core::io_timeout::DEFAULT_IO_TIMEOUT, move || {
128        std::fs::read(&owned)
129    })
130    .unwrap_or_else(|_| {
131        Err(std::io::Error::new(
132            std::io::ErrorKind::TimedOut,
133            format!("timed out reading {path} (file may be unreachable - is its drive connected?)"),
134        ))
135    })
136}
137
138pub fn mime_for_ext(ext: &str) -> &'static str {
139    match ext {
140        "jpg" | "jpeg" => "image/jpeg",
141        "png" => "image/png",
142        "gif" => "image/gif",
143        "webp" => "image/webp",
144        "bmp" => "image/bmp",
145        "tiff" => "image/tiff",
146        "mov" => "video/quicktime",
147        "mp4" => "video/mp4",
148        _ => "application/octet-stream",
149    }
150}
151
152/// The single-row query `face_image_bytes` needs before it can do any image
153/// work, split out so a caller holding a shared/locked `Connection` (the
154/// axum server serializes every request on one `Mutex<Connection>`)
155/// can release that lock immediately after this cheap lookup, instead of
156/// holding it for the entire decode/crop/resize/encode/cache-write below,
157/// which otherwise fully serializes every thumbnail request behind the lock,
158/// turning a many-thousand-singleton library into one thumbnail at a time.
159pub struct FaceLookup {
160    pub bbox_json: String,
161    pub file_path: String,
162    pub hash: String,
163}
164
165/// The cheap part of `face_image_bytes`: just the DB row. No image I/O.
166pub fn face_lookup(conn: &Connection, face_id: i64) -> Result<FaceLookup> {
167    let (bbox_json, file_path, hash): (String, String, String) = conn
168        .query_row(
169            "SELECT f.bbox, fh.path, f.hash FROM faces f \
170             JOIN file_hashes fh ON f.hash = fh.hash WHERE f.id = ?1 LIMIT 1",
171            [face_id],
172            |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
173        )
174        .map_err(|_| Error::NotFound)?;
175    Ok(FaceLookup { bbox_json, file_path, hash })
176}
177
178/// The expensive part of `face_image_bytes`: cache check, decode/crop/encode,
179/// write-through. Takes no `Connection`, so it can run without holding the
180/// shared DB lock.
181pub fn face_bytes_from_lookup(lookup: &FaceLookup, face_id: i64) -> Result<Vec<u8>> {
182    let cache = videre_core::thumb_cache::face_thumb_path(&lookup.hash, face_id, FACE_THUMB_SIZE);
183    if videre_core::thumb_cache::face_thumb_exists(&lookup.hash, face_id, FACE_THUMB_SIZE) {
184        if let Ok(bytes) = read_with_timeout(&cache.to_string_lossy()) {
185            return Ok(bytes);
186        }
187    }
188
189    let parts: Vec<f32> = lookup.bbox_json.split(',').filter_map(|s| s.trim().parse().ok()).collect();
190    if parts.len() != 4 {
191        return Err(Error::NotFound);
192    }
193    let bbox = [parts[0], parts[1], parts[0] + parts[2], parts[1] + parts[3]];
194    let thumb = make_face_thumb(&lookup.file_path, bbox, face_id).ok_or(Error::NotFound)?;
195    let mut buf = Vec::new();
196    thumb
197        .write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Jpeg)
198        .map_err(|_| Error::NotFound)?;
199
200    // Best-effort write-through (a cache-write failure must not fail the read).
201    if let Some(parent) = cache.parent() {
202        let _ = std::fs::create_dir_all(parent);
203    }
204    let tmp = cache.with_extension(format!("tmp{}", std::process::id()));
205    if std::fs::write(&tmp, &buf).is_ok() {
206        let _ = std::fs::rename(&tmp, &cache);
207    }
208    Ok(buf)
209}
210
211/// JPEG bytes for a single aligned face thumbnail (140px), reading the disk
212/// cache first and converting from the source image (HEIC via QuickLook) on a
213/// miss, writing through to the cache. Returns `Error::NotFound` if the face id
214/// is unknown or the crop cannot be produced. Synchronous: callers that need
215/// async should run this on a blocking thread.
216///
217/// Holds `conn` only for the initial lookup (see `face_lookup`); callers that
218/// share `conn` behind a lock across many concurrent requests should call
219/// `face_lookup`/`face_bytes_from_lookup` directly instead, releasing the
220/// lock between the two.
221pub fn face_image_bytes(conn: &Connection, face_id: i64) -> Result<Vec<u8>> {
222    let lookup = face_lookup(conn, face_id)?;
223    face_bytes_from_lookup(&lookup, face_id)
224}
225
226/// The single-row query `original_image_bytes` needs before any image I/O.
227/// See `FaceLookup` for why this split matters for concurrency.
228pub struct OriginalLookup {
229    pub file_path: String,
230    pub hash: String,
231}
232
233/// The cheap part of `original_image_bytes`: just the DB row. No image I/O.
234pub fn original_lookup(conn: &Connection, face_id: i64) -> Result<OriginalLookup> {
235    let (file_path, hash): (String, String) = conn
236        .query_row(
237            "SELECT fh.path, f.hash FROM faces f \
238             JOIN file_hashes fh ON f.hash = fh.hash WHERE f.id = ?1 LIMIT 1",
239            [face_id],
240            |r| Ok((r.get(0)?, r.get(1)?)),
241        )
242        .map_err(|_| Error::NotFound)?;
243    Ok(OriginalLookup { file_path, hash })
244}
245
246/// The expensive part of `original_image_bytes`: read/convert/cache. Takes no
247/// `Connection`, so it can run without holding the shared DB lock.
248pub fn original_bytes_from_lookup(
249    lookup: &OriginalLookup,
250    face_id: i64,
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) =
262            read_with_timeout(&videre_core::thumb_cache::original_path(&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::heic_via_quicklook(&file_path, &format!("orig{face_id}"), None)
269            .ok_or(Error::NotFound)?;
270        let mut buf = Vec::new();
271        img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Jpeg)
272            .map_err(|_| Error::NotFound)?;
273        let final_path = videre_core::thumb_cache::original_path(&hash);
274        if let Some(parent) = final_path.parent() {
275            let _ = std::fs::create_dir_all(parent);
276        }
277        let tmp = final_path.with_extension(format!("tmp{}", std::process::id()));
278        if std::fs::write(&tmp, &buf).is_ok() {
279            let _ = std::fs::rename(&tmp, &final_path);
280        }
281        Ok(("image/jpeg", buf))
282    } else {
283        let bytes = read_with_timeout(file_path).map_err(|e| {
284            eprintln!("warning: original image unavailable for {file_path}: {e}; skipping");
285            Error::NotFound
286        })?;
287        Ok((mime_for_ext(&ext), bytes))
288    }
289}
290
291/// Bytes for the full original image behind a face (raw for common formats,
292/// QuickLook-converted JPEG for HEIC, with the HEIC result cached). Returns the
293/// MIME type alongside the bytes. `Error::NotFound` if the id is unknown or the
294/// file cannot be read/converted. Synchronous.
295///
296/// Holds `conn` only for the initial lookup (see `original_lookup`); callers
297/// that share `conn` behind a lock across many concurrent requests should
298/// call `original_lookup`/`original_bytes_from_lookup` directly instead,
299/// releasing the lock between the two.
300pub fn original_image_bytes(conn: &Connection, face_id: i64) -> Result<(&'static str, Vec<u8>)> {
301    let lookup = original_lookup(conn, face_id)?;
302    original_bytes_from_lookup(&lookup, face_id)
303}
304
305#[cfg(test)]
306mod tests {
307    use super::*;
308
309    #[test]
310    fn unknown_face_id_is_not_found() {
311        let conn = Connection::open_in_memory().unwrap();
312        videre_core::face_db::create_faces_table(&conn).unwrap();
313        conn.execute_batch("CREATE TABLE file_hashes (hash TEXT PRIMARY KEY, path TEXT);").unwrap();
314        assert!(matches!(face_image_bytes(&conn, 999), Err(Error::NotFound)));
315        assert!(matches!(original_image_bytes(&conn, 999), Err(Error::NotFound)));
316    }
317
318    #[test]
319    fn face_lookup_unknown_id_is_not_found() {
320        let conn = Connection::open_in_memory().unwrap();
321        videre_core::face_db::create_faces_table(&conn).unwrap();
322        conn.execute_batch("CREATE TABLE file_hashes (hash TEXT PRIMARY KEY, path TEXT);").unwrap();
323        assert!(matches!(face_lookup(&conn, 999), Err(Error::NotFound)));
324    }
325
326    #[test]
327    fn original_lookup_unknown_id_is_not_found() {
328        let conn = Connection::open_in_memory().unwrap();
329        videre_core::face_db::create_faces_table(&conn).unwrap();
330        conn.execute_batch("CREATE TABLE file_hashes (hash TEXT PRIMARY KEY, path TEXT);").unwrap();
331        assert!(matches!(original_lookup(&conn, 999), Err(Error::NotFound)));
332    }
333
334    #[test]
335    fn face_lookup_does_not_touch_the_filesystem() {
336        // Regression test for the thumbnail-rendering serialization bug: the
337        // DB lookup must be a pure query with no image I/O, so callers can
338        // release the connection lock before doing the expensive part.
339        let conn = Connection::open_in_memory().unwrap();
340        videre_core::face_db::create_faces_table(&conn).unwrap();
341        conn.execute_batch("CREATE TABLE file_hashes (hash TEXT PRIMARY KEY, path TEXT);").unwrap();
342        conn.execute(
343            "INSERT INTO file_hashes (hash, path) VALUES ('h1', '/no/such/file.jpg')",
344            [],
345        )
346        .unwrap();
347        conn.execute(
348            "INSERT INTO faces (id, hash, bbox, embedding) VALUES (1, 'h1', '0,0,10,10', X'00')",
349            [],
350        )
351        .unwrap();
352        let lookup = face_lookup(&conn, 1).unwrap();
353        assert_eq!(lookup.file_path, "/no/such/file.jpg");
354        assert_eq!(lookup.hash, "h1");
355        assert_eq!(lookup.bbox_json, "0,0,10,10");
356    }
357}