feat: font rendering + logo branding on wrapup slides
Some checks failed
CI / Check / Test (push) Failing after 43s

This commit is contained in:
2026-06-02 23:16:55 +02:00
parent 21c33b169e
commit efd1214a4c
5 changed files with 368 additions and 87 deletions

View File

@@ -9,7 +9,7 @@ async-trait = { workspace = true }
tracing = { workspace = true } tracing = { workspace = true }
image = "0.25" image = "0.25"
imageproc = "0.25" imageproc = "0.25"
rusttype = "0.9" ab_glyph = "0.2"
plotters = { version = "0.3", default-features = false, features = ["bitmap_backend", "bitmap_encoder"] } plotters = { version = "0.3", default-features = false, features = ["bitmap_backend", "bitmap_encoder"] }
tokio = { workspace = true, features = ["process"] } tokio = { workspace = true, features = ["process"] }
tempfile = "3" tempfile = "3"

View File

@@ -1,6 +1,6 @@
mod slides;
mod charts; mod charts;
mod ffmpeg; mod ffmpeg;
mod slides;
use async_trait::async_trait; use async_trait::async_trait;
use domain::errors::DomainError; use domain::errors::DomainError;
@@ -25,22 +25,27 @@ impl WrapUpVideoRenderer for FfmpegWrapUpRenderer {
) -> Result<Vec<u8>, DomainError> { ) -> Result<Vec<u8>, DomainError> {
let (width, height) = config.resolution; let (width, height) = config.resolution;
let renderer = slides::SlideRenderer::new(
config.font_path.as_deref(),
config.logo_path.as_deref(),
)?;
// 1. Generate slide images // 1. Generate slide images
let mut slide_pngs = Vec::new(); let mut slide_pngs = Vec::new();
slide_pngs.push(slides::render_hero_slide(report, width, height)?); slide_pngs.push(renderer.render_hero(report, width, height)?);
slide_pngs.push(slides::render_ratings_slide(report, width, height)?); slide_pngs.push(renderer.render_ratings(report, width, height)?);
if !report.top_directors.is_empty() { if !report.top_directors.is_empty() {
slide_pngs.push(slides::render_directors_slide(report, width, height)?); slide_pngs.push(renderer.render_directors(report, width, height)?);
} }
if !report.top_actors.is_empty() { if !report.top_actors.is_empty() {
slide_pngs.push(slides::render_actors_slide(report, width, height)?); slide_pngs.push(renderer.render_actors(report, width, height)?);
} }
if !report.top_genres.is_empty() { if !report.top_genres.is_empty() {
slide_pngs.push(charts::render_genre_chart(report, width, height)?); slide_pngs.push(charts::render_genre_chart(report, width, height)?);
} }
slide_pngs.push(slides::render_highlights_slide(report, width, height)?); slide_pngs.push(renderer.render_highlights(report, width, height)?);
if !poster_images.is_empty() { if !poster_images.is_empty() {
slide_pngs.push(slides::render_mosaic_slide(&poster_images, width, height)?); slide_pngs.push(renderer.render_mosaic(&poster_images, width, height)?);
} }
// 2. Stitch into video // 2. Stitch into video

View File

@@ -1,10 +1,338 @@
use ab_glyph::{FontArc, PxScale};
use domain::errors::DomainError; use domain::errors::DomainError;
use domain::models::wrapup::WrapUpReport; use domain::models::wrapup::WrapUpReport;
use image::{Rgba, RgbaImage}; use image::{Rgba, RgbaImage};
use imageproc::drawing::{draw_filled_rect_mut, draw_text_mut};
use imageproc::rect::Rect;
const BG_COLOR: Rgba<u8> = Rgba([26, 26, 36, 255]); // dark blue-gray const BG: Rgba<u8> = Rgba([26, 26, 36, 255]);
const _PRIMARY: Rgba<u8> = Rgba([229, 192, 52, 255]); // gold #e5c034 const GOLD: Rgba<u8> = Rgba([229, 192, 52, 255]);
const _TEXT_COLOR: Rgba<u8> = Rgba([255, 255, 255, 255]); // white 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]);
pub struct SlideRenderer {
font: FontArc,
logo: Option<RgbaImage>,
}
impl SlideRenderer {
pub fn new(font_path: Option<&str>, logo_path: 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
};
Ok(Self { font, logo })
}
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);
// 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);
}
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);
}
pub fn render_hero(
&self,
report: &WrapUpReport,
w: u32,
h: u32,
) -> Result<Vec<u8>, DomainError> {
let mut img = fill(w, 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 = fill(w, 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);
}
// rating distribution bars
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);
// 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,
&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,
w: u32,
h: u32,
) -> Result<Vec<u8>, DomainError> {
let mut img = fill(w, h);
self.draw_centered(&mut img, "Top Directors", (h / 8) as i32, 56.0, GOLD);
let margin = 80i32;
let start_y = (h / 4) as i32;
for (i, d) in report.top_directors.iter().take(5).enumerate() {
let y = start_y + (i as i32) * 90;
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);
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.stamp_logo(&mut img);
to_png(&img)
}
pub fn render_actors(
&self,
report: &WrapUpReport,
w: u32,
h: u32,
) -> Result<Vec<u8>, DomainError> {
let mut img = fill(w, h);
self.draw_centered(&mut img, "Top Actors", (h / 8) as i32, 56.0, GOLD);
let margin = 80i32;
let start_y = (h / 4) as i32;
for (i, a) in report.top_actors.iter().take(5).enumerate() {
let y = start_y + (i as i32) * 90;
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);
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.stamp_logo(&mut img);
to_png(&img)
}
pub fn render_highlights(
&self,
report: &WrapUpReport,
w: u32,
h: u32,
) -> Result<Vec<u8>, DomainError> {
let mut img = fill(w, h);
self.draw_centered(&mut img, "Highlights", (h / 10) as i32, 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 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;
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, y + 36, 26.0, WHITE);
let sub = format!("({})", m.year);
self.draw_left(&mut img, &sub, x, 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 = fill(w, h);
let cols = 4u32;
let thumb_w = w / cols;
let thumb_h = (thumb_w * 3) / 2;
for (i, (_, bytes)) in posters.iter().enumerate() {
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;
}
if let Ok(poster) = image::load_from_memory(bytes) {
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);
}
}
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> { fn to_png(img: &RgbaImage) -> Result<Vec<u8>, DomainError> {
let mut buf = Vec::new(); let mut buf = Vec::new();
@@ -16,82 +344,26 @@ fn to_png(img: &RgbaImage) -> Result<Vec<u8>, DomainError> {
Ok(buf) Ok(buf)
} }
fn fill_background(width: u32, height: u32) -> RgbaImage { fn load_system_font() -> Result<FontArc, DomainError> {
RgbaImage::from_pixel(width, height, BG_COLOR) let candidates = [
} "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/TTF/DejaVuSans.ttf",
pub fn render_hero_slide( "/usr/share/fonts/dejavu-sans-fonts/DejaVuSans.ttf",
_report: &WrapUpReport, "/usr/share/fonts/noto/NotoSans-Regular.ttf",
width: u32, "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
height: u32, "/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc",
) -> Result<Vec<u8>, DomainError> { "/System/Library/Fonts/Helvetica.ttc",
let img = fill_background(width, height); ];
// MVP: solid background. Text overlay added with font rendering later. for path in &candidates {
to_png(&img) if let Ok(bytes) = std::fs::read(path) {
} if let Ok(font) = FontArc::try_from_vec(bytes) {
tracing::info!("loaded system font: {path}");
pub fn render_ratings_slide( return Ok(font);
_report: &WrapUpReport, }
width: u32,
height: u32,
) -> Result<Vec<u8>, DomainError> {
let img = fill_background(width, height);
to_png(&img)
}
pub fn render_directors_slide(
_report: &WrapUpReport,
width: u32,
height: u32,
) -> Result<Vec<u8>, DomainError> {
let img = fill_background(width, height);
to_png(&img)
}
pub fn render_actors_slide(
_report: &WrapUpReport,
width: u32,
height: u32,
) -> Result<Vec<u8>, DomainError> {
let img = fill_background(width, height);
to_png(&img)
}
pub fn render_highlights_slide(
_report: &WrapUpReport,
width: u32,
height: u32,
) -> Result<Vec<u8>, DomainError> {
let img = fill_background(width, height);
to_png(&img)
}
pub fn render_mosaic_slide(
posters: &[(String, Vec<u8>)],
width: u32,
height: u32,
) -> Result<Vec<u8>, DomainError> {
let mut canvas = fill_background(width, height);
let cols = 4u32;
let thumb_w = width / cols;
let thumb_h = (thumb_w * 3) / 2; // 2:3 poster ratio
for (i, (_, bytes)) in posters.iter().enumerate() {
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 > height {
break;
}
if let Ok(poster) = image::load_from_memory(bytes) {
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(DomainError::InfrastructureError(
to_png(&canvas) "no system font found; set font_path in VideoRenderConfig or WRAPUP_FONT_PATH env"
.to_string(),
))
} }

View File

@@ -44,6 +44,8 @@ pub async fn execute(
transition_duration_secs: 0.8, transition_duration_secs: 0.8,
resolution: (1080, 1920), resolution: (1080, 1920),
ffmpeg_path: "ffmpeg".to_string(), ffmpeg_path: "ffmpeg".to_string(),
font_path: None,
logo_path: None,
}; };
match renderer.render(&report, poster_images, &config).await { match renderer.render(&report, poster_images, &config).await {
Ok(video_bytes) => { Ok(video_bytes) => {

View File

@@ -530,6 +530,8 @@ pub struct VideoRenderConfig {
pub transition_duration_secs: f32, pub transition_duration_secs: f32,
pub resolution: (u32, u32), pub resolution: (u32, u32),
pub ffmpeg_path: String, pub ffmpeg_path: String,
pub font_path: Option<String>,
pub logo_path: Option<String>,
} }
#[async_trait] #[async_trait]