From 6e21b08f59ddb8b662ee15c4f75e43fa01bf30f6 Mon Sep 17 00:00:00 2001 From: John Lewis Date: Sun, 10 Mar 2024 16:16:54 -0500 Subject: [PATCH 1/2] feat: added basic `PhotoMeta` object to `core_types` --- crates/core_types/src/photo.rs | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/crates/core_types/src/photo.rs b/crates/core_types/src/photo.rs index c088cd4..5c94ca9 100644 --- a/crates/core_types/src/photo.rs +++ b/crates/core_types/src/photo.rs @@ -1,3 +1,5 @@ +use std::collections::HashMap; + use serde::{Deserialize, Serialize}; /// The table name for the photo table. @@ -18,13 +20,26 @@ pub struct PhotoRecordId(pub ulid::Ulid); #[derive(Clone, Debug, Deserialize, Serialize)] pub struct Photo { /// The record ID. - pub id: PhotoRecordId, + pub id: PhotoRecordId, /// The photo group that contains this photo. - pub group: PhotoGroupRecordId, + pub group: PhotoGroupRecordId, /// The photo's artifacts. - pub artifacts: PhotoArtifacts, + pub artifacts: PhotoArtifacts, + /// Data derived from the photo's EXIF data. + pub photo_meta: PhotoMeta, /// Object metadata. - pub meta: crate::ObjectMeta, + pub meta: crate::ObjectMeta, +} + +/// Photo metadata derived from EXIF data. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct PhotoMeta { + /// The date and time the photo was taken. + pub date_time: Option>, + /// The GPS coordinates where the photo was taken. + pub gps: Option<(f64, f64)>, + /// Extra EXIF data. + pub extra: HashMap, } /// The artifacts for a photo. Not a table. From 4c79d2ac1b2799313fa9f3bf9f870a3dd6ca3802 Mon Sep 17 00:00:00 2001 From: John Lewis Date: Sun, 10 Mar 2024 17:20:29 -0500 Subject: [PATCH 2/2] feat: basic exif parsing and orientation --- Cargo.lock | 17 ++++++ crates/bl/Cargo.toml | 5 +- crates/bl/src/upload/mod.rs | 100 ++++++++++++++++++++++++++++++--- crates/core_types/src/photo.rs | 4 +- 4 files changed, 115 insertions(+), 11 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3de45cf..7b7582f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -570,11 +570,13 @@ dependencies = [ "artifact", "base64", "bytes", + "chrono", "clients", "color-eyre", "core_types", "http 1.1.0", "image", + "kamadak-exif", "leptos", "qrcode", "rayon", @@ -2322,6 +2324,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "kamadak-exif" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef4fc70d0ab7e5b6bafa30216a6b48705ea964cdfc29c050f2412295eba58077" +dependencies = [ + "mutate_once", +] + [[package]] name = "kinded" version = "0.3.0" @@ -2869,6 +2880,12 @@ dependencies = [ "version_check", ] +[[package]] +name = "mutate_once" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16cf681a23b4d0a43fc35024c176437f9dcd818db34e0f42ab456a0ee5ad497b" + [[package]] name = "nanoid" version = "0.4.0" diff --git a/crates/bl/Cargo.toml b/crates/bl/Cargo.toml index 74f82f1..2208c11 100644 --- a/crates/bl/Cargo.toml +++ b/crates/bl/Cargo.toml @@ -19,6 +19,7 @@ artifact = { path = "../artifact", optional = true } rmp-serde = "1.1.2" base64 = { workspace = true, optional = true } +chrono = { workspace = true, optional = true } color-eyre = { workspace = true, optional = true } image = { workspace = true, optional = true } qrcode = { workspace = true, optional = true } @@ -28,11 +29,13 @@ surrealdb = { workspace = true, optional = true } strum.workspace = true tokio = { workspace = true, optional = true } +kamadak-exif = { version = "0.5", optional = true } + [features] default = [] hydrate = [ "leptos/hydrate" ] ssr = [ "core_types/ssr", "leptos/ssr", "dep:clients", "dep:artifact", "dep:base64", "dep:color-eyre", "dep:image", "dep:qrcode", "dep:rayon", "dep:surrealdb", - "dep:tokio", "dep:tracing", + "dep:tokio", "dep:tracing", "dep:kamadak-exif", "dep:chrono", ] diff --git a/crates/bl/src/upload/mod.rs b/crates/bl/src/upload/mod.rs index 4ad6998..013a5e0 100644 --- a/crates/bl/src/upload/mod.rs +++ b/crates/bl/src/upload/mod.rs @@ -56,7 +56,13 @@ pub async fn upload_photo_group( .enumerate() .par_bridge() // load original images - .map(|(i, p)| image::load_from_memory(&p.original).map(|img| (i, img))) + .map(|(i, p)| { + let exif_reader = exif::Reader::new(); + let meta = exif_reader + .read_from_container(&mut std::io::Cursor::new(&p.original)) + .ok(); + image::load_from_memory(&p.original).map(|img| (i, (img, meta))) + }) // collect into a hashmap & short circuit on error .collect::, _>>() .map_err(|e| { @@ -65,10 +71,10 @@ pub async fn upload_photo_group( })?; // spawn tasks for each image - for (i, img) in images { + for (i, (img, meta)) in images { let tx = tx.clone(); tokio::spawn(async move { - let result = create_photo(img).await; + let result = create_photo(img, meta).await; tx.send((i, result)).await.unwrap(); }); } @@ -135,12 +141,89 @@ pub async fn upload_photo_group( } #[cfg(feature = "ssr")] -async fn create_photo(img: image::DynamicImage) -> Result { +fn photo_meta_from_exif(input: Option) -> core_types::PhotoMeta { + let mut meta = core_types::PhotoMeta::default(); + + let Some(exif) = input else { + return meta; + }; + + // extract datetime + if let Some(field) = exif.get_field(exif::Tag::DateTime, exif::In::PRIMARY) { + match field.value { + exif::Value::Ascii(ref vec) if !vec.is_empty() => { + if let Ok(datetime) = exif::DateTime::from_ascii(&vec[0]) { + meta.date_time = chrono::NaiveDate::from_ymd_opt( + datetime.year.into(), + datetime.month.into(), + datetime.day.into(), + ) + .and_then(|date| { + chrono::NaiveTime::from_hms_opt( + datetime.hour.into(), + datetime.minute.into(), + datetime.second.into(), + ) + .map(|time| date.and_time(time)) + }); + } + } + _ => {} + } + } + + // extract gps + // this isn't implemented yet + + meta +} + +#[cfg(feature = "ssr")] +fn rotate_image_from_exif( + img: &mut image::DynamicImage, + orientation: Option<&exif::Exif>, +) { + let Some(exif) = orientation else { + return; + }; + + let Some(orientation) = + exif.get_field(exif::Tag::Orientation, exif::In::PRIMARY) + else { + return; + }; + + let Some(value) = orientation.value.as_uint().ok().and_then(|v| v.get(0)) + else { + return; + }; + + *img = match value { + 1 => img.clone(), + 2 => img.fliph(), + 3 => img.rotate180(), + 4 => img.flipv(), + 5 => img.rotate90().fliph(), + 6 => img.rotate90(), + 7 => img.rotate270().fliph(), + 8 => img.rotate270(), + _ => img.clone(), + }; +} + +#[cfg(feature = "ssr")] +async fn create_photo( + mut img: image::DynamicImage, + meta: Option, +) -> Result { use artifact::Artifact; use color_eyre::eyre::WrapErr; use crate::model_ext::ModelExt; + // rotate image based on exif orientation + rotate_image_from_exif(&mut img, meta.as_ref()); + // encode original image as jpeg let mut original_jpeg_bytes = Vec::new(); let encoder = image::codecs::jpeg::JpegEncoder::new(&mut original_jpeg_bytes); @@ -203,9 +286,9 @@ async fn create_photo(img: image::DynamicImage) -> Result { // create a photo and upload it to surreal let photo = core_types::Photo { - id: core_types::PhotoRecordId(core_types::Ulid::new()), - group: core_types::PhotoGroupRecordId(core_types::Ulid::nil()), - artifacts: core_types::PhotoArtifacts { + id: core_types::PhotoRecordId(core_types::Ulid::new()), + group: core_types::PhotoGroupRecordId(core_types::Ulid::nil()), + artifacts: core_types::PhotoArtifacts { original: core_types::PrivateImageArtifact { artifact_id: original_artifact.id, size: (img.width(), img.height()), @@ -215,7 +298,8 @@ async fn create_photo(img: image::DynamicImage) -> Result { size: (thumbnail_image.width(), thumbnail_image.height()), }, }, - meta: Default::default(), + photo_meta: photo_meta_from_exif(meta), + meta: Default::default(), }; let client = clients::surreal::SurrealRootClient::new().await?; diff --git a/crates/core_types/src/photo.rs b/crates/core_types/src/photo.rs index 5c94ca9..17ba2e0 100644 --- a/crates/core_types/src/photo.rs +++ b/crates/core_types/src/photo.rs @@ -32,10 +32,10 @@ pub struct Photo { } /// Photo metadata derived from EXIF data. -#[derive(Clone, Debug, Deserialize, Serialize)] +#[derive(Clone, Debug, Default, Deserialize, Serialize)] pub struct PhotoMeta { /// The date and time the photo was taken. - pub date_time: Option>, + pub date_time: Option, /// The GPS coordinates where the photo was taken. pub gps: Option<(f64, f64)>, /// Extra EXIF data.