Files
movies-diary/crates/adapters/wrapup-renderer/src/slides.rs

766 lines
26 KiB
Rust

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<DynamicImage, String> {
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<u8> = Rgba([26, 26, 36, 255]);
const GOLD: Rgba<u8> = Rgba([229, 192, 52, 255]);
const WHITE: Rgba<u8> = Rgba([255, 255, 255, 255]);
const DIM: Rgba<u8> = Rgba([255, 255, 255, 140]);
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 {
font: FontArc,
logo: Option<RgbaImage>,
backgrounds: Vec<RgbaImage>,
}
impl SlideRenderer {
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 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<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) {
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<u8>,
) {
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<u8>,
) {
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(
&self,
report: &WrapUpReport,
w: u32,
h: u32,
) -> Result<Vec<u8>, 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<Vec<u8>, 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<u8>)],
w: u32,
h: u32,
) -> Result<Vec<u8>, 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<u8>)],
w: u32,
h: u32,
) -> Result<Vec<u8>, 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<Vec<u8>, 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<u8>)],
w: u32,
h: u32,
) -> Result<Vec<u8>, 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<u8>)],
w: u32,
h: u32,
) -> Result<Vec<u8>, 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<Vec<u8>, 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<FontArc, DomainError> {
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(),
))
}