diff --git a/crates/adapters/wrapup-renderer/src/slides.rs b/crates/adapters/wrapup-renderer/src/slides.rs index 0ec1413..bb82def 100644 --- a/crates/adapters/wrapup-renderer/src/slides.rs +++ b/crates/adapters/wrapup-renderer/src/slides.rs @@ -34,14 +34,21 @@ 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>) -> Result { + 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}")))?; @@ -59,7 +66,88 @@ impl SlideRenderer { None }; - Ok(Self { font, logo }) + 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) { @@ -87,7 +175,6 @@ impl SlideRenderer { 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); @@ -105,13 +192,73 @@ impl SlideRenderer { 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 = fill(w, h); + 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!( "{} - {}", @@ -158,7 +305,15 @@ impl SlideRenderer { w: u32, h: u32, ) -> Result, DomainError> { - let mut img = fill(w, h); + 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 { @@ -167,7 +322,6 @@ impl SlideRenderer { 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() @@ -186,18 +340,15 @@ impl SlideRenderer { 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, @@ -216,21 +367,64 @@ impl SlideRenderer { pub fn render_directors( &self, report: &WrapUpReport, + cast_images: &[(String, Vec)], 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 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) * 90; + 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, 36.0, GOLD); - self.draw_left(&mut img, &d.name, margin + 60, y, 36.0, WHITE); + 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 + 60, y + 44, 24.0, DIM); + self.draw_left( + &mut img, + &detail, + margin + text_offset, + y + 54, + 24.0, + DIM, + ); } self.stamp_logo(&mut img); @@ -240,21 +434,62 @@ impl SlideRenderer { pub fn render_actors( &self, report: &WrapUpReport, + cast_images: &[(String, Vec)], 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 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) * 90; + 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, 36.0, GOLD); - self.draw_left(&mut img, &a.name, margin + 60, y, 36.0, WHITE); + 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 + 60, y + 44, 24.0, DIM); + self.draw_left( + &mut img, + &detail, + margin + text_offset, + y + 54, + 24.0, + DIM, + ); } self.stamp_logo(&mut img); @@ -267,15 +502,26 @@ impl SlideRenderer { w: u32, h: u32, ) -> Result, DomainError> { - let mut img = fill(w, h); + 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 margin = 80i32; let bar_area_w = (w as i32 - margin * 2 - 200) as u32; - let start_y = (h / 4) as i32; 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() { @@ -316,18 +562,28 @@ impl SlideRenderer { pub fn render_highlights( &self, report: &WrapUpReport, + poster_images: &[(String, Vec)], 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); + 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); - // 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 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()), @@ -346,6 +602,29 @@ impl SlideRenderer { 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 { @@ -353,9 +632,23 @@ impl SlideRenderer { } else { m.title.clone() }; - self.draw_left(&mut img, &title, x, y + 36, 26.0, WHITE); + 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, y + 68, 22.0, DIM); + 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); } @@ -382,20 +675,42 @@ impl SlideRenderer { w: u32, h: u32, ) -> Result, DomainError> { - let mut canvas = fill(w, h); + 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 cols = 4u32; let thumb_w = w / cols; - let thumb_h = (thumb_w * 3) / 2; + 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, (name, bytes)) in posters.iter().enumerate() { + 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; - if y + thumb_h > h { - break; - } + match decode_image(bytes) { Ok(poster) => { let thumb = poster.resize_exact(