1use 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
30fn 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
53fn 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
66fn 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
85pub 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 let img = videre_core::heic::heic_via_quicklook(path, &format!("thumb{face_id}"), None)?;
111 Some(crop_face_square(&img, bbox))
112 } else {
113 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
137fn 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
169pub struct FaceLookup {
177 pub bbox_json: String,
178 pub file_path: String,
179 pub hash: String,
180}
181
182pub 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
199pub 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 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
239pub 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
254pub struct OriginalLookup {
257 pub file_path: String,
258 pub hash: String,
259}
260
261pub 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
274pub 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 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
323pub 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 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 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 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 #[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 fn jpeg_with_orientation(orientation: u16) -> Vec<u8> {
438 jpeg_with_orientation_of_type(orientation, 3)
439 }
440
441 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"); tiff.extend_from_slice(&42u16.to_le_bytes()); tiff.extend_from_slice(&8u32.to_le_bytes()); tiff.extend_from_slice(&1u16.to_le_bytes()); tiff.extend_from_slice(&0x0112u16.to_le_bytes()); tiff.extend_from_slice(&tiff_type.to_le_bytes()); tiff.extend_from_slice(&1u32.to_le_bytes()); tiff.extend_from_slice(&orientation.to_le_bytes()); tiff.extend_from_slice(&[0, 0]); tiff.extend_from_slice(&0u32.to_le_bytes()); let mut app1 = Vec::from(*b"Exif\0\0");
457 app1.extend_from_slice(&tiff);
458
459 let mut jpeg = vec![0xFF, 0xD8]; jpeg.extend_from_slice(&[0xFF, 0xE1]); 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]); 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 #[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 #[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 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(¬_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 #[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 #[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], [45.0, 45.0, 60.0, 60.0], [-20.0, -20.0, 5.0, 5.0], [0.0, 0.0, 500.0, 500.0], ] {
565 let out = crop_face_square(&img, bbox);
566 assert_eq!((out.width(), out.height()), (140, 140), "bbox {bbox:?}");
567 }
568 }
569
570 #[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 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}