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(
52 path: &str,
53 bbox: [f32; 4],
54 oriented: bool,
55 face_id: i64,
56) -> Option<image::DynamicImage> {
57 let ext = std::path::Path::new(path)
58 .extension()
59 .and_then(|e| e.to_str())
60 .unwrap_or("")
61 .to_lowercase();
62 if ext == "heic" {
63 let img = videre_core::heic::heic_via_quicklook(path, &format!("thumb{face_id}"), None)?;
66 return Some(crop_face_square(&img, bbox));
67 }
68 let timeout_path = path.to_string();
69 let decoded = match videre_core::io_timeout::run_with_timeout(
70 videre_core::io_timeout::DEFAULT_IO_TIMEOUT,
71 move || {
72 if oriented {
73 videre_core::image_decode::decode_oriented_file(std::path::Path::new(&timeout_path))
74 .map(|img| (img, None))
75 } else {
76 videre_core::image_decode::decode_raw_with_orientation(std::path::Path::new(
77 &timeout_path,
78 ))
79 .map(|(img, o)| (img, Some(o)))
80 }
81 },
82 ) {
83 Ok(Ok(img)) => img,
84 Ok(Err(e)) => {
85 eprintln!("warning: face thumbnail unavailable for {path}: {e}; skipping");
86 return None;
87 }
88 Err(_) => {
89 eprintln!(
90 "warning: timed out reading {path} for face thumbnail \
91 (file may be unreachable - is its drive connected?); skipping"
92 );
93 return None;
94 }
95 };
96 let (img, raw_canvas_orientation) = decoded;
97 let cropped = crop_face_square(&img, bbox);
98 match raw_canvas_orientation {
99 Some(orientation) => {
102 let mut cropped = image::DynamicImage::ImageRgba8(cropped.to_rgba8());
103 cropped.apply_orientation(orientation);
104 Some(cropped)
105 }
106 None => Some(cropped),
107 }
108}
109
110fn read_with_timeout(path: &str) -> std::io::Result<Vec<u8>> {
115 let owned = path.to_string();
116 videre_core::io_timeout::run_with_timeout(
117 videre_core::io_timeout::DEFAULT_IO_TIMEOUT,
118 move || std::fs::read(&owned),
119 )
120 .unwrap_or_else(|_| {
121 Err(std::io::Error::new(
122 std::io::ErrorKind::TimedOut,
123 format!("timed out reading {path} (file may be unreachable - is its drive connected?)"),
124 ))
125 })
126}
127
128pub fn mime_for_ext(ext: &str) -> &'static str {
129 match ext {
130 "jpg" | "jpeg" => "image/jpeg",
131 "png" => "image/png",
132 "gif" => "image/gif",
133 "webp" => "image/webp",
134 "bmp" => "image/bmp",
135 "tiff" => "image/tiff",
136 "mov" => "video/quicktime",
137 "mp4" => "video/mp4",
138 _ => "application/octet-stream",
139 }
140}
141
142pub struct FaceLookup {
150 pub bbox_json: String,
151 pub file_path: String,
152 pub hash: String,
153 pub oriented: bool,
157}
158
159pub fn face_lookup(conn: &Connection, face_id: i64) -> Result<FaceLookup> {
161 let (bbox_json, file_path, hash, oriented): (String, String, String, i64) = conn
162 .query_row(
163 "SELECT f.bbox, fh.path, f.hash, COALESCE(f.oriented, 0) FROM faces f \
164 JOIN file_hashes fh ON f.hash = fh.hash WHERE f.id = ?1 LIMIT 1",
165 [face_id],
166 |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)),
167 )
168 .map_err(|_| Error::NotFound)?;
169 Ok(FaceLookup {
170 bbox_json,
171 file_path,
172 hash,
173 oriented: oriented != 0,
174 })
175}
176
177pub fn face_bytes_from_lookup(
181 lookup: &FaceLookup,
182 face_id: i64,
183 cache: &videre_core::library::CachePaths,
184) -> Result<Vec<u8>> {
185 let parts: Vec<f32> = lookup
186 .bbox_json
187 .split(',')
188 .filter_map(|s| s.trim().parse().ok())
189 .collect();
190 if parts.len() != 4 {
191 return Err(Error::NotFound);
192 }
193 let bbox = [parts[0], parts[1], parts[0] + parts[2], parts[1] + parts[3]];
194
195 let cache_path = videre_core::thumb_cache::face_thumb_path_in(
198 cache,
199 &lookup.hash,
200 face_id,
201 bbox,
202 FACE_THUMB_SIZE,
203 );
204 if videre_core::thumb_cache::face_thumb_exists_in(
205 cache,
206 &lookup.hash,
207 face_id,
208 bbox,
209 FACE_THUMB_SIZE,
210 ) {
211 if let Ok(bytes) = read_with_timeout(&cache_path.to_string_lossy()) {
212 return Ok(bytes);
213 }
214 }
215
216 let thumb = make_face_thumb(&lookup.file_path, bbox, lookup.oriented, face_id)
217 .ok_or(Error::NotFound)?;
218 let mut buf = Vec::new();
219 thumb
220 .write_to(
221 &mut std::io::Cursor::new(&mut buf),
222 image::ImageFormat::Jpeg,
223 )
224 .map_err(|_| Error::NotFound)?;
225
226 if let Some(parent) = cache_path.parent() {
228 let _ = std::fs::create_dir_all(parent);
229 }
230 let tmp = cache_path.with_extension(format!("tmp{}", std::process::id()));
231 if std::fs::write(&tmp, &buf).is_ok() {
232 let _ = std::fs::rename(&tmp, &cache_path);
233 }
234 Ok(buf)
235}
236
237pub fn face_image_bytes(
248 conn: &Connection,
249 face_id: i64,
250 cache: &videre_core::library::CachePaths,
251) -> Result<Vec<u8>> {
252 let lookup = face_lookup(conn, face_id)?;
253 face_bytes_from_lookup(&lookup, face_id, cache)
254}
255
256pub struct OriginalLookup {
259 pub file_path: String,
260 pub hash: String,
261}
262
263pub fn original_lookup(conn: &Connection, face_id: i64) -> Result<OriginalLookup> {
265 let (file_path, hash): (String, String) = conn
266 .query_row(
267 "SELECT fh.path, f.hash FROM faces f \
268 JOIN file_hashes fh ON f.hash = fh.hash WHERE f.id = ?1 LIMIT 1",
269 [face_id],
270 |r| Ok((r.get(0)?, r.get(1)?)),
271 )
272 .map_err(|_| Error::NotFound)?;
273 Ok(OriginalLookup { file_path, hash })
274}
275
276pub fn original_bytes_from_lookup(
279 lookup: &OriginalLookup,
280 face_id: i64,
281 cache: &videre_core::library::CachePaths,
282) -> Result<(&'static str, Vec<u8>)> {
283 let file_path = &lookup.file_path;
284 let hash = &lookup.hash;
285 let ext = std::path::Path::new(file_path)
286 .extension()
287 .and_then(|e| e.to_str())
288 .unwrap_or("")
289 .to_lowercase();
290
291 if ext == "heic" {
292 if let Ok(bytes) = read_with_timeout(
293 &videre_core::thumb_cache::original_path_in(cache, hash).to_string_lossy(),
294 ) {
295 return Ok(("image/jpeg", bytes));
296 }
297 let img = videre_core::heic::heic_via_quicklook(file_path, &format!("orig{face_id}"), None)
300 .ok_or(Error::NotFound)?;
301 let mut buf = Vec::new();
302 img.write_to(
303 &mut std::io::Cursor::new(&mut buf),
304 image::ImageFormat::Jpeg,
305 )
306 .map_err(|_| Error::NotFound)?;
307 let final_path = videre_core::thumb_cache::original_path_in(cache, hash);
308 if let Some(parent) = final_path.parent() {
309 let _ = std::fs::create_dir_all(parent);
310 }
311 let tmp = final_path.with_extension(format!("tmp{}", std::process::id()));
312 if std::fs::write(&tmp, &buf).is_ok() {
313 let _ = std::fs::rename(&tmp, &final_path);
314 }
315 Ok(("image/jpeg", buf))
316 } else {
317 let bytes = read_with_timeout(file_path).map_err(|e| {
318 eprintln!("warning: original image unavailable for {file_path}: {e}; skipping");
319 Error::NotFound
320 })?;
321 Ok((mime_for_ext(&ext), bytes))
322 }
323}
324
325pub fn original_image_bytes(
335 conn: &Connection,
336 face_id: i64,
337 cache: &videre_core::library::CachePaths,
338) -> Result<(&'static str, Vec<u8>)> {
339 let lookup = original_lookup(conn, face_id)?;
340 original_bytes_from_lookup(&lookup, face_id, cache)
341}
342
343#[cfg(test)]
344mod tests {
345 use super::*;
346
347 #[test]
359 fn oriented_and_legacy_branches_render_the_same_upright_crop() {
360 let base = concat!(env!("CARGO_MANIFEST_DIR"), "/../videre/tests/fixtures");
361 let tagged = format!("{base}/ai-generated-couple_o6.jpg");
362
363 let raw_center = (600u32, 772u32);
368 let display_center = (1543 - raw_center.1, raw_center.0);
369 let raw_bbox = [
370 (raw_center.0 - 200) as f32,
371 (raw_center.1 - 200) as f32,
372 (raw_center.0 + 200) as f32,
373 (raw_center.1 + 200) as f32,
374 ];
375 let display_bbox = [
376 (display_center.0 - 200) as f32,
377 (display_center.1 - 200) as f32,
378 (display_center.0 + 200) as f32,
379 (display_center.1 + 200) as f32,
380 ];
381
382 let legacy = make_face_thumb(&tagged, raw_bbox, false, 1).unwrap();
383 let oriented = make_face_thumb(&tagged, display_bbox, true, 1).unwrap();
384 assert_eq!(
385 (legacy.width(), legacy.height()),
386 (140, 140),
387 "both branches produce 140x140 thumbnails"
388 );
389 let a: Vec<u8> = legacy.to_rgb8().pixels().map(|p| p.0[0]).collect();
390 let b: Vec<u8> = oriented.to_rgb8().pixels().map(|p| p.0[0]).collect();
391 let diff: u64 = a
392 .iter()
393 .zip(&b)
394 .map(|(x, y)| (*x as i32 - *y as i32).unsigned_abs() as u64)
395 .sum();
396 assert!(
397 diff < 1000,
398 "both branches must render the same upright face, sum |diff| = {diff}"
399 );
400 }
401
402 #[test]
406 fn the_oriented_flag_actually_changes_the_crop() {
407 let base = concat!(env!("CARGO_MANIFEST_DIR"), "/../videre/tests/fixtures");
408 let tagged = format!("{base}/ai-generated-couple_o6.jpg");
409 let raw_bbox = [400.0, 572.0, 800.0, 972.0];
412 let as_legacy = make_face_thumb(&tagged, raw_bbox, false, 1).unwrap();
413 let as_oriented = make_face_thumb(&tagged, raw_bbox, true, 1).unwrap();
414 let a: Vec<u8> = as_legacy.to_rgb8().pixels().map(|p| p.0[0]).collect();
415 let b: Vec<u8> = as_oriented.to_rgb8().pixels().map(|p| p.0[0]).collect();
416 assert_ne!(a, b, "the flag must select between two different canvases");
417 }
418
419 #[test]
420 fn a_face_crop_is_square_and_thumbnail_sized() {
421 let img = image::DynamicImage::ImageLuma8(image::GrayImage::new(200, 100));
422 let out = crop_face_square(&img, [80.0, 40.0, 120.0, 80.0]);
423 assert_eq!((out.width(), out.height()), (140, 140));
424 }
425
426 #[test]
430 fn a_face_crop_clamps_to_the_image_bounds() {
431 let img = image::DynamicImage::ImageLuma8(image::GrayImage::new(50, 50));
432 for bbox in [
433 [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], ] {
438 let out = crop_face_square(&img, bbox);
439 assert_eq!((out.width(), out.height()), (140, 140), "bbox {bbox:?}");
440 }
441 }
442
443 #[test]
446 fn a_degenerate_bbox_still_produces_a_thumbnail() {
447 let img = image::DynamicImage::ImageLuma8(image::GrayImage::new(50, 50));
448 let out = crop_face_square(&img, [25.0, 25.0, 25.0, 25.0]);
449 assert_eq!((out.width(), out.height()), (140, 140));
450 }
451
452 #[test]
453 fn mime_types_cover_gallery_image_and_video_extensions() {
454 for (ext, expected) in [
455 ("jpg", "image/jpeg"),
456 ("jpeg", "image/jpeg"),
457 ("png", "image/png"),
458 ("gif", "image/gif"),
459 ("webp", "image/webp"),
460 ("bmp", "image/bmp"),
461 ("tiff", "image/tiff"),
462 ("mov", "video/quicktime"),
463 ("mp4", "video/mp4"),
464 ("unknown", "application/octet-stream"),
465 ] {
466 assert_eq!(mime_for_ext(ext), expected, "extension {ext}");
467 }
468 }
469
470 #[test]
471 fn face_thumbnail_cache_is_returned_without_reading_the_source() {
472 let temp = tempfile::tempdir().unwrap();
473 let ctx =
474 videre_core::library::LibraryContext::new(temp.path(), &temp.path().join("cache"))
475 .unwrap();
476 let lookup = FaceLookup {
477 bbox_json: "10,20,30,40".to_string(),
478 file_path: temp.path().join("missing.jpg").to_string_lossy().into(),
479 hash: "face-cache-hash".to_string(),
480 oriented: true,
481 };
482 let bbox = [10.0, 20.0, 40.0, 60.0];
483 let cache_path = videre_core::thumb_cache::face_thumb_path_in(
484 &ctx.cache,
485 &lookup.hash,
486 42,
487 bbox,
488 FACE_THUMB_SIZE,
489 );
490 std::fs::create_dir_all(cache_path.parent().unwrap()).unwrap();
491 std::fs::write(&cache_path, b"cached thumbnail").unwrap();
492
493 assert_eq!(
494 face_bytes_from_lookup(&lookup, 42, &ctx.cache).unwrap(),
495 b"cached thumbnail"
496 );
497 }
498
499 #[test]
500 fn malformed_face_bbox_is_not_found_before_image_io() {
501 let temp = tempfile::tempdir().unwrap();
502 let ctx =
503 videre_core::library::LibraryContext::new(temp.path(), &temp.path().join("cache"))
504 .unwrap();
505 for bbox_json in ["", "1,2,3", "1,2,three,4", "1,2,3,4,5"] {
506 let lookup = FaceLookup {
507 bbox_json: bbox_json.to_string(),
508 file_path: temp.path().join("missing.jpg").to_string_lossy().into(),
509 hash: "bad-bbox-hash".to_string(),
510 oriented: false,
511 };
512 assert!(matches!(
513 face_bytes_from_lookup(&lookup, 1, &ctx.cache),
514 Err(Error::NotFound)
515 ));
516 }
517 }
518
519 #[test]
520 fn original_bytes_preserve_plain_file_contents_and_choose_mime() {
521 let temp = tempfile::tempdir().unwrap();
522 let ctx =
523 videre_core::library::LibraryContext::new(temp.path(), &temp.path().join("cache"))
524 .unwrap();
525 let source = temp.path().join("original.JpEg");
526 std::fs::write(&source, b"original image bytes").unwrap();
527 let lookup = OriginalLookup {
528 file_path: source.to_string_lossy().into(),
529 hash: "original-hash".to_string(),
530 };
531
532 let (mime, bytes) = original_bytes_from_lookup(&lookup, 1, &ctx.cache).unwrap();
533 assert_eq!(mime, "image/jpeg");
534 assert_eq!(bytes, b"original image bytes");
535 }
536
537 #[test]
538 fn missing_original_file_is_not_found() {
539 let temp = tempfile::tempdir().unwrap();
540 let ctx =
541 videre_core::library::LibraryContext::new(temp.path(), &temp.path().join("cache"))
542 .unwrap();
543 let lookup = OriginalLookup {
544 file_path: temp.path().join("missing.jpg").to_string_lossy().into(),
545 hash: "missing-original-hash".to_string(),
546 };
547 assert!(matches!(
548 original_bytes_from_lookup(&lookup, 1, &ctx.cache),
549 Err(Error::NotFound)
550 ));
551 }
552
553 #[test]
554 fn unknown_face_id_is_not_found() {
555 let conn = Connection::open_in_memory().unwrap();
556 videre_core::face_db::create_faces_table(&conn).unwrap();
557 conn.execute_batch("CREATE TABLE file_hashes (hash TEXT PRIMARY KEY, path TEXT);")
558 .unwrap();
559 let temp = tempfile::tempdir().unwrap();
560 let ctx =
561 videre_core::library::LibraryContext::new(temp.path(), &temp.path().join("cache"))
562 .unwrap();
563 assert!(matches!(
564 face_image_bytes(&conn, 999, &ctx.cache),
565 Err(Error::NotFound)
566 ));
567 assert!(matches!(
568 original_image_bytes(&conn, 999, &ctx.cache),
569 Err(Error::NotFound)
570 ));
571 }
572
573 #[test]
574 fn face_lookup_unknown_id_is_not_found() {
575 let conn = Connection::open_in_memory().unwrap();
576 videre_core::face_db::create_faces_table(&conn).unwrap();
577 conn.execute_batch("CREATE TABLE file_hashes (hash TEXT PRIMARY KEY, path TEXT);")
578 .unwrap();
579 assert!(matches!(face_lookup(&conn, 999), Err(Error::NotFound)));
580 }
581
582 #[test]
583 fn original_lookup_unknown_id_is_not_found() {
584 let conn = Connection::open_in_memory().unwrap();
585 videre_core::face_db::create_faces_table(&conn).unwrap();
586 conn.execute_batch("CREATE TABLE file_hashes (hash TEXT PRIMARY KEY, path TEXT);")
587 .unwrap();
588 assert!(matches!(original_lookup(&conn, 999), Err(Error::NotFound)));
589 }
590
591 #[test]
592 fn face_lookup_does_not_touch_the_filesystem() {
593 let conn = Connection::open_in_memory().unwrap();
597 videre_core::face_db::create_faces_table(&conn).unwrap();
598 conn.execute_batch("CREATE TABLE file_hashes (hash TEXT PRIMARY KEY, path TEXT);")
599 .unwrap();
600 conn.execute(
601 "INSERT INTO file_hashes (hash, path) VALUES ('h1', '/no/such/file.jpg')",
602 [],
603 )
604 .unwrap();
605 conn.execute(
606 "INSERT INTO faces (id, hash, bbox, embedding) VALUES (1, 'h1', '0,0,10,10', X'00')",
607 [],
608 )
609 .unwrap();
610 let lookup = face_lookup(&conn, 1).unwrap();
611 assert_eq!(lookup.file_path, "/no/such/file.jpg");
612 assert_eq!(lookup.hash, "h1");
613 assert_eq!(lookup.bbox_json, "0,0,10,10");
614 }
615}