feature/exif-batch-endpoint for Apollo #58
@@ -418,6 +418,10 @@ pub struct ExifBatchRequest {
|
|||||||
pub date_from: Option<i64>,
|
pub date_from: Option<i64>,
|
||||||
/// Upper bound (inclusive). Same semantics as `date_to` on `/photos`.
|
/// Upper bound (inclusive). Same semantics as `date_to` on `/photos`.
|
||||||
pub date_to: Option<i64>,
|
pub date_to: Option<i64>,
|
||||||
|
/// Restrict results to a single library by id. Omit (or "" / "all") for
|
||||||
|
/// union mode — the default. Filtered post-query in the handler so the
|
||||||
|
/// existing `query_by_exif` DAO trait stays untouched.
|
||||||
|
pub library: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
|
|||||||
32
src/exif.rs
32
src/exif.rs
@@ -4,6 +4,7 @@ use std::path::Path;
|
|||||||
|
|
||||||
use anyhow::{Result, anyhow};
|
use anyhow::{Result, anyhow};
|
||||||
use exif::{In, Reader, Tag, Value};
|
use exif::{In, Reader, Tag, Value};
|
||||||
|
use image::DynamicImage;
|
||||||
use log::debug;
|
use log::debug;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
@@ -177,6 +178,37 @@ pub fn extract_exif_from_path(path: &Path) -> Result<ExifData> {
|
|||||||
Ok(data)
|
Ok(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Read just the EXIF Orientation tag (1..=8) from a file. Cheaper than a
|
||||||
|
/// full `extract_exif_from_path` when the caller only needs orientation —
|
||||||
|
/// e.g. the thumbnail pipeline, which has to bake the rotation into the
|
||||||
|
/// resized pixels because the saved thumb has no EXIF chunk for the browser
|
||||||
|
/// to apply.
|
||||||
|
pub fn read_orientation(path: &Path) -> Option<i32> {
|
||||||
|
let file = File::open(path).ok()?;
|
||||||
|
let mut reader = BufReader::new(file);
|
||||||
|
let exif = Reader::new().read_from_container(&mut reader).ok()?;
|
||||||
|
let field = exif.get_field(Tag::Orientation, In::PRIMARY)?;
|
||||||
|
get_u32_value(field).map(|v| v as i32)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply an EXIF Orientation (1..=8) to a `DynamicImage`, returning a
|
||||||
|
/// canonically-oriented copy. Orientations:
|
||||||
|
/// 1 → as-is, 2 → flipH, 3 → rot180, 4 → flipV,
|
||||||
|
/// 5 → rot90CW + flipH, 6 → rot90CW, 7 → rot270CW + flipH, 8 → rot270CW.
|
||||||
|
/// Anything else (missing tag, garbage values) is returned unchanged.
|
||||||
|
pub fn apply_orientation(img: DynamicImage, orientation: i32) -> DynamicImage {
|
||||||
|
match orientation {
|
||||||
|
2 => img.fliph(),
|
||||||
|
3 => img.rotate180(),
|
||||||
|
4 => img.flipv(),
|
||||||
|
5 => img.rotate90().fliph(),
|
||||||
|
6 => img.rotate90(),
|
||||||
|
7 => img.rotate270().fliph(),
|
||||||
|
8 => img.rotate270(),
|
||||||
|
_ => img,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn get_string_value(field: &exif::Field) -> Option<String> {
|
fn get_string_value(field: &exif::Field) -> Option<String> {
|
||||||
match &field.value {
|
match &field.value {
|
||||||
Value::Ascii(vec) => {
|
Value::Ascii(vec) => {
|
||||||
|
|||||||
20
src/files.rs
20
src/files.rs
@@ -1217,6 +1217,21 @@ pub async fn list_exif_summary(
|
|||||||
"date_to",
|
"date_to",
|
||||||
req.date_to.map(|v| v.to_string()).unwrap_or_default(),
|
req.date_to.map(|v| v.to_string()).unwrap_or_default(),
|
||||||
));
|
));
|
||||||
|
span.set_attribute(KeyValue::new(
|
||||||
|
"library",
|
||||||
|
req.library.clone().unwrap_or_default(),
|
||||||
|
));
|
||||||
|
|
||||||
|
// Resolve the library filter up front so a bad id/name 400s before we
|
||||||
|
// ever take the DAO mutex. None == union across all libraries.
|
||||||
|
let library_filter =
|
||||||
|
match crate::libraries::resolve_library_param(&app_state, req.library.as_deref()) {
|
||||||
|
Ok(lib) => lib.map(|l| l.id),
|
||||||
|
Err(msg) => {
|
||||||
|
span.set_status(Status::error(msg.clone()));
|
||||||
|
return Ok(HttpResponse::BadRequest().json(serde_json::json!({ "error": msg })));
|
||||||
|
}
|
||||||
|
};
|
||||||
let cx = opentelemetry::Context::current_with_span(span);
|
let cx = opentelemetry::Context::current_with_span(span);
|
||||||
|
|
||||||
// Pre-build an id → name map so we don't linear-scan libraries per row.
|
// Pre-build an id → name map so we don't linear-scan libraries per row.
|
||||||
@@ -1231,6 +1246,11 @@ pub async fn list_exif_summary(
|
|||||||
Ok(rows) => {
|
Ok(rows) => {
|
||||||
let photos: Vec<ExifSummary> = rows
|
let photos: Vec<ExifSummary> = rows
|
||||||
.into_iter()
|
.into_iter()
|
||||||
|
// Library filter post-query: keeps the DAO trait (and its
|
||||||
|
// mocks) unchanged. For typical 2–3 library setups the in-
|
||||||
|
// memory pass over a date-bounded result set is negligible;
|
||||||
|
// can be pushed into SQL later if it ever isn't.
|
||||||
|
.filter(|r| library_filter.is_none_or(|id| r.library_id == id))
|
||||||
.map(|r| ExifSummary {
|
.map(|r| ExifSummary {
|
||||||
library_name: library_names.get(&r.library_id).cloned(),
|
library_name: library_names.get(&r.library_id).cloned(),
|
||||||
file_path: r.file_path,
|
file_path: r.file_path,
|
||||||
|
|||||||
@@ -1071,6 +1071,13 @@ pub fn unsupported_thumbnail_sentinel(thumb_path: &Path) -> PathBuf {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn generate_image_thumbnail(src: &Path, thumb_path: &Path) -> std::io::Result<()> {
|
fn generate_image_thumbnail(src: &Path, thumb_path: &Path) -> std::io::Result<()> {
|
||||||
|
// The `image` crate doesn't auto-apply EXIF Orientation on load, and
|
||||||
|
// saving back out as JPEG drops EXIF entirely — so without baking the
|
||||||
|
// rotation into the pixels here, browsers see the raw landscape buffer
|
||||||
|
// of a portrait phone shot and render it sideways. Read once up front
|
||||||
|
// and apply to whichever decode branch we end up taking.
|
||||||
|
let orientation = exif::read_orientation(src).unwrap_or(1);
|
||||||
|
|
||||||
// RAW formats (ARW/NEF/CR2/etc): try the file's embedded JPEG preview
|
// RAW formats (ARW/NEF/CR2/etc): try the file's embedded JPEG preview
|
||||||
// first. Avoids ffmpeg choking on proprietary RAW compression (Sony ARW
|
// first. Avoids ffmpeg choking on proprietary RAW compression (Sony ARW
|
||||||
// in particular), and is faster than decoding RAW pixels anyway.
|
// in particular), and is faster than decoding RAW pixels anyway.
|
||||||
@@ -1081,6 +1088,7 @@ fn generate_image_thumbnail(src: &Path, thumb_path: &Path) -> std::io::Result<()
|
|||||||
format!("decode embedded preview {:?}: {}", src, e),
|
format!("decode embedded preview {:?}: {}", src, e),
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
let img = exif::apply_orientation(img, orientation);
|
||||||
let scaled = img.thumbnail(200, u32::MAX);
|
let scaled = img.thumbnail(200, u32::MAX);
|
||||||
scaled
|
scaled
|
||||||
.save_with_format(thumb_path, image::ImageFormat::Jpeg)
|
.save_with_format(thumb_path, image::ImageFormat::Jpeg)
|
||||||
@@ -1095,6 +1103,7 @@ fn generate_image_thumbnail(src: &Path, thumb_path: &Path) -> std::io::Result<()
|
|||||||
let img = image::open(src).map_err(|e| {
|
let img = image::open(src).map_err(|e| {
|
||||||
std::io::Error::new(std::io::ErrorKind::InvalidData, format!("{:?}: {}", src, e))
|
std::io::Error::new(std::io::ErrorKind::InvalidData, format!("{:?}: {}", src, e))
|
||||||
})?;
|
})?;
|
||||||
|
let img = exif::apply_orientation(img, orientation);
|
||||||
let scaled = img.thumbnail(200, u32::MAX);
|
let scaled = img.thumbnail(200, u32::MAX);
|
||||||
scaled
|
scaled
|
||||||
.save(thumb_path)
|
.save(thumb_path)
|
||||||
|
|||||||
Reference in New Issue
Block a user