From f7f1547592c5747863d8d6033a00c8820a0c9b81 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sat, 15 Nov 2025 14:43:59 +0100 Subject: [PATCH] feat: Enhance XMP writing capabilities with face region and tag support --- libertas_core/src/media_utils.rs | 53 ++++++--- libertas_core/src/plugins.rs | 11 +- libertas_worker/src/main.rs | 13 ++- libertas_worker/src/plugins/xmp_writer.rs | 126 ++++++++++++++++++---- 4 files changed, 166 insertions(+), 37 deletions(-) diff --git a/libertas_core/src/media_utils.rs b/libertas_core/src/media_utils.rs index e6a2338..a7f4c0e 100644 --- a/libertas_core/src/media_utils.rs +++ b/libertas_core/src/media_utils.rs @@ -1,9 +1,15 @@ -use std::{io::Cursor, path::{Path, PathBuf}}; +use std::{ + io::Cursor, + path::{Path, PathBuf}, +}; use chrono::{DateTime, Datelike, NaiveDateTime, Utc}; use nom_exif::{AsyncMediaParser, AsyncMediaSource, ExifIter, MediaParser, MediaSource, TrackInfo}; -use crate::{error::{CoreError, CoreResult}, models::MediaMetadataSource}; +use crate::{ + error::{CoreError, CoreResult}, + models::MediaMetadataSource, +}; #[derive(Default, Debug)] pub struct ExtractedExif { @@ -26,9 +32,7 @@ pub fn parse_exif_datetime(s: &str) -> Option> { None } -pub fn extract_exif_data_from_bytes( - bytes: &[u8], -) -> CoreResult { +pub fn extract_exif_data_from_bytes(bytes: &[u8]) -> CoreResult { let ms = MediaSource::seekable(Cursor::new(bytes)) .map_err(|e| CoreError::Unknown(format!("Failed to open bytes for EXIF: {}", e)))?; @@ -54,7 +58,11 @@ pub fn extract_exif_data_from_bytes( v.to_string(), )), Err(e) => { - println!("!! EXIF parsing error for tag 0x{:04x}: {}", x.tag_code(), e); + println!( + "!! EXIF parsing error for tag 0x{:04x}: {}", + x.tag_code(), + e + ); None } } @@ -83,7 +91,7 @@ pub fn extract_exif_data_from_bytes( } pub async fn extract_exif_data(file_path: &Path) -> CoreResult { - let ms = AsyncMediaSource::file_path(file_path) + let ms = AsyncMediaSource::file_path(file_path) .await .map_err(|e| CoreError::Unknown(format!("Failed to open file for EXIF: {}", e)))?; @@ -146,10 +154,12 @@ pub fn get_storage_path_and_date( extracted_data: &ExtractedExif, filename: &str, ) -> (PathBuf, Option>) { - let date_taken_str = extracted_data.all_tags.iter() + let date_taken_str = extracted_data + .all_tags + .iter() .find(|(source, tag_name, _)| { - *source == MediaMetadataSource::Exif && - (tag_name == "DateTimeOriginal" || tag_name == "ModifyDate") + *source == MediaMetadataSource::Exif + && (tag_name == "DateTimeOriginal" || tag_name == "ModifyDate") }) .map(|(_, _, tag_value)| tag_value); @@ -158,8 +168,25 @@ pub fn get_storage_path_and_date( let year = file_date.year().to_string(); let month = format!("{:02}", file_date.month()); - + let storage_path = PathBuf::from(&year).join(&month).join(filename); - + (storage_path, date_taken) -} \ No newline at end of file +} + +pub fn is_invalid_exif_tag(tag_name: &str, tag_value: &str) -> bool { + if tag_name.starts_with("Unknown(") { + return true; + } + + if tag_value.starts_with("U16Array") + || tag_value.starts_with("U32Array") + || tag_value.starts_with("U8Array") + || tag_value.starts_with("URationalArray") + || tag_value.starts_with("Undefined") + { + return true; + } + + false +} diff --git a/libertas_core/src/plugins.rs b/libertas_core/src/plugins.rs index b44d906..025bcf4 100644 --- a/libertas_core/src/plugins.rs +++ b/libertas_core/src/plugins.rs @@ -3,7 +3,13 @@ use std::sync::Arc; use async_trait::async_trait; use crate::{ - config::AppConfig, error::CoreResult, models::Media, repositories::{AlbumRepository, MediaMetadataRepository, MediaRepository, UserRepository} + config::AppConfig, + error::CoreResult, + models::Media, + repositories::{ + AlbumRepository, FaceRegionRepository, MediaMetadataRepository, MediaRepository, + PersonRepository, TagRepository, UserRepository, + }, }; pub struct PluginData { @@ -15,6 +21,9 @@ pub struct PluginContext { pub album_repo: Arc, pub user_repo: Arc, pub metadata_repo: Arc, + pub tag_repo: Arc, + pub person_repo: Arc, + pub face_region_repo: Arc, pub media_library_path: String, pub config: Arc, } diff --git a/libertas_worker/src/main.rs b/libertas_worker/src/main.rs index 2a12291..8d2d1ca 100644 --- a/libertas_worker/src/main.rs +++ b/libertas_worker/src/main.rs @@ -3,7 +3,9 @@ use std::{path::PathBuf, sync::Arc}; use futures_util::StreamExt; use libertas_core::plugins::PluginContext; use libertas_infra::factory::{ - build_album_repository, build_database_pool, build_media_metadata_repository, build_media_repository, build_user_repository + build_album_repository, build_database_pool, build_face_region_repository, + build_media_metadata_repository, build_media_repository, build_person_repository, + build_tag_repository, build_user_repository, }; use serde::Deserialize; use tokio::fs; @@ -37,13 +39,18 @@ async fn main() -> anyhow::Result<()> { let media_repo = build_media_repository(&config, db_pool.clone()).await?; let album_repo = build_album_repository(&config.database, db_pool.clone()).await?; let user_repo = build_user_repository(&config.database, db_pool.clone()).await?; - let metadata_repo = - build_media_metadata_repository(&config.database, db_pool.clone()).await?; + let metadata_repo = build_media_metadata_repository(&config.database, db_pool.clone()).await?; + let tag_repo = build_tag_repository(&config.database, db_pool.clone()).await?; + let person_repo = build_person_repository(&config.database, db_pool.clone()).await?; + let face_region_repo = build_face_region_repository(&config.database, db_pool.clone()).await?; let context = Arc::new(PluginContext { media_repo, album_repo, user_repo, + tag_repo, + person_repo, + face_region_repo, metadata_repo, media_library_path: config.media_library_path.clone(), config: Arc::new(config.clone()), diff --git a/libertas_worker/src/plugins/xmp_writer.rs b/libertas_worker/src/plugins/xmp_writer.rs index 602076d..7583fce 100644 --- a/libertas_worker/src/plugins/xmp_writer.rs +++ b/libertas_worker/src/plugins/xmp_writer.rs @@ -3,11 +3,15 @@ use std::path::PathBuf; use async_trait::async_trait; use libertas_core::{ error::{CoreError, CoreResult}, - models::Media, + media_utils::is_invalid_exif_tag, + models::{FaceRegion, Media, MediaMetadataSource, Tag}, plugins::{MediaProcessorPlugin, PluginContext, PluginData}, }; use tokio::fs; -use xmp_toolkit::{XmpMeta, XmpValue}; +use xmp_toolkit::{ + XmpMeta, XmpValue, + xmp_ns::{DC, EXIF, XMP}, +}; pub struct XmpWriterPlugin; @@ -19,31 +23,52 @@ impl MediaProcessorPlugin for XmpWriterPlugin { async fn process(&self, media: &Media, context: &PluginContext) -> CoreResult { let metadata = context.metadata_repo.find_by_media_id(media.id).await?; + let tags: Vec = context.tag_repo.list_tags_for_media(media.id).await?; + let faces: Vec = context.face_region_repo.find_by_media_id(media.id).await?; + let file_path = PathBuf::from(&context.media_library_path).join(&media.storage_path); let xmp_path = format!("{}.xmp", file_path.to_string_lossy()); let mut xmp = XmpMeta::new() .map_err(|e| CoreError::Unknown(format!("Failed to create new XMP metadata: {}", e)))?; - xmp.set_property( - "http://purl.org/dc/elements/1.1/", - "description", - &XmpValue::from(media.original_filename.as_str()), - ) - .map_err(|e| { - CoreError::Unknown(format!("Failed to set description property in XMP: {}", e)) - })?; + XmpMeta::register_namespace(DC, "dc") + .map_err(|e| CoreError::Unknown(format!("Failed to register DC namespace: {}", e)))?; + XmpMeta::register_namespace(EXIF, "exif") + .map_err(|e| CoreError::Unknown(format!("Failed to register EXIF namespace: {}", e)))?; + XmpMeta::register_namespace(XMP, "xmp") + .map_err(|e| CoreError::Unknown(format!("Failed to register XMP namespace: {}", e)))?; - if let Some(date_tag) = metadata.iter().find(|m| m.tag_name == "DateTimeOriginal") { - let date_str = &date_tag.tag_value; - xmp.set_property( - "http://ns.adobe.com/exif/1.0/", - "DateTimeOriginal", - &XmpValue::from(date_str.as_str()), - ) - .map_err(|e| { - CoreError::Unknown(format!("Failed to set DateTimeOriginal in XMP: {}", e)) - })?; + set_xmp_prop(&mut xmp, DC, "title", &media.original_filename); + + for meta in metadata { + match meta.source { + MediaMetadataSource::Exif => { + if is_invalid_exif_tag(&meta.tag_name, &meta.tag_value) { + continue; + } + + set_xmp_prop(&mut xmp, EXIF, &meta.tag_name, &meta.tag_value); + } + MediaMetadataSource::TrackInfo => { + set_xmp_prop(&mut xmp, XMP, &meta.tag_name, &meta.tag_value); + } + } + } + + if !tags.is_empty() { + xmp.set_property(DC, "subject", &XmpValue::from("[]")) + .map_err(|e| { + CoreError::Unknown(format!("Failed to create subject array in XMP: {}", e)) + })?; + + for tag in tags { + add_xmp_array_item(&mut xmp, DC, "subject", &tag.name); + } + } + + if let Err(e) = write_face_regions(&mut xmp, &faces, context).await { + eprintln!("Failed to write face regions to XMP: {}", e); } let xmp_str = xmp.to_string(); @@ -55,3 +80,64 @@ impl MediaProcessorPlugin for XmpWriterPlugin { }) } } + +fn set_xmp_prop(xmp: &mut XmpMeta, ns: &str, key: &str, value: &str) { + if let Err(e) = xmp.set_property(ns, key, &XmpValue::from(value)) { + eprintln!("Failed to set {}:{} in XMP: {}", ns, key, e); + } +} + +fn add_xmp_array_item(xmp: &mut XmpMeta, ns: &str, key: &str, value: &str) { + if let Err(e) = xmp.append_array_item(ns, &XmpValue::from(key), &XmpValue::from(value)) { + eprintln!("Failed to add item to {}:{} in XMP: {}", ns, key, e); + } +} + +async fn write_face_regions( + xmp: &mut XmpMeta, + faces: &[FaceRegion], + context: &PluginContext, +) -> CoreResult<()> { + if faces.is_empty() { + return Ok(()); + } + + XmpMeta::register_namespace("", "mwg-rs") + .map_err(|e| CoreError::Unknown(format!("Failed to register MWG namespace: {}", e)))?; + + xmp.set_property("", "mwg-rs:Regions", &XmpValue::from("[]")) + .map_err(|e| CoreError::Unknown(format!("Failed to create Regions array in XMP: {}", e)))?; + + for face in faces { + let mut person_name = "Unknown".to_string(); + if let Some(person_id) = face.person_id { + if let Ok(Some(person)) = context.person_repo.find_by_id(person_id).await { + person_name = person.name; + } + } + + let region_path = format!("Regions[last()]/mwg-rs:RegionInfo/{{ {} }}", face.id); + xmp.set_property("mwg-rs", ®ion_path, &XmpValue::from("[]")) + .map_err(|e| { + CoreError::Unknown(format!("Failed to create RegionInfo in XMP: {}", e)) + })?; + + let name_path = format!("{}/mwg-rs:Name", region_path); + set_xmp_prop(xmp, "mwg-rs", &name_path, &person_name); + + let area_str = format!( + "{}, {}, {}, {}", + face.x_min, + face.y_min, + face.x_max - face.x_min, // Width + face.y_max - face.y_min // Height + ); + let area_path = format!("{}/mwg-rs:Area", region_path); + set_xmp_prop(xmp, "mwg-rs", &area_path, &area_str); + + let type_path = format!("{}/mwg-rs:Type", region_path); + set_xmp_prop(xmp, "mwg-rs", &type_path, "Face"); + } + + Ok(()) +}