feat: frutiger aero visual overhaul for wrapup video slides

This commit is contained in:
2026-06-03 00:32:39 +02:00
parent 0d02f23f4f
commit e57ddd78ac

View File

@@ -34,14 +34,21 @@ const GOLD: Rgba<u8> = Rgba([229, 192, 52, 255]);
const WHITE: Rgba<u8> = Rgba([255, 255, 255, 255]); const WHITE: Rgba<u8> = Rgba([255, 255, 255, 255]);
const DIM: Rgba<u8> = Rgba([255, 255, 255, 140]); const DIM: Rgba<u8> = Rgba([255, 255, 255, 140]);
const BAR_BG: Rgba<u8> = Rgba([50, 50, 65, 255]); const BAR_BG: Rgba<u8> = Rgba([50, 50, 65, 255]);
const GLASS: Rgba<u8> = Rgba([20, 20, 30, 180]);
const GLASS_PADDING: u32 = 30;
pub struct SlideRenderer { pub struct SlideRenderer {
font: FontArc, font: FontArc,
logo: Option<RgbaImage>, logo: Option<RgbaImage>,
backgrounds: Vec<RgbaImage>,
} }
impl SlideRenderer { impl SlideRenderer {
pub fn new(font_path: Option<&str>, logo_path: Option<&str>) -> Result<Self, DomainError> { pub fn new(
font_path: Option<&str>,
logo_path: Option<&str>,
bg_dir: Option<&str>,
) -> Result<Self, DomainError> {
let font = if let Some(path) = font_path { let font = if let Some(path) = font_path {
let bytes = std::fs::read(path) let bytes = std::fs::read(path)
.map_err(|e| DomainError::InfrastructureError(format!("font load: {e}")))?; .map_err(|e| DomainError::InfrastructureError(format!("font load: {e}")))?;
@@ -59,7 +66,88 @@ impl SlideRenderer {
None 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<RgbaImage> {
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) { fn stamp_logo(&self, canvas: &mut RgbaImage) {
@@ -87,7 +175,6 @@ impl SlideRenderer {
color: Rgba<u8>, color: Rgba<u8>,
) { ) {
let px = PxScale::from(scale); let px = PxScale::from(scale);
// approximate width: ~0.5 * scale * len
let approx_w = (text.len() as f32 * scale * 0.45) as i32; let approx_w = (text.len() as f32 * scale * 0.45) as i32;
let x = ((canvas.width() as i32 - approx_w) / 2).max(10); let x = ((canvas.width() as i32 - approx_w) / 2).max(10);
draw_text_mut(canvas, color, x, y, px, &self.font, text); 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_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<u8>)],
) -> 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<u8>)],
) -> 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( pub fn render_hero(
&self, &self,
report: &WrapUpReport, report: &WrapUpReport,
w: u32, w: u32,
h: u32, h: u32,
) -> Result<Vec<u8>, DomainError> { ) -> Result<Vec<u8>, 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!( let year_label = format!(
"{} - {}", "{} - {}",
@@ -158,7 +305,15 @@ impl SlideRenderer {
w: u32, w: u32,
h: u32, h: u32,
) -> Result<Vec<u8>, DomainError> { ) -> Result<Vec<u8>, 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); self.draw_centered(&mut img, "Ratings", (h / 8) as i32, 56.0, GOLD);
if let Some(avg) = report.avg_rating { 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); self.draw_centered(&mut img, "average rating", (h / 4 + 90) as i32, 32.0, DIM);
} }
// rating distribution bars
let max_count = report let max_count = report
.rating_distribution .rating_distribution
.iter() .iter()
@@ -186,18 +340,15 @@ impl SlideRenderer {
let y = bar_area_top + (i as i32) * (bar_h as i32 + bar_gap as i32); 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); self.draw_left(&mut img, &label, margin_x - 60, y + 2, 28.0, GOLD);
// background bar
draw_filled_rect_mut( draw_filled_rect_mut(
&mut img, &mut img,
Rect::at(margin_x, y).of_size(max_bar_w, bar_h), Rect::at(margin_x, y).of_size(max_bar_w, bar_h),
BAR_BG, BAR_BG,
); );
// filled bar
let fill_w = ((count as f32 / max_count as f32) * max_bar_w as f32) as u32; let fill_w = ((count as f32 / max_count as f32) * max_bar_w as f32) as u32;
if fill_w > 0 { if fill_w > 0 {
draw_filled_rect_mut(&mut img, Rect::at(margin_x, y).of_size(fill_w, bar_h), GOLD); 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(); let count_s = count.to_string();
self.draw_left( self.draw_left(
&mut img, &mut img,
@@ -216,21 +367,64 @@ impl SlideRenderer {
pub fn render_directors( pub fn render_directors(
&self, &self,
report: &WrapUpReport, report: &WrapUpReport,
cast_images: &[(String, Vec<u8>)],
w: u32, w: u32,
h: u32, h: u32,
) -> Result<Vec<u8>, DomainError> { ) -> Result<Vec<u8>, DomainError> {
let mut img = fill(w, h); let mut img = self.make_canvas(2, w, h);
self.draw_centered(&mut img, "Top Directors", (h / 8) as i32, 56.0, GOLD);
let margin = 80i32; let margin = 80i32;
let start_y = (h / 4) as i32; 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() { 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); let rank = format!("{}.", i + 1);
self.draw_left(&mut img, &rank, margin, y, 36.0, GOLD); self.draw_left(&mut img, &rank, margin, y + 10, 36.0, GOLD);
self.draw_left(&mut img, &d.name, margin + 60, y, 36.0, WHITE); 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); 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); self.stamp_logo(&mut img);
@@ -240,21 +434,62 @@ impl SlideRenderer {
pub fn render_actors( pub fn render_actors(
&self, &self,
report: &WrapUpReport, report: &WrapUpReport,
cast_images: &[(String, Vec<u8>)],
w: u32, w: u32,
h: u32, h: u32,
) -> Result<Vec<u8>, DomainError> { ) -> Result<Vec<u8>, DomainError> {
let mut img = fill(w, h); let mut img = self.make_canvas(3, w, h);
self.draw_centered(&mut img, "Top Actors", (h / 8) as i32, 56.0, GOLD);
let margin = 80i32; let margin = 80i32;
let start_y = (h / 4) as i32; 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() { 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); let rank = format!("{}.", i + 1);
self.draw_left(&mut img, &rank, margin, y, 36.0, GOLD); self.draw_left(&mut img, &rank, margin, y + 10, 36.0, GOLD);
self.draw_left(&mut img, &a.name, margin + 60, y, 36.0, WHITE); 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); 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); self.stamp_logo(&mut img);
@@ -267,15 +502,26 @@ impl SlideRenderer {
w: u32, w: u32,
h: u32, h: u32,
) -> Result<Vec<u8>, DomainError> { ) -> Result<Vec<u8>, 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); self.draw_centered(&mut img, "Genre Breakdown", (h / 8) as i32, 56.0, GOLD);
let detail = format!("{} genres explored", report.genre_diversity); let detail = format!("{} genres explored", report.genre_diversity);
self.draw_centered(&mut img, &detail, (h / 8) as i32 + 64, 28.0, DIM); 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 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); 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() { for (i, g) in report.top_genres.iter().take(8).enumerate() {
@@ -316,18 +562,28 @@ impl SlideRenderer {
pub fn render_highlights( pub fn render_highlights(
&self, &self,
report: &WrapUpReport, report: &WrapUpReport,
poster_images: &[(String, Vec<u8>)],
w: u32, w: u32,
h: u32, h: u32,
) -> Result<Vec<u8>, DomainError> { ) -> Result<Vec<u8>, DomainError> {
let mut img = fill(w, h); let mut img = self.make_canvas(5, w, h);
self.draw_centered(&mut img, "Highlights", (h / 10) as i32, 56.0, GOLD);
// 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 col_w = w / 2;
let start_y = (h / 5) as i32; let start_y = (h / 5) as i32;
let row_h = (h / 5) as i32; let row_h = (h / 5) as i32;
let left = 60i32; let left = 60i32;
let right = col_w as i32 + 40; 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![ let items: Vec<(&str, Option<&domain::models::wrapup::MovieRef>)> = vec![
("Highest Rated", report.highest_rated_movie.as_ref()), ("Highest Rated", report.highest_rated_movie.as_ref()),
@@ -346,6 +602,29 @@ impl SlideRenderer {
let x = if col == 0 { left } else { right }; let x = if col == 0 { left } else { right };
let y = start_y + (row as i32) * row_h; 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); self.draw_left(&mut img, label, x, y, 28.0, GOLD);
if let Some(m) = movie_ref { if let Some(m) = movie_ref {
let title = if m.title.len() > 22 { let title = if m.title.len() > 22 {
@@ -353,9 +632,23 @@ impl SlideRenderer {
} else { } else {
m.title.clone() 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); 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 { } else {
self.draw_left(&mut img, "-", x, y + 36, 26.0, DIM); self.draw_left(&mut img, "-", x, y + 36, 26.0, DIM);
} }
@@ -382,20 +675,42 @@ impl SlideRenderer {
w: u32, w: u32,
h: u32, h: u32,
) -> Result<Vec<u8>, DomainError> { ) -> Result<Vec<u8>, 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_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 col = (i as u32) % cols;
let row = (i as u32) / cols; let row = (i as u32) / cols;
let x = col * thumb_w; let x = col * thumb_w;
let y = row * thumb_h; let y = row * thumb_h;
if y + thumb_h > h {
break;
}
match decode_image(bytes) { match decode_image(bytes) {
Ok(poster) => { Ok(poster) => {
let thumb = poster.resize_exact( let thumb = poster.resize_exact(