feat: video renderer adapter w/ slides + charts + ffmpeg

This commit is contained in:
2026-06-02 22:31:45 +02:00
parent f00a2cbbb8
commit d45d8aa913
8 changed files with 820 additions and 8 deletions

View File

@@ -0,0 +1,15 @@
[package]
name = "wrapup-renderer"
version = "0.1.0"
edition = "2024"
[dependencies]
domain = { workspace = true }
async-trait = { workspace = true }
tracing = { workspace = true }
image = "0.25"
imageproc = "0.25"
rusttype = "0.9"
plotters = { version = "0.3", default-features = false, features = ["bitmap_backend", "bitmap_encoder"] }
tokio = { workspace = true, features = ["process"] }
tempfile = "3"

View File

@@ -0,0 +1,71 @@
use domain::errors::DomainError;
use domain::models::wrapup::WrapUpReport;
use plotters::prelude::*;
pub fn render_genre_chart(
report: &WrapUpReport,
width: u32,
height: u32,
) -> Result<Vec<u8>, DomainError> {
let mut buf = vec![0u8; (width * height * 3) as usize];
{
let root =
BitMapBackend::with_buffer(&mut buf, (width, height)).into_drawing_area();
root.fill(&RGBColor(26, 26, 36))
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
let max_count = report
.top_genres
.iter()
.map(|g| g.count)
.max()
.unwrap_or(1);
let mut chart = ChartBuilder::on(&root)
.margin(40)
.x_label_area_size(60)
.y_label_area_size(60)
.build_cartesian_2d(
0u32..max_count + 1,
(0..report.top_genres.len() as i32).into_segmented(),
)
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
chart
.configure_mesh()
.disable_mesh()
.label_style(("sans-serif", 14, &WHITE))
.axis_style(&RGBColor(100, 100, 100))
.draw()
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
chart
.draw_series(report.top_genres.iter().enumerate().map(|(i, g)| {
let color = RGBColor(229, 192, 52);
Rectangle::new(
[
(0, SegmentValue::Exact(i as i32)),
(g.count, SegmentValue::Exact(i as i32 + 1)),
],
color.filled(),
)
}))
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
root.present()
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
}
// Convert raw RGB to PNG via image crate
let img = image::RgbImage::from_raw(width, height, buf)
.ok_or_else(|| DomainError::InfrastructureError("invalid image buffer".into()))?;
let rgba = image::DynamicImage::ImageRgb8(img).to_rgba8();
let mut png_buf = Vec::new();
rgba.write_to(
&mut std::io::Cursor::new(&mut png_buf),
image::ImageFormat::Png,
)
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
Ok(png_buf)
}

View File

@@ -0,0 +1,56 @@
use domain::errors::DomainError;
use domain::ports::VideoRenderConfig;
use tokio::process::Command;
pub async fn stitch_slides(
slides: &[Vec<u8>],
config: &VideoRenderConfig,
) -> Result<Vec<u8>, DomainError> {
let dir =
tempfile::tempdir().map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
// Write slide PNGs
for (i, png) in slides.iter().enumerate() {
let path = dir.path().join(format!("slide_{:04}.png", i));
std::fs::write(&path, png)
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
}
let output_path = dir.path().join("output.mp4");
// -framerate 1/N makes each image last N seconds
let framerate = format!("1/{}", config.slide_duration_secs);
let (w, h) = config.resolution;
let status = Command::new(&config.ffmpeg_path)
.args([
"-y",
"-framerate",
&framerate,
"-i",
&dir.path().join("slide_%04d.png").to_string_lossy(),
"-vf",
&format!("scale={}:{},format=yuv420p", w, h),
"-c:v",
"libx264",
"-preset",
"fast",
"-crf",
"23",
"-movflags",
"+faststart",
&output_path.to_string_lossy(),
])
.output()
.await
.map_err(|e| DomainError::InfrastructureError(format!("ffmpeg failed: {e}")))?;
if !status.status.success() {
let stderr = String::from_utf8_lossy(&status.stderr);
return Err(DomainError::InfrastructureError(format!(
"ffmpeg error: {stderr}"
)));
}
std::fs::read(&output_path).map_err(|e| DomainError::InfrastructureError(e.to_string()))
}

View File

@@ -0,0 +1,49 @@
mod slides;
mod charts;
mod ffmpeg;
use async_trait::async_trait;
use domain::errors::DomainError;
use domain::models::wrapup::WrapUpReport;
use domain::ports::{VideoRenderConfig, WrapUpVideoRenderer};
pub struct FfmpegWrapUpRenderer;
impl FfmpegWrapUpRenderer {
pub fn new() -> Self {
Self
}
}
#[async_trait]
impl WrapUpVideoRenderer for FfmpegWrapUpRenderer {
async fn render(
&self,
report: &WrapUpReport,
poster_images: Vec<(String, Vec<u8>)>,
config: &VideoRenderConfig,
) -> Result<Vec<u8>, DomainError> {
let (width, height) = config.resolution;
// 1. Generate slide images
let mut slide_pngs = Vec::new();
slide_pngs.push(slides::render_hero_slide(report, width, height)?);
slide_pngs.push(slides::render_ratings_slide(report, width, height)?);
if !report.top_directors.is_empty() {
slide_pngs.push(slides::render_directors_slide(report, width, height)?);
}
if !report.top_actors.is_empty() {
slide_pngs.push(slides::render_actors_slide(report, width, height)?);
}
if !report.top_genres.is_empty() {
slide_pngs.push(charts::render_genre_chart(report, width, height)?);
}
slide_pngs.push(slides::render_highlights_slide(report, width, height)?);
if !poster_images.is_empty() {
slide_pngs.push(slides::render_mosaic_slide(&poster_images, width, height)?);
}
// 2. Stitch into video
ffmpeg::stitch_slides(&slide_pngs, config).await
}
}

View File

@@ -0,0 +1,97 @@
use domain::errors::DomainError;
use domain::models::wrapup::WrapUpReport;
use image::{Rgba, RgbaImage};
const BG_COLOR: Rgba<u8> = Rgba([26, 26, 36, 255]); // dark blue-gray
const _PRIMARY: Rgba<u8> = Rgba([229, 192, 52, 255]); // gold #e5c034
const _TEXT_COLOR: Rgba<u8> = Rgba([255, 255, 255, 255]); // white
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 fill_background(width: u32, height: u32) -> RgbaImage {
RgbaImage::from_pixel(width, height, BG_COLOR)
}
pub fn render_hero_slide(
_report: &WrapUpReport,
width: u32,
height: u32,
) -> Result<Vec<u8>, DomainError> {
let img = fill_background(width, height);
// MVP: solid background. Text overlay added with font rendering later.
to_png(&img)
}
pub fn render_ratings_slide(
_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);
}
}
to_png(&canvas)
}