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 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<DateTime<Utc>> {
None
}
pub fn extract_exif_data_from_bytes(
bytes: &[u8],
) -> CoreResult<ExtractedExif> {
pub fn extract_exif_data_from_bytes(bytes: &[u8]) -> CoreResult<ExtractedExif> {
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<ExtractedExif> {
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<DateTime<Utc>>) {
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);
@@ -163,3 +173,20 @@ pub fn get_storage_path_and_date(
(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 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<dyn AlbumRepository>,
pub user_repo: Arc<dyn UserRepository>,
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 config: Arc<AppConfig>,
}

View File

@@ -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()),

View File

@@ -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<PluginData> {
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 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", &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(())
}