1use crate::error::{Error, Result};
6use rusqlite::Connection;
7
8const FACE_THUMB_SIZE: u32 = 140;
9
10fn 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
29pub fn make_face_thumb(
48 path: &str,
49 bbox: [f32; 4],
50 face_id: i64,
51 original: Option<(&videre_core::library::CachePaths, &str)>,
52) -> Option<image::DynamicImage> {
53 let ext = std::path::Path::new(path)
54 .extension()
55 .and_then(|e| e.to_str())
56 .unwrap_or("")
57 .to_lowercase();
58 if ext == "heic" {
59 let img = videre_core::heic::decode_fullres_cached(
65 std::path::Path::new(path),
66 &format!("thumb{face_id}"),
67 original,
68 )
69 .inspect_err(videre_core::heic::warn_if_timeout)
70 .ok()?;
71 return Some(crop_face_square(&img, bbox));
72 }
73 let timeout_path = path.to_string();
74 let decoded = match videre_core::io_timeout::run_with_timeout(
75 videre_core::io_timeout::DEFAULT_IO_TIMEOUT,
76 move || {
77 videre_core::image_decode::decode_oriented_file(std::path::Path::new(&timeout_path))
78 },
79 ) {
80 Ok(Ok(img)) => img,
81 Ok(Err(e)) => {
82 tracing::warn!("face thumbnail unavailable for {path}: {e}; skipping");
83 return None;
84 }
85 Err(_) => {
86 tracing::warn!(
87 "timed out reading {path} for face thumbnail \
88 (file may be unreachable - is its drive connected?); skipping"
89 );
90 return None;
91 }
92 };
93 Some(crop_face_square(&decoded, bbox))
94}
95
96fn read_with_timeout(path: &str) -> std::io::Result<Vec<u8>> {
101 let owned = path.to_string();
102 videre_core::io_timeout::run_with_timeout(
103 videre_core::io_timeout::DEFAULT_IO_TIMEOUT,
104 move || std::fs::read(&owned),
105 )
106 .unwrap_or_else(|_| {
107 Err(std::io::Error::new(
108 std::io::ErrorKind::TimedOut,
109 format!("timed out reading {path} (file may be unreachable - is its drive connected?)"),
110 ))
111 })
112}
113
114pub fn mime_for_ext(ext: &str) -> &'static str {
115 match ext {
116 "jpg" | "jpeg" => "image/jpeg",
117 "png" => "image/png",
118 "gif" => "image/gif",
119 "webp" => "image/webp",
120 "bmp" => "image/bmp",
121 "tiff" => "image/tiff",
122 "mov" => "video/quicktime",
123 "mp4" => "video/mp4",
124 _ => "application/octet-stream",
125 }
126}
127
128pub struct FaceLookup {
136 pub bbox_json: String,
137 pub file_path: String,
138 pub hash: String,
139}
140
141pub fn face_lookup(conn: &Connection, face_id: i64) -> Result<FaceLookup> {
143 let (bbox_json, file_path, hash): (String, String, String) = conn
144 .query_row(
145 "SELECT f.bbox, fh.path, f.hash FROM faces f \
146 JOIN file_hashes fh ON f.hash = fh.hash WHERE f.id = ?1 LIMIT 1",
147 [face_id],
148 |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
149 )
150 .map_err(|_| Error::NotFound)?;
151 Ok(FaceLookup {
152 bbox_json,
153 file_path,
154 hash,
155 })
156}
157
158pub fn face_bytes_from_lookup(
162 lookup: &FaceLookup,
163 face_id: i64,
164 cache: &videre_core::library::CachePaths,
165) -> Result<Vec<u8>> {
166 let parts: Vec<f32> = lookup
167 .bbox_json
168 .split(',')
169 .filter_map(|s| s.trim().parse().ok())
170 .collect();
171 if parts.len() != 4 {
172 return Err(Error::NotFound);
173 }
174 let bbox = [parts[0], parts[1], parts[0] + parts[2], parts[1] + parts[3]];
175
176 let cache_path = videre_core::thumb_cache::face_thumb_path_in(
179 cache,
180 &lookup.hash,
181 face_id,
182 bbox,
183 FACE_THUMB_SIZE,
184 );
185 if videre_core::thumb_cache::face_thumb_exists_in(
186 cache,
187 &lookup.hash,
188 face_id,
189 bbox,
190 FACE_THUMB_SIZE,
191 ) {
192 if let Ok(bytes) = read_with_timeout(&cache_path.to_string_lossy()) {
193 return Ok(bytes);
194 }
195 }
196
197 let thumb = make_face_thumb(
198 &lookup.file_path,
199 bbox,
200 face_id,
201 Some((cache, &lookup.hash)),
202 )
203 .ok_or(Error::NotFound)?;
204 let mut buf = Vec::new();
205 thumb
206 .write_to(
207 &mut std::io::Cursor::new(&mut buf),
208 image::ImageFormat::Jpeg,
209 )
210 .map_err(|_| Error::NotFound)?;
211
212 if let Some(parent) = cache_path.parent() {
214 let _ = std::fs::create_dir_all(parent);
215 }
216 let tmp = cache_path.with_extension(format!("tmp{}", std::process::id()));
217 if std::fs::write(&tmp, &buf).is_ok() {
218 let _ = std::fs::rename(&tmp, &cache_path);
219 }
220 Ok(buf)
221}
222
223pub fn face_image_bytes(
234 conn: &Connection,
235 face_id: i64,
236 cache: &videre_core::library::CachePaths,
237) -> Result<Vec<u8>> {
238 let lookup = face_lookup(conn, face_id)?;
239 face_bytes_from_lookup(&lookup, face_id, cache)
240}
241
242pub struct OriginalLookup {
245 pub file_path: String,
246 pub hash: String,
247}
248
249pub fn original_lookup(conn: &Connection, face_id: i64) -> Result<OriginalLookup> {
251 let (file_path, hash): (String, String) = conn
252 .query_row(
253 "SELECT fh.path, f.hash FROM faces f \
254 JOIN file_hashes fh ON f.hash = fh.hash WHERE f.id = ?1 LIMIT 1",
255 [face_id],
256 |r| Ok((r.get(0)?, r.get(1)?)),
257 )
258 .map_err(|_| Error::NotFound)?;
259 Ok(OriginalLookup { file_path, hash })
260}
261
262pub fn original_bytes_from_lookup(
265 lookup: &OriginalLookup,
266 face_id: i64,
267 cache: &videre_core::library::CachePaths,
268) -> Result<(&'static str, Vec<u8>)> {
269 let file_path = &lookup.file_path;
270 let hash = &lookup.hash;
271 let ext = std::path::Path::new(file_path)
272 .extension()
273 .and_then(|e| e.to_str())
274 .unwrap_or("")
275 .to_lowercase();
276
277 if ext == "heic" {
278 if let Ok(bytes) = read_with_timeout(
279 &videre_core::thumb_cache::original_path_in(cache, hash).to_string_lossy(),
280 ) {
281 return Ok(("image/jpeg", bytes));
282 }
283 let img = videre_core::heic::decode_via_quicklook(
288 std::path::Path::new(file_path),
289 &format!("orig{face_id}"),
290 None,
291 )
292 .inspect_err(videre_core::heic::warn_if_timeout)
293 .map_err(|_| Error::NotFound)?;
294 let mut buf = Vec::new();
295 img.write_to(
296 &mut std::io::Cursor::new(&mut buf),
297 image::ImageFormat::Jpeg,
298 )
299 .map_err(|_| Error::NotFound)?;
300 videre_core::heic::publish_cached_original(cache, hash, &buf);
301 Ok(("image/jpeg", buf))
302 } else {
303 let bytes = read_with_timeout(file_path).map_err(|e| {
304 tracing::warn!("original image unavailable for {file_path}: {e}; skipping");
305 Error::NotFound
306 })?;
307 Ok((mime_for_ext(&ext), bytes))
308 }
309}
310
311pub fn original_image_bytes(
321 conn: &Connection,
322 face_id: i64,
323 cache: &videre_core::library::CachePaths,
324) -> Result<(&'static str, Vec<u8>)> {
325 let lookup = original_lookup(conn, face_id)?;
326 original_bytes_from_lookup(&lookup, face_id, cache)
327}
328
329#[cfg(test)]
330mod tests {
331 use super::*;
332
333 #[test]
342 fn a_face_thumbnail_is_cropped_from_the_upright_image() {
343 let base = concat!(env!("CARGO_MANIFEST_DIR"), "/../videre/tests/fixtures");
344 let tagged = format!("{base}/ai-generated-couple_o6.jpg");
345
346 let raw_center = (600u32, 772u32);
351 let display_center = (1543 - raw_center.1, raw_center.0);
352 let raw_bbox = [
353 (raw_center.0 - 200) as f32,
354 (raw_center.1 - 200) as f32,
355 (raw_center.0 + 200) as f32,
356 (raw_center.1 + 200) as f32,
357 ];
358 let display_bbox = [
359 (display_center.0 - 200) as f32,
360 (display_center.1 - 200) as f32,
361 (display_center.0 + 200) as f32,
362 (display_center.1 + 200) as f32,
363 ];
364
365 let (raw, orientation) =
366 videre_core::image_decode::decode_raw_with_orientation(std::path::Path::new(&tagged))
367 .unwrap();
368 let mut expected =
369 image::DynamicImage::ImageRgba8(crop_face_square(&raw, raw_bbox).to_rgba8());
370 expected.apply_orientation(orientation);
371 let thumb = make_face_thumb(&tagged, display_bbox, 1, None).unwrap();
372 assert_eq!((thumb.width(), thumb.height()), (140, 140));
373 let a: Vec<u8> = expected.to_rgb8().pixels().map(|p| p.0[0]).collect();
374 let b: Vec<u8> = thumb.to_rgb8().pixels().map(|p| p.0[0]).collect();
375 let diff: u64 = a
376 .iter()
377 .zip(&b)
378 .map(|(x, y)| (*x as i32 - *y as i32).unsigned_abs() as u64)
379 .sum();
380 assert!(
381 diff < 1000,
382 "the thumbnail must show the upright face, sum |diff| = {diff}"
383 );
384 }
385
386 #[test]
387 fn a_face_crop_is_square_and_thumbnail_sized() {
388 let img = image::DynamicImage::ImageLuma8(image::GrayImage::new(200, 100));
389 let out = crop_face_square(&img, [80.0, 40.0, 120.0, 80.0]);
390 assert_eq!((out.width(), out.height()), (140, 140));
391 }
392
393 #[test]
397 fn a_face_crop_clamps_to_the_image_bounds() {
398 let img = image::DynamicImage::ImageLuma8(image::GrayImage::new(50, 50));
399 for bbox in [
400 [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], ] {
405 let out = crop_face_square(&img, bbox);
406 assert_eq!((out.width(), out.height()), (140, 140), "bbox {bbox:?}");
407 }
408 }
409
410 #[test]
413 fn a_degenerate_bbox_still_produces_a_thumbnail() {
414 let img = image::DynamicImage::ImageLuma8(image::GrayImage::new(50, 50));
415 let out = crop_face_square(&img, [25.0, 25.0, 25.0, 25.0]);
416 assert_eq!((out.width(), out.height()), (140, 140));
417 }
418
419 #[test]
420 fn mime_types_cover_gallery_image_and_video_extensions() {
421 for (ext, expected) in [
422 ("jpg", "image/jpeg"),
423 ("jpeg", "image/jpeg"),
424 ("png", "image/png"),
425 ("gif", "image/gif"),
426 ("webp", "image/webp"),
427 ("bmp", "image/bmp"),
428 ("tiff", "image/tiff"),
429 ("mov", "video/quicktime"),
430 ("mp4", "video/mp4"),
431 ("unknown", "application/octet-stream"),
432 ] {
433 assert_eq!(mime_for_ext(ext), expected, "extension {ext}");
434 }
435 }
436
437 #[test]
438 fn face_thumbnail_cache_is_returned_without_reading_the_source() {
439 let temp = tempfile::tempdir().unwrap();
440 let ctx =
441 videre_core::library::LibraryContext::new(temp.path(), &temp.path().join("cache"))
442 .unwrap();
443 let lookup = FaceLookup {
444 bbox_json: "10,20,30,40".to_string(),
445 file_path: temp.path().join("missing.jpg").to_string_lossy().into(),
446 hash: "face-cache-hash".to_string(),
447 };
448 let bbox = [10.0, 20.0, 40.0, 60.0];
449 let cache_path = videre_core::thumb_cache::face_thumb_path_in(
450 &ctx.cache,
451 &lookup.hash,
452 42,
453 bbox,
454 FACE_THUMB_SIZE,
455 );
456 std::fs::create_dir_all(cache_path.parent().unwrap()).unwrap();
457 std::fs::write(&cache_path, b"cached thumbnail").unwrap();
458
459 assert_eq!(
460 face_bytes_from_lookup(&lookup, 42, &ctx.cache).unwrap(),
461 b"cached thumbnail"
462 );
463 }
464
465 #[test]
466 fn malformed_face_bbox_is_not_found_before_image_io() {
467 let temp = tempfile::tempdir().unwrap();
468 let ctx =
469 videre_core::library::LibraryContext::new(temp.path(), &temp.path().join("cache"))
470 .unwrap();
471 for bbox_json in ["", "1,2,3", "1,2,three,4", "1,2,3,4,5"] {
472 let lookup = FaceLookup {
473 bbox_json: bbox_json.to_string(),
474 file_path: temp.path().join("missing.jpg").to_string_lossy().into(),
475 hash: "bad-bbox-hash".to_string(),
476 };
477 assert!(matches!(
478 face_bytes_from_lookup(&lookup, 1, &ctx.cache),
479 Err(Error::NotFound)
480 ));
481 }
482 }
483
484 #[test]
485 fn original_bytes_preserve_plain_file_contents_and_choose_mime() {
486 let temp = tempfile::tempdir().unwrap();
487 let ctx =
488 videre_core::library::LibraryContext::new(temp.path(), &temp.path().join("cache"))
489 .unwrap();
490 let source = temp.path().join("original.JpEg");
491 std::fs::write(&source, b"original image bytes").unwrap();
492 let lookup = OriginalLookup {
493 file_path: source.to_string_lossy().into(),
494 hash: "original-hash".to_string(),
495 };
496
497 let (mime, bytes) = original_bytes_from_lookup(&lookup, 1, &ctx.cache).unwrap();
498 assert_eq!(mime, "image/jpeg");
499 assert_eq!(bytes, b"original image bytes");
500 }
501
502 #[test]
503 fn missing_original_file_is_not_found() {
504 let temp = tempfile::tempdir().unwrap();
505 let ctx =
506 videre_core::library::LibraryContext::new(temp.path(), &temp.path().join("cache"))
507 .unwrap();
508 let lookup = OriginalLookup {
509 file_path: temp.path().join("missing.jpg").to_string_lossy().into(),
510 hash: "missing-original-hash".to_string(),
511 };
512 assert!(matches!(
513 original_bytes_from_lookup(&lookup, 1, &ctx.cache),
514 Err(Error::NotFound)
515 ));
516 }
517
518 #[test]
519 fn unknown_face_id_is_not_found() {
520 let conn = Connection::open_in_memory().unwrap();
521 videre_core::face_db::create_faces_table(&conn).unwrap();
522 conn.execute_batch("CREATE TABLE file_hashes (hash TEXT PRIMARY KEY, path TEXT);")
523 .unwrap();
524 let temp = tempfile::tempdir().unwrap();
525 let ctx =
526 videre_core::library::LibraryContext::new(temp.path(), &temp.path().join("cache"))
527 .unwrap();
528 assert!(matches!(
529 face_image_bytes(&conn, 999, &ctx.cache),
530 Err(Error::NotFound)
531 ));
532 assert!(matches!(
533 original_image_bytes(&conn, 999, &ctx.cache),
534 Err(Error::NotFound)
535 ));
536 }
537
538 #[test]
539 fn face_lookup_unknown_id_is_not_found() {
540 let conn = Connection::open_in_memory().unwrap();
541 videre_core::face_db::create_faces_table(&conn).unwrap();
542 conn.execute_batch("CREATE TABLE file_hashes (hash TEXT PRIMARY KEY, path TEXT);")
543 .unwrap();
544 assert!(matches!(face_lookup(&conn, 999), Err(Error::NotFound)));
545 }
546
547 #[test]
548 fn original_lookup_unknown_id_is_not_found() {
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 assert!(matches!(original_lookup(&conn, 999), Err(Error::NotFound)));
554 }
555
556 #[test]
557 fn face_lookup_does_not_touch_the_filesystem() {
558 let conn = Connection::open_in_memory().unwrap();
562 videre_core::face_db::create_faces_table(&conn).unwrap();
563 conn.execute_batch("CREATE TABLE file_hashes (hash TEXT PRIMARY KEY, path TEXT);")
564 .unwrap();
565 conn.execute(
566 "INSERT INTO file_hashes (hash, path) VALUES ('h1', '/no/such/file.jpg')",
567 [],
568 )
569 .unwrap();
570 conn.execute(
571 "INSERT INTO faces (id, hash, bbox, embedding) VALUES (1, 'h1', '0,0,10,10', X'00')",
572 [],
573 )
574 .unwrap();
575 let lookup = face_lookup(&conn, 1).unwrap();
576 assert_eq!(lookup.file_path, "/no/such/file.jpg");
577 assert_eq!(lookup.hash, "h1");
578 assert_eq!(lookup.bbox_json, "0,0,10,10");
579 }
580
581 #[test]
587 fn a_cached_original_feeds_the_face_crop_without_quicklook() {
588 let temp = tempfile::tempdir().unwrap();
589 let ctx =
590 videre_core::library::LibraryContext::new(temp.path(), &temp.path().join("cache"))
591 .unwrap();
592 let hash = format!("face-crop-original-{}", std::process::id());
593 let cached = videre_core::thumb_cache::original_path_in(&ctx.cache, &hash);
594 std::fs::create_dir_all(cached.parent().unwrap()).unwrap();
595 let img = image::RgbImage::from_pixel(2, 2, image::Rgb([255, 0, 0]));
596 image::DynamicImage::ImageRgb8(img).save(&cached).unwrap();
597
598 let thumb = make_face_thumb(
599 "/nonexistent/should-not-be-read.heic",
600 [0.0, 0.0, 2.0, 2.0],
601 1,
602 Some((&ctx.cache, &hash)),
603 )
604 .unwrap();
605 assert_eq!((thumb.width(), thumb.height()), (140, 140));
606 assert!(
607 thumb
608 .to_rgb8()
609 .pixels()
610 .all(|p| p.0[0] >= 200 && p.0[1] <= 64 && p.0[2] <= 64),
611 "the crop must show the cached original's pixels (JPEG round-trip shifts them a little)"
612 );
613 }
614
615 #[test]
621 fn a_corrupt_cached_original_is_skipped_not_fatal() {
622 let temp = tempfile::tempdir().unwrap();
623 let ctx =
624 videre_core::library::LibraryContext::new(temp.path(), &temp.path().join("cache"))
625 .unwrap();
626 let hash = format!("face-crop-corrupt-{}", std::process::id());
627 let cached = videre_core::thumb_cache::original_path_in(&ctx.cache, &hash);
628 std::fs::create_dir_all(cached.parent().unwrap()).unwrap();
629 std::fs::write(&cached, b"not a jpeg").unwrap();
630
631 assert!(make_face_thumb(
632 "/nonexistent/should-not-be-read.heic",
633 [0.0, 0.0, 2.0, 2.0],
634 1,
635 Some((&ctx.cache, &hash)),
636 )
637 .is_none());
638 }
639
640 #[test]
645 #[cfg(target_os = "macos")]
646 fn a_fallback_render_publishes_the_cached_original() {
647 let temp = tempfile::tempdir().unwrap();
648 let ctx =
649 videre_core::library::LibraryContext::new(temp.path(), &temp.path().join("cache"))
650 .unwrap();
651 let heic = concat!(
652 env!("CARGO_MANIFEST_DIR"),
653 "/../videre/tests/fixtures/content_key/tiny.heic"
654 );
655 let hash = format!("face-crop-publish-{}", std::process::id());
656
657 let thumb =
658 make_face_thumb(heic, [0.0, 0.0, 200.0, 200.0], 1, Some((&ctx.cache, &hash))).unwrap();
659 assert_eq!((thumb.width(), thumb.height()), (140, 140));
660
661 let cached = videre_core::thumb_cache::original_path_in(&ctx.cache, &hash);
662 assert!(cached.is_file(), "the render was not published: {cached:?}");
663 let opened = image::open(&cached).unwrap();
664 assert!(opened.width() > 0);
665 }
666}