feat: thumbnail generator plugin with configurable size/format

- ThumbnailGeneratorPort in domain (bytes + config → resized bytes)
- adapters-thumbnail: ImageThumbnailGenerator using image crate
- ThumbnailGeneratorPlugin reads width/height/format/profile from step config
- PostgresDerivativeRepository + 012_derivatives migration
- Seeded in extract_metadata pipeline as step 2 (300x300 webp)
- Standalone generate_derivative pipeline for on-demand use
This commit is contained in:
2026-05-31 20:44:55 +02:00
parent 45669ec848
commit 35d5baf7be
15 changed files with 1155 additions and 18 deletions

View File

@@ -16,6 +16,7 @@ adapters-storage = { workspace = true }
adapters-nats = { workspace = true }
event-transport = { workspace = true }
adapters-exif = { workspace = true }
adapters-thumbnail = { workspace = true }
async-nats = { workspace = true }
futures = { workspace = true }

View File

@@ -1,7 +1,7 @@
use adapters_postgres::{
PostgresAssetMetadataRepository, PostgresAssetRepository, PostgresJobBatchRepository,
PostgresJobRepository, PostgresPipelineRepository, PostgresPluginRepository,
PostgresSidecarRepository,
PostgresAssetMetadataRepository, PostgresAssetRepository, PostgresDerivativeRepository,
PostgresJobBatchRepository, PostgresJobRepository, PostgresPipelineRepository,
PostgresPluginRepository, PostgresSidecarRepository,
};
use std::sync::Arc;
@@ -12,6 +12,7 @@ pub struct Repos {
pub plugin: Arc<PostgresPluginRepository>,
pub asset: Arc<PostgresAssetRepository>,
pub metadata: Arc<PostgresAssetMetadataRepository>,
pub derivative: Arc<PostgresDerivativeRepository>,
pub sidecar: Arc<PostgresSidecarRepository>,
}
@@ -24,6 +25,7 @@ impl Repos {
plugin: Arc::new(PostgresPluginRepository::new(pool.clone())),
asset: Arc::new(PostgresAssetRepository::new(pool.clone())),
metadata: Arc::new(PostgresAssetMetadataRepository::new(pool.clone())),
derivative: Arc::new(PostgresDerivativeRepository::new(pool.clone())),
sidecar: Arc::new(PostgresSidecarRepository::new(pool)),
}
}

View File

@@ -1,6 +1,8 @@
use crate::plugin_registry::InMemoryPluginRegistry;
use crate::plugins::{MetadataExtractorPlugin, NoOpPlugin, SidecarSyncPlugin};
use domain::ports::{MetadataExtractorPort, SidecarWriterPort};
use crate::plugins::{
MetadataExtractorPlugin, NoOpPlugin, SidecarSyncPlugin, ThumbnailGeneratorPlugin,
};
use domain::ports::{MetadataExtractorPort, SidecarWriterPort, ThumbnailGeneratorPort};
use std::sync::Arc;
use super::Repos;
@@ -10,16 +12,23 @@ pub fn build_plugin_registry(
file_storage: Arc<dyn domain::ports::FileStoragePort>,
sidecar_writer: Arc<dyn SidecarWriterPort>,
extractor: Arc<dyn MetadataExtractorPort>,
thumbnail_gen: Arc<dyn ThumbnailGeneratorPort>,
) -> InMemoryPluginRegistry {
let mut registry = InMemoryPluginRegistry::new();
registry.register(Arc::new(NoOpPlugin));
registry.register(Arc::new(MetadataExtractorPlugin::new(
repos.asset.clone(),
file_storage,
file_storage.clone(),
repos.metadata.clone(),
extractor,
)));
registry.register(Arc::new(ThumbnailGeneratorPlugin::new(
repos.asset.clone(),
file_storage,
repos.derivative.clone(),
thumbnail_gen,
)));
let export_handler = Arc::new(application::sidecar::ExportSidecarHandler::new(
repos.metadata.clone(),

View File

@@ -53,11 +53,14 @@ async fn main() -> anyhow::Result<()> {
let extractor: Arc<dyn domain::ports::MetadataExtractorPort> =
Arc::new(adapters_exif::NomExifExtractor);
let thumbnail_gen: Arc<dyn domain::ports::ThumbnailGeneratorPort> =
Arc::new(adapters_thumbnail::ImageThumbnailGenerator);
let registry = Arc::new(build_plugin_registry(
&repos,
file_storage,
sidecar_writer,
extractor,
thumbnail_gen,
));
let process_next = Arc::new(build_process_next_handler(
&repos,

View File

@@ -1,7 +1,9 @@
pub mod metadata_extractor;
pub mod no_op;
pub mod sidecar_sync;
pub mod thumbnail_generator;
pub use metadata_extractor::MetadataExtractorPlugin;
pub use no_op::NoOpPlugin;
pub use sidecar_sync::SidecarSyncPlugin;
pub use thumbnail_generator::ThumbnailGeneratorPlugin;

View File

@@ -0,0 +1,138 @@
use async_trait::async_trait;
use domain::{
entities::{DerivativeAsset, DerivativeProfile},
errors::DomainError,
ports::{
AssetRepository, DerivativeRepository, FileStoragePort, PluginExecutor,
ThumbnailGeneratorPort,
},
value_objects::{MetadataValue, StructuredData, SystemId},
};
use std::sync::Arc;
use tracing::info;
pub struct ThumbnailGeneratorPlugin {
asset_repo: Arc<dyn AssetRepository>,
file_storage: Arc<dyn FileStoragePort>,
derivative_repo: Arc<dyn DerivativeRepository>,
thumbnail_gen: Arc<dyn ThumbnailGeneratorPort>,
}
impl ThumbnailGeneratorPlugin {
pub fn new(
asset_repo: Arc<dyn AssetRepository>,
file_storage: Arc<dyn FileStoragePort>,
derivative_repo: Arc<dyn DerivativeRepository>,
thumbnail_gen: Arc<dyn ThumbnailGeneratorPort>,
) -> Self {
Self {
asset_repo,
file_storage,
derivative_repo,
thumbnail_gen,
}
}
}
fn parse_profile(s: &str) -> DerivativeProfile {
match s {
"ThumbnailLarge" => DerivativeProfile::ThumbnailLarge,
"WebOptimized" => DerivativeProfile::WebOptimized,
"VideoSd" => DerivativeProfile::VideoSd,
_ => DerivativeProfile::ThumbnailSquare,
}
}
fn format_extension(format: &str) -> &str {
match format {
"jpeg" | "jpg" => "jpg",
"png" => "png",
_ => "webp",
}
}
#[async_trait]
impl PluginExecutor for ThumbnailGeneratorPlugin {
fn plugin_name(&self) -> &str {
"thumbnail_generator"
}
async fn execute(
&self,
asset_id: Option<SystemId>,
_payload: &StructuredData,
config: &StructuredData,
) -> Result<StructuredData, DomainError> {
let asset_id = asset_id.ok_or_else(|| {
DomainError::Validation("thumbnail_generator requires asset_id".into())
})?;
let width = config
.get_string("width")
.and_then(|s| s.parse().ok())
.unwrap_or(300u32);
let height = config
.get_string("height")
.and_then(|s| s.parse().ok())
.unwrap_or(300u32);
let format = config.get_string("format").unwrap_or("webp");
let profile = config
.get_string("profile")
.map(parse_profile)
.unwrap_or(DerivativeProfile::ThumbnailSquare);
let asset = self
.asset_repo
.find_by_id(&asset_id)
.await?
.ok_or_else(|| DomainError::NotFound(format!("Asset {} not found", asset_id)))?;
if !asset.mime_type.starts_with("image/") {
return Ok(StructuredData::new());
}
let source_bytes = self
.file_storage
.read_file(&asset.source_reference.relative_path)
.await?;
let output = self
.thumbnail_gen
.generate(&source_bytes, width, height, format)?;
let ext = format_extension(format);
let storage_path = format!("derivatives/{asset_id}_{profile:?}.{ext}");
let byte_len = output.bytes.len() as u64;
self.file_storage
.store_file(&storage_path, output.bytes)
.await?;
let mut derivative = match self
.derivative_repo
.find_by_asset_and_profile(&asset_id, profile)
.await?
{
Some(d) => d,
None => DerivativeAsset::new_pending(asset_id, profile, &storage_path),
};
derivative.storage_path = storage_path.clone();
derivative.mark_ready(&output.mime_type, byte_len, (output.width, output.height));
self.derivative_repo.save(&derivative).await?;
let mut result = StructuredData::new();
result.insert("thumbnail_path", MetadataValue::String(storage_path));
result.insert(
"thumbnail_width",
MetadataValue::Integer(output.width as i64),
);
result.insert(
"thumbnail_height",
MetadataValue::Integer(output.height as i64),
);
info!(asset_id = %asset_id, w = output.width, h = output.height, "thumbnail generated");
Ok(result)
}
}