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 =
300 videre_core::heic::heic_via_quicklook(&file_path, &format!("orig{face_id}"), None)
301 .ok_or(Error::NotFound)?;
302 let mut buf = Vec::new();
303 img.write_to(
304 &mut std::io::Cursor::new(&mut buf),
305 image::ImageFormat::Jpeg,
306 )
307 .map_err(|_| Error::NotFound)?;
308 let final_path = videre_core::thumb_cache::original_path_in(cache, hash);
309 if let Some(parent) = final_path.parent() {
310 let _ = std::fs::create_dir_all(parent);
311 }
312 let tmp = final_path.with_extension(format!("tmp{}", std::process::id()));
313 if std::fs::write(&tmp, &buf).is_ok() {
314 let _ = std::fs::rename(&tmp, &final_path);
315 }
316 Ok(("image/jpeg", buf))
317 } else {
318 let bytes = read_with_timeout(file_path).map_err(|e| {
319 eprintln!("warning: original image unavailable for {file_path}: {e}; skipping");
320 Error::NotFound
321 })?;
322 Ok((mime_for_ext(&ext), bytes))
323 }
324}
325
326pub fn original_image_bytes(
336 conn: &Connection,
337 face_id: i64,
338 cache: &videre_core::library::CachePaths,
339) -> Result<(&'static str, Vec<u8>)> {
340 let lookup = original_lookup(conn, face_id)?;
341 original_bytes_from_lookup(&lookup, face_id, cache)
342}
343
344#[cfg(test)]
345mod tests {
346 use super::*;
347
348 #[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 unknown_face_id_is_not_found() {
454 let conn = Connection::open_in_memory().unwrap();
455 videre_core::face_db::create_faces_table(&conn).unwrap();
456 conn.execute_batch("CREATE TABLE file_hashes (hash TEXT PRIMARY KEY, path TEXT);")
457 .unwrap();
458 let temp = tempfile::tempdir().unwrap();
459 let ctx =
460 videre_core::library::LibraryContext::new(temp.path(), &temp.path().join("cache"))
461 .unwrap();
462 assert!(matches!(
463 face_image_bytes(&conn, 999, &ctx.cache),
464 Err(Error::NotFound)
465 ));
466 assert!(matches!(
467 original_image_bytes(&conn, 999, &ctx.cache),
468 Err(Error::NotFound)
469 ));
470 }
471
472 #[test]
473 fn face_lookup_unknown_id_is_not_found() {
474 let conn = Connection::open_in_memory().unwrap();
475 videre_core::face_db::create_faces_table(&conn).unwrap();
476 conn.execute_batch("CREATE TABLE file_hashes (hash TEXT PRIMARY KEY, path TEXT);")
477 .unwrap();
478 assert!(matches!(face_lookup(&conn, 999), Err(Error::NotFound)));
479 }
480
481 #[test]
482 fn original_lookup_unknown_id_is_not_found() {
483 let conn = Connection::open_in_memory().unwrap();
484 videre_core::face_db::create_faces_table(&conn).unwrap();
485 conn.execute_batch("CREATE TABLE file_hashes (hash TEXT PRIMARY KEY, path TEXT);")
486 .unwrap();
487 assert!(matches!(original_lookup(&conn, 999), Err(Error::NotFound)));
488 }
489
490 #[test]
491 fn face_lookup_does_not_touch_the_filesystem() {
492 let conn = Connection::open_in_memory().unwrap();
496 videre_core::face_db::create_faces_table(&conn).unwrap();
497 conn.execute_batch("CREATE TABLE file_hashes (hash TEXT PRIMARY KEY, path TEXT);")
498 .unwrap();
499 conn.execute(
500 "INSERT INTO file_hashes (hash, path) VALUES ('h1', '/no/such/file.jpg')",
501 [],
502 )
503 .unwrap();
504 conn.execute(
505 "INSERT INTO faces (id, hash, bbox, embedding) VALUES (1, 'h1', '0,0,10,10', X'00')",
506 [],
507 )
508 .unwrap();
509 let lookup = face_lookup(&conn, 1).unwrap();
510 assert_eq!(lookup.file_path, "/no/such/file.jpg");
511 assert_eq!(lookup.hash, "h1");
512 assert_eq!(lookup.bbox_json, "0,0,10,10");
513 }
514}