remove wrapup video rendering (ffmpeg)
All checks were successful
CI / Check / Test (push) Successful in 15m34s
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:
@@ -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"),
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
@@ -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()))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
))
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -7,4 +7,3 @@ pub mod get_wrapup;
|
||||
pub mod handle_requested;
|
||||
pub mod list_wrapups;
|
||||
pub mod queries;
|
||||
pub mod storage;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user