feat: Enhance XMP writing capabilities with face region and tag support

This commit is contained in:
2025-11-15 14:43:59 +01:00
parent 8d05bdfd63
commit f7f1547592
4 changed files with 166 additions and 37 deletions

View File

@@ -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 chrono::{DateTime, Datelike, NaiveDateTime, Utc};
use nom_exif::{AsyncMediaParser, AsyncMediaSource, ExifIter, MediaParser, MediaSource, TrackInfo}; 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)] #[derive(Default, Debug)]
pub struct ExtractedExif { pub struct ExtractedExif {
@@ -26,9 +32,7 @@ pub fn parse_exif_datetime(s: &str) -> Option<DateTime<Utc>> {
None None
} }
pub fn extract_exif_data_from_bytes( pub fn extract_exif_data_from_bytes(bytes: &[u8]) -> CoreResult<ExtractedExif> {
bytes: &[u8],
) -> CoreResult<ExtractedExif> {
let ms = MediaSource::seekable(Cursor::new(bytes)) let ms = MediaSource::seekable(Cursor::new(bytes))
.map_err(|e| CoreError::Unknown(format!("Failed to open bytes for EXIF: {}", e)))?; .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(), v.to_string(),
)), )),
Err(e) => { 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 None
} }
} }
@@ -83,7 +91,7 @@ pub fn extract_exif_data_from_bytes(
} }
pub async fn extract_exif_data(file_path: &Path) -> CoreResult<ExtractedExif> { pub async fn extract_exif_data(file_path: &Path) -> CoreResult<ExtractedExif> {
let ms = AsyncMediaSource::file_path(file_path) let ms = AsyncMediaSource::file_path(file_path)
.await .await
.map_err(|e| CoreError::Unknown(format!("Failed to open file for EXIF: {}", e)))?; .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, extracted_data: &ExtractedExif,
filename: &str, filename: &str,
) -> (PathBuf, Option<DateTime<Utc>>) { ) -> (PathBuf, Option<DateTime<Utc>>) {
let date_taken_str = extracted_data.all_tags.iter() let date_taken_str = extracted_data
.all_tags
.iter()
.find(|(source, tag_name, _)| { .find(|(source, tag_name, _)| {
*source == MediaMetadataSource::Exif && *source == MediaMetadataSource::Exif
(tag_name == "DateTimeOriginal" || tag_name == "ModifyDate") && (tag_name == "DateTimeOriginal" || tag_name == "ModifyDate")
}) })
.map(|(_, _, tag_value)| tag_value); .map(|(_, _, tag_value)| tag_value);
@@ -158,8 +168,25 @@ pub fn get_storage_path_and_date(
let year = file_date.year().to_string(); let year = file_date.year().to_string();
let month = format!("{:02}", file_date.month()); let month = format!("{:02}", file_date.month());
let storage_path = PathBuf::from(&year).join(&month).join(filename); let storage_path = PathBuf::from(&year).join(&month).join(filename);
(storage_path, date_taken) (storage_path, date_taken)
} }
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
}

View File

@@ -3,7 +3,13 @@ use std::sync::Arc;
use async_trait::async_trait; use async_trait::async_trait;
use crate::{ 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 { pub struct PluginData {
@@ -15,6 +21,9 @@ pub struct PluginContext {
pub album_repo: Arc<dyn AlbumRepository>, pub album_repo: Arc<dyn AlbumRepository>,
pub user_repo: Arc<dyn UserRepository>, pub user_repo: Arc<dyn UserRepository>,
pub metadata_repo: Arc<dyn MediaMetadataRepository>, pub metadata_repo: Arc<dyn MediaMetadataRepository>,
pub tag_repo: Arc<dyn TagRepository>,
pub person_repo: Arc<dyn PersonRepository>,
pub face_region_repo: Arc<dyn FaceRegionRepository>,
pub media_library_path: String, pub media_library_path: String,
pub config: Arc<AppConfig>, pub config: Arc<AppConfig>,
} }

View File

@@ -3,7 +3,9 @@ use std::{path::PathBuf, sync::Arc};
use futures_util::StreamExt; use futures_util::StreamExt;
use libertas_core::plugins::PluginContext; use libertas_core::plugins::PluginContext;
use libertas_infra::factory::{ 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 serde::Deserialize;
use tokio::fs; use tokio::fs;
@@ -37,13 +39,18 @@ async fn main() -> anyhow::Result<()> {
let media_repo = build_media_repository(&config, db_pool.clone()).await?; let media_repo = build_media_repository(&config, db_pool.clone()).await?;
let album_repo = build_album_repository(&config.database, 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 user_repo = build_user_repository(&config.database, db_pool.clone()).await?;
let metadata_repo = let metadata_repo = build_media_metadata_repository(&config.database, db_pool.clone()).await?;
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 { let context = Arc::new(PluginContext {
media_repo, media_repo,
album_repo, album_repo,
user_repo, user_repo,
tag_repo,
person_repo,
face_region_repo,
metadata_repo, metadata_repo,
media_library_path: config.media_library_path.clone(), media_library_path: config.media_library_path.clone(),
config: Arc::new(config.clone()), config: Arc::new(config.clone()),

View File

@@ -3,11 +3,15 @@ use std::path::PathBuf;
use async_trait::async_trait; use async_trait::async_trait;
use libertas_core::{ use libertas_core::{
error::{CoreError, CoreResult}, error::{CoreError, CoreResult},
models::Media, media_utils::is_invalid_exif_tag,
models::{FaceRegion, Media, MediaMetadataSource, Tag},
plugins::{MediaProcessorPlugin, PluginContext, PluginData}, plugins::{MediaProcessorPlugin, PluginContext, PluginData},
}; };
use tokio::fs; use tokio::fs;
use xmp_toolkit::{XmpMeta, XmpValue}; use xmp_toolkit::{
XmpMeta, XmpValue,
xmp_ns::{DC, EXIF, XMP},
};
pub struct XmpWriterPlugin; pub struct XmpWriterPlugin;
@@ -19,31 +23,52 @@ impl MediaProcessorPlugin for XmpWriterPlugin {
async fn process(&self, media: &Media, context: &PluginContext) -> CoreResult<PluginData> { async fn process(&self, media: &Media, context: &PluginContext) -> CoreResult<PluginData> {
let metadata = context.metadata_repo.find_by_media_id(media.id).await?; let metadata = context.metadata_repo.find_by_media_id(media.id).await?;
let tags: Vec<Tag> = context.tag_repo.list_tags_for_media(media.id).await?;
let faces: Vec<FaceRegion> = 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 file_path = PathBuf::from(&context.media_library_path).join(&media.storage_path);
let xmp_path = format!("{}.xmp", file_path.to_string_lossy()); let xmp_path = format!("{}.xmp", file_path.to_string_lossy());
let mut xmp = XmpMeta::new() let mut xmp = XmpMeta::new()
.map_err(|e| CoreError::Unknown(format!("Failed to create new XMP metadata: {}", e)))?; .map_err(|e| CoreError::Unknown(format!("Failed to create new XMP metadata: {}", e)))?;
xmp.set_property( XmpMeta::register_namespace(DC, "dc")
"http://purl.org/dc/elements/1.1/", .map_err(|e| CoreError::Unknown(format!("Failed to register DC namespace: {}", e)))?;
"description", XmpMeta::register_namespace(EXIF, "exif")
&XmpValue::from(media.original_filename.as_str()), .map_err(|e| CoreError::Unknown(format!("Failed to register EXIF namespace: {}", e)))?;
) XmpMeta::register_namespace(XMP, "xmp")
.map_err(|e| { .map_err(|e| CoreError::Unknown(format!("Failed to register XMP namespace: {}", e)))?;
CoreError::Unknown(format!("Failed to set description property in XMP: {}", e))
})?;
if let Some(date_tag) = metadata.iter().find(|m| m.tag_name == "DateTimeOriginal") { set_xmp_prop(&mut xmp, DC, "title", &media.original_filename);
let date_str = &date_tag.tag_value;
xmp.set_property( for meta in metadata {
"http://ns.adobe.com/exif/1.0/", match meta.source {
"DateTimeOriginal", MediaMetadataSource::Exif => {
&XmpValue::from(date_str.as_str()), if is_invalid_exif_tag(&meta.tag_name, &meta.tag_value) {
) continue;
.map_err(|e| { }
CoreError::Unknown(format!("Failed to set DateTimeOriginal in XMP: {}", e))
})?; 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(); 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", &region_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(())
}