,
}
diff --git a/crates/adapters/template-askama/templates/wrapup.html b/crates/adapters/template-askama/templates/wrapup.html
index d117372..8da50e3 100644
--- a/crates/adapters/template-askama/templates/wrapup.html
+++ b/crates/adapters/template-askama/templates/wrapup.html
@@ -10,9 +10,6 @@
{% if report.total_watch_time_minutes > 0 %}
{{ watch_time_display }} of watch time
{% endif %}
- {% if let Some(url) = video_url %}
- Download Video
- {% endif %}
diff --git a/crates/adapters/wrapup-renderer/Cargo.toml b/crates/adapters/wrapup-renderer/Cargo.toml
deleted file mode 100644
index 278b1bd..0000000
--- a/crates/adapters/wrapup-renderer/Cargo.toml
+++ /dev/null
@@ -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"
diff --git a/crates/adapters/wrapup-renderer/src/ffmpeg.rs b/crates/adapters/wrapup-renderer/src/ffmpeg.rs
deleted file mode 100644
index f197827..0000000
--- a/crates/adapters/wrapup-renderer/src/ffmpeg.rs
+++ /dev/null
@@ -1,52 +0,0 @@
-use domain::errors::DomainError;
-use tokio::process::Command;
-
-pub async fn stitch_slides(
- slides: &[Vec],
- ffmpeg_path: &str,
- slide_duration_secs: u32,
- resolution: (u32, u32),
-) -> Result, 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()))
-}
diff --git a/crates/adapters/wrapup-renderer/src/lib.rs b/crates/adapters/wrapup-renderer/src/lib.rs
deleted file mode 100644
index c86d19a..0000000
--- a/crates/adapters/wrapup-renderer/src/lib.rs
+++ /dev/null
@@ -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,
- pub logo_path: Option,
- pub bg_dir: Option,
-}
-
-pub struct FfmpegWrapUpRenderer {
- config: RendererConfig,
- slide_renderer: slides::SlideRenderer,
-}
-
-impl FfmpegWrapUpRenderer {
- pub fn new(config: RendererConfig) -> Result {
- 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, 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
- }
-}
diff --git a/crates/adapters/wrapup-renderer/src/slides.rs b/crates/adapters/wrapup-renderer/src/slides.rs
deleted file mode 100644
index 3c7099c..0000000
--- a/crates/adapters/wrapup-renderer/src/slides.rs
+++ /dev/null
@@ -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 {
- 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 = Rgba([26, 26, 36, 255]);
-const GOLD: Rgba = Rgba([229, 192, 52, 255]);
-const WHITE: Rgba = Rgba([255, 255, 255, 255]);
-const DIM: Rgba = Rgba([255, 255, 255, 140]);
-const BAR_BG: Rgba = Rgba([50, 50, 65, 255]);
-const GLASS: Rgba = Rgba([20, 20, 30, 180]);
-const GLASS_PADDING: u32 = 30;
-
-pub struct SlideRenderer {
- font: FontArc,
- logo: Option,
- bg_paths: Vec,
-}
-
-impl SlideRenderer {
- pub fn new(
- font_path: Option<&str>,
- logo_path: Option<&str>,
- bg_dir: Option<&str>,
- ) -> Result {
- 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 {
- 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 {
- 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,
- ) {
- 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,
- ) {
- 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)]) -> 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)],
- ) -> 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, 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, 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)],
- w: u32,
- h: u32,
- ) -> Result, 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)],
- w: u32,
- h: u32,
- ) -> Result, 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, 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)],
- w: u32,
- h: u32,
- ) -> Result, 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)],
- w: u32,
- h: u32,
- ) -> Result, 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, 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 {
- 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(),
- ))
-}
diff --git a/crates/application/src/config.rs b/crates/application/src/config.rs
index 2e9c584..29d3c9d 100644
--- a/crates/application/src/config.rs
+++ b/crates/application/src/config.rs
@@ -11,8 +11,6 @@ pub struct WrapUpConfig {
pub font_path: Option,
pub logo_path: Option,
pub bg_dir: Option,
- 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),
}
}
}
diff --git a/crates/application/src/context.rs b/crates/application/src/context.rs
index c5afad8..f28236c 100644
--- a/crates/application/src/context.rs
+++ b/crates/application/src/context.rs
@@ -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,
pub diary_exporter: Arc,
pub document_parser: Arc,
- pub video_renderer: Option>,
}
#[derive(Clone)]
diff --git a/crates/application/src/test_helpers.rs b/crates/application/src/test_helpers.rs
index 721b75c..92e0846 100644
--- a/crates/application/src/test_helpers.rs
+++ b/crates/application/src/test_helpers.rs
@@ -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,
}
diff --git a/crates/application/src/wrapup/delete.rs b/crates/application/src/wrapup/delete.rs
index 60949a9..26ced67 100644
--- a/crates/application/src/wrapup/delete.rs
+++ b/crates/application/src/wrapup/delete.rs
@@ -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
}
diff --git a/crates/application/src/wrapup/event_handler.rs b/crates/application/src/wrapup/event_handler.rs
index d96ca8a..f0d2b9e 100644
--- a/crates/application/src/wrapup/event_handler.rs
+++ b/crates/application/src/wrapup/event_handler.rs
@@ -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)),
}
}
}
diff --git a/crates/application/src/wrapup/handle_requested.rs b/crates/application/src/wrapup/handle_requested.rs
index e425bf2..f3b1ec2 100644
--- a/crates/application/src/wrapup/handle_requested.rs
+++ b/crates/application/src/wrapup/handle_requested.rs
@@ -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 })
diff --git a/crates/application/src/wrapup/mod.rs b/crates/application/src/wrapup/mod.rs
index 2723685..d2be4dd 100644
--- a/crates/application/src/wrapup/mod.rs
+++ b/crates/application/src/wrapup/mod.rs
@@ -7,4 +7,3 @@ pub mod get_wrapup;
pub mod handle_requested;
pub mod list_wrapups;
pub mod queries;
-pub mod storage;
diff --git a/crates/application/src/wrapup/storage.rs b/crates/application/src/wrapup/storage.rs
deleted file mode 100644
index c057396..0000000
--- a/crates/application/src/wrapup/storage.rs
+++ /dev/null
@@ -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,
-}
-
-impl WrapUpStorage {
- pub fn new(storage: Arc) -> 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)> {
- 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)> {
- 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
- }
-}
diff --git a/crates/domain/src/ports.rs b/crates/domain/src/ports.rs
index 0f7f8e0..618ca9e 100644
--- a/crates/domain/src/ports.rs
+++ b/crates/domain/src/ports.rs
@@ -587,19 +587,3 @@ pub trait WrapUpStatsQuery: Send + Sync {
range: &DateRange,
) -> Result, DomainError>;
}
-
-// ── Video renderer ──────────────────────────────────────────────────────────
-
-pub struct VideoRenderAssets {
- pub poster_images: Vec<(String, Vec)>,
- pub cast_images: Vec<(String, Vec)>,
-}
-
-#[async_trait]
-pub trait WrapUpVideoRenderer: Send + Sync {
- async fn render(
- &self,
- report: &WrapUpReport,
- assets: VideoRenderAssets,
- ) -> Result, DomainError>;
-}
diff --git a/crates/presentation/src/handlers/wrapup.rs b/crates/presentation/src/handlers/wrapup.rs
index 3fac829..18f5f8e 100644
--- a/crates/presentation/src/handlers/wrapup.rs
+++ b/crates/presentation/src/handlers/wrapup.rs
@@ -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, Path(id): Path) -> 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,
) -> 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)
}
diff --git a/crates/presentation/src/main.rs b/crates/presentation/src/main.rs
index c66d5bb..f206352 100644
--- a/crates/presentation/src/main.rs
+++ b/crates/presentation/src/main.rs
@@ -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,
document_parser: Arc::new(ImporterDocumentParser) as Arc,
- video_renderer: None,
},
config: app_config,
};
diff --git a/crates/presentation/src/openapi/wrapup.rs b/crates/presentation/src/openapi/wrapup.rs
index d6dded4..ba6e009 100644
--- a/crates/presentation/src/openapi/wrapup.rs
+++ b/crates/presentation/src/openapi/wrapup.rs
@@ -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(
diff --git a/crates/presentation/src/routes.rs b/crates/presentation/src/routes.rs
index cadc2a6..5f27f63 100644
--- a/crates/presentation/src/routes.rs
+++ b/crates/presentation/src/routes.rs
@@ -427,10 +427,6 @@ fn api_routes(rate_limit: u64) -> Router {
"/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),
diff --git a/crates/presentation/src/tests/extractors.rs b/crates/presentation/src/tests/extractors.rs
index cdabfd0..b2c137a 100644
--- a/crates/presentation/src/tests/extractors.rs
+++ b/crates/presentation/src/tests/extractors.rs
@@ -759,7 +759,6 @@ pub fn make_test_state(auth_service: Arc) -> 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) -> crate::state::AppS
font_path: None,
logo_path: None,
bg_dir: None,
- ffmpeg_path: "ffmpeg".into(),
- max_concurrent_renders: 2,
},
},
},
diff --git a/crates/presentation/tests/api_test.rs b/crates/presentation/tests/api_test.rs
index a1a7099..d3ee061 100644
--- a/crates/presentation/tests/api_test.rs
+++ b/crates/presentation/tests/api_test.rs
@@ -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,
},
},
},
diff --git a/crates/worker/Cargo.toml b/crates/worker/Cargo.toml
index cc20c8f..7718ef3 100644
--- a/crates/worker/Cargo.toml
+++ b/crates/worker/Cargo.toml
@@ -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 }
diff --git a/crates/worker/src/main.rs b/crates/worker/src/main.rs
index f99b60f..48c10b7 100644
--- a/crates/worker/src/main.rs
+++ b/crates/worker/src/main.rs
@@ -107,38 +107,6 @@ async fn main() -> anyhow::Result<()> {
event_publisher: event_publisher_arc,
diary_exporter: Arc::new(ExportAdapter) as Arc,
document_parser: Arc::new(ImporterDocumentParser) as Arc,
- 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)
- }
- 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,
};