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(
203 lookup: &FaceLookup,
204 face_id: i64,
205 cache: &videre_core::library::CachePaths,
206) -> Result<Vec<u8>> {
207 let parts: Vec<f32> = lookup
208 .bbox_json
209 .split(',')
210 .filter_map(|s| s.trim().parse().ok())
211 .collect();
212 if parts.len() != 4 {
213 return Err(Error::NotFound);
214 }
215 let bbox = [parts[0], parts[1], parts[0] + parts[2], parts[1] + parts[3]];
216
217 let cache_path = videre_core::thumb_cache::face_thumb_path_in(
220 cache,
221 &lookup.hash,
222 face_id,
223 bbox,
224 FACE_THUMB_SIZE,
225 );
226 if videre_core::thumb_cache::face_thumb_exists_in(
227 cache,
228 &lookup.hash,
229 face_id,
230 bbox,
231 FACE_THUMB_SIZE,
232 ) {
233 if let Ok(bytes) = read_with_timeout(&cache_path.to_string_lossy()) {
234 return Ok(bytes);
235 }
236 }
237
238 let thumb = make_face_thumb(&lookup.file_path, bbox, face_id).ok_or(Error::NotFound)?;
239 let mut buf = Vec::new();
240 thumb
241 .write_to(
242 &mut std::io::Cursor::new(&mut buf),
243 image::ImageFormat::Jpeg,
244 )
245 .map_err(|_| Error::NotFound)?;
246
247 if let Some(parent) = cache_path.parent() {
249 let _ = std::fs::create_dir_all(parent);
250 }
251 let tmp = cache_path.with_extension(format!("tmp{}", std::process::id()));
252 if std::fs::write(&tmp, &buf).is_ok() {
253 let _ = std::fs::rename(&tmp, &cache_path);
254 }
255 Ok(buf)
256}
257
258pub fn face_image_bytes(
269 conn: &Connection,
270 face_id: i64,
271 cache: &videre_core::library::CachePaths,
272) -> Result<Vec<u8>> {
273 let lookup = face_lookup(conn, face_id)?;
274 face_bytes_from_lookup(&lookup, face_id, cache)
275}
276
277pub struct OriginalLookup {
280 pub file_path: String,
281 pub hash: String,
282}
283
284pub fn original_lookup(conn: &Connection, face_id: i64) -> Result<OriginalLookup> {
286 let (file_path, hash): (String, String) = conn
287 .query_row(
288 "SELECT fh.path, f.hash FROM faces f \
289 JOIN file_hashes fh ON f.hash = fh.hash WHERE f.id = ?1 LIMIT 1",
290 [face_id],
291 |r| Ok((r.get(0)?, r.get(1)?)),
292 )
293 .map_err(|_| Error::NotFound)?;
294 Ok(OriginalLookup { file_path, hash })
295}
296
297pub fn original_bytes_from_lookup(
300 lookup: &OriginalLookup,
301 face_id: i64,
302 cache: &videre_core::library::CachePaths,
303) -> Result<(&'static str, Vec<u8>)> {
304 let file_path = &lookup.file_path;
305 let hash = &lookup.hash;
306 let ext = std::path::Path::new(file_path)
307 .extension()
308 .and_then(|e| e.to_str())
309 .unwrap_or("")
310 .to_lowercase();
311
312 if ext == "heic" {
313 if let Ok(bytes) = read_with_timeout(
314 &videre_core::thumb_cache::original_path_in(cache, hash).to_string_lossy(),
315 ) {
316 return Ok(("image/jpeg", bytes));
317 }
318 let img =
321 videre_core::heic::heic_via_quicklook(&file_path, &format!("orig{face_id}"), None)
322 .ok_or(Error::NotFound)?;
323 let mut buf = Vec::new();
324 img.write_to(
325 &mut std::io::Cursor::new(&mut buf),
326 image::ImageFormat::Jpeg,
327 )
328 .map_err(|_| Error::NotFound)?;
329 let final_path = videre_core::thumb_cache::original_path_in(cache, hash);
330 if let Some(parent) = final_path.parent() {
331 let _ = std::fs::create_dir_all(parent);
332 }
333 let tmp = final_path.with_extension(format!("tmp{}", std::process::id()));
334 if std::fs::write(&tmp, &buf).is_ok() {
335 let _ = std::fs::rename(&tmp, &final_path);
336 }
337 Ok(("image/jpeg", buf))
338 } else {
339 let bytes = read_with_timeout(file_path).map_err(|e| {
340 eprintln!("warning: original image unavailable for {file_path}: {e}; skipping");
341 Error::NotFound
342 })?;
343 Ok((mime_for_ext(&ext), bytes))
344 }
345}
346
347pub fn original_image_bytes(
357 conn: &Connection,
358 face_id: i64,
359 cache: &videre_core::library::CachePaths,
360) -> Result<(&'static str, Vec<u8>)> {
361 let lookup = original_lookup(conn, face_id)?;
362 original_bytes_from_lookup(&lookup, face_id, cache)
363}
364
365#[cfg(test)]
366mod tests {
367 use super::*;
368
369 fn asymmetric() -> image::DynamicImage {
381 let mut img = image::GrayImage::new(2, 3);
382 for y in 0..3u32 {
383 for x in 0..2u32 {
384 img.put_pixel(x, y, image::Luma([(x * 10 + y) as u8]));
385 }
386 }
387 image::DynamicImage::ImageLuma8(img)
388 }
389
390 fn pixels(img: &image::DynamicImage) -> (u32, u32, Vec<u8>) {
391 let g = img.to_luma8();
392 (g.width(), g.height(), g.pixels().map(|p| p.0[0]).collect())
393 }
394
395 #[test]
396 fn orientation_1_and_unknown_values_are_the_identity() {
397 let expected = pixels(&asymmetric());
398 for o in [0u16, 1, 9, 42, u16::MAX] {
401 assert_eq!(
402 pixels(&apply_orientation(asymmetric(), o)),
403 expected,
404 "orientation {o} must not transform the image"
405 );
406 }
407 }
408
409 #[test]
410 fn orientation_2_mirrors_horizontally() {
411 let (w, h, px) = pixels(&apply_orientation(asymmetric(), 2));
412 assert_eq!((w, h), (2, 3));
413 assert_eq!(px, vec![10, 0, 11, 1, 12, 2]);
415 }
416
417 #[test]
418 fn orientation_3_rotates_180() {
419 let (w, h, px) = pixels(&apply_orientation(asymmetric(), 3));
420 assert_eq!((w, h), (2, 3));
421 assert_eq!(px, vec![12, 2, 11, 1, 10, 0]);
422 }
423
424 #[test]
425 fn orientation_4_mirrors_vertically() {
426 let (w, h, px) = pixels(&apply_orientation(asymmetric(), 4));
427 assert_eq!((w, h), (2, 3));
428 assert_eq!(px, vec![2, 12, 1, 11, 0, 10]);
429 }
430
431 #[test]
434 fn orientation_5_and_7_transpose_and_differ_from_each_other() {
435 let five = pixels(&apply_orientation(asymmetric(), 5));
436 let seven = pixels(&apply_orientation(asymmetric(), 7));
437 assert_eq!((five.0, five.1), (3, 2));
438 assert_eq!((seven.0, seven.1), (3, 2));
439 assert_ne!(five.2, seven.2, "5 and 7 must not be the same transform");
440 assert_eq!(five.2, vec![0, 1, 2, 10, 11, 12]);
441 assert_eq!(seven.2, vec![12, 11, 10, 2, 1, 0]);
442 }
443
444 #[test]
445 fn orientation_6_and_8_rotate_opposite_ways() {
446 let six = pixels(&apply_orientation(asymmetric(), 6));
447 let eight = pixels(&apply_orientation(asymmetric(), 8));
448 assert_eq!((six.0, six.1), (3, 2));
449 assert_eq!((eight.0, eight.1), (3, 2));
450 assert_ne!(six.2, eight.2, "90 and 270 must not be the same transform");
451 assert_eq!(six.2, vec![2, 1, 0, 12, 11, 10]);
452 assert_eq!(eight.2, vec![10, 11, 12, 0, 1, 2]);
453 }
454
455 fn jpeg_with_orientation(orientation: u16) -> Vec<u8> {
466 jpeg_with_orientation_of_type(orientation, 3)
467 }
468
469 fn jpeg_with_orientation_of_type(orientation: u16, tiff_type: u16) -> Vec<u8> {
472 let mut tiff = Vec::new();
473 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");
485 app1.extend_from_slice(&tiff);
486
487 let mut jpeg = vec![0xFF, 0xD8]; jpeg.extend_from_slice(&[0xFF, 0xE1]); jpeg.extend_from_slice(&((app1.len() + 2) as u16).to_be_bytes());
490 jpeg.extend_from_slice(&app1);
491 jpeg.extend_from_slice(&[0xFF, 0xD9]); jpeg
493 }
494
495 #[test]
496 fn every_exif_orientation_value_is_read_back() {
497 let dir = std::env::temp_dir().join(format!("videre-api-orient-{}", std::process::id()));
498 std::fs::create_dir_all(&dir).unwrap();
499 for o in 1..=8u16 {
500 let p = dir.join(format!("o{o}.jpg"));
501 std::fs::write(&p, jpeg_with_orientation(o)).unwrap();
502 assert_eq!(
503 read_exif_orientation(p.to_str().unwrap()),
504 o,
505 "orientation {o} did not round-trip"
506 );
507 }
508 let _ = std::fs::remove_dir_all(&dir);
509 }
510
511 #[test]
516 fn a_jpeg_declaring_rotation_is_actually_rotated() {
517 let dir = std::env::temp_dir().join(format!("videre-api-rot-{}", std::process::id()));
518 std::fs::create_dir_all(&dir).unwrap();
519 let p = dir.join("rot90.jpg");
520 std::fs::write(&p, jpeg_with_orientation(6)).unwrap();
521
522 let out = apply_exif_orientation(asymmetric(), p.to_str().unwrap());
523 let (w, h, px) = pixels(&out);
524 assert_eq!((w, h), (3, 2), "orientation 6 must swap the dimensions");
525 assert_eq!(px, vec![2, 1, 0, 12, 11, 10]);
526 let _ = std::fs::remove_dir_all(&dir);
527 }
528
529 #[test]
533 fn an_orientation_of_the_wrong_exif_type_falls_back_to_1() {
534 let dir = std::env::temp_dir().join(format!("videre-api-badtype-{}", std::process::id()));
535 std::fs::create_dir_all(&dir).unwrap();
536 let p = dir.join("badtype.jpg");
537 std::fs::write(&p, jpeg_with_orientation_of_type(6, 4)).unwrap();
539 assert_eq!(read_exif_orientation(p.to_str().unwrap()), 1);
540 let _ = std::fs::remove_dir_all(&dir);
541 }
542
543 #[test]
544 fn exif_orientation_defaults_to_1_for_a_missing_or_non_exif_file() {
545 assert_eq!(read_exif_orientation("/nonexistent/path/nope.jpg"), 1);
546
547 let dir = std::env::temp_dir().join(format!("videre-api-exif-{}", std::process::id()));
548 std::fs::create_dir_all(&dir).unwrap();
549 let not_an_image = dir.join("plain.jpg");
550 std::fs::write(¬_an_image, b"definitely not a jpeg").unwrap();
551 assert_eq!(read_exif_orientation(not_an_image.to_str().unwrap()), 1);
552 let _ = std::fs::remove_dir_all(&dir);
553 }
554
555 #[test]
559 fn non_exif_extensions_skip_orientation_entirely() {
560 let expected = pixels(&asymmetric());
561 for path in [
562 "/nonexistent/a.png",
563 "/nonexistent/b.heic",
564 "/nonexistent/c",
565 ] {
566 assert_eq!(
567 pixels(&apply_exif_orientation(asymmetric(), path)),
568 expected,
569 "{path} must be returned unchanged"
570 );
571 }
572 }
573
574 #[test]
575 fn a_face_crop_is_square_and_thumbnail_sized() {
576 let img = image::DynamicImage::ImageLuma8(image::GrayImage::new(200, 100));
577 let out = crop_face_square(&img, [80.0, 40.0, 120.0, 80.0]);
578 assert_eq!((out.width(), out.height()), (140, 140));
579 }
580
581 #[test]
585 fn a_face_crop_clamps_to_the_image_bounds() {
586 let img = image::DynamicImage::ImageLuma8(image::GrayImage::new(50, 50));
587 for bbox in [
588 [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], ] {
593 let out = crop_face_square(&img, bbox);
594 assert_eq!((out.width(), out.height()), (140, 140), "bbox {bbox:?}");
595 }
596 }
597
598 #[test]
601 fn a_degenerate_bbox_still_produces_a_thumbnail() {
602 let img = image::DynamicImage::ImageLuma8(image::GrayImage::new(50, 50));
603 let out = crop_face_square(&img, [25.0, 25.0, 25.0, 25.0]);
604 assert_eq!((out.width(), out.height()), (140, 140));
605 }
606
607 #[test]
608 fn unknown_face_id_is_not_found() {
609 let conn = Connection::open_in_memory().unwrap();
610 videre_core::face_db::create_faces_table(&conn).unwrap();
611 conn.execute_batch("CREATE TABLE file_hashes (hash TEXT PRIMARY KEY, path TEXT);")
612 .unwrap();
613 let temp = tempfile::tempdir().unwrap();
614 let ctx =
615 videre_core::library::LibraryContext::new(temp.path(), &temp.path().join("cache"))
616 .unwrap();
617 assert!(matches!(
618 face_image_bytes(&conn, 999, &ctx.cache),
619 Err(Error::NotFound)
620 ));
621 assert!(matches!(
622 original_image_bytes(&conn, 999, &ctx.cache),
623 Err(Error::NotFound)
624 ));
625 }
626
627 #[test]
628 fn face_lookup_unknown_id_is_not_found() {
629 let conn = Connection::open_in_memory().unwrap();
630 videre_core::face_db::create_faces_table(&conn).unwrap();
631 conn.execute_batch("CREATE TABLE file_hashes (hash TEXT PRIMARY KEY, path TEXT);")
632 .unwrap();
633 assert!(matches!(face_lookup(&conn, 999), Err(Error::NotFound)));
634 }
635
636 #[test]
637 fn original_lookup_unknown_id_is_not_found() {
638 let conn = Connection::open_in_memory().unwrap();
639 videre_core::face_db::create_faces_table(&conn).unwrap();
640 conn.execute_batch("CREATE TABLE file_hashes (hash TEXT PRIMARY KEY, path TEXT);")
641 .unwrap();
642 assert!(matches!(original_lookup(&conn, 999), Err(Error::NotFound)));
643 }
644
645 #[test]
646 fn face_lookup_does_not_touch_the_filesystem() {
647 let conn = Connection::open_in_memory().unwrap();
651 videre_core::face_db::create_faces_table(&conn).unwrap();
652 conn.execute_batch("CREATE TABLE file_hashes (hash TEXT PRIMARY KEY, path TEXT);")
653 .unwrap();
654 conn.execute(
655 "INSERT INTO file_hashes (hash, path) VALUES ('h1', '/no/such/file.jpg')",
656 [],
657 )
658 .unwrap();
659 conn.execute(
660 "INSERT INTO faces (id, hash, bbox, embedding) VALUES (1, 'h1', '0,0,10,10', X'00')",
661 [],
662 )
663 .unwrap();
664 let lookup = face_lookup(&conn, 1).unwrap();
665 assert_eq!(lookup.file_path, "/no/such/file.jpg");
666 assert_eq!(lookup.hash, "h1");
667 assert_eq!(lookup.bbox_json, "0,0,10,10");
668 }
669}