feat: Enhance XMP writing capabilities with face region and tag support
This commit is contained in:
@@ -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);
|
||||||
|
|
||||||
@@ -163,3 +173,20 @@ pub fn get_storage_path_and_date(
|
|||||||
|
|
||||||
(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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()),
|
||||||
|
|||||||
@@ -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", ®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(())
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user