From 490bd97a40c6f3742c1f1096536f35db9597d59f Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Tue, 2 Jun 2026 22:34:55 +0200 Subject: [PATCH] feat: wire video renderer pipeline + download endpoint --- crates/application/src/context.rs | 4 +- crates/application/src/test_helpers.rs | 1 + .../src/wrapup/handle_requested.rs | 37 ++++++++++++++++++- crates/presentation/src/handlers/wrapup.rs | 30 +++++++++++++++ crates/presentation/src/main.rs | 1 + crates/presentation/src/openapi/wrapup.rs | 1 + crates/presentation/src/routes.rs | 4 ++ crates/presentation/src/tests/extractors.rs | 1 + crates/presentation/tests/api_test.rs | 1 + crates/worker/src/main.rs | 1 + 10 files changed, 79 insertions(+), 2 deletions(-) diff --git a/crates/application/src/context.rs b/crates/application/src/context.rs index c9121a9..e3064a7 100644 --- a/crates/application/src/context.rs +++ b/crates/application/src/context.rs @@ -6,7 +6,8 @@ use domain::ports::{ MovieRepository, PasswordHasher, PersonCommand, PersonQuery, PosterFetcherClient, RemoteWatchlistRepository, ReviewRepository, SearchCommand, SearchPort, SocialQueryPort, StatsRepository, UserProfileFieldsRepository, UserRepository, WatchEventRepository, - WatchlistRepository, WrapUpRepository, WrapUpStatsQuery, WebhookTokenRepository, + WatchlistRepository, WrapUpRepository, WrapUpStatsQuery, WrapUpVideoRenderer, + WebhookTokenRepository, }; use crate::config::AppConfig; @@ -45,6 +46,7 @@ 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 9d0c667..6999733 100644 --- a/crates/application/src/test_helpers.rs +++ b/crates/application/src/test_helpers.rs @@ -166,6 +166,7 @@ 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/handle_requested.rs b/crates/application/src/wrapup/handle_requested.rs index 3720f58..67daa0e 100644 --- a/crates/application/src/wrapup/handle_requested.rs +++ b/crates/application/src/wrapup/handle_requested.rs @@ -2,7 +2,8 @@ use crate::context::AppContext; use crate::wrapup::{compute, queries::ComputeWrapUpQuery}; use domain::errors::DomainError; use domain::events::DomainEvent; -use domain::models::wrapup::{DateRange, WrapUpScope, WrapUpStatus}; +use domain::models::wrapup::{DateRange, WrapUpReport, WrapUpScope, WrapUpStatus}; +use domain::ports::VideoRenderConfig; use domain::value_objects::WrapUpId; pub async fn execute( @@ -34,6 +35,29 @@ pub async fn execute( let json = serde_json::to_string(&report) .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; ctx.repos.wrapup_repo.set_complete(&wrapup_id, &json).await?; + + // Optionally render video (non-fatal) + if let Some(ref renderer) = ctx.services.video_renderer { + let poster_images = resolve_poster_images(ctx, &report).await; + let config = VideoRenderConfig { + slide_duration_secs: 4, + transition_duration_secs: 0.8, + resolution: (1080, 1920), + ffmpeg_path: "ffmpeg".to_string(), + }; + match renderer.render(&report, poster_images, &config).await { + Ok(video_bytes) => { + let video_key = format!("wrapups/{}/video.mp4", wrapup_id.value()); + if let Err(e) = ctx.services.image_storage.store(&video_key, &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 }) @@ -49,3 +73,14 @@ pub async fn execute( } } } + +async fn resolve_poster_images(ctx: &AppContext, report: &WrapUpReport) -> Vec<(String, Vec)> { + let mut images = Vec::new(); + for path in report.poster_paths.iter().take(20) { + match ctx.services.image_storage.get(path).await { + Ok(bytes) => images.push((path.clone(), bytes)), + Err(_) => {} + } + } + images +} diff --git a/crates/presentation/src/handlers/wrapup.rs b/crates/presentation/src/handlers/wrapup.rs index e6e8620..c94004d 100644 --- a/crates/presentation/src/handlers/wrapup.rs +++ b/crates/presentation/src/handlers/wrapup.rs @@ -152,6 +152,36 @@ 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; // used only for status check + let video_key = format!("wrapups/{}/video.mp4", id); + match state.app_ctx.services.image_storage.get(&video_key).await { + Ok(bytes) => ( + StatusCode::OK, + [(axum::http::header::CONTENT_TYPE, "video/mp4"), + (axum::http::header::CONTENT_DISPOSITION, "attachment; filename=\"wrapup.mp4\"")], + bytes, + ).into_response(), + Err(_) => StatusCode::NOT_FOUND.into_response(), + } +} + // ── HTML handlers ─────────────────────────────────────────────────────────── fn format_watch_time(minutes: u32) -> String { diff --git a/crates/presentation/src/main.rs b/crates/presentation/src/main.rs index c5c6985..faf9d8c 100644 --- a/crates/presentation/src/main.rs +++ b/crates/presentation/src/main.rs @@ -205,6 +205,7 @@ 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 7c47c6e..e79b644 100644 --- a/crates/presentation/src/openapi/wrapup.rs +++ b/crates/presentation/src/openapi/wrapup.rs @@ -7,6 +7,7 @@ use utoipa::OpenApi; crate::handlers::wrapup::get_list, crate::handlers::wrapup::get_status, crate::handlers::wrapup::get_report, + crate::handlers::wrapup::get_video, ), components(schemas( api_types::wrapup::GenerateWrapUpRequest, diff --git a/crates/presentation/src/routes.rs b/crates/presentation/src/routes.rs index 2b6d145..b85286d 100644 --- a/crates/presentation/src/routes.rs +++ b/crates/presentation/src/routes.rs @@ -364,6 +364,10 @@ fn api_routes(rate_limit: u64) -> Router { .route( "/wrapups/{id}/report", routing::get(handlers::wrapup::get_report), + ) + .route( + "/wrapups/{id}/video", + routing::get(handlers::wrapup::get_video), ); #[cfg(feature = "federation")] diff --git a/crates/presentation/src/tests/extractors.rs b/crates/presentation/src/tests/extractors.rs index 38d54a3..bf99b0d 100644 --- a/crates/presentation/src/tests/extractors.rs +++ b/crates/presentation/src/tests/extractors.rs @@ -651,6 +651,7 @@ 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, diff --git a/crates/presentation/tests/api_test.rs b/crates/presentation/tests/api_test.rs index c5a2d2e..8d955d2 100644 --- a/crates/presentation/tests/api_test.rs +++ b/crates/presentation/tests/api_test.rs @@ -427,6 +427,7 @@ 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, diff --git a/crates/worker/src/main.rs b/crates/worker/src/main.rs index 384de47..0feda76 100644 --- a/crates/worker/src/main.rs +++ b/crates/worker/src/main.rs @@ -104,6 +104,7 @@ 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: None, }, config: app_config, };