diff --git a/crates/adapters/wrapup-renderer/Cargo.toml b/crates/adapters/wrapup-renderer/Cargo.toml index a10443b..23a8e3f 100644 --- a/crates/adapters/wrapup-renderer/Cargo.toml +++ b/crates/adapters/wrapup-renderer/Cargo.toml @@ -9,7 +9,7 @@ async-trait = { workspace = true } tracing = { workspace = true } image = "0.25" imageproc = "0.25" -rusttype = "0.9" +ab_glyph = "0.2" plotters = { version = "0.3", default-features = false, features = ["bitmap_backend", "bitmap_encoder"] } tokio = { workspace = true, features = ["process"] } tempfile = "3" diff --git a/crates/adapters/wrapup-renderer/src/lib.rs b/crates/adapters/wrapup-renderer/src/lib.rs index 177c17e..93b53f2 100644 --- a/crates/adapters/wrapup-renderer/src/lib.rs +++ b/crates/adapters/wrapup-renderer/src/lib.rs @@ -1,6 +1,6 @@ -mod slides; mod charts; mod ffmpeg; +mod slides; use async_trait::async_trait; use domain::errors::DomainError; @@ -25,22 +25,27 @@ impl WrapUpVideoRenderer for FfmpegWrapUpRenderer { ) -> Result, DomainError> { let (width, height) = config.resolution; + let renderer = slides::SlideRenderer::new( + config.font_path.as_deref(), + config.logo_path.as_deref(), + )?; + // 1. Generate slide images let mut slide_pngs = Vec::new(); - slide_pngs.push(slides::render_hero_slide(report, width, height)?); - slide_pngs.push(slides::render_ratings_slide(report, width, height)?); + 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(slides::render_directors_slide(report, width, height)?); + slide_pngs.push(renderer.render_directors(report, width, height)?); } if !report.top_actors.is_empty() { - slide_pngs.push(slides::render_actors_slide(report, width, height)?); + slide_pngs.push(renderer.render_actors(report, width, height)?); } if !report.top_genres.is_empty() { slide_pngs.push(charts::render_genre_chart(report, width, height)?); } - slide_pngs.push(slides::render_highlights_slide(report, width, height)?); + slide_pngs.push(renderer.render_highlights(report, width, height)?); if !poster_images.is_empty() { - slide_pngs.push(slides::render_mosaic_slide(&poster_images, width, height)?); + slide_pngs.push(renderer.render_mosaic(&poster_images, width, height)?); } // 2. Stitch into video diff --git a/crates/adapters/wrapup-renderer/src/slides.rs b/crates/adapters/wrapup-renderer/src/slides.rs index 172b168..a4b48f2 100644 --- a/crates/adapters/wrapup-renderer/src/slides.rs +++ b/crates/adapters/wrapup-renderer/src/slides.rs @@ -1,10 +1,338 @@ +use ab_glyph::{FontArc, PxScale}; use domain::errors::DomainError; use domain::models::wrapup::WrapUpReport; use image::{Rgba, RgbaImage}; +use imageproc::drawing::{draw_filled_rect_mut, draw_text_mut}; +use imageproc::rect::Rect; -const BG_COLOR: Rgba = Rgba([26, 26, 36, 255]); // dark blue-gray -const _PRIMARY: Rgba = Rgba([229, 192, 52, 255]); // gold #e5c034 -const _TEXT_COLOR: Rgba = Rgba([255, 255, 255, 255]); // white +const BG: Rgba = Rgba([26, 26, 36, 255]); +const GOLD: Rgba = Rgba([229, 192, 52, 255]); +const WHITE: Rgba = Rgba([255, 255, 255, 255]); +const DIM: Rgba = Rgba([255, 255, 255, 140]); +const BAR_BG: Rgba = Rgba([50, 50, 65, 255]); + +pub struct SlideRenderer { + font: FontArc, + logo: Option, +} + +impl SlideRenderer { + pub fn new(font_path: Option<&str>, logo_path: Option<&str>) -> Result { + let font = if let Some(path) = font_path { + let bytes = std::fs::read(path) + .map_err(|e| DomainError::InfrastructureError(format!("font load: {e}")))?; + FontArc::try_from_vec(bytes) + .map_err(|e| DomainError::InfrastructureError(format!("font parse: {e}")))? + } else { + load_system_font()? + }; + + let logo = if let Some(path) = logo_path { + let img = image::open(path) + .map_err(|e| DomainError::InfrastructureError(format!("logo load: {e}")))?; + Some(img.to_rgba8()) + } else { + None + }; + + Ok(Self { font, logo }) + } + + fn stamp_logo(&self, canvas: &mut RgbaImage) { + if let Some(ref logo) = self.logo { + let logo_size = 64u32; + let resized = image::imageops::resize( + logo, + logo_size, + logo_size, + image::imageops::FilterType::Triangle, + ); + let margin = 20i64; + let x = canvas.width() as i64 - logo_size as i64 - margin; + let y = canvas.height() as i64 - logo_size as i64 - margin; + image::imageops::overlay(canvas, &resized, x, y); + } + } + + fn draw_centered( + &self, + canvas: &mut RgbaImage, + text: &str, + y: i32, + scale: f32, + color: Rgba, + ) { + let px = PxScale::from(scale); + // approximate width: ~0.5 * scale * len + let approx_w = (text.len() as f32 * scale * 0.45) as i32; + let x = ((canvas.width() as i32 - approx_w) / 2).max(10); + draw_text_mut(canvas, color, x, y, px, &self.font, text); + } + + fn draw_left( + &self, + canvas: &mut RgbaImage, + text: &str, + x: i32, + y: i32, + scale: f32, + color: Rgba, + ) { + draw_text_mut(canvas, color, x, y, PxScale::from(scale), &self.font, text); + } + + pub fn render_hero( + &self, + report: &WrapUpReport, + w: u32, + h: u32, + ) -> Result, DomainError> { + let mut img = fill(w, h); + + let year_label = format!( + "{} - {}", + report.date_range.start.format("%b %Y"), + report.date_range.end.format("%b %Y") + ); + self.draw_centered(&mut img, &year_label, (h / 6) as i32, 48.0, DIM); + self.draw_centered( + &mut img, + &report.total_movies.to_string(), + (h / 3) as i32, + 160.0, + GOLD, + ); + self.draw_centered( + &mut img, + "movies watched", + (h / 3 + 170) as i32, + 40.0, + WHITE, + ); + + let hours = report.total_watch_time_minutes / 60; + let mins = report.total_watch_time_minutes % 60; + let time_str = format!("{}h {}m of watch time", hours, mins); + self.draw_centered(&mut img, &time_str, (h / 2 + 60) as i32, 36.0, DIM); + + if let Some(ref month) = report.busiest_month { + let s = format!("Busiest month: {month}"); + self.draw_centered(&mut img, &s, (h * 2 / 3) as i32, 32.0, DIM); + } + if let Some(ref dow) = report.busiest_day_of_week { + let s = format!("Favorite day: {dow}"); + self.draw_centered(&mut img, &s, (h * 2 / 3 + 50) as i32, 32.0, DIM); + } + + self.stamp_logo(&mut img); + to_png(&img) + } + + pub fn render_ratings( + &self, + report: &WrapUpReport, + w: u32, + h: u32, + ) -> Result, DomainError> { + let mut img = fill(w, h); + self.draw_centered(&mut img, "Ratings", (h / 8) as i32, 56.0, GOLD); + + if let Some(avg) = report.avg_rating { + let s = format!("{:.1} / 5", avg); + self.draw_centered(&mut img, &s, (h / 4) as i32, 80.0, WHITE); + self.draw_centered(&mut img, "average rating", (h / 4 + 90) as i32, 32.0, DIM); + } + + // rating distribution bars + let max_count = report.rating_distribution.iter().copied().max().unwrap_or(1).max(1); + let bar_area_top = (h / 2) as i32; + let bar_h = 36u32; + let bar_gap = 16u32; + let margin_x = 120i32; + let max_bar_w = (w as i32 - margin_x * 2) as u32; + + for (i, &count) in report.rating_distribution.iter().enumerate() { + let label = format!("{}\u{2605}", i + 1); + let y = bar_area_top + (i as i32) * (bar_h as i32 + bar_gap as i32); + self.draw_left(&mut img, &label, margin_x - 60, y + 2, 28.0, GOLD); + + // background bar + draw_filled_rect_mut( + &mut img, + Rect::at(margin_x, y).of_size(max_bar_w, bar_h), + BAR_BG, + ); + // filled bar + let fill_w = ((count as f32 / max_count as f32) * max_bar_w as f32) as u32; + if fill_w > 0 { + draw_filled_rect_mut( + &mut img, + Rect::at(margin_x, y).of_size(fill_w, bar_h), + GOLD, + ); + } + // count label + let count_s = count.to_string(); + self.draw_left( + &mut img, + &count_s, + margin_x + fill_w as i32 + 10, + y + 2, + 24.0, + DIM, + ); + } + + self.stamp_logo(&mut img); + to_png(&img) + } + + pub fn render_directors( + &self, + report: &WrapUpReport, + w: u32, + h: u32, + ) -> Result, DomainError> { + let mut img = fill(w, h); + self.draw_centered(&mut img, "Top Directors", (h / 8) as i32, 56.0, GOLD); + + let margin = 80i32; + let start_y = (h / 4) as i32; + for (i, d) in report.top_directors.iter().take(5).enumerate() { + let y = start_y + (i as i32) * 90; + let rank = format!("{}.", i + 1); + self.draw_left(&mut img, &rank, margin, y, 36.0, GOLD); + self.draw_left(&mut img, &d.name, margin + 60, y, 36.0, WHITE); + let detail = format!("{} films avg {:.1}\u{2605}", d.count, d.avg_rating); + self.draw_left(&mut img, &detail, margin + 60, y + 44, 24.0, DIM); + } + + self.stamp_logo(&mut img); + to_png(&img) + } + + pub fn render_actors( + &self, + report: &WrapUpReport, + w: u32, + h: u32, + ) -> Result, DomainError> { + let mut img = fill(w, h); + self.draw_centered(&mut img, "Top Actors", (h / 8) as i32, 56.0, GOLD); + + let margin = 80i32; + let start_y = (h / 4) as i32; + for (i, a) in report.top_actors.iter().take(5).enumerate() { + let y = start_y + (i as i32) * 90; + let rank = format!("{}.", i + 1); + self.draw_left(&mut img, &rank, margin, y, 36.0, GOLD); + self.draw_left(&mut img, &a.name, margin + 60, y, 36.0, WHITE); + let detail = format!("{} films avg {:.1}\u{2605}", a.count, a.avg_rating); + self.draw_left(&mut img, &detail, margin + 60, y + 44, 24.0, DIM); + } + + self.stamp_logo(&mut img); + to_png(&img) + } + + pub fn render_highlights( + &self, + report: &WrapUpReport, + w: u32, + h: u32, + ) -> Result, DomainError> { + let mut img = fill(w, h); + self.draw_centered(&mut img, "Highlights", (h / 10) as i32, 56.0, GOLD); + + // 2-column layout of notable movies + let col_w = w / 2; + let start_y = (h / 5) as i32; + let row_h = (h / 5) as i32; + let left = 60i32; + let right = col_w as i32 + 40; + + let items: Vec<(&str, Option<&domain::models::wrapup::MovieRef>)> = vec![ + ("Highest Rated", report.highest_rated_movie.as_ref()), + ("Lowest Rated", report.lowest_rated_movie.as_ref()), + ("Oldest Film", report.oldest_movie.as_ref()), + ("Newest Film", report.newest_movie.as_ref()), + ("Longest", report.longest_movie.as_ref()), + ("Shortest", report.shortest_movie.as_ref()), + ("First Watched", report.first_movie_of_period.as_ref()), + ("Last Watched", report.last_movie_of_period.as_ref()), + ]; + + for (i, (label, movie_ref)) in items.iter().enumerate() { + let col = i % 2; + let row = i / 2; + let x = if col == 0 { left } else { right }; + let y = start_y + (row as i32) * row_h; + + self.draw_left(&mut img, label, x, y, 28.0, GOLD); + if let Some(m) = movie_ref { + let title = if m.title.len() > 22 { + format!("{}...", &m.title[..19]) + } else { + m.title.clone() + }; + self.draw_left(&mut img, &title, x, y + 36, 26.0, WHITE); + let sub = format!("({})", m.year); + self.draw_left(&mut img, &sub, x, y + 68, 22.0, DIM); + } else { + self.draw_left(&mut img, "-", x, y + 36, 26.0, DIM); + } + } + + // Rewatches + if report.total_rewatches > 0 { + let rewatch_y = start_y + 4 * row_h + 20; + let s = format!("{} rewatches", report.total_rewatches); + self.draw_centered(&mut img, &s, rewatch_y, 30.0, DIM); + if let Some(ref m) = report.most_rewatched_movie { + let s2 = format!("Most rewatched: {}", m.title); + self.draw_centered(&mut img, &s2, rewatch_y + 40, 26.0, WHITE); + } + } + + self.stamp_logo(&mut img); + to_png(&img) + } + + pub fn render_mosaic( + &self, + posters: &[(String, Vec)], + w: u32, + h: u32, + ) -> Result, DomainError> { + let mut canvas = fill(w, h); + + let cols = 4u32; + let thumb_w = w / cols; + let thumb_h = (thumb_w * 3) / 2; + + for (i, (_, bytes)) in posters.iter().enumerate() { + let col = (i as u32) % cols; + let row = (i as u32) / cols; + let x = col * thumb_w; + let y = row * thumb_h; + if y + thumb_h > h { + break; + } + if let Ok(poster) = image::load_from_memory(bytes) { + let thumb = + poster.resize_exact(thumb_w, thumb_h, image::imageops::FilterType::Triangle); + image::imageops::overlay(&mut canvas, &thumb.to_rgba8(), x as i64, y as i64); + } + } + + self.stamp_logo(&mut canvas); + to_png(&canvas) + } +} + +fn fill(w: u32, h: u32) -> RgbaImage { + RgbaImage::from_pixel(w, h, BG) +} fn to_png(img: &RgbaImage) -> Result, DomainError> { let mut buf = Vec::new(); @@ -16,82 +344,26 @@ fn to_png(img: &RgbaImage) -> Result, DomainError> { Ok(buf) } -fn fill_background(width: u32, height: u32) -> RgbaImage { - RgbaImage::from_pixel(width, height, BG_COLOR) -} - -pub fn render_hero_slide( - _report: &WrapUpReport, - width: u32, - height: u32, -) -> Result, DomainError> { - let img = fill_background(width, height); - // MVP: solid background. Text overlay added with font rendering later. - to_png(&img) -} - -pub fn render_ratings_slide( - _report: &WrapUpReport, - width: u32, - height: u32, -) -> Result, DomainError> { - let img = fill_background(width, height); - to_png(&img) -} - -pub fn render_directors_slide( - _report: &WrapUpReport, - width: u32, - height: u32, -) -> Result, DomainError> { - let img = fill_background(width, height); - to_png(&img) -} - -pub fn render_actors_slide( - _report: &WrapUpReport, - width: u32, - height: u32, -) -> Result, DomainError> { - let img = fill_background(width, height); - to_png(&img) -} - -pub fn render_highlights_slide( - _report: &WrapUpReport, - width: u32, - height: u32, -) -> Result, DomainError> { - let img = fill_background(width, height); - to_png(&img) -} - -pub fn render_mosaic_slide( - posters: &[(String, Vec)], - width: u32, - height: u32, -) -> Result, DomainError> { - let mut canvas = fill_background(width, height); - - let cols = 4u32; - let thumb_w = width / cols; - let thumb_h = (thumb_w * 3) / 2; // 2:3 poster ratio - - for (i, (_, bytes)) in posters.iter().enumerate() { - let col = (i as u32) % cols; - let row = (i as u32) / cols; - let x = col * thumb_w; - let y = row * thumb_h; - if y + thumb_h > height { - break; - } - - if let Ok(poster) = image::load_from_memory(bytes) { - let thumb = - poster.resize_exact(thumb_w, thumb_h, image::imageops::FilterType::Triangle); - image::imageops::overlay(&mut canvas, &thumb.to_rgba8(), x as i64, y as i64); +fn load_system_font() -> Result { + let candidates = [ + "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", + "/usr/share/fonts/TTF/DejaVuSans.ttf", + "/usr/share/fonts/dejavu-sans-fonts/DejaVuSans.ttf", + "/usr/share/fonts/noto/NotoSans-Regular.ttf", + "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf", + "/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc", + "/System/Library/Fonts/Helvetica.ttc", + ]; + for path in &candidates { + if let Ok(bytes) = std::fs::read(path) { + if let Ok(font) = FontArc::try_from_vec(bytes) { + tracing::info!("loaded system font: {path}"); + return Ok(font); + } } } - - to_png(&canvas) + Err(DomainError::InfrastructureError( + "no system font found; set font_path in VideoRenderConfig or WRAPUP_FONT_PATH env" + .to_string(), + )) } diff --git a/crates/application/src/wrapup/handle_requested.rs b/crates/application/src/wrapup/handle_requested.rs index 67daa0e..337f9cf 100644 --- a/crates/application/src/wrapup/handle_requested.rs +++ b/crates/application/src/wrapup/handle_requested.rs @@ -44,6 +44,8 @@ pub async fn execute( transition_duration_secs: 0.8, resolution: (1080, 1920), ffmpeg_path: "ffmpeg".to_string(), + font_path: None, + logo_path: None, }; match renderer.render(&report, poster_images, &config).await { Ok(video_bytes) => { diff --git a/crates/domain/src/ports.rs b/crates/domain/src/ports.rs index 9cf23cd..71c0020 100644 --- a/crates/domain/src/ports.rs +++ b/crates/domain/src/ports.rs @@ -530,6 +530,8 @@ pub struct VideoRenderConfig { pub transition_duration_secs: f32, pub resolution: (u32, u32), pub ffmpeg_path: String, + pub font_path: Option, + pub logo_path: Option, } #[async_trait]