refactor: move VideoRenderConfig from domain to adapter, inject at construction
Some checks failed
CI / Check / Test (push) Failing after 44s

This commit is contained in:
2026-06-03 01:18:52 +02:00
parent e8e83d3f16
commit 3cec726e3d
5 changed files with 75 additions and 56 deletions

View File

@@ -1,26 +1,24 @@
use domain::errors::DomainError; use domain::errors::DomainError;
use domain::ports::VideoRenderConfig;
use tokio::process::Command; use tokio::process::Command;
pub async fn stitch_slides( pub async fn stitch_slides(
slides: &[Vec<u8>], slides: &[Vec<u8>],
config: &VideoRenderConfig, ffmpeg_path: &str,
slide_duration_secs: u32,
resolution: (u32, u32),
) -> Result<Vec<u8>, DomainError> { ) -> Result<Vec<u8>, DomainError> {
let dir = tempfile::tempdir().map_err(|e| DomainError::InfrastructureError(e.to_string()))?; let dir = tempfile::tempdir().map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
// Write slide PNGs
for (i, png) in slides.iter().enumerate() { for (i, png) in slides.iter().enumerate() {
let path = dir.path().join(format!("slide_{:04}.png", i)); let path = dir.path().join(format!("slide_{:04}.png", i));
std::fs::write(&path, png).map_err(|e| DomainError::InfrastructureError(e.to_string()))?; std::fs::write(&path, png).map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
} }
let output_path = dir.path().join("output.mp4"); 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 status = Command::new(ffmpeg_path)
let framerate = format!("1/{}", config.slide_duration_secs);
let (w, h) = config.resolution;
let status = Command::new(&config.ffmpeg_path)
.args([ .args([
"-y", "-y",
"-framerate", "-framerate",

View File

@@ -4,14 +4,34 @@ mod slides;
use async_trait::async_trait; use async_trait::async_trait;
use domain::errors::DomainError; use domain::errors::DomainError;
use domain::models::wrapup::WrapUpReport; use domain::models::wrapup::WrapUpReport;
use domain::ports::{VideoRenderAssets, VideoRenderConfig, WrapUpVideoRenderer}; use domain::ports::{VideoRenderAssets, WrapUpVideoRenderer};
#[derive(Default)] pub struct RendererConfig {
pub struct FfmpegWrapUpRenderer; pub slide_duration_secs: u32,
pub transition_duration_secs: f32,
pub resolution: (u32, u32),
pub ffmpeg_path: String,
pub font_path: Option<String>,
pub logo_path: Option<String>,
pub bg_dir: Option<String>,
}
pub struct FfmpegWrapUpRenderer {
config: RendererConfig,
slide_renderer: slides::SlideRenderer,
}
impl FfmpegWrapUpRenderer { impl FfmpegWrapUpRenderer {
pub fn new() -> Self { pub fn new(config: RendererConfig) -> Result<Self, DomainError> {
Self 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, &self,
report: &WrapUpReport, report: &WrapUpReport,
assets: VideoRenderAssets, assets: VideoRenderAssets,
config: &VideoRenderConfig,
) -> Result<Vec<u8>, DomainError> { ) -> Result<Vec<u8>, DomainError> {
let (width, height) = config.resolution; let (width, height) = self.config.resolution;
let renderer = slides::SlideRenderer::new(
config.font_path.as_deref(),
config.logo_path.as_deref(),
config.bg_dir.as_deref(),
)?;
let mut slide_pngs = Vec::new(); let mut slide_pngs = Vec::new();
slide_pngs.push(renderer.render_hero(report, width, height)?); slide_pngs.push(self.slide_renderer.render_hero(report, width, height)?);
slide_pngs.push(renderer.render_ratings(report, width, height)?); slide_pngs.push(self.slide_renderer.render_ratings(report, width, height)?);
if !report.top_directors.is_empty() { if !report.top_directors.is_empty() {
slide_pngs.push(renderer.render_directors( slide_pngs.push(self.slide_renderer.render_directors(
report, report,
&assets.cast_images, &assets.cast_images,
width, width,
@@ -43,23 +56,35 @@ impl WrapUpVideoRenderer for FfmpegWrapUpRenderer {
)?); )?);
} }
if !report.top_actors.is_empty() { 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() { 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, report,
&assets.poster_images, &assets.poster_images,
width, width,
height, height,
)?); )?);
if !assets.poster_images.is_empty() { 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 { } else {
tracing::warn!("no poster images resolved, skipping mosaic slide"); 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
} }
} }

View File

@@ -3,7 +3,7 @@ use crate::wrapup::{compute, queries::ComputeWrapUpQuery};
use domain::errors::DomainError; use domain::errors::DomainError;
use domain::events::DomainEvent; use domain::events::DomainEvent;
use domain::models::wrapup::{DateRange, WrapUpScope, WrapUpStatus}; use domain::models::wrapup::{DateRange, WrapUpScope, WrapUpStatus};
use domain::ports::{VideoRenderAssets, VideoRenderConfig}; use domain::ports::VideoRenderAssets;
use domain::value_objects::WrapUpId; use domain::value_objects::WrapUpId;
pub async fn execute( pub async fn execute(
@@ -55,21 +55,11 @@ pub async fn execute(
.map(|p| format!("cast{p}")) .map(|p| format!("cast{p}"))
.collect(); .collect();
let cast_images = resolve_images(ctx, &cast_keys, "cast").await; 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 { let assets = VideoRenderAssets {
poster_images, poster_images,
cast_images, cast_images,
}; };
match renderer.render(&report, assets, &config).await { match renderer.render(&report, assets).await {
Ok(video_bytes) => { Ok(video_bytes) => {
let video_key = format!("wrapups/{}/video.mp4", wrapup_id.value()); let video_key = format!("wrapups/{}/video.mp4", wrapup_id.value());
if let Err(e) = ctx if let Err(e) = ctx

View File

@@ -534,16 +534,6 @@ pub trait WrapUpStatsQuery: Send + Sync {
// ── Video renderer ────────────────────────────────────────────────────────── // ── 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<String>,
pub logo_path: Option<String>,
pub bg_dir: Option<String>,
}
pub struct VideoRenderAssets { pub struct VideoRenderAssets {
pub poster_images: Vec<(String, Vec<u8>)>, pub poster_images: Vec<(String, Vec<u8>)>,
pub cast_images: Vec<(String, Vec<u8>)>, pub cast_images: Vec<(String, Vec<u8>)>,
@@ -555,6 +545,5 @@ pub trait WrapUpVideoRenderer: Send + Sync {
&self, &self,
report: &WrapUpReport, report: &WrapUpReport,
assets: VideoRenderAssets, assets: VideoRenderAssets,
config: &VideoRenderConfig,
) -> Result<Vec<u8>, DomainError>; ) -> Result<Vec<u8>, DomainError>;
} }

View File

@@ -105,15 +105,32 @@ async fn main() -> anyhow::Result<()> {
diary_exporter: Arc::new(ExportAdapter) as Arc<dyn DiaryExporter>, diary_exporter: Arc::new(ExportAdapter) as Arc<dyn DiaryExporter>,
document_parser: Arc::new(ImporterDocumentParser) as Arc<dyn DocumentParser>, document_parser: Arc::new(ImporterDocumentParser) as Arc<dyn DocumentParser>,
video_renderer: { 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) if std::process::Command::new(ffmpeg)
.arg("-version") .arg("-version")
.output() .output()
.is_ok() .is_ok()
{ {
tracing::info!("wrapup video renderer enabled (ffmpeg={ffmpeg})"); let renderer_cfg = wrapup_renderer::RendererConfig {
Some(Arc::new(wrapup_renderer::FfmpegWrapUpRenderer::new()) slide_duration_secs: 4,
as Arc<dyn domain::ports::WrapUpVideoRenderer>) 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<dyn domain::ports::WrapUpVideoRenderer>)
}
Err(e) => {
tracing::warn!("wrapup video renderer init failed: {e}");
None
}
}
} else { } else {
tracing::info!("wrapup video renderer disabled (ffmpeg not found)"); tracing::info!("wrapup video renderer disabled (ffmpeg not found)");
None None