remove wrapup video rendering (ffmpeg)
All checks were successful
CI / Check / Test (push) Successful in 15m34s

SPA handles wrapup visuals client-side; server-side
renderer was dead code pulling in ffmpeg + image crates.
This commit is contained in:
2026-06-09 00:36:44 +02:00
parent f4fd915e35
commit 30a6200b5b
31 changed files with 27 additions and 1585 deletions

View File

@@ -152,11 +152,13 @@ impl ImportProfileRepository for SqliteImportProfileRepository {
let ca: String = r.get("created_at");
Ok(ImportProfile {
id: ImportProfileId::from_uuid(
id_str.parse::<uuid::Uuid>()
id_str
.parse::<uuid::Uuid>()
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
),
user_id: UserId::from_uuid(
uid_str.parse::<uuid::Uuid>()
uid_str
.parse::<uuid::Uuid>()
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
),
name: r.get("name"),

View File

@@ -354,16 +354,14 @@ impl ImportSessionRepository for SqliteImportSessionRepository {
async fn update(&self, s: &ImportSession) -> Result<(), DomainError> {
let id = s.id.value().to_string();
let (_, field_mappings, row_results) = Self::serialize_session(s)?;
sqlx::query(
"UPDATE import_sessions SET field_mappings = ?, row_results = ? WHERE id = ?",
)
.bind(&field_mappings)
.bind(&row_results)
.bind(&id)
.execute(&self.pool)
.await
.map(|_| ())
.map_err(Self::map_err)
sqlx::query("UPDATE import_sessions SET field_mappings = ?, row_results = ? WHERE id = ?")
.bind(&field_mappings)
.bind(&row_results)
.bind(&id)
.execute(&self.pool)
.await
.map(|_| ())
.map_err(Self::map_err)
}
async fn delete(&self, id: &ImportSessionId) -> Result<(), DomainError> {
@@ -377,11 +375,10 @@ impl ImportSessionRepository for SqliteImportSessionRepository {
}
async fn delete_expired(&self) -> Result<u64, DomainError> {
let result =
sqlx::query("DELETE FROM import_sessions WHERE expires_at < datetime('now')")
.execute(&self.pool)
.await
.map_err(Self::map_err)?;
let result = sqlx::query("DELETE FROM import_sessions WHERE expires_at < datetime('now')")
.execute(&self.pool)
.await
.map_err(Self::map_err)?;
Ok(result.rows_affected())
}

View File

@@ -481,5 +481,4 @@ pub struct WrapUpPageTemplate<'a> {
pub genre_max: u32,
pub rating_pcts: [f64; 5],
pub genre_pcts: Vec<f64>,
pub video_url: Option<String>,
}

View File

@@ -10,9 +10,6 @@
{% if report.total_watch_time_minutes > 0 %}
<div class="wu-detail">{{ watch_time_display }} of watch time</div>
{% endif %}
{% if let Some(url) = video_url %}
<a href="{{ url }}" class="wu-video-link" download>Download Video</a>
{% endif %}
</section>
<section class="wu-section">

View File

@@ -1,14 +0,0 @@
[package]
name = "wrapup-renderer"
version = "0.1.0"
edition = "2024"
[dependencies]
domain = { workspace = true }
async-trait = { workspace = true }
tracing = { workspace = true }
image = { version = "0.25", features = ["avif"] }
imageproc = "0.25"
ab_glyph = "0.2"
tokio = { workspace = true, features = ["process"] }
tempfile = "3"

View File

@@ -1,52 +0,0 @@
use domain::errors::DomainError;
use tokio::process::Command;
pub async fn stitch_slides(
slides: &[Vec<u8>],
ffmpeg_path: &str,
slide_duration_secs: u32,
resolution: (u32, u32),
) -> Result<Vec<u8>, DomainError> {
let dir = tempfile::tempdir().map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
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");
let framerate = format!("1/{}", slide_duration_secs);
let (w, h) = resolution;
let status = Command::new(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

@@ -1,93 +0,0 @@
mod ffmpeg;
mod slides;
use async_trait::async_trait;
use domain::errors::DomainError;
use domain::models::wrapup::WrapUpReport;
use domain::ports::{VideoRenderAssets, WrapUpVideoRenderer};
pub struct RendererConfig {
pub slide_duration_secs: u32,
pub transition_duration_secs: f32,
pub resolution: (u32, u32),
pub ffmpeg_path: String,
pub font_path: Option<String>,
pub logo_path: Option<String>,
pub bg_dir: Option<String>,
}
pub struct FfmpegWrapUpRenderer {
config: RendererConfig,
slide_renderer: slides::SlideRenderer,
}
impl FfmpegWrapUpRenderer {
pub fn new(config: RendererConfig) -> Result<Self, DomainError> {
let slide_renderer = slides::SlideRenderer::new(
config.font_path.as_deref(),
config.logo_path.as_deref(),
config.bg_dir.as_deref(),
)?;
Ok(Self {
config,
slide_renderer,
})
}
}
#[async_trait]
impl WrapUpVideoRenderer for FfmpegWrapUpRenderer {
async fn render(
&self,
report: &WrapUpReport,
assets: VideoRenderAssets,
) -> Result<Vec<u8>, DomainError> {
let (width, height) = self.config.resolution;
let mut slide_pngs = Vec::new();
slide_pngs.push(self.slide_renderer.render_hero(report, width, height)?);
slide_pngs.push(self.slide_renderer.render_ratings(report, width, height)?);
if !report.top_directors.is_empty() {
slide_pngs.push(self.slide_renderer.render_directors(
report,
&assets.cast_images,
width,
height,
)?);
}
if !report.top_actors.is_empty() {
slide_pngs.push(self.slide_renderer.render_actors(
report,
&assets.cast_images,
width,
height,
)?);
}
if !report.top_genres.is_empty() {
slide_pngs.push(self.slide_renderer.render_genres(report, width, height)?);
}
slide_pngs.push(self.slide_renderer.render_highlights(
report,
&assets.poster_images,
width,
height,
)?);
if !assets.poster_images.is_empty() {
slide_pngs.push(self.slide_renderer.render_mosaic(
&assets.poster_images,
width,
height,
)?);
} else {
tracing::warn!("no poster images resolved, skipping mosaic slide");
}
ffmpeg::stitch_slides(
&slide_pngs,
&self.config.ffmpeg_path,
self.config.slide_duration_secs,
self.config.resolution,
)
.await
}
}

View File

@@ -1,746 +0,0 @@
use ab_glyph::{FontArc, PxScale};
use domain::errors::DomainError;
use domain::models::wrapup::WrapUpReport;
use image::{DynamicImage, Rgba, RgbaImage};
use imageproc::drawing::{draw_filled_rect_mut, draw_text_mut};
use imageproc::rect::Rect;
fn decode_image(bytes: &[u8]) -> Result<DynamicImage, String> {
image::load_from_memory(bytes).or_else(|_| {
let dir = tempfile::tempdir().map_err(|e| e.to_string())?;
let input = dir.path().join("input");
let output = dir.path().join("output.png");
std::fs::write(&input, bytes).map_err(|e| e.to_string())?;
let status = std::process::Command::new("ffmpeg")
.args([
"-y",
"-i",
&input.to_string_lossy(),
&output.to_string_lossy(),
])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map_err(|e| e.to_string())?;
if !status.success() {
return Err("ffmpeg conversion failed".into());
}
let png_bytes = std::fs::read(&output).map_err(|e| e.to_string())?;
image::load_from_memory(&png_bytes).map_err(|e| e.to_string())
})
}
fn resize_cover(img: &RgbaImage, w: u32, h: u32) -> RgbaImage {
let (iw, ih) = (img.width() as f64, img.height() as f64);
let scale = (w as f64 / iw).max(h as f64 / ih);
let sw = (iw * scale).ceil() as u32;
let sh = (ih * scale).ceil() as u32;
let scaled = image::imageops::resize(img, sw, sh, image::imageops::FilterType::CatmullRom);
let cx = (sw.saturating_sub(w)) / 2;
let cy = (sh.saturating_sub(h)) / 2;
image::imageops::crop_imm(&scaled, cx, cy, w, h).to_image()
}
const BG: Rgba<u8> = Rgba([26, 26, 36, 255]);
const GOLD: Rgba<u8> = Rgba([229, 192, 52, 255]);
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]);
const GLASS: Rgba<u8> = Rgba([20, 20, 30, 180]);
const GLASS_PADDING: u32 = 30;
pub struct SlideRenderer {
font: FontArc,
logo: Option<RgbaImage>,
bg_paths: Vec<std::path::PathBuf>,
}
impl SlideRenderer {
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 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
};
let mut bg_paths = Vec::new();
if let Some(dir) = bg_dir
&& 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") {
bg_paths.push(path);
}
}
bg_paths.sort();
}
Ok(Self {
font,
logo,
bg_paths,
})
}
fn load_background(&self, index: usize) -> Option<RgbaImage> {
let path = self.bg_paths.get(index % self.bg_paths.len())?;
match image::open(path) {
Ok(img) => Some(img.to_rgba8()),
Err(e) => {
tracing::warn!("bg load {}: {e}", path.display());
None
}
}
}
/// 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> {
let bg = self.load_background(index)?;
let mut out = resize_cover(&bg, w, h);
// 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) {
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;
}
let alpha = GLASS[3] as f32 / 255.0;
let inv = 1.0 - alpha;
for py in y0..y1 {
for px in x0..x1 {
let bg = canvas.get_pixel(px, py);
let r = (GLASS[0] as f32 * alpha + bg[0] as f32 * inv) as u8;
let g = (GLASS[1] as f32 * alpha + bg[1] as f32 * inv) as u8;
let b = (GLASS[2] as f32 * alpha + bg[2] as f32 * inv) as u8;
canvas.put_pixel(px, py, Rgba([r, g, b, 255]));
}
}
}
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);
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);
}
/// 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(
&self,
report: &WrapUpReport,
w: u32,
h: u32,
) -> Result<Vec<u8>, DomainError> {
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!(
"{} - {}",
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 = 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);
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);
}
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 row in 0..5 {
let stars = 5 - row;
let count = report.rating_distribution[stars - 1];
let label = format!("{stars}\u{2605}");
let y = bar_area_top + (row as i32) * (bar_h as i32 + bar_gap as i32);
self.draw_left(&mut img, &label, margin_x - 60, y + 2, 28.0, GOLD);
draw_filled_rect_mut(
&mut img,
Rect::at(margin_x, y).of_size(max_bar_w, bar_h),
BAR_BG,
);
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);
}
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,
cast_images: &[(String, Vec<u8>)],
w: u32,
h: u32,
) -> Result<Vec<u8>, DomainError> {
let mut img = self.make_canvas(2, w, h);
let margin = 80i32;
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() {
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);
self.draw_left(&mut img, &rank, margin, y + 10, 36.0, GOLD);
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);
self.draw_left(&mut img, &detail, margin + text_offset, y + 54, 24.0, DIM);
}
self.stamp_logo(&mut img);
to_png(&img)
}
pub fn render_actors(
&self,
report: &WrapUpReport,
cast_images: &[(String, Vec<u8>)],
w: u32,
h: u32,
) -> Result<Vec<u8>, DomainError> {
let mut img = self.make_canvas(3, w, h);
let margin = 80i32;
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() {
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);
self.draw_left(&mut img, &rank, margin, y + 10, 36.0, GOLD);
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);
self.draw_left(&mut img, &detail, margin + text_offset, y + 54, 24.0, DIM);
}
self.stamp_logo(&mut img);
to_png(&img)
}
pub fn render_genres(
&self,
report: &WrapUpReport,
w: u32,
h: u32,
) -> Result<Vec<u8>, DomainError> {
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);
let detail = format!("{} genres explored", report.genre_diversity);
self.draw_centered(&mut img, &detail, (h / 8) as i32 + 64, 28.0, DIM);
let bar_area_w = (w as i32 - margin * 2 - 200) as u32;
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() {
let y = start_y + (i as i32) * 80;
self.draw_left(&mut img, &g.genre, margin, y, 30.0, WHITE);
let count_str = format!("{}", g.count);
self.draw_left(&mut img, &count_str, w as i32 - margin - 40, y, 30.0, DIM);
let bar_y = y + 38;
let bar_w = (g.count as f64 / max_count as f64 * bar_area_w as f64) as u32;
draw_filled_rect_mut(
&mut img,
Rect::at(margin, bar_y).of_size(bar_area_w, 12),
BAR_BG,
);
if bar_w > 0 {
draw_filled_rect_mut(&mut img, Rect::at(margin, bar_y).of_size(bar_w, 12), GOLD);
}
}
if let Some(ref best) = report.highest_rated_genre {
let text = format!("Highest rated: {best}");
self.draw_centered(&mut img, &text, h as i32 - 180, 28.0, WHITE);
}
if let Some(ref worst) = report.lowest_rated_genre {
let text = format!("Lowest rated: {worst}");
self.draw_centered(&mut img, &text, h as i32 - 140, 28.0, DIM);
}
self.stamp_logo(&mut img);
to_png(&img)
}
pub fn render_highlights(
&self,
report: &WrapUpReport,
poster_images: &[(String, Vec<u8>)],
w: u32,
h: u32,
) -> Result<Vec<u8>, DomainError> {
let mut img = self.make_canvas(5, w, h);
// 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);
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 poster_w = 100u32;
let poster_h = 150u32;
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;
// 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);
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 + text_x_offset, y + 36, 26.0, WHITE);
let sub = format!("({})", m.year);
self.draw_left(&mut img, &sub, x + text_x_offset, 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 = 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.div_ceil(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 thumb_w = w / cols;
let thumb_h = (thumb_w as f32 / poster_ratio) as u32;
let total_rows = h.div_ceil(thumb_h);
let total_cells = (total_rows * cols) as usize;
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 row = (i as u32) / cols;
let x = col * thumb_w;
let y = row * thumb_h;
match decode_image(bytes) {
Ok(poster) => {
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(e) => tracing::debug!("mosaic: skipped {name}: {e}"),
}
}
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> {
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 load_system_font() -> Result<FontArc, DomainError> {
let candidates = [
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/TTF/DejaVuSans.ttf",
"/usr/share/fonts/dejavu-sans-fonts/DejaVuSans.ttf",
"/usr/share/fonts/noto/NotoSans-Regular.ttf",
"/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
"/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc",
"/System/Library/Fonts/Helvetica.ttc",
];
for path in &candidates {
if let Ok(bytes) = std::fs::read(path)
&& let Ok(font) = FontArc::try_from_vec(bytes)
{
tracing::info!("loaded system font: {path}");
return Ok(font);
}
}
Err(DomainError::InfrastructureError(
"no system font found; set font_path in VideoRenderConfig or WRAPUP_FONT_PATH env"
.to_string(),
))
}

View File

@@ -11,8 +11,6 @@ pub struct WrapUpConfig {
pub font_path: Option<String>,
pub logo_path: Option<String>,
pub bg_dir: Option<String>,
pub ffmpeg_path: String,
pub max_concurrent_renders: usize,
}
impl AppConfig {
@@ -41,11 +39,6 @@ impl WrapUpConfig {
font_path: std::env::var("WRAPUP_FONT_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()),
max_concurrent_renders: std::env::var("WRAPUP_MAX_CONCURRENT")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(2),
}
}
}

View File

@@ -7,7 +7,7 @@ use domain::ports::{
PosterFetcherClient, RemoteGoalRepository, RemoteWatchlistRepository, ReviewRepository,
SearchCommand, SearchPort, SocialQueryPort, StatsRepository, UserProfileFieldsRepository,
UserRepository, UserSettingsRepository, WatchEventRepository, WatchlistRepository,
WebhookTokenRepository, WrapUpRepository, WrapUpStatsQuery, WrapUpVideoRenderer,
WebhookTokenRepository, WrapUpRepository, WrapUpStatsQuery,
};
use crate::config::AppConfig;
@@ -49,7 +49,6 @@ pub struct Services {
pub event_publisher: Arc<dyn EventPublisher>,
pub diary_exporter: Arc<dyn DiaryExporter>,
pub document_parser: Arc<dyn DocumentParser>,
pub video_renderer: Option<Arc<dyn WrapUpVideoRenderer>>,
}
#[derive(Clone)]

View File

@@ -102,8 +102,6 @@ impl TestContextBuilder {
font_path: None,
logo_path: None,
bg_dir: None,
ffmpeg_path: "ffmpeg".into(),
max_concurrent_renders: 2,
},
},
}
@@ -185,7 +183,6 @@ impl TestContextBuilder {
event_publisher: self.event_publisher,
diary_exporter: self.diary_exporter,
document_parser: self.document_parser,
video_renderer: None,
},
config: self.config,
}

View File

@@ -2,7 +2,6 @@ use domain::errors::DomainError;
use domain::value_objects::WrapUpId;
use crate::context::AppContext;
use crate::wrapup::storage::WrapUpStorage;
pub async fn execute(ctx: &AppContext, id: WrapUpId) -> Result<(), DomainError> {
ctx.repos
@@ -11,8 +10,5 @@ pub async fn execute(ctx: &AppContext, id: WrapUpId) -> Result<(), DomainError>
.await?
.ok_or_else(|| DomainError::NotFound("wrap-up not found".into()))?;
let storage = WrapUpStorage::new(ctx.services.object_storage.clone());
let _ = storage.delete_video(&id).await;
ctx.repos.wrapup_repo.delete(&id).await
}

View File

@@ -15,10 +15,9 @@ pub struct WrapUpEventHandler {
impl WrapUpEventHandler {
pub fn new(ctx: AppContext) -> Self {
let max = ctx.config.wrapup.max_concurrent_renders;
Self {
ctx,
semaphore: Arc::new(Semaphore::new(max)),
semaphore: Arc::new(Semaphore::new(2)),
}
}
}

View File

@@ -1,9 +1,8 @@
use crate::context::AppContext;
use crate::wrapup::{compute, queries::ComputeWrapUpQuery, storage::WrapUpStorage};
use crate::wrapup::{compute, queries::ComputeWrapUpQuery};
use domain::errors::DomainError;
use domain::events::DomainEvent;
use domain::models::wrapup::{DateRange, WrapUpScope, WrapUpStatus};
use domain::ports::VideoRenderAssets;
use domain::value_objects::WrapUpId;
pub async fn execute(
@@ -45,30 +44,6 @@ pub async fn execute(
.set_complete(&wrapup_id, &report)
.await?;
if let Some(ref renderer) = ctx.services.video_renderer {
let asset_storage = WrapUpStorage::new(ctx.services.object_storage.clone());
let poster_images = asset_storage
.resolve_poster_images(&report.poster_paths)
.await;
let cast_images = asset_storage
.resolve_cast_images(&report.top_cast_profile_paths)
.await;
let assets = VideoRenderAssets {
poster_images,
cast_images,
};
match renderer.render(&report, assets).await {
Ok(video_bytes) => {
if let Err(e) = asset_storage.store_video(&wrapup_id, &video_bytes).await {
tracing::warn!("failed to store wrapup video: {e}");
}
}
Err(e) => {
tracing::warn!("video render failed (non-fatal): {e}");
}
}
}
ctx.services
.event_publisher
.publish(&DomainEvent::WrapUpCompleted { wrapup_id })

View File

@@ -7,4 +7,3 @@ pub mod get_wrapup;
pub mod handle_requested;
pub mod list_wrapups;
pub mod queries;
pub mod storage;

View File

@@ -1,58 +0,0 @@
use domain::errors::DomainError;
use domain::ports::ObjectStorage;
use domain::value_objects::WrapUpId;
use std::sync::Arc;
pub struct WrapUpStorage {
inner: Arc<dyn ObjectStorage>,
}
impl WrapUpStorage {
pub fn new(storage: Arc<dyn ObjectStorage>) -> Self {
Self { inner: storage }
}
pub async fn store_video(&self, id: &WrapUpId, bytes: &[u8]) -> Result<(), DomainError> {
let key = format!("wrapups/{}/video.mp4", id.value());
self.inner.store(&key, bytes).await?;
Ok(())
}
pub async fn delete_video(&self, id: &WrapUpId) -> Result<(), DomainError> {
let key = format!("wrapups/{}/video.mp4", id.value());
self.inner.delete(&key).await
}
pub fn cast_image_key(profile_path: &str) -> String {
format!("cast{profile_path}")
}
pub async fn resolve_cast_images(&self, profile_paths: &[String]) -> Vec<(String, Vec<u8>)> {
let mut images = Vec::new();
for path in profile_paths.iter().take(20) {
let key = Self::cast_image_key(path);
match self.inner.get(&key).await {
Ok(bytes) => images.push((key, bytes)),
Err(e) => tracing::debug!("cast fetch skipped for {key}: {e}"),
}
}
tracing::info!(
"resolved {}/{} cast images",
images.len(),
profile_paths.len()
);
images
}
pub async fn resolve_poster_images(&self, paths: &[String]) -> Vec<(String, Vec<u8>)> {
let mut images = Vec::new();
for path in paths.iter().take(20) {
match self.inner.get(path).await {
Ok(bytes) => images.push((path.clone(), bytes)),
Err(e) => tracing::debug!("poster fetch skipped for {path}: {e}"),
}
}
tracing::info!("resolved {}/{} poster images", images.len(), paths.len());
images
}
}

View File

@@ -587,19 +587,3 @@ pub trait WrapUpStatsQuery: Send + Sync {
range: &DateRange,
) -> Result<Vec<WrapUpMovieRow>, DomainError>;
}
// ── Video renderer ──────────────────────────────────────────────────────────
pub struct VideoRenderAssets {
pub poster_images: Vec<(String, Vec<u8>)>,
pub cast_images: Vec<(String, Vec<u8>)>,
}
#[async_trait]
pub trait WrapUpVideoRenderer: Send + Sync {
async fn render(
&self,
report: &WrapUpReport,
assets: VideoRenderAssets,
) -> Result<Vec<u8>, DomainError>;
}

View File

@@ -147,54 +147,6 @@ pub async fn get_report(
}
}
#[utoipa::path(
get, path = "/api/v1/wrapups/{id}/video",
params(("id" = Uuid, Path, description = "Wrap-up ID")),
responses(
(status = 200, description = "MP4 video file", content_type = "video/mp4"),
(status = 404, description = "Not found or video not generated"),
),
security(("bearer_auth" = []))
)]
pub async fn get_video(State(state): State<AppState>, Path(id): Path<Uuid>) -> impl IntoResponse {
let record = match state
.app_ctx
.repos
.wrapup_repo
.get_by_id(&WrapUpId::from_uuid(id))
.await
{
Ok(Some(r)) if r.status == WrapUpStatus::Ready => r,
_ => return StatusCode::NOT_FOUND.into_response(),
};
let _ = record;
let video_key = format!("wrapups/{}/video.mp4", id);
match state
.app_ctx
.services
.object_storage
.get_stream(&video_key)
.await
{
Ok(stream) => {
let body = axum::body::Body::from_stream(stream);
(
StatusCode::OK,
[
(axum::http::header::CONTENT_TYPE, "video/mp4"),
(
axum::http::header::CONTENT_DISPOSITION,
"attachment; filename=\"wrapup.mp4\"",
),
],
body,
)
.into_response()
}
Err(_) => StatusCode::NOT_FOUND.into_response(),
}
}
#[utoipa::path(
delete, path = "/api/v1/wrapups/{id}",
params(("id" = Uuid, Path, description = "Wrap-up ID")),
@@ -233,7 +185,6 @@ fn render_wrapup(
report: &WrapUpReport,
year: i32,
ctx: &application::ports::HtmlPageContext,
video_url: Option<String>,
) -> axum::response::Response {
let rating_max = report
.rating_distribution
@@ -265,7 +216,6 @@ fn render_wrapup(
genre_max,
rating_pcts,
genre_pcts,
video_url,
};
render_page(tmpl)
}
@@ -301,9 +251,8 @@ pub async fn get_user_wrapup_html(
None => return StatusCode::NOT_FOUND.into_response(),
};
let video_url = format!("/api/v1/wrapups/{}/video", record.id.value());
let ctx = super::helpers::build_page_context(&state, viewer, csrf.0).await;
render_wrapup(&report, year, &ctx, Some(video_url))
render_wrapup(&report, year, &ctx)
}
pub async fn get_global_wrapup_html(
@@ -337,7 +286,6 @@ pub async fn get_global_wrapup_html(
None => return StatusCode::NOT_FOUND.into_response(),
};
let video_url = format!("/api/v1/wrapups/{}/video", record.id.value());
let ctx = super::helpers::build_page_context(&state, viewer, csrf.0).await;
render_wrapup(&report, year, &ctx, Some(video_url))
render_wrapup(&report, year, &ctx)
}

View File

@@ -209,7 +209,6 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
event_publisher: event_publisher_arc,
diary_exporter: Arc::new(ExportAdapter) as Arc<dyn DiaryExporter>,
document_parser: Arc::new(ImporterDocumentParser) as Arc<dyn DocumentParser>,
video_renderer: None,
},
config: app_config,
};

View File

@@ -7,7 +7,6 @@ use utoipa::OpenApi;
crate::handlers::wrapup::get_list,
crate::handlers::wrapup::get_status,
crate::handlers::wrapup::get_report,
crate::handlers::wrapup::get_video,
crate::handlers::wrapup::delete_wrapup_handler,
),
components(schemas(

View File

@@ -427,10 +427,6 @@ fn api_routes(rate_limit: u64) -> Router<AppState> {
"/wrapups/{id}/report",
routing::get(handlers::wrapup::get_report),
)
.route(
"/wrapups/{id}/video",
routing::get(handlers::wrapup::get_video),
)
.route(
"/admin/reindex-search",
routing::post(handlers::search::post_reindex_search),

View File

@@ -759,7 +759,6 @@ pub fn make_test_state(auth_service: Arc<dyn AuthService>) -> crate::state::AppS
event_publisher: Arc::clone(&repo) as _,
diary_exporter: Arc::clone(&repo) as _,
document_parser: Arc::clone(&repo) as _,
video_renderer: None,
},
config: AppConfig {
allow_registration: false,
@@ -769,8 +768,6 @@ pub fn make_test_state(auth_service: Arc<dyn AuthService>) -> crate::state::AppS
font_path: None,
logo_path: None,
bg_dir: None,
ffmpeg_path: "ffmpeg".into(),
max_concurrent_renders: 2,
},
},
},

View File

@@ -450,7 +450,6 @@ async fn test_app() -> Router {
event_publisher: Arc::new(NoopEventPublisher),
diary_exporter: Arc::new(PanicExporter),
document_parser: Arc::new(PanicDocumentParser),
video_renderer: None,
},
config: AppConfig {
allow_registration: false,
@@ -460,8 +459,6 @@ async fn test_app() -> Router {
font_path: None,
logo_path: None,
bg_dir: None,
ffmpeg_path: "ffmpeg".into(),
max_concurrent_renders: 2,
},
},
},

View File

@@ -29,7 +29,6 @@ export = { workspace = true }
tmdb-enrichment = { workspace = true }
importer = { workspace = true }
image-converter = { workspace = true }
wrapup-renderer = { workspace = true }
nats = { workspace = true, optional = true }
sqlx = { workspace = true }
async-trait = { workspace = true }

View File

@@ -107,38 +107,6 @@ async fn main() -> anyhow::Result<()> {
event_publisher: event_publisher_arc,
diary_exporter: Arc::new(ExportAdapter) as Arc<dyn DiaryExporter>,
document_parser: Arc::new(ImporterDocumentParser) as Arc<dyn DocumentParser>,
video_renderer: {
let wc = &app_config.wrapup;
let ffmpeg = &wc.ffmpeg_path;
if std::process::Command::new(ffmpeg)
.arg("-version")
.output()
.is_ok()
{
let renderer_cfg = wrapup_renderer::RendererConfig {
slide_duration_secs: 4,
transition_duration_secs: 0.8,
resolution: (1080, 1920),
ffmpeg_path: ffmpeg.clone(),
font_path: wc.font_path.clone(),
logo_path: wc.logo_path.clone(),
bg_dir: wc.bg_dir.clone(),
};
match wrapup_renderer::FfmpegWrapUpRenderer::new(renderer_cfg) {
Ok(r) => {
tracing::info!("wrapup video renderer enabled (ffmpeg={ffmpeg})");
Some(Arc::new(r) as Arc<dyn domain::ports::WrapUpVideoRenderer>)
}
Err(e) => {
tracing::warn!("wrapup video renderer init failed: {e}");
None
}
}
} else {
tracing::info!("wrapup video renderer disabled (ffmpeg not found)");
None
}
},
},
config: app_config,
};