use ab_glyph::{FontArc, PxScale}; use domain::errors::DomainError; use domain::models::wrapup::WrapUpReport; use image::{DynamicImage, Rgba, RgbaImage}; use imageproc::drawing::{draw_filled_rect_mut, draw_text_mut}; use imageproc::rect::Rect; fn decode_image(bytes: &[u8]) -> Result { image::load_from_memory(bytes).or_else(|_| { let dir = tempfile::tempdir().map_err(|e| e.to_string())?; let input = dir.path().join("input"); let output = dir.path().join("output.png"); std::fs::write(&input, bytes).map_err(|e| e.to_string())?; let status = std::process::Command::new("ffmpeg") .args([ "-y", "-i", &input.to_string_lossy(), &output.to_string_lossy(), ]) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) .status() .map_err(|e| e.to_string())?; if !status.success() { return Err("ffmpeg conversion failed".into()); } let png_bytes = std::fs::read(&output).map_err(|e| e.to_string())?; image::load_from_memory(&png_bytes).map_err(|e| e.to_string()) }) } 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]); const GLASS: Rgba = Rgba([20, 20, 30, 180]); const GLASS_PADDING: u32 = 30; pub struct SlideRenderer { font: FontArc, logo: Option, backgrounds: Vec, } impl SlideRenderer { pub fn new( font_path: Option<&str>, logo_path: Option<&str>, bg_dir: 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 }; let mut backgrounds = Vec::new(); if let Some(dir) = bg_dir { if let Ok(entries) = std::fs::read_dir(dir) { for entry in entries.flatten() { let path = entry.path(); let ext = path .extension() .and_then(|e| e.to_str()) .unwrap_or("") .to_lowercase(); if matches!(ext.as_str(), "jpg" | "jpeg" | "png" | "webp") { match image::open(&path) { Ok(img) => backgrounds.push(img.to_rgba8()), Err(e) => tracing::warn!("bg load {}: {e}", path.display()), } } } } } Ok(Self { font, logo, backgrounds, }) } /// Pick a background for slide at `index`, resized to `w x h` with dark gradient overlay. fn pick_background(&self, index: usize, w: u32, h: u32) -> Option { if self.backgrounds.is_empty() { return None; } let bg = &self.backgrounds[index % self.backgrounds.len()]; let resized = image::imageops::resize(bg, w, h, image::imageops::FilterType::Triangle); let mut out = resized; // darken top 40% and bottom 40% with gradient to ~70% black let top_cutoff = (h as f32 * 0.4) as u32; let bot_start = h - top_cutoff; for y in 0..h { let darken = if y < top_cutoff { // fade from 0.70 at top to 0.0 at cutoff 0.70 * (1.0 - y as f32 / top_cutoff as f32) } else if y >= bot_start { // fade from 0.0 at bot_start to 0.70 at bottom 0.70 * ((y - bot_start) as f32 / top_cutoff as f32) } else { 0.0 }; if darken > 0.0 { let factor = 1.0 - darken; for x in 0..w { let px = out.get_pixel_mut(x, y); px[0] = (px[0] as f32 * factor) as u8; px[1] = (px[1] as f32 * factor) as u8; px[2] = (px[2] as f32 * factor) as u8; } } } Some(out) } /// Start a canvas: background image if available, else solid color. fn make_canvas(&self, slide_index: usize, w: u32, h: u32) -> RgbaImage { self.pick_background(slide_index, w, h) .unwrap_or_else(|| fill(w, h)) } /// Draw a semi-transparent dark glass panel. fn draw_glass_panel(&self, canvas: &mut RgbaImage, x: i32, y: i32, pw: u32, ph: u32) { // clamp to canvas bounds let x0 = x.max(0) as u32; let y0 = y.max(0) as u32; let x1 = (x as u32 + pw).min(canvas.width()); let y1 = (y as u32 + ph).min(canvas.height()); if x1 <= x0 || y1 <= y0 { return; } draw_filled_rect_mut( canvas, Rect::at(x0 as i32, y0 as i32).of_size(x1 - x0, y1 - y0), GLASS, ); } 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); 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); } /// Draw a small thumbnail from raw image bytes, resized to `size x size`. fn draw_thumbnail( canvas: &mut RgbaImage, bytes: &[u8], x: i64, y: i64, tw: u32, th: u32, ) { if let Ok(img) = decode_image(bytes) { let thumb = img.resize_exact(tw, th, image::imageops::FilterType::Triangle); image::imageops::overlay(canvas, &thumb.to_rgba8(), x, y); } } /// Find cast photo bytes matching `name` (case-insensitive substring). fn find_cast_photo<'a>( name: &str, cast_images: &'a [(String, Vec)], ) -> Option<&'a [u8]> { let lower = name.to_lowercase(); cast_images .iter() .find(|(n, _)| { let cn = n.to_lowercase(); cn.contains(&lower) || lower.contains(&cn) }) .map(|(_, b)| b.as_slice()) } /// Find poster bytes matching a poster_path (compare filename stem). fn find_poster<'a>( poster_path: &str, poster_images: &'a [(String, Vec)], ) -> Option<&'a [u8]> { let target = std::path::Path::new(poster_path) .file_name() .and_then(|f| f.to_str()) .unwrap_or(poster_path); poster_images .iter() .find(|(p, _)| { let fname = std::path::Path::new(p) .file_name() .and_then(|f| f.to_str()) .unwrap_or(p); fname == target }) .map(|(_, b)| b.as_slice()) } // ── Slides ────────────────────────────────────────────── pub fn render_hero( &self, report: &WrapUpReport, w: u32, h: u32, ) -> Result, DomainError> { let mut img = self.make_canvas(0, w, h); // glass panel in center area let panel_x = GLASS_PADDING as i32; let panel_y = (h / 7) as i32; let panel_w = w - GLASS_PADDING * 2; let panel_h = h * 5 / 7; self.draw_glass_panel(&mut img, panel_x, panel_y, panel_w, panel_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 = self.make_canvas(1, w, h); // glass panel covering content area let panel_x = (GLASS_PADDING / 2) as i32; let panel_y = (h / 10) as i32; let panel_w = w - GLASS_PADDING; let panel_h = h * 4 / 5; self.draw_glass_panel(&mut img, panel_x, panel_y, panel_w, panel_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); } 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); draw_filled_rect_mut( &mut img, Rect::at(margin_x, y).of_size(max_bar_w, bar_h), BAR_BG, ); 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); } 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, cast_images: &[(String, Vec)], w: u32, h: u32, ) -> Result, DomainError> { let mut img = self.make_canvas(2, w, h); let margin = 80i32; let start_y = (h / 4) as i32; let row_h = 100i32; let panel_h = (report.top_directors.len().min(5) as u32) * row_h as u32 + GLASS_PADDING * 2; self.draw_glass_panel( &mut img, margin - GLASS_PADDING as i32, start_y - GLASS_PADDING as i32, w - (margin as u32 - GLASS_PADDING) * 2, panel_h, ); self.draw_centered(&mut img, "Top Directors", (h / 8) as i32, 56.0, GOLD); let thumb_size = 60u32; // offset text right when cast photos present let text_offset = if cast_images.is_empty() { 60 } else { thumb_size as i32 + 20 }; for (i, d) in report.top_directors.iter().take(5).enumerate() { let y = start_y + (i as i32) * row_h; // cast photo thumbnail if let Some(photo) = Self::find_cast_photo(&d.name, cast_images) { Self::draw_thumbnail( &mut img, photo, margin as i64 + 40, y as i64, thumb_size, thumb_size, ); } let rank = format!("{}.", i + 1); self.draw_left(&mut img, &rank, margin, y + 10, 36.0, GOLD); self.draw_left( &mut img, &d.name, margin + text_offset, y + 10, 36.0, WHITE, ); let detail = format!("{} films avg {:.1}\u{2605}", d.count, d.avg_rating); self.draw_left( &mut img, &detail, margin + text_offset, y + 54, 24.0, DIM, ); } self.stamp_logo(&mut img); to_png(&img) } pub fn render_actors( &self, report: &WrapUpReport, cast_images: &[(String, Vec)], w: u32, h: u32, ) -> Result, DomainError> { let mut img = self.make_canvas(3, w, h); let margin = 80i32; let start_y = (h / 4) as i32; let row_h = 100i32; let panel_h = (report.top_actors.len().min(5) as u32) * row_h as u32 + GLASS_PADDING * 2; self.draw_glass_panel( &mut img, margin - GLASS_PADDING as i32, start_y - GLASS_PADDING as i32, w - (margin as u32 - GLASS_PADDING) * 2, panel_h, ); self.draw_centered(&mut img, "Top Actors", (h / 8) as i32, 56.0, GOLD); let thumb_size = 60u32; let text_offset = if cast_images.is_empty() { 60 } else { thumb_size as i32 + 20 }; for (i, a) in report.top_actors.iter().take(5).enumerate() { let y = start_y + (i as i32) * row_h; if let Some(photo) = Self::find_cast_photo(&a.name, cast_images) { Self::draw_thumbnail( &mut img, photo, margin as i64 + 40, y as i64, thumb_size, thumb_size, ); } let rank = format!("{}.", i + 1); self.draw_left(&mut img, &rank, margin, y + 10, 36.0, GOLD); self.draw_left( &mut img, &a.name, margin + text_offset, y + 10, 36.0, WHITE, ); let detail = format!("{} films avg {:.1}\u{2605}", a.count, a.avg_rating); self.draw_left( &mut img, &detail, margin + text_offset, y + 54, 24.0, DIM, ); } self.stamp_logo(&mut img); to_png(&img) } pub fn render_genres( &self, report: &WrapUpReport, w: u32, h: u32, ) -> Result, DomainError> { let mut img = self.make_canvas(4, w, h); let margin = 80i32; let start_y = (h / 4) as i32; let num_genres = report.top_genres.len().min(8) as u32; let panel_h = num_genres * 80 + GLASS_PADDING * 2 + 80; self.draw_glass_panel( &mut img, margin - GLASS_PADDING as i32, (h / 10) as i32, w - (margin as u32 - GLASS_PADDING) * 2, panel_h + (start_y as u32 - h / 10), ); self.draw_centered(&mut img, "Genre Breakdown", (h / 8) as i32, 56.0, GOLD); let detail = format!("{} genres explored", report.genre_diversity); self.draw_centered(&mut img, &detail, (h / 8) as i32 + 64, 28.0, DIM); let bar_area_w = (w as i32 - margin * 2 - 200) as u32; let max_count = report.top_genres.first().map(|g| g.count).unwrap_or(1).max(1); for (i, g) in report.top_genres.iter().take(8).enumerate() { let y = start_y + (i as i32) * 80; self.draw_left(&mut img, &g.genre, margin, y, 30.0, WHITE); let count_str = format!("{}", g.count); self.draw_left(&mut img, &count_str, w as i32 - margin - 40, y, 30.0, DIM); let bar_y = y + 38; let bar_w = (g.count as f64 / max_count as f64 * bar_area_w as f64) as u32; draw_filled_rect_mut( &mut img, Rect::at(margin, bar_y).of_size(bar_area_w, 12), BAR_BG, ); if bar_w > 0 { draw_filled_rect_mut( &mut img, Rect::at(margin, bar_y).of_size(bar_w, 12), GOLD, ); } } if let Some(ref best) = report.highest_rated_genre { let text = format!("Highest rated: {best}"); self.draw_centered(&mut img, &text, h as i32 - 180, 28.0, WHITE); } if let Some(ref worst) = report.lowest_rated_genre { let text = format!("Lowest rated: {worst}"); self.draw_centered(&mut img, &text, h as i32 - 140, 28.0, DIM); } self.stamp_logo(&mut img); to_png(&img) } pub fn render_highlights( &self, report: &WrapUpReport, poster_images: &[(String, Vec)], w: u32, h: u32, ) -> Result, DomainError> { let mut img = self.make_canvas(5, w, h); // glass panel behind highlights grid let panel_x = GLASS_PADDING as i32; let panel_y = (h / 10) as i32; let panel_w = w - GLASS_PADDING * 2; let panel_h = h * 4 / 5; self.draw_glass_panel(&mut img, panel_x, panel_y, panel_w, panel_h); self.draw_centered(&mut img, "Highlights", (h / 10) as i32 + 10, 56.0, GOLD); 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 poster_w = 60u32; let poster_h = 90u32; 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; // poster thumbnail if available let text_x_offset = if let Some(m) = movie_ref { if let Some(ref pp) = m.poster_path { if let Some(pb) = Self::find_poster(pp, poster_images) { Self::draw_thumbnail( &mut img, pb, x as i64, (y + 30) as i64, poster_w, poster_h, ); poster_w as i32 + 10 } else { 0 } } else { 0 } } else { 0 }; 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 + text_x_offset, y + 36, 26.0, WHITE, ); let sub = format!("({})", m.year); self.draw_left( &mut img, &sub, x + text_x_offset, 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 = RgbaImage::from_pixel(w, h, Rgba([0, 0, 0, 255])); // poster aspect 2:3, calculate grid to fill entire frame // find cols that best tile the width, then rows to fill height let poster_ratio = 2.0_f32 / 3.0; // try col counts from 3..8, pick one that wastes least space let cols = (3..=8) .min_by_key(|&c| { let tw = w / c; let th = (tw as f32 / poster_ratio) as u32; let rows_needed = (h + th - 1) / th; let total = rows_needed * c; // prefer filling screen with fewer leftover pixels let waste_y = (rows_needed * th).saturating_sub(h); let shortage = total.saturating_sub(posters.len() as u32); waste_y + shortage * 100 }) .unwrap_or(4); let thumb_w = w / cols; let thumb_h = (thumb_w as f32 / poster_ratio) as u32; let total_rows = (h + thumb_h - 1) / thumb_h; let total_cells = (total_rows * cols) as usize; for i in 0..total_cells { if posters.is_empty() { break; } // tile/repeat if not enough posters let idx = i % posters.len(); let (name, bytes) = &posters[idx]; let col = (i as u32) % cols; let row = (i as u32) / cols; let x = col * thumb_w; let y = row * thumb_h; match decode_image(bytes) { Ok(poster) => { 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); } Err(e) => tracing::debug!("mosaic: skipped {name}: {e}"), } } 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(); img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png) .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; Ok(buf) } 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) && let Ok(font) = FontArc::try_from_vec(bytes) { tracing::info!("loaded system font: {path}"); return Ok(font); } } Err(DomainError::InfrastructureError( "no system font found; set font_path in VideoRenderConfig or WRAPUP_FONT_PATH env" .to_string(), )) }