feat: wire video renderer pipeline + download endpoint
Some checks failed
CI / Check / Test (push) Failing after 41s

This commit is contained in:
2026-06-02 22:34:55 +02:00
parent d45d8aa913
commit 490bd97a40
10 changed files with 79 additions and 2 deletions

View File

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

View File

@@ -166,6 +166,7 @@ impl TestContextBuilder {
event_publisher: self.event_publisher, event_publisher: self.event_publisher,
diary_exporter: self.diary_exporter, diary_exporter: self.diary_exporter,
document_parser: self.document_parser, document_parser: self.document_parser,
video_renderer: None,
}, },
config: self.config, config: self.config,
} }

View File

@@ -2,7 +2,8 @@ use crate::context::AppContext;
use crate::wrapup::{compute, queries::ComputeWrapUpQuery}; use crate::wrapup::{compute, queries::ComputeWrapUpQuery};
use domain::errors::DomainError; use domain::errors::DomainError;
use domain::events::DomainEvent; 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; use domain::value_objects::WrapUpId;
pub async fn execute( pub async fn execute(
@@ -34,6 +35,29 @@ pub async fn execute(
let json = serde_json::to_string(&report) let json = serde_json::to_string(&report)
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?; .map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
ctx.repos.wrapup_repo.set_complete(&wrapup_id, &json).await?; 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 ctx.services
.event_publisher .event_publisher
.publish(&DomainEvent::WrapUpCompleted { wrapup_id }) .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<u8>)> {
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
}

View File

@@ -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<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; // 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 ─────────────────────────────────────────────────────────── // ── HTML handlers ───────────────────────────────────────────────────────────
fn format_watch_time(minutes: u32) -> String { fn format_watch_time(minutes: u32) -> String {

View File

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

View File

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

View File

@@ -364,6 +364,10 @@ fn api_routes(rate_limit: u64) -> Router<AppState> {
.route( .route(
"/wrapups/{id}/report", "/wrapups/{id}/report",
routing::get(handlers::wrapup::get_report), routing::get(handlers::wrapup::get_report),
)
.route(
"/wrapups/{id}/video",
routing::get(handlers::wrapup::get_video),
); );
#[cfg(feature = "federation")] #[cfg(feature = "federation")]

View File

@@ -651,6 +651,7 @@ pub fn make_test_state(auth_service: Arc<dyn AuthService>) -> crate::state::AppS
event_publisher: Arc::clone(&repo) as _, event_publisher: Arc::clone(&repo) as _,
diary_exporter: Arc::clone(&repo) as _, diary_exporter: Arc::clone(&repo) as _,
document_parser: Arc::clone(&repo) as _, document_parser: Arc::clone(&repo) as _,
video_renderer: None,
}, },
config: AppConfig { config: AppConfig {
allow_registration: false, allow_registration: false,

View File

@@ -427,6 +427,7 @@ async fn test_app() -> Router {
event_publisher: Arc::new(NoopEventPublisher), event_publisher: Arc::new(NoopEventPublisher),
diary_exporter: Arc::new(PanicExporter), diary_exporter: Arc::new(PanicExporter),
document_parser: Arc::new(PanicDocumentParser), document_parser: Arc::new(PanicDocumentParser),
video_renderer: None,
}, },
config: AppConfig { config: AppConfig {
allow_registration: false, allow_registration: false,

View File

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