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(e) => {
86 tracing::warn!("face thumbnail unavailable for {path}: {e}; skipping");
87 return None;
88 }
89 };
90 Some(crop_face_square(&decoded, bbox))
91}
92
93fn read_with_timeout(path: &str) -> std::io::Result<Vec<u8>> {
98 let owned = path.to_string();
99 videre_core::io_timeout::run_with_timeout(
100 videre_core::io_timeout::DEFAULT_IO_TIMEOUT,
101 move || std::fs::read(&owned),
102 )
103 .unwrap_or_else(|e| Err(e.into_io_error()))
104}
105
106pub fn mime_for_ext(ext: &str) -> &'static str {
107 match ext {
108 "jpg" | "jpeg" => "image/jpeg",
109 "png" => "image/png",
110 "gif" => "image/gif",
111 "webp" => "image/webp",
112 "bmp" => "image/bmp",
113 "tiff" => "image/tiff",
114 "mov" => "video/quicktime",
115 "mp4" => "video/mp4",
116 _ => "application/octet-stream",
117 }
118}
119
120pub struct FaceLookup {
128 pub bbox_json: String,
129 pub file_path: String,
130 pub hash: String,
131}
132
133pub fn face_lookup(conn: &Connection, face_id: i64) -> Result<FaceLookup> {
135 let (bbox_json, file_path, hash): (String, String, String) = conn
136 .query_row(
137 "SELECT f.bbox, fh.path, f.hash FROM faces f \
138 JOIN file_hashes fh ON f.hash = fh.hash WHERE f.id = ?1 LIMIT 1",
139 [face_id],
140 |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
141 )
142 .map_err(|_| Error::NotFound)?;
143 Ok(FaceLookup {
144 bbox_json,
145 file_path,
146 hash,
147 })
148}
149
150pub fn face_bytes_from_lookup(
154 lookup: &FaceLookup,
155 face_id: i64,
156 cache: &videre_core::library::CachePaths,
157) -> Result<Vec<u8>> {
158 let parts: Vec<f32> = lookup
159 .bbox_json
160 .split(',')
161 .filter_map(|s| s.trim().parse().ok())
162 .collect();
163 if parts.len() != 4 {
164 return Err(Error::NotFound);
165 }
166 let bbox = [parts[0], parts[1], parts[0] + parts[2], parts[1] + parts[3]];
167
168 let cache_path = videre_core::thumb_cache::face_thumb_path_in(
171 cache,
172 &lookup.hash,
173 face_id,
174 bbox,
175 FACE_THUMB_SIZE,
176 );
177 if videre_core::thumb_cache::face_thumb_exists_in(
178 cache,
179 &lookup.hash,
180 face_id,
181 bbox,
182 FACE_THUMB_SIZE,
183 ) {
184 if let Ok(bytes) = read_with_timeout(&cache_path.to_string_lossy()) {
185 return Ok(bytes);
186 }
187 }
188
189 let thumb = make_face_thumb(
190 &lookup.file_path,
191 bbox,
192 face_id,
193 Some((cache, &lookup.hash)),
194 )
195 .ok_or(Error::NotFound)?;
196 let mut buf = Vec::new();
197 thumb
198 .write_to(
199 &mut std::io::Cursor::new(&mut buf),
200 image::ImageFormat::Jpeg,
201 )
202 .map_err(|_| Error::NotFound)?;
203
204 if let Some(parent) = cache_path.parent() {
206 let _ = std::fs::create_dir_all(parent);
207 }
208 let tmp = cache_path.with_extension(format!("tmp{}", std::process::id()));
209 if std::fs::write(&tmp, &buf).is_ok() {
210 let _ = std::fs::rename(&tmp, &cache_path);
211 }
212 Ok(buf)
213}
214
215pub fn face_image_bytes(
226 conn: &Connection,
227 face_id: i64,
228 cache: &videre_core::library::CachePaths,
229) -> Result<Vec<u8>> {
230 let lookup = face_lookup(conn, face_id)?;
231 face_bytes_from_lookup(&lookup, face_id, cache)
232}
233
234pub struct OriginalLookup {
237 pub file_path: String,
238 pub hash: String,
239}
240
241pub fn original_lookup(conn: &Connection, face_id: i64) -> Result<OriginalLookup> {
243 let (file_path, hash): (String, String) = conn
244 .query_row(
245 "SELECT fh.path, f.hash FROM faces f \
246 JOIN file_hashes fh ON f.hash = fh.hash WHERE f.id = ?1 LIMIT 1",
247 [face_id],
248 |r| Ok((r.get(0)?, r.get(1)?)),
249 )
250 .map_err(|_| Error::NotFound)?;
251 Ok(OriginalLookup { file_path, hash })
252}
253
254pub fn original_bytes_from_lookup(
257 lookup: &OriginalLookup,
258 face_id: i64,
259 cache: &videre_core::library::CachePaths,
260) -> Result<(&'static str, Vec<u8>)> {
261 let file_path = &lookup.file_path;
262 let hash = &lookup.hash;
263 let ext = std::path::Path::new(file_path)
264 .extension()
265 .and_then(|e| e.to_str())
266 .unwrap_or("")
267 .to_lowercase();
268
269 if ext == "heic" {
270 if let Ok(bytes) = read_with_timeout(
271 &videre_core::thumb_cache::original_path_in(cache, hash).to_string_lossy(),
272 ) {
273 return Ok(("image/jpeg", bytes));
274 }
275 let img = videre_core::heic::decode_via_quicklook(
280 std::path::Path::new(file_path),
281 &format!("orig{face_id}"),
282 None,
283 )
284 .inspect_err(videre_core::heic::warn_if_timeout)
285 .map_err(|_| Error::NotFound)?;
286 let mut buf = Vec::new();
287 img.write_to(
288 &mut std::io::Cursor::new(&mut buf),
289 image::ImageFormat::Jpeg,
290 )
291 .map_err(|_| Error::NotFound)?;
292 videre_core::heic::publish_cached_original(cache, hash, &buf);
293 Ok(("image/jpeg", buf))
294 } else {
295 let bytes = read_with_timeout(file_path).map_err(|e| {
296 tracing::warn!("original image unavailable for {file_path}: {e}; skipping");
297 Error::NotFound
298 })?;
299 Ok((mime_for_ext(&ext), bytes))
300 }
301}
302
303pub fn original_image_bytes(
313 conn: &Connection,
314 face_id: i64,
315 cache: &videre_core::library::CachePaths,
316) -> Result<(&'static str, Vec<u8>)> {
317 let lookup = original_lookup(conn, face_id)?;
318 original_bytes_from_lookup(&lookup, face_id, cache)
319}
320
321#[cfg(test)]
322mod tests {
323 use super::*;
324
325 #[test]
334 fn a_face_thumbnail_is_cropped_from_the_upright_image() {
335 let base = concat!(env!("CARGO_MANIFEST_DIR"), "/../videre/tests/fixtures");
336 let tagged = format!("{base}/ai-generated-couple_o6.jpg");
337
338 let raw_center = (600u32, 772u32);
343 let display_center = (1543 - raw_center.1, raw_center.0);
344 let raw_bbox = [
345 (raw_center.0 - 200) as f32,
346 (raw_center.1 - 200) as f32,
347 (raw_center.0 + 200) as f32,
348 (raw_center.1 + 200) as f32,
349 ];
350 let display_bbox = [
351 (display_center.0 - 200) as f32,
352 (display_center.1 - 200) as f32,
353 (display_center.0 + 200) as f32,
354 (display_center.1 + 200) as f32,
355 ];
356
357 let (raw, orientation) =
358 videre_core::image_decode::decode_raw_with_orientation(std::path::Path::new(&tagged))
359 .unwrap();
360 let mut expected =
361 image::DynamicImage::ImageRgba8(crop_face_square(&raw, raw_bbox).to_rgba8());
362 expected.apply_orientation(orientation);
363 let thumb = make_face_thumb(&tagged, display_bbox, 1, None).unwrap();
364 assert_eq!((thumb.width(), thumb.height()), (140, 140));
365 let a: Vec<u8> = expected.to_rgb8().pixels().map(|p| p.0[0]).collect();
366 let b: Vec<u8> = thumb.to_rgb8().pixels().map(|p| p.0[0]).collect();
367 let diff: u64 = a
368 .iter()
369 .zip(&b)
370 .map(|(x, y)| (*x as i32 - *y as i32).unsigned_abs() as u64)
371 .sum();
372 assert!(
373 diff < 1000,
374 "the thumbnail must show the upright face, sum |diff| = {diff}"
375 );
376 }
377
378 #[test]
379 fn a_face_crop_is_square_and_thumbnail_sized() {
380 let img = image::DynamicImage::ImageLuma8(image::GrayImage::new(200, 100));
381 let out = crop_face_square(&img, [80.0, 40.0, 120.0, 80.0]);
382 assert_eq!((out.width(), out.height()), (140, 140));
383 }
384
385 #[test]
389 fn a_face_crop_clamps_to_the_image_bounds() {
390 let img = image::DynamicImage::ImageLuma8(image::GrayImage::new(50, 50));
391 for bbox in [
392 [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], ] {
397 let out = crop_face_square(&img, bbox);
398 assert_eq!((out.width(), out.height()), (140, 140), "bbox {bbox:?}");
399 }
400 }
401
402 #[test]
405 fn a_degenerate_bbox_still_produces_a_thumbnail() {
406 let img = image::DynamicImage::ImageLuma8(image::GrayImage::new(50, 50));
407 let out = crop_face_square(&img, [25.0, 25.0, 25.0, 25.0]);
408 assert_eq!((out.width(), out.height()), (140, 140));
409 }
410
411 #[test]
412 fn mime_types_cover_gallery_image_and_video_extensions() {
413 for (ext, expected) in [
414 ("jpg", "image/jpeg"),
415 ("jpeg", "image/jpeg"),
416 ("png", "image/png"),
417 ("gif", "image/gif"),
418 ("webp", "image/webp"),
419 ("bmp", "image/bmp"),
420 ("tiff", "image/tiff"),
421 ("mov", "video/quicktime"),
422 ("mp4", "video/mp4"),
423 ("unknown", "application/octet-stream"),
424 ] {
425 assert_eq!(mime_for_ext(ext), expected, "extension {ext}");
426 }
427 }
428
429 #[test]
430 fn face_thumbnail_cache_is_returned_without_reading_the_source() {
431 let temp = tempfile::tempdir().unwrap();
432 let ctx =
433 videre_core::library::LibraryContext::new(temp.path(), &temp.path().join("cache"))
434 .unwrap();
435 let lookup = FaceLookup {
436 bbox_json: "10,20,30,40".to_string(),
437 file_path: temp.path().join("missing.jpg").to_string_lossy().into(),
438 hash: "face-cache-hash".to_string(),
439 };
440 let bbox = [10.0, 20.0, 40.0, 60.0];
441 let cache_path = videre_core::thumb_cache::face_thumb_path_in(
442 &ctx.cache,
443 &lookup.hash,
444 42,
445 bbox,
446 FACE_THUMB_SIZE,
447 );
448 std::fs::create_dir_all(cache_path.parent().unwrap()).unwrap();
449 std::fs::write(&cache_path, b"cached thumbnail").unwrap();
450
451 assert_eq!(
452 face_bytes_from_lookup(&lookup, 42, &ctx.cache).unwrap(),
453 b"cached thumbnail"
454 );
455 }
456
457 #[test]
458 fn malformed_face_bbox_is_not_found_before_image_io() {
459 let temp = tempfile::tempdir().unwrap();
460 let ctx =
461 videre_core::library::LibraryContext::new(temp.path(), &temp.path().join("cache"))
462 .unwrap();
463 for bbox_json in ["", "1,2,3", "1,2,three,4", "1,2,3,4,5"] {
464 let lookup = FaceLookup {
465 bbox_json: bbox_json.to_string(),
466 file_path: temp.path().join("missing.jpg").to_string_lossy().into(),
467 hash: "bad-bbox-hash".to_string(),
468 };
469 assert!(matches!(
470 face_bytes_from_lookup(&lookup, 1, &ctx.cache),
471 Err(Error::NotFound)
472 ));
473 }
474 }
475
476 #[test]
477 fn original_bytes_preserve_plain_file_contents_and_choose_mime() {
478 let temp = tempfile::tempdir().unwrap();
479 let ctx =
480 videre_core::library::LibraryContext::new(temp.path(), &temp.path().join("cache"))
481 .unwrap();
482 let source = temp.path().join("original.JpEg");
483 std::fs::write(&source, b"original image bytes").unwrap();
484 let lookup = OriginalLookup {
485 file_path: source.to_string_lossy().into(),
486 hash: "original-hash".to_string(),
487 };
488
489 let (mime, bytes) = original_bytes_from_lookup(&lookup, 1, &ctx.cache).unwrap();
490 assert_eq!(mime, "image/jpeg");
491 assert_eq!(bytes, b"original image bytes");
492 }
493
494 #[test]
495 fn missing_original_file_is_not_found() {
496 let temp = tempfile::tempdir().unwrap();
497 let ctx =
498 videre_core::library::LibraryContext::new(temp.path(), &temp.path().join("cache"))
499 .unwrap();
500 let lookup = OriginalLookup {
501 file_path: temp.path().join("missing.jpg").to_string_lossy().into(),
502 hash: "missing-original-hash".to_string(),
503 };
504 assert!(matches!(
505 original_bytes_from_lookup(&lookup, 1, &ctx.cache),
506 Err(Error::NotFound)
507 ));
508 }
509
510 #[test]
511 fn unknown_face_id_is_not_found() {
512 let conn = Connection::open_in_memory().unwrap();
513 videre_core::face_db::create_faces_table(&conn).unwrap();
514 conn.execute_batch("CREATE TABLE file_hashes (hash TEXT PRIMARY KEY, path TEXT);")
515 .unwrap();
516 let temp = tempfile::tempdir().unwrap();
517 let ctx =
518 videre_core::library::LibraryContext::new(temp.path(), &temp.path().join("cache"))
519 .unwrap();
520 assert!(matches!(
521 face_image_bytes(&conn, 999, &ctx.cache),
522 Err(Error::NotFound)
523 ));
524 assert!(matches!(
525 original_image_bytes(&conn, 999, &ctx.cache),
526 Err(Error::NotFound)
527 ));
528 }
529
530 #[test]
531 fn face_lookup_unknown_id_is_not_found() {
532 let conn = Connection::open_in_memory().unwrap();
533 videre_core::face_db::create_faces_table(&conn).unwrap();
534 conn.execute_batch("CREATE TABLE file_hashes (hash TEXT PRIMARY KEY, path TEXT);")
535 .unwrap();
536 assert!(matches!(face_lookup(&conn, 999), Err(Error::NotFound)));
537 }
538
539 #[test]
540 fn original_lookup_unknown_id_is_not_found() {
541 let conn = Connection::open_in_memory().unwrap();
542 videre_core::face_db::create_faces_table(&conn).unwrap();
543 conn.execute_batch("CREATE TABLE file_hashes (hash TEXT PRIMARY KEY, path TEXT);")
544 .unwrap();
545 assert!(matches!(original_lookup(&conn, 999), Err(Error::NotFound)));
546 }
547
548 #[test]
549 fn face_lookup_does_not_touch_the_filesystem() {
550 let conn = Connection::open_in_memory().unwrap();
554 videre_core::face_db::create_faces_table(&conn).unwrap();
555 conn.execute_batch("CREATE TABLE file_hashes (hash TEXT PRIMARY KEY, path TEXT);")
556 .unwrap();
557 conn.execute(
558 "INSERT INTO file_hashes (hash, path) VALUES ('h1', '/no/such/file.jpg')",
559 [],
560 )
561 .unwrap();
562 conn.execute(
563 "INSERT INTO faces (id, hash, bbox, embedding) VALUES (1, 'h1', '0,0,10,10', X'00')",
564 [],
565 )
566 .unwrap();
567 let lookup = face_lookup(&conn, 1).unwrap();
568 assert_eq!(lookup.file_path, "/no/such/file.jpg");
569 assert_eq!(lookup.hash, "h1");
570 assert_eq!(lookup.bbox_json, "0,0,10,10");
571 }
572
573 #[test]
579 fn a_cached_original_feeds_the_face_crop_without_quicklook() {
580 let temp = tempfile::tempdir().unwrap();
581 let ctx =
582 videre_core::library::LibraryContext::new(temp.path(), &temp.path().join("cache"))
583 .unwrap();
584 let hash = format!("face-crop-original-{}", std::process::id());
585 let cached = videre_core::thumb_cache::original_path_in(&ctx.cache, &hash);
586 std::fs::create_dir_all(cached.parent().unwrap()).unwrap();
587 let img = image::RgbImage::from_pixel(2, 2, image::Rgb([255, 0, 0]));
588 image::DynamicImage::ImageRgb8(img).save(&cached).unwrap();
589
590 let thumb = make_face_thumb(
591 "/nonexistent/should-not-be-read.heic",
592 [0.0, 0.0, 2.0, 2.0],
593 1,
594 Some((&ctx.cache, &hash)),
595 )
596 .unwrap();
597 assert_eq!((thumb.width(), thumb.height()), (140, 140));
598 assert!(
599 thumb
600 .to_rgb8()
601 .pixels()
602 .all(|p| p.0[0] >= 200 && p.0[1] <= 64 && p.0[2] <= 64),
603 "the crop must show the cached original's pixels (JPEG round-trip shifts them a little)"
604 );
605 }
606
607 #[test]
613 fn a_corrupt_cached_original_is_skipped_not_fatal() {
614 let temp = tempfile::tempdir().unwrap();
615 let ctx =
616 videre_core::library::LibraryContext::new(temp.path(), &temp.path().join("cache"))
617 .unwrap();
618 let hash = format!("face-crop-corrupt-{}", std::process::id());
619 let cached = videre_core::thumb_cache::original_path_in(&ctx.cache, &hash);
620 std::fs::create_dir_all(cached.parent().unwrap()).unwrap();
621 std::fs::write(&cached, b"not a jpeg").unwrap();
622
623 assert!(make_face_thumb(
624 "/nonexistent/should-not-be-read.heic",
625 [0.0, 0.0, 2.0, 2.0],
626 1,
627 Some((&ctx.cache, &hash)),
628 )
629 .is_none());
630 }
631
632 #[test]
637 #[cfg(target_os = "macos")]
638 fn a_fallback_render_publishes_the_cached_original() {
639 let temp = tempfile::tempdir().unwrap();
640 let ctx =
641 videre_core::library::LibraryContext::new(temp.path(), &temp.path().join("cache"))
642 .unwrap();
643 let heic = concat!(
644 env!("CARGO_MANIFEST_DIR"),
645 "/../videre/tests/fixtures/content_key/tiny.heic"
646 );
647 let hash = format!("face-crop-publish-{}", std::process::id());
648
649 let thumb =
650 make_face_thumb(heic, [0.0, 0.0, 200.0, 200.0], 1, Some((&ctx.cache, &hash))).unwrap();
651 assert_eq!((thumb.width(), thumb.height()), (140, 140));
652
653 let cached = videre_core::thumb_cache::original_path_in(&ctx.cache, &hash);
654 assert!(cached.is_file(), "the render was not published: {cached:?}");
655 let opened = image::open(&cached).unwrap();
656 assert!(opened.width() > 0);
657 }
658}