use crate::error::{Error, Result};
use rusqlite::Connection;
const FACE_THUMB_SIZE: u32 = 140;
fn crop_face_square(img: &image::DynamicImage, bbox: [f32; 4]) -> image::DynamicImage {
let w = img.width() as f32;
let h = img.height() as f32;
let bw = bbox[2] - bbox[0];
let bh = bbox[3] - bbox[1];
let pad = (bw.max(bh) * 0.25).max(4.0);
let half = bw.max(bh) * 0.5 + pad;
let cx = (bbox[0] + bbox[2]) * 0.5;
let cy = (bbox[1] + bbox[3]) * 0.5;
let x1 = (cx - half).max(0.0) as u32;
let y1 = (cy - half).max(0.0) as u32;
let x2 = (cx + half).min(w) as u32;
let y2 = (cy + half).min(h) as u32;
let side = (x2 - x1).min(y2 - y1).max(1);
img.crop_imm(x1, y1, side, side)
.resize_exact(140, 140, image::imageops::FilterType::Triangle)
}
pub fn make_face_thumb(
path: &str,
bbox: [f32; 4],
oriented: bool,
face_id: i64,
) -> Option<image::DynamicImage> {
let ext = std::path::Path::new(path)
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_lowercase();
if ext == "heic" {
let img = videre_core::heic::heic_via_quicklook(path, &format!("thumb{face_id}"), None)?;
return Some(crop_face_square(&img, bbox));
}
let timeout_path = path.to_string();
let decoded = match videre_core::io_timeout::run_with_timeout(
videre_core::io_timeout::DEFAULT_IO_TIMEOUT,
move || {
if oriented {
videre_core::image_decode::decode_oriented_file(std::path::Path::new(&timeout_path))
.map(|img| (img, None))
} else {
videre_core::image_decode::decode_raw_with_orientation(std::path::Path::new(
&timeout_path,
))
.map(|(img, o)| (img, Some(o)))
}
},
) {
Ok(Ok(img)) => img,
Ok(Err(e)) => {
eprintln!("warning: face thumbnail unavailable for {path}: {e}; skipping");
return None;
}
Err(_) => {
eprintln!(
"warning: timed out reading {path} for face thumbnail \
(file may be unreachable - is its drive connected?); skipping"
);
return None;
}
};
let (img, raw_canvas_orientation) = decoded;
let cropped = crop_face_square(&img, bbox);
match raw_canvas_orientation {
Some(orientation) => {
let mut cropped = image::DynamicImage::ImageRgba8(cropped.to_rgba8());
cropped.apply_orientation(orientation);
Some(cropped)
}
None => Some(cropped),
}
}
fn read_with_timeout(path: &str) -> std::io::Result<Vec<u8>> {
let owned = path.to_string();
videre_core::io_timeout::run_with_timeout(
videre_core::io_timeout::DEFAULT_IO_TIMEOUT,
move || std::fs::read(&owned),
)
.unwrap_or_else(|_| {
Err(std::io::Error::new(
std::io::ErrorKind::TimedOut,
format!("timed out reading {path} (file may be unreachable - is its drive connected?)"),
))
})
}
pub fn mime_for_ext(ext: &str) -> &'static str {
match ext {
"jpg" | "jpeg" => "image/jpeg",
"png" => "image/png",
"gif" => "image/gif",
"webp" => "image/webp",
"bmp" => "image/bmp",
"tiff" => "image/tiff",
"mov" => "video/quicktime",
"mp4" => "video/mp4",
_ => "application/octet-stream",
}
}
pub struct FaceLookup {
pub bbox_json: String,
pub file_path: String,
pub hash: String,
pub oriented: bool,
}
pub fn face_lookup(conn: &Connection, face_id: i64) -> Result<FaceLookup> {
let (bbox_json, file_path, hash, oriented): (String, String, String, i64) = conn
.query_row(
"SELECT f.bbox, fh.path, f.hash, COALESCE(f.oriented, 0) FROM faces f \
JOIN file_hashes fh ON f.hash = fh.hash WHERE f.id = ?1 LIMIT 1",
[face_id],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)),
)
.map_err(|_| Error::NotFound)?;
Ok(FaceLookup {
bbox_json,
file_path,
hash,
oriented: oriented != 0,
})
}
pub fn face_bytes_from_lookup(
lookup: &FaceLookup,
face_id: i64,
cache: &videre_core::library::CachePaths,
) -> Result<Vec<u8>> {
let parts: Vec<f32> = lookup
.bbox_json
.split(',')
.filter_map(|s| s.trim().parse().ok())
.collect();
if parts.len() != 4 {
return Err(Error::NotFound);
}
let bbox = [parts[0], parts[1], parts[0] + parts[2], parts[1] + parts[3]];
let cache_path = videre_core::thumb_cache::face_thumb_path_in(
cache,
&lookup.hash,
face_id,
bbox,
FACE_THUMB_SIZE,
);
if videre_core::thumb_cache::face_thumb_exists_in(
cache,
&lookup.hash,
face_id,
bbox,
FACE_THUMB_SIZE,
) {
if let Ok(bytes) = read_with_timeout(&cache_path.to_string_lossy()) {
return Ok(bytes);
}
}
let thumb = make_face_thumb(&lookup.file_path, bbox, lookup.oriented, face_id)
.ok_or(Error::NotFound)?;
let mut buf = Vec::new();
thumb
.write_to(
&mut std::io::Cursor::new(&mut buf),
image::ImageFormat::Jpeg,
)
.map_err(|_| Error::NotFound)?;
if let Some(parent) = cache_path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let tmp = cache_path.with_extension(format!("tmp{}", std::process::id()));
if std::fs::write(&tmp, &buf).is_ok() {
let _ = std::fs::rename(&tmp, &cache_path);
}
Ok(buf)
}
pub fn face_image_bytes(
conn: &Connection,
face_id: i64,
cache: &videre_core::library::CachePaths,
) -> Result<Vec<u8>> {
let lookup = face_lookup(conn, face_id)?;
face_bytes_from_lookup(&lookup, face_id, cache)
}
pub struct OriginalLookup {
pub file_path: String,
pub hash: String,
}
pub fn original_lookup(conn: &Connection, face_id: i64) -> Result<OriginalLookup> {
let (file_path, hash): (String, String) = conn
.query_row(
"SELECT fh.path, f.hash FROM faces f \
JOIN file_hashes fh ON f.hash = fh.hash WHERE f.id = ?1 LIMIT 1",
[face_id],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.map_err(|_| Error::NotFound)?;
Ok(OriginalLookup { file_path, hash })
}
pub fn original_bytes_from_lookup(
lookup: &OriginalLookup,
face_id: i64,
cache: &videre_core::library::CachePaths,
) -> Result<(&'static str, Vec<u8>)> {
let file_path = &lookup.file_path;
let hash = &lookup.hash;
let ext = std::path::Path::new(file_path)
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_lowercase();
if ext == "heic" {
if let Ok(bytes) = read_with_timeout(
&videre_core::thumb_cache::original_path_in(cache, hash).to_string_lossy(),
) {
return Ok(("image/jpeg", bytes));
}
let img = videre_core::heic::heic_via_quicklook(file_path, &format!("orig{face_id}"), None)
.ok_or(Error::NotFound)?;
let mut buf = Vec::new();
img.write_to(
&mut std::io::Cursor::new(&mut buf),
image::ImageFormat::Jpeg,
)
.map_err(|_| Error::NotFound)?;
let final_path = videre_core::thumb_cache::original_path_in(cache, hash);
if let Some(parent) = final_path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let tmp = final_path.with_extension(format!("tmp{}", std::process::id()));
if std::fs::write(&tmp, &buf).is_ok() {
let _ = std::fs::rename(&tmp, &final_path);
}
Ok(("image/jpeg", buf))
} else {
let bytes = read_with_timeout(file_path).map_err(|e| {
eprintln!("warning: original image unavailable for {file_path}: {e}; skipping");
Error::NotFound
})?;
Ok((mime_for_ext(&ext), bytes))
}
}
pub fn original_image_bytes(
conn: &Connection,
face_id: i64,
cache: &videre_core::library::CachePaths,
) -> Result<(&'static str, Vec<u8>)> {
let lookup = original_lookup(conn, face_id)?;
original_bytes_from_lookup(&lookup, face_id, cache)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn oriented_and_legacy_branches_render_the_same_upright_crop() {
let base = concat!(env!("CARGO_MANIFEST_DIR"), "/../videre/tests/fixtures");
let tagged = format!("{base}/ai-generated-couple_o6.jpg");
let raw_center = (600u32, 772u32);
let display_center = (1543 - raw_center.1, raw_center.0);
let raw_bbox = [
(raw_center.0 - 200) as f32,
(raw_center.1 - 200) as f32,
(raw_center.0 + 200) as f32,
(raw_center.1 + 200) as f32,
];
let display_bbox = [
(display_center.0 - 200) as f32,
(display_center.1 - 200) as f32,
(display_center.0 + 200) as f32,
(display_center.1 + 200) as f32,
];
let legacy = make_face_thumb(&tagged, raw_bbox, false, 1).unwrap();
let oriented = make_face_thumb(&tagged, display_bbox, true, 1).unwrap();
assert_eq!(
(legacy.width(), legacy.height()),
(140, 140),
"both branches produce 140x140 thumbnails"
);
let a: Vec<u8> = legacy.to_rgb8().pixels().map(|p| p.0[0]).collect();
let b: Vec<u8> = oriented.to_rgb8().pixels().map(|p| p.0[0]).collect();
let diff: u64 = a
.iter()
.zip(&b)
.map(|(x, y)| (*x as i32 - *y as i32).unsigned_abs() as u64)
.sum();
assert!(
diff < 1000,
"both branches must render the same upright face, sum |diff| = {diff}"
);
}
#[test]
fn the_oriented_flag_actually_changes_the_crop() {
let base = concat!(env!("CARGO_MANIFEST_DIR"), "/../videre/tests/fixtures");
let tagged = format!("{base}/ai-generated-couple_o6.jpg");
let raw_bbox = [400.0, 572.0, 800.0, 972.0];
let as_legacy = make_face_thumb(&tagged, raw_bbox, false, 1).unwrap();
let as_oriented = make_face_thumb(&tagged, raw_bbox, true, 1).unwrap();
let a: Vec<u8> = as_legacy.to_rgb8().pixels().map(|p| p.0[0]).collect();
let b: Vec<u8> = as_oriented.to_rgb8().pixels().map(|p| p.0[0]).collect();
assert_ne!(a, b, "the flag must select between two different canvases");
}
#[test]
fn a_face_crop_is_square_and_thumbnail_sized() {
let img = image::DynamicImage::ImageLuma8(image::GrayImage::new(200, 100));
let out = crop_face_square(&img, [80.0, 40.0, 120.0, 80.0]);
assert_eq!((out.width(), out.height()), (140, 140));
}
#[test]
fn a_face_crop_clamps_to_the_image_bounds() {
let img = image::DynamicImage::ImageLuma8(image::GrayImage::new(50, 50));
for bbox in [
[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], ] {
let out = crop_face_square(&img, bbox);
assert_eq!((out.width(), out.height()), (140, 140), "bbox {bbox:?}");
}
}
#[test]
fn a_degenerate_bbox_still_produces_a_thumbnail() {
let img = image::DynamicImage::ImageLuma8(image::GrayImage::new(50, 50));
let out = crop_face_square(&img, [25.0, 25.0, 25.0, 25.0]);
assert_eq!((out.width(), out.height()), (140, 140));
}
#[test]
fn unknown_face_id_is_not_found() {
let conn = Connection::open_in_memory().unwrap();
videre_core::face_db::create_faces_table(&conn).unwrap();
conn.execute_batch("CREATE TABLE file_hashes (hash TEXT PRIMARY KEY, path TEXT);")
.unwrap();
let temp = tempfile::tempdir().unwrap();
let ctx =
videre_core::library::LibraryContext::new(temp.path(), &temp.path().join("cache"))
.unwrap();
assert!(matches!(
face_image_bytes(&conn, 999, &ctx.cache),
Err(Error::NotFound)
));
assert!(matches!(
original_image_bytes(&conn, 999, &ctx.cache),
Err(Error::NotFound)
));
}
#[test]
fn face_lookup_unknown_id_is_not_found() {
let conn = Connection::open_in_memory().unwrap();
videre_core::face_db::create_faces_table(&conn).unwrap();
conn.execute_batch("CREATE TABLE file_hashes (hash TEXT PRIMARY KEY, path TEXT);")
.unwrap();
assert!(matches!(face_lookup(&conn, 999), Err(Error::NotFound)));
}
#[test]
fn original_lookup_unknown_id_is_not_found() {
let conn = Connection::open_in_memory().unwrap();
videre_core::face_db::create_faces_table(&conn).unwrap();
conn.execute_batch("CREATE TABLE file_hashes (hash TEXT PRIMARY KEY, path TEXT);")
.unwrap();
assert!(matches!(original_lookup(&conn, 999), Err(Error::NotFound)));
}
#[test]
fn face_lookup_does_not_touch_the_filesystem() {
let conn = Connection::open_in_memory().unwrap();
videre_core::face_db::create_faces_table(&conn).unwrap();
conn.execute_batch("CREATE TABLE file_hashes (hash TEXT PRIMARY KEY, path TEXT);")
.unwrap();
conn.execute(
"INSERT INTO file_hashes (hash, path) VALUES ('h1', '/no/such/file.jpg')",
[],
)
.unwrap();
conn.execute(
"INSERT INTO faces (id, hash, bbox, embedding) VALUES (1, 'h1', '0,0,10,10', X'00')",
[],
)
.unwrap();
let lookup = face_lookup(&conn, 1).unwrap();
assert_eq!(lookup.file_path, "/no/such/file.jpg");
assert_eq!(lookup.hash, "h1");
assert_eq!(lookup.bbox_json, "0,0,10,10");
}
}