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(path: &str, bbox: [f32; 4], face_id: i64) -> Option<image::DynamicImage> {
45 let ext = std::path::Path::new(path)
46 .extension()
47 .and_then(|e| e.to_str())
48 .unwrap_or("")
49 .to_lowercase();
50 if ext == "heic" {
51 let img = videre_core::heic::decode_via_quicklook(
54 std::path::Path::new(path),
55 &format!("thumb{face_id}"),
56 None,
57 )
58 .inspect_err(videre_core::heic::warn_if_timeout)
59 .ok()?;
60 return Some(crop_face_square(&img, bbox));
61 }
62 let timeout_path = path.to_string();
63 let decoded = match videre_core::io_timeout::run_with_timeout(
64 videre_core::io_timeout::DEFAULT_IO_TIMEOUT,
65 move || {
66 videre_core::image_decode::decode_oriented_file(std::path::Path::new(&timeout_path))
67 },
68 ) {
69 Ok(Ok(img)) => img,
70 Ok(Err(e)) => {
71 tracing::warn!("face thumbnail unavailable for {path}: {e}; skipping");
72 return None;
73 }
74 Err(_) => {
75 tracing::warn!(
76 "timed out reading {path} for face thumbnail \
77 (file may be unreachable - is its drive connected?); skipping"
78 );
79 return None;
80 }
81 };
82 Some(crop_face_square(&decoded, bbox))
83}
84
85fn read_with_timeout(path: &str) -> std::io::Result<Vec<u8>> {
90 let owned = path.to_string();
91 videre_core::io_timeout::run_with_timeout(
92 videre_core::io_timeout::DEFAULT_IO_TIMEOUT,
93 move || std::fs::read(&owned),
94 )
95 .unwrap_or_else(|_| {
96 Err(std::io::Error::new(
97 std::io::ErrorKind::TimedOut,
98 format!("timed out reading {path} (file may be unreachable - is its drive connected?)"),
99 ))
100 })
101}
102
103pub fn mime_for_ext(ext: &str) -> &'static str {
104 match ext {
105 "jpg" | "jpeg" => "image/jpeg",
106 "png" => "image/png",
107 "gif" => "image/gif",
108 "webp" => "image/webp",
109 "bmp" => "image/bmp",
110 "tiff" => "image/tiff",
111 "mov" => "video/quicktime",
112 "mp4" => "video/mp4",
113 _ => "application/octet-stream",
114 }
115}
116
117pub struct FaceLookup {
125 pub bbox_json: String,
126 pub file_path: String,
127 pub hash: String,
128}
129
130pub fn face_lookup(conn: &Connection, face_id: i64) -> Result<FaceLookup> {
132 let (bbox_json, file_path, hash): (String, String, String) = conn
133 .query_row(
134 "SELECT f.bbox, fh.path, f.hash FROM faces f \
135 JOIN file_hashes fh ON f.hash = fh.hash WHERE f.id = ?1 LIMIT 1",
136 [face_id],
137 |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
138 )
139 .map_err(|_| Error::NotFound)?;
140 Ok(FaceLookup {
141 bbox_json,
142 file_path,
143 hash,
144 })
145}
146
147pub fn face_bytes_from_lookup(
151 lookup: &FaceLookup,
152 face_id: i64,
153 cache: &videre_core::library::CachePaths,
154) -> Result<Vec<u8>> {
155 let parts: Vec<f32> = lookup
156 .bbox_json
157 .split(',')
158 .filter_map(|s| s.trim().parse().ok())
159 .collect();
160 if parts.len() != 4 {
161 return Err(Error::NotFound);
162 }
163 let bbox = [parts[0], parts[1], parts[0] + parts[2], parts[1] + parts[3]];
164
165 let cache_path = videre_core::thumb_cache::face_thumb_path_in(
168 cache,
169 &lookup.hash,
170 face_id,
171 bbox,
172 FACE_THUMB_SIZE,
173 );
174 if videre_core::thumb_cache::face_thumb_exists_in(
175 cache,
176 &lookup.hash,
177 face_id,
178 bbox,
179 FACE_THUMB_SIZE,
180 ) {
181 if let Ok(bytes) = read_with_timeout(&cache_path.to_string_lossy()) {
182 return Ok(bytes);
183 }
184 }
185
186 let thumb = make_face_thumb(&lookup.file_path, bbox, face_id).ok_or(Error::NotFound)?;
187 let mut buf = Vec::new();
188 thumb
189 .write_to(
190 &mut std::io::Cursor::new(&mut buf),
191 image::ImageFormat::Jpeg,
192 )
193 .map_err(|_| Error::NotFound)?;
194
195 if let Some(parent) = cache_path.parent() {
197 let _ = std::fs::create_dir_all(parent);
198 }
199 let tmp = cache_path.with_extension(format!("tmp{}", std::process::id()));
200 if std::fs::write(&tmp, &buf).is_ok() {
201 let _ = std::fs::rename(&tmp, &cache_path);
202 }
203 Ok(buf)
204}
205
206pub fn face_image_bytes(
217 conn: &Connection,
218 face_id: i64,
219 cache: &videre_core::library::CachePaths,
220) -> Result<Vec<u8>> {
221 let lookup = face_lookup(conn, face_id)?;
222 face_bytes_from_lookup(&lookup, face_id, cache)
223}
224
225pub struct OriginalLookup {
228 pub file_path: String,
229 pub hash: String,
230}
231
232pub fn original_lookup(conn: &Connection, face_id: i64) -> Result<OriginalLookup> {
234 let (file_path, hash): (String, String) = conn
235 .query_row(
236 "SELECT fh.path, f.hash FROM faces f \
237 JOIN file_hashes fh ON f.hash = fh.hash WHERE f.id = ?1 LIMIT 1",
238 [face_id],
239 |r| Ok((r.get(0)?, r.get(1)?)),
240 )
241 .map_err(|_| Error::NotFound)?;
242 Ok(OriginalLookup { file_path, hash })
243}
244
245pub fn original_bytes_from_lookup(
248 lookup: &OriginalLookup,
249 face_id: i64,
250 cache: &videre_core::library::CachePaths,
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) = read_with_timeout(
262 &videre_core::thumb_cache::original_path_in(cache, hash).to_string_lossy(),
263 ) {
264 return Ok(("image/jpeg", bytes));
265 }
266 let img = videre_core::heic::decode_via_quicklook(
269 std::path::Path::new(file_path),
270 &format!("orig{face_id}"),
271 None,
272 )
273 .inspect_err(videre_core::heic::warn_if_timeout)
274 .map_err(|_| Error::NotFound)?;
275 let mut buf = Vec::new();
276 img.write_to(
277 &mut std::io::Cursor::new(&mut buf),
278 image::ImageFormat::Jpeg,
279 )
280 .map_err(|_| Error::NotFound)?;
281 let final_path = videre_core::thumb_cache::original_path_in(cache, hash);
282 if let Some(parent) = final_path.parent() {
283 let _ = std::fs::create_dir_all(parent);
284 }
285 let tmp = final_path.with_extension(format!("tmp{}", std::process::id()));
286 if std::fs::write(&tmp, &buf).is_ok() {
287 let _ = std::fs::rename(&tmp, &final_path);
288 }
289 Ok(("image/jpeg", buf))
290 } else {
291 let bytes = read_with_timeout(file_path).map_err(|e| {
292 tracing::warn!("original image unavailable for {file_path}: {e}; skipping");
293 Error::NotFound
294 })?;
295 Ok((mime_for_ext(&ext), bytes))
296 }
297}
298
299pub fn original_image_bytes(
309 conn: &Connection,
310 face_id: i64,
311 cache: &videre_core::library::CachePaths,
312) -> Result<(&'static str, Vec<u8>)> {
313 let lookup = original_lookup(conn, face_id)?;
314 original_bytes_from_lookup(&lookup, face_id, cache)
315}
316
317#[cfg(test)]
318mod tests {
319 use super::*;
320
321 #[test]
330 fn a_face_thumbnail_is_cropped_from_the_upright_image() {
331 let base = concat!(env!("CARGO_MANIFEST_DIR"), "/../videre/tests/fixtures");
332 let tagged = format!("{base}/ai-generated-couple_o6.jpg");
333
334 let raw_center = (600u32, 772u32);
339 let display_center = (1543 - raw_center.1, raw_center.0);
340 let raw_bbox = [
341 (raw_center.0 - 200) as f32,
342 (raw_center.1 - 200) as f32,
343 (raw_center.0 + 200) as f32,
344 (raw_center.1 + 200) as f32,
345 ];
346 let display_bbox = [
347 (display_center.0 - 200) as f32,
348 (display_center.1 - 200) as f32,
349 (display_center.0 + 200) as f32,
350 (display_center.1 + 200) as f32,
351 ];
352
353 let (raw, orientation) =
354 videre_core::image_decode::decode_raw_with_orientation(std::path::Path::new(&tagged))
355 .unwrap();
356 let mut expected =
357 image::DynamicImage::ImageRgba8(crop_face_square(&raw, raw_bbox).to_rgba8());
358 expected.apply_orientation(orientation);
359 let thumb = make_face_thumb(&tagged, display_bbox, 1).unwrap();
360 assert_eq!((thumb.width(), thumb.height()), (140, 140));
361 let a: Vec<u8> = expected.to_rgb8().pixels().map(|p| p.0[0]).collect();
362 let b: Vec<u8> = thumb.to_rgb8().pixels().map(|p| p.0[0]).collect();
363 let diff: u64 = a
364 .iter()
365 .zip(&b)
366 .map(|(x, y)| (*x as i32 - *y as i32).unsigned_abs() as u64)
367 .sum();
368 assert!(
369 diff < 1000,
370 "the thumbnail must show the upright face, sum |diff| = {diff}"
371 );
372 }
373
374 #[test]
375 fn a_face_crop_is_square_and_thumbnail_sized() {
376 let img = image::DynamicImage::ImageLuma8(image::GrayImage::new(200, 100));
377 let out = crop_face_square(&img, [80.0, 40.0, 120.0, 80.0]);
378 assert_eq!((out.width(), out.height()), (140, 140));
379 }
380
381 #[test]
385 fn a_face_crop_clamps_to_the_image_bounds() {
386 let img = image::DynamicImage::ImageLuma8(image::GrayImage::new(50, 50));
387 for bbox in [
388 [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], ] {
393 let out = crop_face_square(&img, bbox);
394 assert_eq!((out.width(), out.height()), (140, 140), "bbox {bbox:?}");
395 }
396 }
397
398 #[test]
401 fn a_degenerate_bbox_still_produces_a_thumbnail() {
402 let img = image::DynamicImage::ImageLuma8(image::GrayImage::new(50, 50));
403 let out = crop_face_square(&img, [25.0, 25.0, 25.0, 25.0]);
404 assert_eq!((out.width(), out.height()), (140, 140));
405 }
406
407 #[test]
408 fn mime_types_cover_gallery_image_and_video_extensions() {
409 for (ext, expected) in [
410 ("jpg", "image/jpeg"),
411 ("jpeg", "image/jpeg"),
412 ("png", "image/png"),
413 ("gif", "image/gif"),
414 ("webp", "image/webp"),
415 ("bmp", "image/bmp"),
416 ("tiff", "image/tiff"),
417 ("mov", "video/quicktime"),
418 ("mp4", "video/mp4"),
419 ("unknown", "application/octet-stream"),
420 ] {
421 assert_eq!(mime_for_ext(ext), expected, "extension {ext}");
422 }
423 }
424
425 #[test]
426 fn face_thumbnail_cache_is_returned_without_reading_the_source() {
427 let temp = tempfile::tempdir().unwrap();
428 let ctx =
429 videre_core::library::LibraryContext::new(temp.path(), &temp.path().join("cache"))
430 .unwrap();
431 let lookup = FaceLookup {
432 bbox_json: "10,20,30,40".to_string(),
433 file_path: temp.path().join("missing.jpg").to_string_lossy().into(),
434 hash: "face-cache-hash".to_string(),
435 };
436 let bbox = [10.0, 20.0, 40.0, 60.0];
437 let cache_path = videre_core::thumb_cache::face_thumb_path_in(
438 &ctx.cache,
439 &lookup.hash,
440 42,
441 bbox,
442 FACE_THUMB_SIZE,
443 );
444 std::fs::create_dir_all(cache_path.parent().unwrap()).unwrap();
445 std::fs::write(&cache_path, b"cached thumbnail").unwrap();
446
447 assert_eq!(
448 face_bytes_from_lookup(&lookup, 42, &ctx.cache).unwrap(),
449 b"cached thumbnail"
450 );
451 }
452
453 #[test]
454 fn malformed_face_bbox_is_not_found_before_image_io() {
455 let temp = tempfile::tempdir().unwrap();
456 let ctx =
457 videre_core::library::LibraryContext::new(temp.path(), &temp.path().join("cache"))
458 .unwrap();
459 for bbox_json in ["", "1,2,3", "1,2,three,4", "1,2,3,4,5"] {
460 let lookup = FaceLookup {
461 bbox_json: bbox_json.to_string(),
462 file_path: temp.path().join("missing.jpg").to_string_lossy().into(),
463 hash: "bad-bbox-hash".to_string(),
464 };
465 assert!(matches!(
466 face_bytes_from_lookup(&lookup, 1, &ctx.cache),
467 Err(Error::NotFound)
468 ));
469 }
470 }
471
472 #[test]
473 fn original_bytes_preserve_plain_file_contents_and_choose_mime() {
474 let temp = tempfile::tempdir().unwrap();
475 let ctx =
476 videre_core::library::LibraryContext::new(temp.path(), &temp.path().join("cache"))
477 .unwrap();
478 let source = temp.path().join("original.JpEg");
479 std::fs::write(&source, b"original image bytes").unwrap();
480 let lookup = OriginalLookup {
481 file_path: source.to_string_lossy().into(),
482 hash: "original-hash".to_string(),
483 };
484
485 let (mime, bytes) = original_bytes_from_lookup(&lookup, 1, &ctx.cache).unwrap();
486 assert_eq!(mime, "image/jpeg");
487 assert_eq!(bytes, b"original image bytes");
488 }
489
490 #[test]
491 fn missing_original_file_is_not_found() {
492 let temp = tempfile::tempdir().unwrap();
493 let ctx =
494 videre_core::library::LibraryContext::new(temp.path(), &temp.path().join("cache"))
495 .unwrap();
496 let lookup = OriginalLookup {
497 file_path: temp.path().join("missing.jpg").to_string_lossy().into(),
498 hash: "missing-original-hash".to_string(),
499 };
500 assert!(matches!(
501 original_bytes_from_lookup(&lookup, 1, &ctx.cache),
502 Err(Error::NotFound)
503 ));
504 }
505
506 #[test]
507 fn unknown_face_id_is_not_found() {
508 let conn = Connection::open_in_memory().unwrap();
509 videre_core::face_db::create_faces_table(&conn).unwrap();
510 conn.execute_batch("CREATE TABLE file_hashes (hash TEXT PRIMARY KEY, path TEXT);")
511 .unwrap();
512 let temp = tempfile::tempdir().unwrap();
513 let ctx =
514 videre_core::library::LibraryContext::new(temp.path(), &temp.path().join("cache"))
515 .unwrap();
516 assert!(matches!(
517 face_image_bytes(&conn, 999, &ctx.cache),
518 Err(Error::NotFound)
519 ));
520 assert!(matches!(
521 original_image_bytes(&conn, 999, &ctx.cache),
522 Err(Error::NotFound)
523 ));
524 }
525
526 #[test]
527 fn face_lookup_unknown_id_is_not_found() {
528 let conn = Connection::open_in_memory().unwrap();
529 videre_core::face_db::create_faces_table(&conn).unwrap();
530 conn.execute_batch("CREATE TABLE file_hashes (hash TEXT PRIMARY KEY, path TEXT);")
531 .unwrap();
532 assert!(matches!(face_lookup(&conn, 999), Err(Error::NotFound)));
533 }
534
535 #[test]
536 fn original_lookup_unknown_id_is_not_found() {
537 let conn = Connection::open_in_memory().unwrap();
538 videre_core::face_db::create_faces_table(&conn).unwrap();
539 conn.execute_batch("CREATE TABLE file_hashes (hash TEXT PRIMARY KEY, path TEXT);")
540 .unwrap();
541 assert!(matches!(original_lookup(&conn, 999), Err(Error::NotFound)));
542 }
543
544 #[test]
545 fn face_lookup_does_not_touch_the_filesystem() {
546 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 conn.execute(
554 "INSERT INTO file_hashes (hash, path) VALUES ('h1', '/no/such/file.jpg')",
555 [],
556 )
557 .unwrap();
558 conn.execute(
559 "INSERT INTO faces (id, hash, bbox, embedding) VALUES (1, 'h1', '0,0,10,10', X'00')",
560 [],
561 )
562 .unwrap();
563 let lookup = face_lookup(&conn, 1).unwrap();
564 assert_eq!(lookup.file_path, "/no/such/file.jpg");
565 assert_eq!(lookup.hash, "h1");
566 assert_eq!(lookup.bbox_json, "0,0,10,10");
567 }
568}