feat: video renderer adapter w/ slides + charts + ffmpeg
This commit is contained in:
15
crates/adapters/wrapup-renderer/Cargo.toml
Normal file
15
crates/adapters/wrapup-renderer/Cargo.toml
Normal 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"
|
||||
71
crates/adapters/wrapup-renderer/src/charts.rs
Normal file
71
crates/adapters/wrapup-renderer/src/charts.rs
Normal 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)
|
||||
}
|
||||
56
crates/adapters/wrapup-renderer/src/ffmpeg.rs
Normal file
56
crates/adapters/wrapup-renderer/src/ffmpeg.rs
Normal 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()))
|
||||
}
|
||||
49
crates/adapters/wrapup-renderer/src/lib.rs
Normal file
49
crates/adapters/wrapup-renderer/src/lib.rs
Normal 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
|
||||
}
|
||||
}
|
||||
97
crates/adapters/wrapup-renderer/src/slides.rs
Normal file
97
crates/adapters/wrapup-renderer/src/slides.rs
Normal 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)
|
||||
}
|
||||
@@ -5,6 +5,7 @@ use uuid::Uuid;
|
||||
use crate::{
|
||||
errors::DomainError,
|
||||
events::{DomainEvent, EventEnvelope},
|
||||
models::wrapup::WrapUpReport,
|
||||
models::{
|
||||
AnnotatedRow, DiaryEntry, DiaryFilter, EntityType, ExportFormat, ExternalPersonId,
|
||||
FeedEntry, FieldMapping, FileFormat, ImportError, ImportProfile, ImportSession,
|
||||
@@ -521,3 +522,22 @@ pub trait WrapUpStatsQuery: Send + Sync {
|
||||
range: &DateRange,
|
||||
) -> Result<Vec<WrapUpMovieRow>, DomainError>;
|
||||
}
|
||||
|
||||
// ── Video renderer ──────────────────────────────────────────────────────────
|
||||
|
||||
pub struct VideoRenderConfig {
|
||||
pub slide_duration_secs: u32,
|
||||
pub transition_duration_secs: f32,
|
||||
pub resolution: (u32, u32),
|
||||
pub ffmpeg_path: String,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait WrapUpVideoRenderer: Send + Sync {
|
||||
async fn render(
|
||||
&self,
|
||||
report: &WrapUpReport,
|
||||
poster_images: Vec<(String, Vec<u8>)>,
|
||||
config: &VideoRenderConfig,
|
||||
) -> Result<Vec<u8>, DomainError>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user