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