From 1d2f4e3441a37c77d99c4f539072a7bbba51d95b Mon Sep 17 00:00:00 2001 From: Cameron Date: Mon, 26 Jan 2026 20:04:14 -0500 Subject: [PATCH] Add circular thumbnail creation for Map view --- src/data/mod.rs | 10 +++++++ src/files.rs | 1 - src/main.rs | 79 +++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 89 insertions(+), 1 deletion(-) diff --git a/src/data/mod.rs b/src/data/mod.rs index 53e7020..8303c6a 100644 --- a/src/data/mod.rs +++ b/src/data/mod.rs @@ -186,6 +186,8 @@ pub struct ThumbnailRequest { #[serde(default)] #[allow(dead_code)] // Part of API contract, may be used in future pub(crate) format: Option, + #[serde(default)] + pub(crate) shape: Option, } #[derive(Debug, Deserialize, PartialEq)] @@ -196,6 +198,14 @@ pub enum ThumbnailFormat { Image, } +#[derive(Debug, Deserialize, PartialEq)] +pub enum ThumbnailShape { + #[serde(rename = "circle")] + Circle, + #[serde(rename = "square")] + Square, +} + #[derive(Deserialize)] pub struct LoginRequest { pub username: String, diff --git a/src/files.rs b/src/files.rs index d925742..ce4f9db 100644 --- a/src/files.rs +++ b/src/files.rs @@ -957,7 +957,6 @@ pub async fn get_gps_summary( _: Claims, request: HttpRequest, req: web::Query, - state: web::Data, exif_dao: web::Data>>, ) -> Result { use crate::data::{GpsPhotoSummary, GpsPhotosResponse}; diff --git a/src/main.rs b/src/main.rs index 34a7bf2..4b65dea 100644 --- a/src/main.rs +++ b/src/main.rs @@ -116,6 +116,26 @@ async fn get_image( thumb_path.set_extension("gif"); } + // Handle circular thumbnail request + if req.shape == Some(ThumbnailShape::Circle) { + match create_circular_thumbnail(&thumb_path, &thumbs).await { + Ok(circular_path) => { + if let Ok(file) = NamedFile::open(&circular_path) { + span.set_status(Status::Ok); + return file + .use_etag(true) + .use_last_modified(true) + .prefer_utf8(true) + .into_response(&request); + } + } + Err(e) => { + warn!("Failed to create circular thumbnail: {:?}", e); + // Fall through to serve square thumbnail + } + } + } + trace!("Thumbnail path: {:?}", thumb_path); if let Ok(file) = NamedFile::open(&thumb_path) { span.set_status(Status::Ok); @@ -153,6 +173,65 @@ fn is_video_file(path: &Path) -> bool { file_types::is_video_file(path) } +async fn create_circular_thumbnail(thumb_path: &Path, thumbs_dir: &str) -> Result> { + use image::{ImageBuffer, Rgba, GenericImageView}; + + // Create circular thumbnails directory + let circular_dir = Path::new(thumbs_dir).join("_circular"); + + // Get relative path from thumbs_dir to create same structure + let relative_to_thumbs = thumb_path.strip_prefix(thumbs_dir)?; + let circular_path = circular_dir.join(relative_to_thumbs).with_extension("png"); + + // Check if circular thumbnail already exists + if circular_path.exists() { + return Ok(circular_path); + } + + // Create parent directory if needed + if let Some(parent) = circular_path.parent() { + std::fs::create_dir_all(parent)?; + } + + // Load the square thumbnail + let img = image::open(thumb_path)?; + let (width, height) = img.dimensions(); + + // Fixed output size for consistency + let output_size = 80u32; + let radius = output_size as f32 / 2.0; + + // Calculate crop area to get square center of original image + let crop_size = width.min(height); + let crop_x = (width - crop_size) / 2; + let crop_y = (height - crop_size) / 2; + + // Create a new RGBA image with transparency + let output = ImageBuffer::from_fn(output_size, output_size, |x, y| { + let dx = x as f32 - radius; + let dy = y as f32 - radius; + let distance = (dx * dx + dy * dy).sqrt(); + + if distance <= radius { + // Inside circle - map to cropped source area + // Scale from output coordinates to crop coordinates + let scale = crop_size as f32 / output_size as f32; + let src_x = crop_x + (x as f32 * scale) as u32; + let src_y = crop_y + (y as f32 * scale) as u32; + let pixel = img.get_pixel(src_x, src_y); + Rgba([pixel[0], pixel[1], pixel[2], 255]) + } else { + // Outside circle - transparent + Rgba([0, 0, 0, 0]) + } + }); + + // Save as PNG (supports transparency) + output.save(&circular_path)?; + + Ok(circular_path) +} + #[get("/image/metadata")] async fn get_file_metadata( _: Claims,