diff --git a/crates/adapters/wrapup-renderer/src/lib.rs b/crates/adapters/wrapup-renderer/src/lib.rs index 412403c..743ed17 100644 --- a/crates/adapters/wrapup-renderer/src/lib.rs +++ b/crates/adapters/wrapup-renderer/src/lib.rs @@ -4,7 +4,7 @@ mod slides; use async_trait::async_trait; use domain::errors::DomainError; use domain::models::wrapup::WrapUpReport; -use domain::ports::{VideoRenderConfig, WrapUpVideoRenderer}; +use domain::ports::{VideoRenderAssets, VideoRenderConfig, WrapUpVideoRenderer}; #[derive(Default)] pub struct FfmpegWrapUpRenderer; @@ -20,35 +20,36 @@ impl WrapUpVideoRenderer for FfmpegWrapUpRenderer { async fn render( &self, report: &WrapUpReport, - poster_images: Vec<(String, Vec)>, + assets: VideoRenderAssets, config: &VideoRenderConfig, ) -> Result, DomainError> { let (width, height) = config.resolution; - let renderer = - slides::SlideRenderer::new(config.font_path.as_deref(), config.logo_path.as_deref())?; + let renderer = slides::SlideRenderer::new( + config.font_path.as_deref(), + config.logo_path.as_deref(), + config.bg_dir.as_deref(), + )?; - // 1. Generate slide images let mut slide_pngs = Vec::new(); slide_pngs.push(renderer.render_hero(report, width, height)?); slide_pngs.push(renderer.render_ratings(report, width, height)?); if !report.top_directors.is_empty() { - slide_pngs.push(renderer.render_directors(report, width, height)?); + slide_pngs.push(renderer.render_directors(report, &assets.cast_images, width, height)?); } if !report.top_actors.is_empty() { - slide_pngs.push(renderer.render_actors(report, width, height)?); + slide_pngs.push(renderer.render_actors(report, &assets.cast_images, width, height)?); } if !report.top_genres.is_empty() { slide_pngs.push(renderer.render_genres(report, width, height)?); } - slide_pngs.push(renderer.render_highlights(report, width, height)?); - if !poster_images.is_empty() { - slide_pngs.push(renderer.render_mosaic(&poster_images, width, height)?); + slide_pngs.push(renderer.render_highlights(report, &assets.poster_images, width, height)?); + if !assets.poster_images.is_empty() { + slide_pngs.push(renderer.render_mosaic(&assets.poster_images, width, height)?); } else { tracing::warn!("no poster images resolved, skipping mosaic slide"); } - // 2. Stitch into video ffmpeg::stitch_slides(&slide_pngs, config).await } } diff --git a/crates/application/src/config.rs b/crates/application/src/config.rs index 82d16df..ab17dbd 100644 --- a/crates/application/src/config.rs +++ b/crates/application/src/config.rs @@ -10,6 +10,7 @@ pub struct AppConfig { pub struct WrapUpConfig { pub font_path: Option, pub logo_path: Option, + pub bg_dir: Option, pub ffmpeg_path: String, pub max_concurrent_renders: usize, } @@ -39,6 +40,7 @@ impl WrapUpConfig { Self { font_path: std::env::var("WRAPUP_FONT_PATH").ok(), logo_path: std::env::var("WRAPUP_LOGO_PATH").ok(), + bg_dir: std::env::var("WRAPUP_BG_DIR").ok(), ffmpeg_path: std::env::var("FFMPEG_PATH").unwrap_or_else(|_| "ffmpeg".to_string()), max_concurrent_renders: std::env::var("WRAPUP_MAX_CONCURRENT") .ok() diff --git a/crates/application/src/test_helpers.rs b/crates/application/src/test_helpers.rs index 0c0df97..5b572ce 100644 --- a/crates/application/src/test_helpers.rs +++ b/crates/application/src/test_helpers.rs @@ -95,6 +95,7 @@ impl TestContextBuilder { wrapup: crate::config::WrapUpConfig { font_path: None, logo_path: None, + bg_dir: None, ffmpeg_path: "ffmpeg".into(), max_concurrent_renders: 2, }, diff --git a/crates/application/src/wrapup/handle_requested.rs b/crates/application/src/wrapup/handle_requested.rs index 34e09d3..5adf7dc 100644 --- a/crates/application/src/wrapup/handle_requested.rs +++ b/crates/application/src/wrapup/handle_requested.rs @@ -3,7 +3,7 @@ use crate::wrapup::{compute, queries::ComputeWrapUpQuery}; use domain::errors::DomainError; use domain::events::DomainEvent; use domain::models::wrapup::{DateRange, WrapUpReport, WrapUpScope, WrapUpStatus}; -use domain::ports::VideoRenderConfig; +use domain::ports::{VideoRenderAssets, VideoRenderConfig}; use domain::value_objects::WrapUpId; pub async fn execute( @@ -39,9 +39,10 @@ pub async fn execute( .set_complete(&wrapup_id, &json) .await?; - // Optionally render video (non-fatal) if let Some(ref renderer) = ctx.services.video_renderer { - let poster_images = resolve_poster_images(ctx, &report).await; + let poster_images = resolve_images(ctx, &report.poster_paths, "poster").await; + let cast_images = + resolve_images(ctx, &report.top_cast_profile_paths, "cast").await; let wc = &ctx.config.wrapup; let config = VideoRenderConfig { slide_duration_secs: 4, @@ -50,8 +51,13 @@ pub async fn execute( ffmpeg_path: wc.ffmpeg_path.clone(), font_path: wc.font_path.clone(), logo_path: wc.logo_path.clone(), + bg_dir: wc.bg_dir.clone(), }; - match renderer.render(&report, poster_images, &config).await { + let assets = VideoRenderAssets { + poster_images, + cast_images, + }; + match renderer.render(&report, assets, &config).await { Ok(video_bytes) => { let video_key = format!("wrapups/{}/video.mp4", wrapup_id.value()); if let Err(e) = ctx @@ -85,14 +91,18 @@ pub async fn execute( } } -async fn resolve_poster_images(ctx: &AppContext, report: &WrapUpReport) -> Vec<(String, Vec)> { +async fn resolve_images( + ctx: &AppContext, + paths: &[String], + label: &str, +) -> Vec<(String, Vec)> { let mut images = Vec::new(); - for path in report.poster_paths.iter().take(20) { + for path in paths.iter().take(20) { match ctx.services.image_storage.get(path).await { Ok(bytes) => images.push((path.clone(), bytes)), - Err(e) => tracing::debug!("poster fetch skipped for {path}: {e}"), + Err(e) => tracing::debug!("{label} fetch skipped for {path}: {e}"), } } - tracing::info!("resolved {}/{} poster images for video", images.len(), report.poster_paths.len()); + tracing::info!("resolved {}/{} {label} images", images.len(), paths.len()); images } diff --git a/crates/domain/src/ports.rs b/crates/domain/src/ports.rs index 0cecc92..66403a6 100644 --- a/crates/domain/src/ports.rs +++ b/crates/domain/src/ports.rs @@ -536,6 +536,12 @@ pub struct VideoRenderConfig { pub ffmpeg_path: String, pub font_path: Option, pub logo_path: Option, + pub bg_dir: Option, +} + +pub struct VideoRenderAssets { + pub poster_images: Vec<(String, Vec)>, + pub cast_images: Vec<(String, Vec)>, } #[async_trait] @@ -543,7 +549,7 @@ pub trait WrapUpVideoRenderer: Send + Sync { async fn render( &self, report: &WrapUpReport, - poster_images: Vec<(String, Vec)>, + assets: VideoRenderAssets, config: &VideoRenderConfig, ) -> Result, DomainError>; } diff --git a/crates/presentation/src/tests/extractors.rs b/crates/presentation/src/tests/extractors.rs index b8614b1..01d1d58 100644 --- a/crates/presentation/src/tests/extractors.rs +++ b/crates/presentation/src/tests/extractors.rs @@ -677,6 +677,7 @@ pub fn make_test_state(auth_service: Arc) -> crate::state::AppS wrapup: application::config::WrapUpConfig { font_path: None, logo_path: None, + bg_dir: None, ffmpeg_path: "ffmpeg".into(), max_concurrent_renders: 2, }, diff --git a/crates/presentation/tests/api_test.rs b/crates/presentation/tests/api_test.rs index 8bf0cd7..5b59745 100644 --- a/crates/presentation/tests/api_test.rs +++ b/crates/presentation/tests/api_test.rs @@ -443,6 +443,7 @@ async fn test_app() -> Router { wrapup: application::config::WrapUpConfig { font_path: None, logo_path: None, + bg_dir: None, ffmpeg_path: "ffmpeg".into(), max_concurrent_renders: 2, },