1use crate::error::{Error, Result};
6use rusqlite::Connection;
7use std::io::BufReader;
8
9const FACE_THUMB_SIZE: u32 = 140;
10
11fn read_exif_orientation(path: &str) -> u16 {
12 let Ok(f) = std::fs::File::open(path) else { return 1 };
13 let Ok(exif_data) = exif::Reader::new().read_from_container(&mut BufReader::new(f)) else {
14 return 1;
15 };
16 exif_data
17 .get_field(exif::Tag::Orientation, exif::In::PRIMARY)
18 .and_then(|field| {
19 if let exif::Value::Short(ref v) = field.value {
20 v.first().copied()
21 } else {
22 None
23 }
24 })
25 .unwrap_or(1)
26}
27
28fn apply_exif_orientation(img: image::DynamicImage, path: &str) -> image::DynamicImage {
30 let ext = std::path::Path::new(path)
31 .extension()
32 .and_then(|e| e.to_str())
33 .unwrap_or("")
34 .to_lowercase();
35 if !matches!(ext.as_str(), "jpg" | "jpeg" | "tiff" | "dng") {
36 return img;
37 }
38 match read_exif_orientation(path) {
39 2 => img.fliph(),
40 3 => img.rotate180(),
41 4 => img.flipv(),
42 5 => img.rotate90().fliph(),
43 6 => img.rotate90(),
44 7 => img.rotate270().fliph(),
45 8 => img.rotate270(),
46 _ => img,
47 }
48}
49
50fn crop_face_square(img: &image::DynamicImage, bbox: [f32; 4]) -> image::DynamicImage {
52 let w = img.width() as f32;
53 let h = img.height() as f32;
54 let bw = bbox[2] - bbox[0];
55 let bh = bbox[3] - bbox[1];
56 let pad = (bw.max(bh) * 0.25).max(4.0);
57 let half = bw.max(bh) * 0.5 + pad;
58 let cx = (bbox[0] + bbox[2]) * 0.5;
59 let cy = (bbox[1] + bbox[3]) * 0.5;
60 let x1 = (cx - half).max(0.0) as u32;
61 let y1 = (cy - half).max(0.0) as u32;
62 let x2 = (cx + half).min(w) as u32;
63 let y2 = (cy + half).min(h) as u32;
64 let side = (x2 - x1).min(y2 - y1).max(1);
65 img.crop_imm(x1, y1, side, side)
66 .resize_exact(140, 140, image::imageops::FilterType::Triangle)
67}
68
69pub fn make_face_thumb(path: &str, bbox: [f32; 4], face_id: i64) -> Option<image::DynamicImage> {
86 let ext = std::path::Path::new(path)
87 .extension()
88 .and_then(|e| e.to_str())
89 .unwrap_or("")
90 .to_lowercase();
91 if ext == "heic" {
92 let img = videre_core::heic::heic_via_quicklook(path, &format!("thumb{face_id}"), None)?;
95 Some(crop_face_square(&img, bbox))
96 } else {
97 let timeout_path = path.to_string();
99 let img = match videre_core::io_timeout::run_with_timeout(
100 videre_core::io_timeout::DEFAULT_IO_TIMEOUT,
101 move || image::open(&timeout_path),
102 ) {
103 Ok(Ok(img)) => img,
104 Ok(Err(e)) => {
105 eprintln!("warning: face thumbnail unavailable for {path}: {e}; skipping");
106 return None;
107 }
108 Err(_) => {
109 eprintln!(
110 "warning: timed out reading {path} for face thumbnail \
111 (file may be unreachable - is its drive connected?); skipping"
112 );
113 return None;
114 }
115 };
116 let cropped = crop_face_square(&img, bbox);
117 Some(apply_exif_orientation(cropped, path))
118 }
119}
120
121fn read_with_timeout(path: &str) -> std::io::Result<Vec<u8>> {
126 let owned = path.to_string();
127 videre_core::io_timeout::run_with_timeout(videre_core::io_timeout::DEFAULT_IO_TIMEOUT, move || {
128 std::fs::read(&owned)
129 })
130 .unwrap_or_else(|_| {
131 Err(std::io::Error::new(
132 std::io::ErrorKind::TimedOut,
133 format!("timed out reading {path} (file may be unreachable - is its drive connected?)"),
134 ))
135 })
136}
137
138pub fn mime_for_ext(ext: &str) -> &'static str {
139 match ext {
140 "jpg" | "jpeg" => "image/jpeg",
141 "png" => "image/png",
142 "gif" => "image/gif",
143 "webp" => "image/webp",
144 "bmp" => "image/bmp",
145 "tiff" => "image/tiff",
146 "mov" => "video/quicktime",
147 "mp4" => "video/mp4",
148 _ => "application/octet-stream",
149 }
150}
151
152pub struct FaceLookup {
160 pub bbox_json: String,
161 pub file_path: String,
162 pub hash: String,
163}
164
165pub fn face_lookup(conn: &Connection, face_id: i64) -> Result<FaceLookup> {
167 let (bbox_json, file_path, hash): (String, String, String) = conn
168 .query_row(
169 "SELECT f.bbox, fh.path, f.hash FROM faces f \
170 JOIN file_hashes fh ON f.hash = fh.hash WHERE f.id = ?1 LIMIT 1",
171 [face_id],
172 |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
173 )
174 .map_err(|_| Error::NotFound)?;
175 Ok(FaceLookup { bbox_json, file_path, hash })
176}
177
178pub fn face_bytes_from_lookup(lookup: &FaceLookup, face_id: i64) -> Result<Vec<u8>> {
182 let cache = videre_core::thumb_cache::face_thumb_path(&lookup.hash, face_id, FACE_THUMB_SIZE);
183 if videre_core::thumb_cache::face_thumb_exists(&lookup.hash, face_id, FACE_THUMB_SIZE) {
184 if let Ok(bytes) = read_with_timeout(&cache.to_string_lossy()) {
185 return Ok(bytes);
186 }
187 }
188
189 let parts: Vec<f32> = lookup.bbox_json.split(',').filter_map(|s| s.trim().parse().ok()).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 let thumb = make_face_thumb(&lookup.file_path, bbox, face_id).ok_or(Error::NotFound)?;
195 let mut buf = Vec::new();
196 thumb
197 .write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Jpeg)
198 .map_err(|_| Error::NotFound)?;
199
200 if let Some(parent) = cache.parent() {
202 let _ = std::fs::create_dir_all(parent);
203 }
204 let tmp = cache.with_extension(format!("tmp{}", std::process::id()));
205 if std::fs::write(&tmp, &buf).is_ok() {
206 let _ = std::fs::rename(&tmp, &cache);
207 }
208 Ok(buf)
209}
210
211pub fn face_image_bytes(conn: &Connection, face_id: i64) -> Result<Vec<u8>> {
222 let lookup = face_lookup(conn, face_id)?;
223 face_bytes_from_lookup(&lookup, face_id)
224}
225
226pub struct OriginalLookup {
229 pub file_path: String,
230 pub hash: String,
231}
232
233pub fn original_lookup(conn: &Connection, face_id: i64) -> Result<OriginalLookup> {
235 let (file_path, hash): (String, String) = conn
236 .query_row(
237 "SELECT fh.path, f.hash FROM faces f \
238 JOIN file_hashes fh ON f.hash = fh.hash WHERE f.id = ?1 LIMIT 1",
239 [face_id],
240 |r| Ok((r.get(0)?, r.get(1)?)),
241 )
242 .map_err(|_| Error::NotFound)?;
243 Ok(OriginalLookup { file_path, hash })
244}
245
246pub fn original_bytes_from_lookup(
249 lookup: &OriginalLookup,
250 face_id: i64,
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) =
262 read_with_timeout(&videre_core::thumb_cache::original_path(&hash).to_string_lossy())
263 {
264 return Ok(("image/jpeg", bytes));
265 }
266 let img = videre_core::heic::heic_via_quicklook(&file_path, &format!("orig{face_id}"), None)
269 .ok_or(Error::NotFound)?;
270 let mut buf = Vec::new();
271 img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Jpeg)
272 .map_err(|_| Error::NotFound)?;
273 let final_path = videre_core::thumb_cache::original_path(&hash);
274 if let Some(parent) = final_path.parent() {
275 let _ = std::fs::create_dir_all(parent);
276 }
277 let tmp = final_path.with_extension(format!("tmp{}", std::process::id()));
278 if std::fs::write(&tmp, &buf).is_ok() {
279 let _ = std::fs::rename(&tmp, &final_path);
280 }
281 Ok(("image/jpeg", buf))
282 } else {
283 let bytes = read_with_timeout(file_path).map_err(|e| {
284 eprintln!("warning: original image unavailable for {file_path}: {e}; skipping");
285 Error::NotFound
286 })?;
287 Ok((mime_for_ext(&ext), bytes))
288 }
289}
290
291pub fn original_image_bytes(conn: &Connection, face_id: i64) -> Result<(&'static str, Vec<u8>)> {
301 let lookup = original_lookup(conn, face_id)?;
302 original_bytes_from_lookup(&lookup, face_id)
303}
304
305#[cfg(test)]
306mod tests {
307 use super::*;
308
309 #[test]
310 fn unknown_face_id_is_not_found() {
311 let conn = Connection::open_in_memory().unwrap();
312 videre_core::face_db::create_faces_table(&conn).unwrap();
313 conn.execute_batch("CREATE TABLE file_hashes (hash TEXT PRIMARY KEY, path TEXT);").unwrap();
314 assert!(matches!(face_image_bytes(&conn, 999), Err(Error::NotFound)));
315 assert!(matches!(original_image_bytes(&conn, 999), Err(Error::NotFound)));
316 }
317
318 #[test]
319 fn face_lookup_unknown_id_is_not_found() {
320 let conn = Connection::open_in_memory().unwrap();
321 videre_core::face_db::create_faces_table(&conn).unwrap();
322 conn.execute_batch("CREATE TABLE file_hashes (hash TEXT PRIMARY KEY, path TEXT);").unwrap();
323 assert!(matches!(face_lookup(&conn, 999), Err(Error::NotFound)));
324 }
325
326 #[test]
327 fn original_lookup_unknown_id_is_not_found() {
328 let conn = Connection::open_in_memory().unwrap();
329 videre_core::face_db::create_faces_table(&conn).unwrap();
330 conn.execute_batch("CREATE TABLE file_hashes (hash TEXT PRIMARY KEY, path TEXT);").unwrap();
331 assert!(matches!(original_lookup(&conn, 999), Err(Error::NotFound)));
332 }
333
334 #[test]
335 fn face_lookup_does_not_touch_the_filesystem() {
336 let conn = Connection::open_in_memory().unwrap();
340 videre_core::face_db::create_faces_table(&conn).unwrap();
341 conn.execute_batch("CREATE TABLE file_hashes (hash TEXT PRIMARY KEY, path TEXT);").unwrap();
342 conn.execute(
343 "INSERT INTO file_hashes (hash, path) VALUES ('h1', '/no/such/file.jpg')",
344 [],
345 )
346 .unwrap();
347 conn.execute(
348 "INSERT INTO faces (id, hash, bbox, embedding) VALUES (1, 'h1', '0,0,10,10', X'00')",
349 [],
350 )
351 .unwrap();
352 let lookup = face_lookup(&conn, 1).unwrap();
353 assert_eq!(lookup.file_path, "/no/such/file.jpg");
354 assert_eq!(lookup.hash, "h1");
355 assert_eq!(lookup.bbox_json, "0,0,10,10");
356 }
357}