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:
@@ -1,17 +1,28 @@
|
||||
-- Default plugins matching worker's InMemoryPluginRegistry
|
||||
INSERT INTO plugins (plugin_id, name, plugin_type, is_enabled, configuration)
|
||||
VALUES
|
||||
('a0000000-0000-4000-8000-000000000001', 'metadata_extractor', 'media_processor', true, '{}'),
|
||||
('a0000000-0000-4000-8000-000000000002', 'sidecar_sync', 'sidecar_writer', true, '{}'),
|
||||
('a0000000-0000-4000-8000-000000000003', 'no_op', 'scheduled_task', true, '{}')
|
||||
('a0000000-0000-4000-8000-000000000001', 'metadata_extractor', 'media_processor', true, '{}'),
|
||||
('a0000000-0000-4000-8000-000000000002', 'sidecar_sync', 'sidecar_writer', true, '{}'),
|
||||
('a0000000-0000-4000-8000-000000000003', 'no_op', 'scheduled_task', true, '{}'),
|
||||
('a0000000-0000-4000-8000-000000000004', 'thumbnail_generator', 'media_processor', true, '{}')
|
||||
ON CONFLICT (plugin_id) DO NOTHING;
|
||||
|
||||
-- Pipeline: extract_metadata → metadata_extractor
|
||||
-- Pipeline: extract_metadata → metadata_extractor, then thumbnail_generator
|
||||
INSERT INTO processing_pipelines (pipeline_id, trigger_event, steps)
|
||||
VALUES (
|
||||
'b0000000-0000-4000-8000-000000000001',
|
||||
'extract_metadata',
|
||||
'[{"plugin_id": "a0000000-0000-4000-8000-000000000001", "step_order": 0, "configuration": {}}]'
|
||||
'[{"plugin_id": "a0000000-0000-4000-8000-000000000001", "step_order": 0, "configuration": {}},
|
||||
{"plugin_id": "a0000000-0000-4000-8000-000000000004", "step_order": 1, "configuration": {"width": "300", "height": "300", "format": "webp", "profile": "ThumbnailSquare"}}]'
|
||||
)
|
||||
ON CONFLICT (pipeline_id) DO NOTHING;
|
||||
|
||||
-- Pipeline: generate_derivative (standalone, configurable per-step)
|
||||
INSERT INTO processing_pipelines (pipeline_id, trigger_event, steps)
|
||||
VALUES (
|
||||
'b0000000-0000-4000-8000-000000000003',
|
||||
'generate_derivative',
|
||||
'[{"plugin_id": "a0000000-0000-4000-8000-000000000004", "step_order": 0, "configuration": {"width": "300", "height": "300", "format": "webp", "profile": "ThumbnailSquare"}}]'
|
||||
)
|
||||
ON CONFLICT (pipeline_id) DO NOTHING;
|
||||
|
||||
|
||||
14
crates/adapters/postgres/migrations/012_derivatives.sql
Normal file
14
crates/adapters/postgres/migrations/012_derivatives.sql
Normal file
@@ -0,0 +1,14 @@
|
||||
CREATE TABLE derivatives (
|
||||
derivative_id UUID PRIMARY KEY,
|
||||
parent_asset_id UUID NOT NULL REFERENCES assets(asset_id),
|
||||
profile_type TEXT NOT NULL,
|
||||
storage_path TEXT NOT NULL,
|
||||
mime_type TEXT NOT NULL DEFAULT '',
|
||||
file_size BIGINT NOT NULL DEFAULT 0,
|
||||
width INTEGER NOT NULL DEFAULT 0,
|
||||
height INTEGER NOT NULL DEFAULT 0,
|
||||
generation_status TEXT NOT NULL DEFAULT 'pending'
|
||||
);
|
||||
|
||||
CREATE INDEX idx_derivatives_parent ON derivatives(parent_asset_id);
|
||||
CREATE INDEX idx_derivatives_parent_profile ON derivatives(parent_asset_id, profile_type);
|
||||
@@ -3,11 +3,12 @@ use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use domain::{
|
||||
entities::{
|
||||
Asset, AssetMetadata, AssetType, DetectionMethod, DuplicateCandidate, DuplicateGroup,
|
||||
DuplicateStatus, MetadataSource, SourceReference,
|
||||
Asset, AssetMetadata, AssetType, DerivativeAsset, DerivativeProfile, DetectionMethod,
|
||||
DuplicateCandidate, DuplicateGroup, DuplicateStatus, GenerationStatus, MetadataSource,
|
||||
SourceReference,
|
||||
},
|
||||
errors::DomainError,
|
||||
ports::{AssetMetadataRepository, AssetRepository, DuplicateRepository},
|
||||
ports::{AssetMetadataRepository, AssetRepository, DerivativeRepository, DuplicateRepository},
|
||||
value_objects::{Checksum, DateTimeStamp, MetadataValue, StructuredData, SystemId},
|
||||
};
|
||||
use uuid::Uuid;
|
||||
@@ -452,3 +453,147 @@ impl DuplicateRepository for PostgresDuplicateRepository {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ── DerivativeRepository ──────────────────────────────────────────────
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct DerivativeRow {
|
||||
derivative_id: Uuid,
|
||||
parent_asset_id: Uuid,
|
||||
profile_type: String,
|
||||
storage_path: String,
|
||||
mime_type: String,
|
||||
file_size: i64,
|
||||
width: i32,
|
||||
height: i32,
|
||||
generation_status: String,
|
||||
}
|
||||
|
||||
fn profile_from_str(s: &str) -> DerivativeProfile {
|
||||
match s {
|
||||
"thumbnail_square" => DerivativeProfile::ThumbnailSquare,
|
||||
"thumbnail_large" => DerivativeProfile::ThumbnailLarge,
|
||||
"web_optimized" => DerivativeProfile::WebOptimized,
|
||||
"video_sd" => DerivativeProfile::VideoSd,
|
||||
_ => DerivativeProfile::ThumbnailSquare,
|
||||
}
|
||||
}
|
||||
|
||||
fn profile_to_str(p: &DerivativeProfile) -> &'static str {
|
||||
match p {
|
||||
DerivativeProfile::ThumbnailSquare => "thumbnail_square",
|
||||
DerivativeProfile::ThumbnailLarge => "thumbnail_large",
|
||||
DerivativeProfile::WebOptimized => "web_optimized",
|
||||
DerivativeProfile::VideoSd => "video_sd",
|
||||
}
|
||||
}
|
||||
|
||||
fn gen_status_from_str(s: &str) -> GenerationStatus {
|
||||
match s {
|
||||
"pending" => GenerationStatus::Pending,
|
||||
"ready" => GenerationStatus::Ready,
|
||||
"failed" => GenerationStatus::Failed,
|
||||
_ => GenerationStatus::Pending,
|
||||
}
|
||||
}
|
||||
|
||||
fn gen_status_to_str(s: &GenerationStatus) -> &'static str {
|
||||
match s {
|
||||
GenerationStatus::Pending => "pending",
|
||||
GenerationStatus::Ready => "ready",
|
||||
GenerationStatus::Failed => "failed",
|
||||
}
|
||||
}
|
||||
|
||||
impl From<DerivativeRow> for DerivativeAsset {
|
||||
fn from(r: DerivativeRow) -> Self {
|
||||
Self {
|
||||
derivative_id: SystemId::from_uuid(r.derivative_id),
|
||||
parent_asset_id: SystemId::from_uuid(r.parent_asset_id),
|
||||
profile_type: profile_from_str(&r.profile_type),
|
||||
storage_path: r.storage_path,
|
||||
mime_type: r.mime_type,
|
||||
file_size: r.file_size as u64,
|
||||
dimensions: (r.width as u32, r.height as u32),
|
||||
generation_status: gen_status_from_str(&r.generation_status),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pg_repo!(PostgresDerivativeRepository);
|
||||
|
||||
#[async_trait]
|
||||
impl DerivativeRepository for PostgresDerivativeRepository {
|
||||
async fn find_by_asset(
|
||||
&self,
|
||||
asset_id: &SystemId,
|
||||
) -> Result<Vec<DerivativeAsset>, DomainError> {
|
||||
let rows = sqlx::query_as::<_, DerivativeRow>(
|
||||
"SELECT derivative_id, parent_asset_id, profile_type, storage_path,
|
||||
mime_type, file_size, width, height, generation_status
|
||||
FROM derivatives WHERE parent_asset_id = $1",
|
||||
)
|
||||
.bind(*asset_id.as_uuid())
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_pg()?;
|
||||
|
||||
Ok(rows.into_iter().map(Into::into).collect())
|
||||
}
|
||||
|
||||
async fn find_by_asset_and_profile(
|
||||
&self,
|
||||
asset_id: &SystemId,
|
||||
profile: DerivativeProfile,
|
||||
) -> Result<Option<DerivativeAsset>, DomainError> {
|
||||
let row = sqlx::query_as::<_, DerivativeRow>(
|
||||
"SELECT derivative_id, parent_asset_id, profile_type, storage_path,
|
||||
mime_type, file_size, width, height, generation_status
|
||||
FROM derivatives WHERE parent_asset_id = $1 AND profile_type = $2",
|
||||
)
|
||||
.bind(*asset_id.as_uuid())
|
||||
.bind(profile_to_str(&profile))
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_pg()?;
|
||||
|
||||
Ok(row.map(Into::into))
|
||||
}
|
||||
|
||||
async fn save(&self, d: &DerivativeAsset) -> Result<(), DomainError> {
|
||||
sqlx::query(
|
||||
"INSERT INTO derivatives (derivative_id, parent_asset_id, profile_type, storage_path,
|
||||
mime_type, file_size, width, height, generation_status)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
ON CONFLICT (derivative_id) DO UPDATE SET
|
||||
storage_path = EXCLUDED.storage_path,
|
||||
mime_type = EXCLUDED.mime_type,
|
||||
file_size = EXCLUDED.file_size,
|
||||
width = EXCLUDED.width,
|
||||
height = EXCLUDED.height,
|
||||
generation_status = EXCLUDED.generation_status",
|
||||
)
|
||||
.bind(*d.derivative_id.as_uuid())
|
||||
.bind(*d.parent_asset_id.as_uuid())
|
||||
.bind(profile_to_str(&d.profile_type))
|
||||
.bind(&d.storage_path)
|
||||
.bind(&d.mime_type)
|
||||
.bind(d.file_size as i64)
|
||||
.bind(d.dimensions.0 as i32)
|
||||
.bind(d.dimensions.1 as i32)
|
||||
.bind(gen_status_to_str(&d.generation_status))
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_pg()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete(&self, id: &SystemId) -> Result<(), DomainError> {
|
||||
sqlx::query("DELETE FROM derivatives WHERE derivative_id = $1")
|
||||
.bind(*id.as_uuid())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_pg()?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
9
crates/adapters/thumbnail/Cargo.toml
Normal file
9
crates/adapters/thumbnail/Cargo.toml
Normal file
@@ -0,0 +1,9 @@
|
||||
[package]
|
||||
name = "adapters-thumbnail"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
domain = { workspace = true }
|
||||
bytes = { workspace = true }
|
||||
image = "0.25"
|
||||
57
crates/adapters/thumbnail/src/lib.rs
Normal file
57
crates/adapters/thumbnail/src/lib.rs
Normal file
@@ -0,0 +1,57 @@
|
||||
use bytes::Bytes;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
ports::{ThumbnailGeneratorPort, ThumbnailOutput},
|
||||
};
|
||||
use image::{DynamicImage, ImageFormat, load_from_memory};
|
||||
use std::io::Cursor;
|
||||
|
||||
pub struct ImageThumbnailGenerator;
|
||||
|
||||
impl ThumbnailGeneratorPort for ImageThumbnailGenerator {
|
||||
fn generate(
|
||||
&self,
|
||||
source: &Bytes,
|
||||
width: u32,
|
||||
height: u32,
|
||||
format: &str,
|
||||
) -> Result<ThumbnailOutput, DomainError> {
|
||||
let img = load_from_memory(source)
|
||||
.map_err(|e| DomainError::Internal(format!("failed to decode image: {e}")))?;
|
||||
|
||||
let thumb = img.thumbnail(width, height);
|
||||
let (img_format, mime) = parse_format(format)?;
|
||||
|
||||
let encoded = encode(&thumb, img_format)?;
|
||||
let actual_width = thumb.width();
|
||||
let actual_height = thumb.height();
|
||||
|
||||
Ok(ThumbnailOutput {
|
||||
bytes: Bytes::from(encoded),
|
||||
width: actual_width,
|
||||
height: actual_height,
|
||||
mime_type: mime.to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_format(s: &str) -> Result<(ImageFormat, &'static str), DomainError> {
|
||||
match s {
|
||||
"jpeg" | "jpg" => Ok((ImageFormat::Jpeg, "image/jpeg")),
|
||||
"webp" => Ok((ImageFormat::WebP, "image/webp")),
|
||||
"png" => Ok((ImageFormat::Png, "image/png")),
|
||||
other => Err(DomainError::Validation(format!(
|
||||
"unsupported thumbnail format: {other}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn encode(img: &DynamicImage, format: ImageFormat) -> Result<Vec<u8>, DomainError> {
|
||||
let mut buf = Cursor::new(Vec::new());
|
||||
img.write_to(&mut buf, format)
|
||||
.map_err(|e| DomainError::Internal(format!("failed to encode thumbnail: {e}")))?;
|
||||
Ok(buf.into_inner())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
66
crates/adapters/thumbnail/src/tests.rs
Normal file
66
crates/adapters/thumbnail/src/tests.rs
Normal file
@@ -0,0 +1,66 @@
|
||||
use crate::ImageThumbnailGenerator;
|
||||
use bytes::Bytes;
|
||||
use domain::ports::ThumbnailGeneratorPort as _;
|
||||
|
||||
fn make_test_png() -> Bytes {
|
||||
use image::{ImageFormat, RgbImage};
|
||||
use std::io::Cursor;
|
||||
|
||||
let img = RgbImage::new(100, 200);
|
||||
let mut buf = Cursor::new(Vec::new());
|
||||
img.write_to(&mut buf, ImageFormat::Png).unwrap();
|
||||
Bytes::from(buf.into_inner())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generates_jpeg_thumbnail() {
|
||||
let generator = ImageThumbnailGenerator;
|
||||
let source = make_test_png();
|
||||
|
||||
let out = generator.generate(&source, 50, 50, "jpeg").unwrap();
|
||||
|
||||
assert!(out.width <= 50);
|
||||
assert!(out.height <= 50);
|
||||
assert_eq!(out.mime_type, "image/jpeg");
|
||||
assert!(!out.bytes.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generates_webp_thumbnail() {
|
||||
let generator = ImageThumbnailGenerator;
|
||||
let source = make_test_png();
|
||||
|
||||
let out = generator.generate(&source, 30, 30, "webp").unwrap();
|
||||
|
||||
assert!(out.width <= 30);
|
||||
assert!(out.height <= 30);
|
||||
assert_eq!(out.mime_type, "image/webp");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_aspect_ratio() {
|
||||
let generator = ImageThumbnailGenerator;
|
||||
let source = make_test_png(); // 100x200
|
||||
|
||||
let out = generator.generate(&source, 50, 50, "png").unwrap();
|
||||
|
||||
// 100x200 → fits in 50x50 → 25x50
|
||||
assert_eq!(out.width, 25);
|
||||
assert_eq!(out.height, 50);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unsupported_format() {
|
||||
let generator = ImageThumbnailGenerator;
|
||||
let source = make_test_png();
|
||||
|
||||
let result = generator.generate(&source, 50, 50, "bmp");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_garbage_input() {
|
||||
let generator = ImageThumbnailGenerator;
|
||||
let result = generator.generate(&Bytes::from_static(b"not an image"), 50, 50, "jpeg");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
@@ -81,3 +81,22 @@ pub trait DuplicateRepository: Send + Sync {
|
||||
pub trait MetadataExtractorPort: Send + Sync {
|
||||
fn extract(&self, bytes: &Bytes) -> Result<StructuredData, DomainError>;
|
||||
}
|
||||
|
||||
// --- ThumbnailGeneratorPort ---
|
||||
|
||||
pub struct ThumbnailOutput {
|
||||
pub bytes: Bytes,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub mime_type: String,
|
||||
}
|
||||
|
||||
pub trait ThumbnailGeneratorPort: Send + Sync {
|
||||
fn generate(
|
||||
&self,
|
||||
source: &Bytes,
|
||||
width: u32,
|
||||
height: u32,
|
||||
format: &str,
|
||||
) -> Result<ThumbnailOutput, DomainError>;
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
138
crates/worker/src/plugins/thumbnail_generator.rs
Normal file
138
crates/worker/src/plugins/thumbnail_generator.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user