refactor: move VideoRenderConfig from domain to adapter, inject at construction
Some checks failed
CI / Check / Test (push) Failing after 44s
Some checks failed
CI / Check / Test (push) Failing after 44s
This commit is contained in:
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user