feat: frutiger aero visual overhaul — backgrounds, glass panels, cast photos, full mosaic
Some checks failed
CI / Check / Test (push) Failing after 42s
Some checks failed
CI / Check / Test (push) Failing after 42s
This commit is contained in:
@@ -4,7 +4,7 @@ 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::{VideoRenderConfig, WrapUpVideoRenderer};
|
use domain::ports::{VideoRenderAssets, VideoRenderConfig, WrapUpVideoRenderer};
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub struct FfmpegWrapUpRenderer;
|
pub struct FfmpegWrapUpRenderer;
|
||||||
@@ -20,35 +20,36 @@ impl WrapUpVideoRenderer for FfmpegWrapUpRenderer {
|
|||||||
async fn render(
|
async fn render(
|
||||||
&self,
|
&self,
|
||||||
report: &WrapUpReport,
|
report: &WrapUpReport,
|
||||||
poster_images: Vec<(String, Vec<u8>)>,
|
assets: VideoRenderAssets,
|
||||||
config: &VideoRenderConfig,
|
config: &VideoRenderConfig,
|
||||||
) -> Result<Vec<u8>, DomainError> {
|
) -> Result<Vec<u8>, DomainError> {
|
||||||
let (width, height) = config.resolution;
|
let (width, height) = config.resolution;
|
||||||
|
|
||||||
let renderer =
|
let renderer = slides::SlideRenderer::new(
|
||||||
slides::SlideRenderer::new(config.font_path.as_deref(), config.logo_path.as_deref())?;
|
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();
|
let mut slide_pngs = Vec::new();
|
||||||
slide_pngs.push(renderer.render_hero(report, width, height)?);
|
slide_pngs.push(renderer.render_hero(report, width, height)?);
|
||||||
slide_pngs.push(renderer.render_ratings(report, width, height)?);
|
slide_pngs.push(renderer.render_ratings(report, width, height)?);
|
||||||
if !report.top_directors.is_empty() {
|
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() {
|
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() {
|
if !report.top_genres.is_empty() {
|
||||||
slide_pngs.push(renderer.render_genres(report, width, height)?);
|
slide_pngs.push(renderer.render_genres(report, width, height)?);
|
||||||
}
|
}
|
||||||
slide_pngs.push(renderer.render_highlights(report, width, height)?);
|
slide_pngs.push(renderer.render_highlights(report, &assets.poster_images, width, height)?);
|
||||||
if !poster_images.is_empty() {
|
if !assets.poster_images.is_empty() {
|
||||||
slide_pngs.push(renderer.render_mosaic(&poster_images, width, height)?);
|
slide_pngs.push(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");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Stitch into video
|
|
||||||
ffmpeg::stitch_slides(&slide_pngs, config).await
|
ffmpeg::stitch_slides(&slide_pngs, config).await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ pub struct AppConfig {
|
|||||||
pub struct WrapUpConfig {
|
pub struct WrapUpConfig {
|
||||||
pub font_path: Option<String>,
|
pub font_path: Option<String>,
|
||||||
pub logo_path: Option<String>,
|
pub logo_path: Option<String>,
|
||||||
|
pub bg_dir: Option<String>,
|
||||||
pub ffmpeg_path: String,
|
pub ffmpeg_path: String,
|
||||||
pub max_concurrent_renders: usize,
|
pub max_concurrent_renders: usize,
|
||||||
}
|
}
|
||||||
@@ -39,6 +40,7 @@ impl WrapUpConfig {
|
|||||||
Self {
|
Self {
|
||||||
font_path: std::env::var("WRAPUP_FONT_PATH").ok(),
|
font_path: std::env::var("WRAPUP_FONT_PATH").ok(),
|
||||||
logo_path: std::env::var("WRAPUP_LOGO_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()),
|
ffmpeg_path: std::env::var("FFMPEG_PATH").unwrap_or_else(|_| "ffmpeg".to_string()),
|
||||||
max_concurrent_renders: std::env::var("WRAPUP_MAX_CONCURRENT")
|
max_concurrent_renders: std::env::var("WRAPUP_MAX_CONCURRENT")
|
||||||
.ok()
|
.ok()
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ impl TestContextBuilder {
|
|||||||
wrapup: crate::config::WrapUpConfig {
|
wrapup: crate::config::WrapUpConfig {
|
||||||
font_path: None,
|
font_path: None,
|
||||||
logo_path: None,
|
logo_path: None,
|
||||||
|
bg_dir: None,
|
||||||
ffmpeg_path: "ffmpeg".into(),
|
ffmpeg_path: "ffmpeg".into(),
|
||||||
max_concurrent_renders: 2,
|
max_concurrent_renders: 2,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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, WrapUpReport, WrapUpScope, WrapUpStatus};
|
use domain::models::wrapup::{DateRange, WrapUpReport, WrapUpScope, WrapUpStatus};
|
||||||
use domain::ports::VideoRenderConfig;
|
use domain::ports::{VideoRenderAssets, VideoRenderConfig};
|
||||||
use domain::value_objects::WrapUpId;
|
use domain::value_objects::WrapUpId;
|
||||||
|
|
||||||
pub async fn execute(
|
pub async fn execute(
|
||||||
@@ -39,9 +39,10 @@ pub async fn execute(
|
|||||||
.set_complete(&wrapup_id, &json)
|
.set_complete(&wrapup_id, &json)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// Optionally render video (non-fatal)
|
|
||||||
if let Some(ref renderer) = ctx.services.video_renderer {
|
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 wc = &ctx.config.wrapup;
|
||||||
let config = VideoRenderConfig {
|
let config = VideoRenderConfig {
|
||||||
slide_duration_secs: 4,
|
slide_duration_secs: 4,
|
||||||
@@ -50,8 +51,13 @@ pub async fn execute(
|
|||||||
ffmpeg_path: wc.ffmpeg_path.clone(),
|
ffmpeg_path: wc.ffmpeg_path.clone(),
|
||||||
font_path: wc.font_path.clone(),
|
font_path: wc.font_path.clone(),
|
||||||
logo_path: wc.logo_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) => {
|
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
|
||||||
@@ -85,14 +91,18 @@ pub async fn execute(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn resolve_poster_images(ctx: &AppContext, report: &WrapUpReport) -> Vec<(String, Vec<u8>)> {
|
async fn resolve_images(
|
||||||
|
ctx: &AppContext,
|
||||||
|
paths: &[String],
|
||||||
|
label: &str,
|
||||||
|
) -> Vec<(String, Vec<u8>)> {
|
||||||
let mut images = Vec::new();
|
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 {
|
match ctx.services.image_storage.get(path).await {
|
||||||
Ok(bytes) => images.push((path.clone(), bytes)),
|
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
|
images
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -536,6 +536,12 @@ pub struct VideoRenderConfig {
|
|||||||
pub ffmpeg_path: String,
|
pub ffmpeg_path: String,
|
||||||
pub font_path: Option<String>,
|
pub font_path: Option<String>,
|
||||||
pub logo_path: Option<String>,
|
pub logo_path: Option<String>,
|
||||||
|
pub bg_dir: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct VideoRenderAssets {
|
||||||
|
pub poster_images: Vec<(String, Vec<u8>)>,
|
||||||
|
pub cast_images: Vec<(String, Vec<u8>)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -543,7 +549,7 @@ pub trait WrapUpVideoRenderer: Send + Sync {
|
|||||||
async fn render(
|
async fn render(
|
||||||
&self,
|
&self,
|
||||||
report: &WrapUpReport,
|
report: &WrapUpReport,
|
||||||
poster_images: Vec<(String, Vec<u8>)>,
|
assets: VideoRenderAssets,
|
||||||
config: &VideoRenderConfig,
|
config: &VideoRenderConfig,
|
||||||
) -> Result<Vec<u8>, DomainError>;
|
) -> Result<Vec<u8>, DomainError>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -677,6 +677,7 @@ pub fn make_test_state(auth_service: Arc<dyn AuthService>) -> crate::state::AppS
|
|||||||
wrapup: application::config::WrapUpConfig {
|
wrapup: application::config::WrapUpConfig {
|
||||||
font_path: None,
|
font_path: None,
|
||||||
logo_path: None,
|
logo_path: None,
|
||||||
|
bg_dir: None,
|
||||||
ffmpeg_path: "ffmpeg".into(),
|
ffmpeg_path: "ffmpeg".into(),
|
||||||
max_concurrent_renders: 2,
|
max_concurrent_renders: 2,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -443,6 +443,7 @@ async fn test_app() -> Router {
|
|||||||
wrapup: application::config::WrapUpConfig {
|
wrapup: application::config::WrapUpConfig {
|
||||||
font_path: None,
|
font_path: None,
|
||||||
logo_path: None,
|
logo_path: None,
|
||||||
|
bg_dir: None,
|
||||||
ffmpeg_path: "ffmpeg".into(),
|
ffmpeg_path: "ffmpeg".into(),
|
||||||
max_concurrent_renders: 2,
|
max_concurrent_renders: 2,
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user