feat: font rendering + logo branding on wrapup slides
Some checks failed
CI / Check / Test (push) Failing after 43s
Some checks failed
CI / Check / Test (push) Failing after 43s
This commit is contained in:
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
Reference in New Issue
Block a user