Compare commits
2 Commits
0d02f23f4f
...
d52120d6a9
| Author | SHA1 | Date | |
|---|---|---|---|
| d52120d6a9 | |||
| e57ddd78ac |
@@ -4,7 +4,7 @@ mod slides;
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use domain::errors::DomainError;
|
use domain::errors::DomainError;
|
||||||
use domain::models::wrapup::WrapUpReport;
|
use domain::models::wrapup::WrapUpReport;
|
||||||
use domain::ports::{VideoRenderConfig, WrapUpVideoRenderer};
|
use domain::ports::{VideoRenderAssets, VideoRenderConfig, WrapUpVideoRenderer};
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub struct FfmpegWrapUpRenderer;
|
pub struct FfmpegWrapUpRenderer;
|
||||||
@@ -20,35 +20,36 @@ impl WrapUpVideoRenderer for FfmpegWrapUpRenderer {
|
|||||||
async fn render(
|
async fn render(
|
||||||
&self,
|
&self,
|
||||||
report: &WrapUpReport,
|
report: &WrapUpReport,
|
||||||
poster_images: Vec<(String, Vec<u8>)>,
|
assets: VideoRenderAssets,
|
||||||
config: &VideoRenderConfig,
|
config: &VideoRenderConfig,
|
||||||
) -> Result<Vec<u8>, DomainError> {
|
) -> Result<Vec<u8>, DomainError> {
|
||||||
let (width, height) = config.resolution;
|
let (width, height) = config.resolution;
|
||||||
|
|
||||||
let renderer =
|
let renderer = slides::SlideRenderer::new(
|
||||||
slides::SlideRenderer::new(config.font_path.as_deref(), config.logo_path.as_deref())?;
|
config.font_path.as_deref(),
|
||||||
|
config.logo_path.as_deref(),
|
||||||
|
config.bg_dir.as_deref(),
|
||||||
|
)?;
|
||||||
|
|
||||||
// 1. Generate slide images
|
|
||||||
let mut slide_pngs = Vec::new();
|
let mut slide_pngs = Vec::new();
|
||||||
slide_pngs.push(renderer.render_hero(report, width, height)?);
|
slide_pngs.push(renderer.render_hero(report, width, height)?);
|
||||||
slide_pngs.push(renderer.render_ratings(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(renderer.render_directors(report, width, height)?);
|
slide_pngs.push(renderer.render_directors(report, &assets.cast_images, width, height)?);
|
||||||
}
|
}
|
||||||
if !report.top_actors.is_empty() {
|
if !report.top_actors.is_empty() {
|
||||||
slide_pngs.push(renderer.render_actors(report, width, height)?);
|
slide_pngs.push(renderer.render_actors(report, &assets.cast_images, width, height)?);
|
||||||
}
|
}
|
||||||
if !report.top_genres.is_empty() {
|
if !report.top_genres.is_empty() {
|
||||||
slide_pngs.push(renderer.render_genres(report, width, height)?);
|
slide_pngs.push(renderer.render_genres(report, width, height)?);
|
||||||
}
|
}
|
||||||
slide_pngs.push(renderer.render_highlights(report, width, height)?);
|
slide_pngs.push(renderer.render_highlights(report, &assets.poster_images, width, height)?);
|
||||||
if !poster_images.is_empty() {
|
if !assets.poster_images.is_empty() {
|
||||||
slide_pngs.push(renderer.render_mosaic(&poster_images, width, height)?);
|
slide_pngs.push(renderer.render_mosaic(&assets.poster_images, width, height)?);
|
||||||
} else {
|
} else {
|
||||||
tracing::warn!("no poster images resolved, skipping mosaic slide");
|
tracing::warn!("no poster images resolved, skipping mosaic slide");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Stitch into video
|
|
||||||
ffmpeg::stitch_slides(&slide_pngs, config).await
|
ffmpeg::stitch_slides(&slide_pngs, config).await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ pub struct AppConfig {
|
|||||||
pub struct WrapUpConfig {
|
pub struct WrapUpConfig {
|
||||||
pub font_path: Option<String>,
|
pub font_path: Option<String>,
|
||||||
pub logo_path: Option<String>,
|
pub logo_path: Option<String>,
|
||||||
|
pub bg_dir: Option<String>,
|
||||||
pub ffmpeg_path: String,
|
pub ffmpeg_path: String,
|
||||||
pub max_concurrent_renders: usize,
|
pub max_concurrent_renders: usize,
|
||||||
}
|
}
|
||||||
@@ -39,6 +40,7 @@ impl WrapUpConfig {
|
|||||||
Self {
|
Self {
|
||||||
font_path: std::env::var("WRAPUP_FONT_PATH").ok(),
|
font_path: std::env::var("WRAPUP_FONT_PATH").ok(),
|
||||||
logo_path: std::env::var("WRAPUP_LOGO_PATH").ok(),
|
logo_path: std::env::var("WRAPUP_LOGO_PATH").ok(),
|
||||||
|
bg_dir: std::env::var("WRAPUP_BG_DIR").ok(),
|
||||||
ffmpeg_path: std::env::var("FFMPEG_PATH").unwrap_or_else(|_| "ffmpeg".to_string()),
|
ffmpeg_path: std::env::var("FFMPEG_PATH").unwrap_or_else(|_| "ffmpeg".to_string()),
|
||||||
max_concurrent_renders: std::env::var("WRAPUP_MAX_CONCURRENT")
|
max_concurrent_renders: std::env::var("WRAPUP_MAX_CONCURRENT")
|
||||||
.ok()
|
.ok()
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ impl TestContextBuilder {
|
|||||||
wrapup: crate::config::WrapUpConfig {
|
wrapup: crate::config::WrapUpConfig {
|
||||||
font_path: None,
|
font_path: None,
|
||||||
logo_path: None,
|
logo_path: None,
|
||||||
|
bg_dir: None,
|
||||||
ffmpeg_path: "ffmpeg".into(),
|
ffmpeg_path: "ffmpeg".into(),
|
||||||
max_concurrent_renders: 2,
|
max_concurrent_renders: 2,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ use crate::wrapup::{compute, queries::ComputeWrapUpQuery};
|
|||||||
use domain::errors::DomainError;
|
use domain::errors::DomainError;
|
||||||
use domain::events::DomainEvent;
|
use domain::events::DomainEvent;
|
||||||
use domain::models::wrapup::{DateRange, WrapUpReport, WrapUpScope, WrapUpStatus};
|
use domain::models::wrapup::{DateRange, WrapUpReport, WrapUpScope, WrapUpStatus};
|
||||||
use domain::ports::VideoRenderConfig;
|
use domain::ports::{VideoRenderAssets, VideoRenderConfig};
|
||||||
use domain::value_objects::WrapUpId;
|
use domain::value_objects::WrapUpId;
|
||||||
|
|
||||||
pub async fn execute(
|
pub async fn execute(
|
||||||
@@ -39,9 +39,10 @@ pub async fn execute(
|
|||||||
.set_complete(&wrapup_id, &json)
|
.set_complete(&wrapup_id, &json)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// Optionally render video (non-fatal)
|
|
||||||
if let Some(ref renderer) = ctx.services.video_renderer {
|
if let Some(ref renderer) = ctx.services.video_renderer {
|
||||||
let poster_images = resolve_poster_images(ctx, &report).await;
|
let poster_images = resolve_images(ctx, &report.poster_paths, "poster").await;
|
||||||
|
let cast_images =
|
||||||
|
resolve_images(ctx, &report.top_cast_profile_paths, "cast").await;
|
||||||
let wc = &ctx.config.wrapup;
|
let wc = &ctx.config.wrapup;
|
||||||
let config = VideoRenderConfig {
|
let config = VideoRenderConfig {
|
||||||
slide_duration_secs: 4,
|
slide_duration_secs: 4,
|
||||||
@@ -50,8 +51,13 @@ pub async fn execute(
|
|||||||
ffmpeg_path: wc.ffmpeg_path.clone(),
|
ffmpeg_path: wc.ffmpeg_path.clone(),
|
||||||
font_path: wc.font_path.clone(),
|
font_path: wc.font_path.clone(),
|
||||||
logo_path: wc.logo_path.clone(),
|
logo_path: wc.logo_path.clone(),
|
||||||
|
bg_dir: wc.bg_dir.clone(),
|
||||||
};
|
};
|
||||||
match renderer.render(&report, poster_images, &config).await {
|
let assets = VideoRenderAssets {
|
||||||
|
poster_images,
|
||||||
|
cast_images,
|
||||||
|
};
|
||||||
|
match renderer.render(&report, assets, &config).await {
|
||||||
Ok(video_bytes) => {
|
Ok(video_bytes) => {
|
||||||
let video_key = format!("wrapups/{}/video.mp4", wrapup_id.value());
|
let video_key = format!("wrapups/{}/video.mp4", wrapup_id.value());
|
||||||
if let Err(e) = ctx
|
if let Err(e) = ctx
|
||||||
@@ -85,14 +91,18 @@ pub async fn execute(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn resolve_poster_images(ctx: &AppContext, report: &WrapUpReport) -> Vec<(String, Vec<u8>)> {
|
async fn resolve_images(
|
||||||
|
ctx: &AppContext,
|
||||||
|
paths: &[String],
|
||||||
|
label: &str,
|
||||||
|
) -> Vec<(String, Vec<u8>)> {
|
||||||
let mut images = Vec::new();
|
let mut images = Vec::new();
|
||||||
for path in report.poster_paths.iter().take(20) {
|
for path in paths.iter().take(20) {
|
||||||
match ctx.services.image_storage.get(path).await {
|
match ctx.services.image_storage.get(path).await {
|
||||||
Ok(bytes) => images.push((path.clone(), bytes)),
|
Ok(bytes) => images.push((path.clone(), bytes)),
|
||||||
Err(e) => tracing::debug!("poster fetch skipped for {path}: {e}"),
|
Err(e) => tracing::debug!("{label} fetch skipped for {path}: {e}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
tracing::info!("resolved {}/{} poster images for video", images.len(), report.poster_paths.len());
|
tracing::info!("resolved {}/{} {label} images", images.len(), paths.len());
|
||||||
images
|
images
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -536,6 +536,12 @@ pub struct VideoRenderConfig {
|
|||||||
pub ffmpeg_path: String,
|
pub ffmpeg_path: String,
|
||||||
pub font_path: Option<String>,
|
pub font_path: Option<String>,
|
||||||
pub logo_path: Option<String>,
|
pub logo_path: Option<String>,
|
||||||
|
pub bg_dir: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct VideoRenderAssets {
|
||||||
|
pub poster_images: Vec<(String, Vec<u8>)>,
|
||||||
|
pub cast_images: Vec<(String, Vec<u8>)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -543,7 +549,7 @@ pub trait WrapUpVideoRenderer: Send + Sync {
|
|||||||
async fn render(
|
async fn render(
|
||||||
&self,
|
&self,
|
||||||
report: &WrapUpReport,
|
report: &WrapUpReport,
|
||||||
poster_images: Vec<(String, Vec<u8>)>,
|
assets: VideoRenderAssets,
|
||||||
config: &VideoRenderConfig,
|
config: &VideoRenderConfig,
|
||||||
) -> Result<Vec<u8>, DomainError>;
|
) -> Result<Vec<u8>, DomainError>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -677,6 +677,7 @@ pub fn make_test_state(auth_service: Arc<dyn AuthService>) -> crate::state::AppS
|
|||||||
wrapup: application::config::WrapUpConfig {
|
wrapup: application::config::WrapUpConfig {
|
||||||
font_path: None,
|
font_path: None,
|
||||||
logo_path: None,
|
logo_path: None,
|
||||||
|
bg_dir: None,
|
||||||
ffmpeg_path: "ffmpeg".into(),
|
ffmpeg_path: "ffmpeg".into(),
|
||||||
max_concurrent_renders: 2,
|
max_concurrent_renders: 2,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -443,6 +443,7 @@ async fn test_app() -> Router {
|
|||||||
wrapup: application::config::WrapUpConfig {
|
wrapup: application::config::WrapUpConfig {
|
||||||
font_path: None,
|
font_path: None,
|
||||||
logo_path: None,
|
logo_path: None,
|
||||||
|
bg_dir: None,
|
||||||
ffmpeg_path: "ffmpeg".into(),
|
ffmpeg_path: "ffmpeg".into(),
|
||||||
max_concurrent_renders: 2,
|
max_concurrent_renders: 2,
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user