diff --git a/crates/adapters/wrapup-renderer/src/ffmpeg.rs b/crates/adapters/wrapup-renderer/src/ffmpeg.rs index 9e7dfc8..f197827 100644 --- a/crates/adapters/wrapup-renderer/src/ffmpeg.rs +++ b/crates/adapters/wrapup-renderer/src/ffmpeg.rs @@ -1,26 +1,24 @@ use domain::errors::DomainError; -use domain::ports::VideoRenderConfig; use tokio::process::Command; pub async fn stitch_slides( slides: &[Vec], - config: &VideoRenderConfig, + ffmpeg_path: &str, + slide_duration_secs: u32, + resolution: (u32, u32), ) -> Result, DomainError> { let dir = tempfile::tempdir().map_err(|e| DomainError::InfrastructureError(e.to_string()))?; - // Write slide PNGs for (i, png) in slides.iter().enumerate() { let path = dir.path().join(format!("slide_{:04}.png", i)); std::fs::write(&path, png).map_err(|e| DomainError::InfrastructureError(e.to_string()))?; } let output_path = dir.path().join("output.mp4"); + let framerate = format!("1/{}", slide_duration_secs); + let (w, h) = resolution; - // -framerate 1/N makes each image last N seconds - let framerate = format!("1/{}", config.slide_duration_secs); - let (w, h) = config.resolution; - - let status = Command::new(&config.ffmpeg_path) + let status = Command::new(ffmpeg_path) .args([ "-y", "-framerate", diff --git a/crates/adapters/wrapup-renderer/src/lib.rs b/crates/adapters/wrapup-renderer/src/lib.rs index be31596..ab98194 100644 --- a/crates/adapters/wrapup-renderer/src/lib.rs +++ b/crates/adapters/wrapup-renderer/src/lib.rs @@ -4,14 +4,34 @@ mod slides; use async_trait::async_trait; use domain::errors::DomainError; use domain::models::wrapup::WrapUpReport; -use domain::ports::{VideoRenderAssets, VideoRenderConfig, WrapUpVideoRenderer}; +use domain::ports::{VideoRenderAssets, WrapUpVideoRenderer}; -#[derive(Default)] -pub struct FfmpegWrapUpRenderer; +pub struct RendererConfig { + pub slide_duration_secs: u32, + pub transition_duration_secs: f32, + pub resolution: (u32, u32), + pub ffmpeg_path: String, + pub font_path: Option, + pub logo_path: Option, + pub bg_dir: Option, +} + +pub struct FfmpegWrapUpRenderer { + config: RendererConfig, + slide_renderer: slides::SlideRenderer, +} impl FfmpegWrapUpRenderer { - pub fn new() -> Self { - Self + pub fn new(config: RendererConfig) -> Result { + let slide_renderer = slides::SlideRenderer::new( + config.font_path.as_deref(), + config.logo_path.as_deref(), + config.bg_dir.as_deref(), + )?; + Ok(Self { + config, + slide_renderer, + }) } } @@ -21,21 +41,14 @@ impl WrapUpVideoRenderer for FfmpegWrapUpRenderer { &self, report: &WrapUpReport, 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(), - config.bg_dir.as_deref(), - )?; + let (width, height) = self.config.resolution; let mut slide_pngs = Vec::new(); - slide_pngs.push(renderer.render_hero(report, width, height)?); - slide_pngs.push(renderer.render_ratings(report, width, height)?); + slide_pngs.push(self.slide_renderer.render_hero(report, width, height)?); + slide_pngs.push(self.slide_renderer.render_ratings(report, width, height)?); if !report.top_directors.is_empty() { - slide_pngs.push(renderer.render_directors( + slide_pngs.push(self.slide_renderer.render_directors( report, &assets.cast_images, width, @@ -43,23 +56,35 @@ impl WrapUpVideoRenderer for FfmpegWrapUpRenderer { )?); } if !report.top_actors.is_empty() { - slide_pngs.push(renderer.render_actors(report, &assets.cast_images, width, height)?); + slide_pngs.push( + self.slide_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(self.slide_renderer.render_genres(report, width, height)?); } - slide_pngs.push(renderer.render_highlights( + slide_pngs.push(self.slide_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)?); + slide_pngs.push( + self.slide_renderer + .render_mosaic(&assets.poster_images, width, height)?, + ); } else { tracing::warn!("no poster images resolved, skipping mosaic slide"); } - ffmpeg::stitch_slides(&slide_pngs, config).await + ffmpeg::stitch_slides( + &slide_pngs, + &self.config.ffmpeg_path, + self.config.slide_duration_secs, + self.config.resolution, + ) + .await } } diff --git a/crates/application/src/wrapup/handle_requested.rs b/crates/application/src/wrapup/handle_requested.rs index 1f34608..dab6a90 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, WrapUpScope, WrapUpStatus}; -use domain::ports::{VideoRenderAssets, VideoRenderConfig}; +use domain::ports::VideoRenderAssets; use domain::value_objects::WrapUpId; pub async fn execute( @@ -55,21 +55,11 @@ pub async fn execute( .map(|p| format!("cast{p}")) .collect(); let cast_images = resolve_images(ctx, &cast_keys, "cast").await; - let wc = &ctx.config.wrapup; - let config = VideoRenderConfig { - slide_duration_secs: 4, - transition_duration_secs: 0.8, - resolution: (1080, 1920), - ffmpeg_path: wc.ffmpeg_path.clone(), - font_path: wc.font_path.clone(), - logo_path: wc.logo_path.clone(), - bg_dir: wc.bg_dir.clone(), - }; let assets = VideoRenderAssets { poster_images, cast_images, }; - match renderer.render(&report, assets, &config).await { + match renderer.render(&report, assets).await { Ok(video_bytes) => { let video_key = format!("wrapups/{}/video.mp4", wrapup_id.value()); if let Err(e) = ctx diff --git a/crates/domain/src/ports.rs b/crates/domain/src/ports.rs index 59c7f69..86e7d97 100644 --- a/crates/domain/src/ports.rs +++ b/crates/domain/src/ports.rs @@ -534,16 +534,6 @@ pub trait WrapUpStatsQuery: Send + Sync { // ── Video renderer ────────────────────────────────────────────────────────── -pub struct VideoRenderConfig { - pub slide_duration_secs: u32, - pub transition_duration_secs: f32, - pub resolution: (u32, u32), - 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)>, @@ -555,6 +545,5 @@ pub trait WrapUpVideoRenderer: Send + Sync { &self, report: &WrapUpReport, assets: VideoRenderAssets, - config: &VideoRenderConfig, ) -> Result, DomainError>; } diff --git a/crates/worker/src/main.rs b/crates/worker/src/main.rs index 0b75d0a..f396e5b 100644 --- a/crates/worker/src/main.rs +++ b/crates/worker/src/main.rs @@ -105,15 +105,32 @@ async fn main() -> anyhow::Result<()> { diary_exporter: Arc::new(ExportAdapter) as Arc, document_parser: Arc::new(ImporterDocumentParser) as Arc, video_renderer: { - let ffmpeg = &app_config.wrapup.ffmpeg_path; + let wc = &app_config.wrapup; + let ffmpeg = &wc.ffmpeg_path; if std::process::Command::new(ffmpeg) .arg("-version") .output() .is_ok() { - tracing::info!("wrapup video renderer enabled (ffmpeg={ffmpeg})"); - Some(Arc::new(wrapup_renderer::FfmpegWrapUpRenderer::new()) - as Arc) + let renderer_cfg = wrapup_renderer::RendererConfig { + slide_duration_secs: 4, + transition_duration_secs: 0.8, + resolution: (1080, 1920), + ffmpeg_path: ffmpeg.clone(), + font_path: wc.font_path.clone(), + logo_path: wc.logo_path.clone(), + bg_dir: wc.bg_dir.clone(), + }; + match wrapup_renderer::FfmpegWrapUpRenderer::new(renderer_cfg) { + Ok(r) => { + tracing::info!("wrapup video renderer enabled (ffmpeg={ffmpeg})"); + Some(Arc::new(r) as Arc) + } + Err(e) => { + tracing::warn!("wrapup video renderer init failed: {e}"); + None + } + } } else { tracing::info!("wrapup video renderer disabled (ffmpeg not found)"); None