feat: frutiger aero visual overhaul for wrapup video slides
This commit is contained in:
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user