1use std::collections::BTreeMap;
26use std::future::Future;
27use std::path::{Path, PathBuf};
28use std::pin::Pin;
29use std::sync::Arc;
30
31use autumn_web::job::JobInfo;
32use autumn_web::{AppState, AutumnError, AutumnResult, job};
33use serde::{Deserialize, Serialize};
34
35use crate::encode::{
36 FfmpegHighlightCommand, FfmpegPosterCommand, FfmpegPreviewSpriteCommand,
37 FfmpegRoomCompositeCommand, PREVIEW_CELL_HEIGHT, PREVIEW_CELL_WIDTH,
38 PREVIEW_FRAME_INTERVAL_SECONDS, PREVIEW_SPRITE_COLUMNS, build_preview_webvtt,
39 validate_room_composite_input_count,
40};
41use crate::error::MediaError;
42use crate::sink::{MediaArtifact, MediaArtifactFile, MediaArtifactKind, MediaArtifactSinkExt};
43use crate::storage::MediaStorage;
44
45#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
49pub struct ThumbnailJobArgs {
50 pub source_paths: Vec<PathBuf>,
53 pub output_key: String,
55 pub tail_offset_seconds: u32,
57 pub workflow_id: String,
59 #[serde(default)]
61 pub source_id: Option<String>,
62}
63
64#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
66pub struct PreviewJobArgs {
67 pub source_paths: Vec<PathBuf>,
69 pub sprite_key: String,
71 pub vtt_key: String,
73 pub frame_count: u32,
75 pub workflow_id: String,
77 #[serde(default)]
79 pub source_id: Option<String>,
80}
81
82#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
84pub struct TranscodeJobArgs {
85 pub source_path: PathBuf,
87 pub output_key: String,
89 pub start_seconds: u32,
91 pub duration_seconds: u32,
93 pub content_type: String,
95 pub workflow_id: String,
97 #[serde(default)]
99 pub source_id: Option<String>,
100}
101
102#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
105pub struct RoomCompositeJobArgs {
106 pub source_paths: Vec<PathBuf>,
108 pub output_key: String,
110 pub content_type: String,
112 pub workflow_id: String,
114 #[serde(default)]
116 pub source_id: Option<String>,
117}
118
119#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
122pub struct FinalizeRecordingJobArgs {
123 pub thumbnail: ThumbnailJobArgs,
125 pub preview: PreviewJobArgs,
127}
128
129#[job(
142 name = "autumn_media_extract_thumbnail",
143 max_attempts = 3,
144 backoff_ms = 1000,
145 queue = "media",
146 unique,
147 unique_by = "workflow_id",
148 unique_for_ms = 300_000
149)]
150async fn extract_thumbnail(state: AppState, args: ThumbnailJobArgs) -> AutumnResult<()> {
151 let (storage, ffmpeg) = resolve_engine(&state)?;
152 let workflow_id = args.workflow_id.clone();
153 let result = run_thumbnail(&ffmpeg, &storage, args).await;
154 deliver_result(&state, MediaArtifactKind::Thumbnail, workflow_id, result).await
155}
156
157#[job(
160 name = "autumn_media_extract_preview",
161 max_attempts = 3,
162 backoff_ms = 1000,
163 queue = "media",
164 unique,
165 unique_by = "workflow_id",
166 unique_for_ms = 300_000
167)]
168async fn extract_preview(state: AppState, args: PreviewJobArgs) -> AutumnResult<()> {
169 let (storage, ffmpeg) = resolve_engine(&state)?;
170 let workflow_id = args.workflow_id.clone();
171 let result = run_preview(&ffmpeg, &storage, args).await;
172 deliver_result(&state, MediaArtifactKind::Preview, workflow_id, result).await
173}
174
175#[job(
176 name = "autumn_media_transcode_window",
177 max_attempts = 3,
178 backoff_ms = 1000,
179 queue = "media"
180)]
181async fn transcode_window(state: AppState, args: TranscodeJobArgs) -> AutumnResult<()> {
182 let (storage, ffmpeg) = resolve_engine(&state)?;
183 let workflow_id = args.workflow_id.clone();
184 let result = run_transcode(&ffmpeg, &storage, args).await;
185 deliver_result(&state, MediaArtifactKind::Transcode, workflow_id, result).await
186}
187
188#[job(
189 name = "autumn_media_compose_room_recording",
190 max_attempts = 3,
191 backoff_ms = 1000,
192 queue = "media"
193)]
194async fn compose_room_recording(state: AppState, args: RoomCompositeJobArgs) -> AutumnResult<()> {
195 let (storage, ffmpeg) = resolve_engine(&state)?;
196 let workflow_id = args.workflow_id.clone();
197 let result = run_room_composite(&ffmpeg, &storage, args).await;
198 deliver_result(&state, MediaArtifactKind::Transcode, workflow_id, result).await
199}
200
201#[job(
202 name = "autumn_media_finalize_recording",
203 max_attempts = 3,
204 backoff_ms = 1000,
205 queue = "media"
206)]
207async fn finalize_recording(state: AppState, args: FinalizeRecordingJobArgs) -> AutumnResult<()> {
208 let workflows = state.extension::<MediaWorkflows>().ok_or_else(|| {
222 AutumnError::internal_server_error_msg("MediaWorkflows extension is not installed")
223 })?;
224 workflows.queue_thumbnail(&state, args.thumbnail).await?;
225 workflows.queue_preview(&state, args.preview).await?;
226 Ok(())
227}
228
229#[must_use]
238pub fn media_job_infos(queue: &str) -> Vec<JobInfo> {
239 [
240 __autumn_job_info_extract_thumbnail(),
241 __autumn_job_info_extract_preview(),
242 __autumn_job_info_transcode_window(),
243 __autumn_job_info_compose_room_recording(),
244 __autumn_job_info_finalize_recording(),
245 ]
246 .into_iter()
247 .map(|mut info| {
248 queue.clone_into(&mut info.queue);
249 info
250 })
251 .collect()
252}
253
254pub type MediaWorkflowDelegate = Arc<
263 dyn Fn(
264 &AppState,
265 MediaWorkflowRequest,
266 ) -> Pin<Box<dyn Future<Output = AutumnResult<()>> + Send>>
267 + Send
268 + Sync,
269>;
270
271#[derive(Clone)]
274pub struct MediaWorkflowDelegateExt(pub MediaWorkflowDelegate);
275
276#[derive(Clone, Debug, PartialEq, Eq)]
280pub enum MediaWorkflowRequest {
281 Thumbnail(Box<ThumbnailJobArgs>),
283 Preview(Box<PreviewJobArgs>),
285 Transcode(Box<TranscodeJobArgs>),
287 RoomComposite(Box<RoomCompositeJobArgs>),
289 Finalize(Box<FinalizeRecordingJobArgs>),
291}
292
293#[derive(Clone, Debug)]
299pub struct MediaWorkflows {
300 ffmpeg_bin: String,
301 queue: String,
302}
303
304impl MediaWorkflows {
305 #[must_use]
307 pub fn new(ffmpeg_bin: impl Into<String>, queue: impl Into<String>) -> Self {
308 Self {
309 ffmpeg_bin: ffmpeg_bin.into(),
310 queue: queue.into(),
311 }
312 }
313
314 #[must_use]
316 pub fn ffmpeg_bin(&self) -> &str {
317 &self.ffmpeg_bin
318 }
319
320 #[must_use]
322 pub fn queue(&self) -> &str {
323 &self.queue
324 }
325
326 pub async fn queue_thumbnail(
332 &self,
333 state: &AppState,
334 args: ThumbnailJobArgs,
335 ) -> AutumnResult<()> {
336 self.dispatch(state, MediaWorkflowRequest::Thumbnail(Box::new(args)))
337 .await
338 }
339
340 pub async fn queue_preview(&self, state: &AppState, args: PreviewJobArgs) -> AutumnResult<()> {
346 self.dispatch(state, MediaWorkflowRequest::Preview(Box::new(args)))
347 .await
348 }
349
350 pub async fn queue_transcode(
356 &self,
357 state: &AppState,
358 args: TranscodeJobArgs,
359 ) -> AutumnResult<()> {
360 self.dispatch(state, MediaWorkflowRequest::Transcode(Box::new(args)))
361 .await
362 }
363
364 pub async fn queue_room_composite(
371 &self,
372 state: &AppState,
373 args: RoomCompositeJobArgs,
374 ) -> AutumnResult<()> {
375 self.dispatch(state, MediaWorkflowRequest::RoomComposite(Box::new(args)))
376 .await
377 }
378
379 pub async fn queue_recording_finalize(
385 &self,
386 state: &AppState,
387 args: FinalizeRecordingJobArgs,
388 ) -> AutumnResult<()> {
389 self.dispatch(state, MediaWorkflowRequest::Finalize(Box::new(args)))
390 .await
391 }
392
393 async fn dispatch(&self, state: &AppState, request: MediaWorkflowRequest) -> AutumnResult<()> {
396 if let Some(delegate) = state.extension::<MediaWorkflowDelegateExt>() {
397 tracing::debug!("MediaWorkflows::dispatch: delegating media work via runtime hook");
398 return (delegate.0)(state, request).await;
399 }
400 tracing::debug!("MediaWorkflows::dispatch: enqueuing built-in media job");
401 match request {
402 MediaWorkflowRequest::Thumbnail(args) => ExtractThumbnailJob::enqueue(*args).await,
403 MediaWorkflowRequest::Preview(args) => ExtractPreviewJob::enqueue(*args).await,
404 MediaWorkflowRequest::Transcode(args) => TranscodeWindowJob::enqueue(*args).await,
405 MediaWorkflowRequest::RoomComposite(args) => {
406 ComposeRoomRecordingJob::enqueue(*args).await
407 }
408 MediaWorkflowRequest::Finalize(args) => FinalizeRecordingJob::enqueue(*args).await,
409 }
410 }
411}
412
413fn resolve_engine(state: &AppState) -> AutumnResult<(Arc<MediaStorage>, String)> {
417 let storage = state.extension::<MediaStorage>().ok_or_else(|| {
418 AutumnError::internal_server_error_msg("MediaStorage extension is not installed")
419 })?;
420 let workflows = state.extension::<MediaWorkflows>().ok_or_else(|| {
421 AutumnError::internal_server_error_msg("MediaWorkflows extension is not installed")
422 })?;
423 let ffmpeg = workflows.ffmpeg_bin.clone();
424 Ok((storage, ffmpeg))
425}
426
427async fn deliver_result(
430 state: &AppState,
431 kind: MediaArtifactKind,
432 workflow_id: String,
433 result: Result<MediaArtifact, MediaError>,
434) -> AutumnResult<()> {
435 match result {
436 Ok(artifact) => {
437 if let Some(sink) = state.extension::<MediaArtifactSinkExt>() {
438 sink.0
439 .on_completed(artifact)
440 .await
441 .map_err(|error| media_to_autumn(&error))?;
442 } else {
443 tracing::warn!(
444 workflow_id = %workflow_id,
445 kind = kind.as_str(),
446 "media workflow completed but no MediaArtifactSink is installed; artifact not recorded"
447 );
448 }
449 Ok(())
450 }
451 Err(error) => {
452 let message = error.to_string();
453 if let Some(sink) = state.extension::<MediaArtifactSinkExt>()
454 && let Err(sink_error) = sink.0.on_failed(kind, workflow_id.clone(), message).await
455 {
456 tracing::warn!(%sink_error, workflow_id = %workflow_id, "MediaArtifactSink::on_failed itself failed");
457 }
458 Err(media_to_autumn(&error))
461 }
462 }
463}
464
465fn media_to_autumn(error: &MediaError) -> AutumnError {
466 AutumnError::internal_server_error_msg(error.to_string())
467}
468
469async fn run_thumbnail(
470 ffmpeg_bin: &str,
471 storage: &MediaStorage,
472 args: ThumbnailJobArgs,
473) -> Result<MediaArtifact, MediaError> {
474 let (output_path, ephemeral) = staged_output(storage, &args.output_key, "jpg")?;
475 let command = FfmpegPosterCommand::new(
476 ffmpeg_bin,
477 args.source_paths.clone(),
478 output_path.clone(),
479 args.tail_offset_seconds,
480 );
481 let bytes = run_blocking(move || command.run()).await?;
482 let file = persist_and_cleanup(
483 storage,
484 &args.output_key,
485 &output_path,
486 "image/jpeg",
487 bytes,
488 ephemeral,
489 )
490 .await?;
491 Ok(MediaArtifact {
492 kind: MediaArtifactKind::Thumbnail,
493 workflow_id: args.workflow_id,
494 source_id: args.source_id,
495 primary: file,
496 secondary: None,
497 metadata: BTreeMap::new(),
498 })
499}
500
501async fn run_preview(
502 ffmpeg_bin: &str,
503 storage: &MediaStorage,
504 args: PreviewJobArgs,
505) -> Result<MediaArtifact, MediaError> {
506 let cols = PREVIEW_SPRITE_COLUMNS;
507 let rows = args.frame_count.div_ceil(cols).max(1);
508 let (sprite_path, sprite_ephemeral) = staged_output(storage, &args.sprite_key, "jpg")?;
509 let command = FfmpegPreviewSpriteCommand::new(
510 ffmpeg_bin,
511 args.source_paths.clone(),
512 sprite_path.clone(),
513 cols,
514 rows,
515 );
516 let sprite_bytes = run_blocking(move || command.run()).await?;
517 let sprite_file = persist_and_cleanup(
518 storage,
519 &args.sprite_key,
520 &sprite_path,
521 "image/jpeg",
522 sprite_bytes,
523 sprite_ephemeral,
524 )
525 .await?;
526
527 let vtt = build_preview_webvtt(
530 args.frame_count,
531 PREVIEW_FRAME_INTERVAL_SECONDS,
532 cols,
533 PREVIEW_CELL_WIDTH,
534 PREVIEW_CELL_HEIGHT,
535 &sprite_file.public_url,
536 );
537 let (vtt_path, vtt_ephemeral) = staged_output(storage, &args.vtt_key, "vtt")?;
538 write_text_file(&vtt_path, &vtt).await?;
539 let vtt_bytes = u64::try_from(vtt.len()).unwrap_or(u64::MAX);
540 let vtt_file = persist_and_cleanup(
541 storage,
542 &args.vtt_key,
543 &vtt_path,
544 "text/vtt",
545 vtt_bytes,
546 vtt_ephemeral,
547 )
548 .await?;
549
550 let mut metadata = BTreeMap::new();
551 metadata.insert("frame_count".to_owned(), args.frame_count.to_string());
552 Ok(MediaArtifact {
553 kind: MediaArtifactKind::Preview,
554 workflow_id: args.workflow_id,
555 source_id: args.source_id,
556 primary: sprite_file,
557 secondary: Some(vtt_file),
558 metadata,
559 })
560}
561
562async fn run_transcode(
563 ffmpeg_bin: &str,
564 storage: &MediaStorage,
565 args: TranscodeJobArgs,
566) -> Result<MediaArtifact, MediaError> {
567 let (output_path, ephemeral) = staged_output(storage, &args.output_key, "mp4")?;
568 let command = FfmpegHighlightCommand::new(
569 ffmpeg_bin,
570 args.source_path.clone(),
571 output_path.clone(),
572 args.start_seconds,
573 args.duration_seconds,
574 );
575 let bytes = run_blocking(move || command.run()).await?;
576 let file = persist_and_cleanup(
577 storage,
578 &args.output_key,
579 &output_path,
580 &args.content_type,
581 bytes,
582 ephemeral,
583 )
584 .await?;
585 Ok(MediaArtifact {
586 kind: MediaArtifactKind::Transcode,
587 workflow_id: args.workflow_id,
588 source_id: args.source_id,
589 primary: file,
590 secondary: None,
591 metadata: BTreeMap::new(),
592 })
593}
594
595async fn run_room_composite(
596 ffmpeg_bin: &str,
597 storage: &MediaStorage,
598 args: RoomCompositeJobArgs,
599) -> Result<MediaArtifact, MediaError> {
600 let RoomCompositeJobArgs {
601 source_paths,
602 output_key,
603 content_type,
604 workflow_id,
605 source_id,
606 } = args;
607 validate_room_composite_input_count(source_paths.len())?;
612 let (output_path, ephemeral) = staged_output(storage, &output_key, "mp4")?;
613 let ffprobe_bin = resolve_ffprobe_bin(ffmpeg_bin);
619 let probes: Vec<_> = source_paths
626 .iter()
627 .map(|path| {
628 let ffprobe_bin = ffprobe_bin.clone();
629 let path = path.clone();
630 tokio::spawn(async move { probe_has_audio(&ffprobe_bin, &path).await })
631 })
632 .collect();
633 let mut audio_present = Vec::with_capacity(probes.len());
634 for probe in probes {
635 audio_present.push(probe.await.unwrap_or(false));
636 }
637 let command = FfmpegRoomCompositeCommand::new(
638 ffmpeg_bin,
639 source_paths,
640 output_path.clone(),
641 audio_present,
642 );
643 let bytes = run_blocking(move || command.run()).await?;
644 let file = persist_and_cleanup(
645 storage,
646 &output_key,
647 &output_path,
648 &content_type,
649 bytes,
650 ephemeral,
651 )
652 .await?;
653 Ok(MediaArtifact {
654 kind: MediaArtifactKind::Transcode,
655 workflow_id,
656 source_id,
657 primary: file,
658 secondary: None,
659 metadata: BTreeMap::new(),
660 })
661}
662
663fn resolve_ffprobe_bin(ffmpeg_bin: &str) -> std::ffi::OsString {
671 let candidate = Path::new(ffmpeg_bin).with_file_name("ffprobe");
672 let has_dir = candidate
673 .parent()
674 .is_some_and(|parent| !parent.as_os_str().is_empty());
675 if has_dir && candidate.exists() {
676 candidate.into_os_string()
677 } else {
678 std::ffi::OsString::from("ffprobe")
679 }
680}
681
682async fn probe_has_audio(ffprobe_bin: &std::ffi::OsStr, path: &Path) -> bool {
695 let output = tokio::process::Command::new(ffprobe_bin)
696 .args([
697 "-v",
698 "error",
699 "-select_streams",
700 "a",
701 "-show_entries",
702 "stream=index",
703 "-of",
704 "csv=p=0",
705 ])
706 .arg(path)
707 .output()
708 .await;
709 match output {
710 Ok(out) if out.status.success() => !out.stdout.iter().all(u8::is_ascii_whitespace),
711 _ => false,
712 }
713}
714
715async fn run_blocking<F>(f: F) -> Result<u64, MediaError>
717where
718 F: FnOnce() -> Result<u64, MediaError> + Send + 'static,
719{
720 tokio::task::spawn_blocking(f)
721 .await
722 .map_err(|error| MediaError::EncodeTaskJoin {
723 message: error.to_string(),
724 })?
725}
726
727fn staged_output(
741 storage: &MediaStorage,
742 key: &str,
743 ext: &str,
744) -> Result<(PathBuf, bool), MediaError> {
745 match storage {
746 MediaStorage::Local { root, .. } => {
747 let path = join_within_root(root, &storage.object_key(key))?;
751 Ok((path, false))
752 }
753 MediaStorage::S3(_) => Ok((
754 std::env::temp_dir().join(format!("autumn-media-{}.{ext}", uuid::Uuid::new_v4())),
755 true,
756 )),
757 }
758}
759
760fn join_within_root(root: &Path, key: &str) -> Result<PathBuf, MediaError> {
773 use std::path::Component;
774 let relative = Path::new(key);
775 let mut has_normal = false;
776 for component in relative.components() {
777 match component {
778 Component::Normal(_) => has_normal = true,
779 _ => return Err(rejected_output_key(key)),
780 }
781 }
782 if !has_normal {
783 return Err(rejected_output_key(key));
785 }
786 Ok(root.join(relative))
787}
788
789fn rejected_output_key(key: &str) -> MediaError {
793 MediaError::ArtifactWrite {
794 path: key.to_owned(),
795 source: std::io::Error::new(
796 std::io::ErrorKind::InvalidInput,
797 "media output key must stay within the storage root \
798 (no `..`, absolute, or dot-only path segments)",
799 ),
800 }
801}
802
803async fn persist_and_cleanup(
806 storage: &MediaStorage,
807 key: &str,
808 local_path: &Path,
809 content_type: &str,
810 bytes: u64,
811 ephemeral: bool,
812) -> Result<MediaArtifactFile, MediaError> {
813 let stored = storage
814 .persist_file_with_content_type(key, local_path, content_type)
815 .await;
816 if ephemeral {
817 let _ = tokio::fs::remove_file(local_path).await;
820 }
821 let stored = stored?;
822 Ok(MediaArtifactFile {
823 key: stored.key,
824 storage_uri: stored.uri,
825 public_url: storage.public_url(key),
826 content_type: content_type.to_owned(),
827 bytes,
828 })
829}
830
831async fn write_text_file(path: &Path, contents: &str) -> Result<(), MediaError> {
833 if let Some(parent) = path.parent() {
834 tokio::fs::create_dir_all(parent)
835 .await
836 .map_err(|source| MediaError::ArtifactWrite {
837 path: parent.display().to_string(),
838 source,
839 })?;
840 }
841 tokio::fs::write(path, contents)
842 .await
843 .map_err(|source| MediaError::ArtifactWrite {
844 path: path.display().to_string(),
845 source,
846 })
847}
848
849#[cfg(test)]
850mod tests {
851 use super::{
852 FinalizeRecordingJobArgs, MediaWorkflowDelegate, MediaWorkflowDelegateExt,
853 MediaWorkflowRequest, MediaWorkflows, PreviewJobArgs, RoomCompositeJobArgs,
854 ThumbnailJobArgs, TranscodeJobArgs, compose_room_recording, extract_thumbnail,
855 media_job_infos, persist_and_cleanup, run_room_composite, staged_output,
856 };
857 use crate::error::MediaError;
858 use crate::sink::MediaArtifactSinkExt;
859 use crate::sink::tests::CountingSink;
860 use crate::storage::MediaStorage;
861 use autumn_web::AppState;
862 use std::path::PathBuf;
863 use std::sync::Arc;
864 use std::sync::atomic::{AtomicUsize, Ordering};
865
866 fn local_storage(root: &std::path::Path) -> MediaStorage {
867 MediaStorage::Local {
868 root: root.to_path_buf(),
869 public_base_url: "/media".to_owned(),
870 }
871 }
872
873 fn thumbnail_args(source: PathBuf) -> ThumbnailJobArgs {
874 ThumbnailJobArgs {
875 source_paths: vec![source],
876 output_key: "thumbnails/7.jpg".to_owned(),
877 tail_offset_seconds: 5,
878 workflow_id: "wf-thumb".to_owned(),
879 source_id: Some("stream-7".to_owned()),
880 }
881 }
882
883 #[test]
884 fn media_job_infos_override_declared_queue() {
885 let infos = media_job_infos("custom-queue");
886 assert_eq!(infos.len(), 5);
887 assert!(infos.iter().all(|info| info.queue == "custom-queue"));
888 let names: Vec<&str> = infos.iter().map(|info| info.name.as_str()).collect();
889 assert!(names.contains(&"autumn_media_extract_thumbnail"));
890 assert!(names.contains(&"autumn_media_extract_preview"));
891 assert!(names.contains(&"autumn_media_transcode_window"));
892 assert!(names.contains(&"autumn_media_compose_room_recording"));
893 assert!(names.contains(&"autumn_media_finalize_recording"));
894 }
895
896 #[test]
897 fn derived_sub_jobs_dedup_on_workflow_id() {
898 use autumn_web::job::JobUniquenessWindow;
899 let infos = media_job_infos("media");
904 let uniqueness = |name: &str| {
905 infos
906 .iter()
907 .find(|info| info.name == name)
908 .expect("job registered")
909 .uniqueness
910 .clone()
911 };
912 for name in [
913 "autumn_media_extract_thumbnail",
914 "autumn_media_extract_preview",
915 ] {
916 let u = uniqueness(name).unwrap_or_else(|| panic!("{name} must be unique"));
917 assert_eq!(
918 u.by,
919 vec!["workflow_id".to_owned()],
920 "{name} keys on workflow_id"
921 );
922 assert!(
923 matches!(u.window, JobUniquenessWindow::TtlMs(ms) if ms > 0),
924 "{name} must use a TTL dedup window, got {:?}",
925 u.window
926 );
927 }
928 assert!(uniqueness("autumn_media_transcode_window").is_none());
929 assert!(uniqueness("autumn_media_finalize_recording").is_none());
930 }
931
932 #[test]
933 fn job_args_round_trip_through_json() {
934 let args = thumbnail_args(PathBuf::from("/rec/seg.mp4"));
935 let json = serde_json::to_string(&args).expect("serialize");
936 let back: ThumbnailJobArgs = serde_json::from_str(&json).expect("deserialize");
937 assert_eq!(args, back);
938
939 let preview = PreviewJobArgs {
940 source_paths: vec![PathBuf::from("/rec/a.mp4")],
941 sprite_key: "previews/7.jpg".to_owned(),
942 vtt_key: "previews/7.vtt".to_owned(),
943 frame_count: 24,
944 workflow_id: "wf-prev".to_owned(),
945 source_id: None,
946 };
947 let back: PreviewJobArgs =
948 serde_json::from_str(&serde_json::to_string(&preview).expect("ser")).expect("de");
949 assert_eq!(preview, back);
950
951 let transcode = TranscodeJobArgs {
952 source_path: PathBuf::from("/rec/a.mp4"),
953 output_key: "clips/7.mp4".to_owned(),
954 start_seconds: 10,
955 duration_seconds: 30,
956 content_type: "video/mp4".to_owned(),
957 workflow_id: "wf-clip".to_owned(),
958 source_id: Some("clip-7".to_owned()),
959 };
960 let back: TranscodeJobArgs =
961 serde_json::from_str(&serde_json::to_string(&transcode).expect("ser")).expect("de");
962 assert_eq!(transcode, back);
963 }
964
965 #[test]
966 fn staged_output_is_final_for_local_and_ephemeral_for_temp() {
967 let storage = local_storage(std::path::Path::new("/srv/media"));
968 let (path, ephemeral) =
969 staged_output(&storage, "/thumbnails/7.jpg", "jpg").expect("normal key");
970 assert!(!ephemeral, "local backend writes to its served location");
971 assert_eq!(path, PathBuf::from("/srv/media/thumbnails/7.jpg"));
972 }
973
974 #[test]
975 fn staged_output_rejects_local_traversal_keys() {
976 let storage = local_storage(std::path::Path::new("/srv/media"));
977 for key in [
982 "../../etc/passwd",
983 "clips/../../../etc/passwd",
984 "..",
985 ".",
986 "",
987 ] {
988 let err = staged_output(&storage, key, "jpg")
989 .expect_err("traversal/degenerate key must be rejected");
990 assert!(
991 matches!(err, super::MediaError::ArtifactWrite { .. }),
992 "expected ArtifactWrite rejection for {key:?}, got {err:?}"
993 );
994 }
995 let (path, _) = staged_output(&storage, "previews/7.vtt", "vtt").expect("normal key");
997 assert_eq!(path, PathBuf::from("/srv/media/previews/7.vtt"));
998 }
999
1000 #[tokio::test]
1001 async fn dispatch_uses_delegate_when_present() {
1002 let state = AppState::for_test();
1003 let calls = Arc::new(AtomicUsize::new(0));
1004 let seen = Arc::new(std::sync::Mutex::new(None::<MediaWorkflowRequest>));
1005 let calls_c = calls.clone();
1006 let seen_c = seen.clone();
1007 let delegate: MediaWorkflowDelegate = Arc::new(move |_state, request| {
1008 calls_c.fetch_add(1, Ordering::SeqCst);
1009 *seen_c.lock().expect("lock") = Some(request);
1010 Box::pin(async { Ok(()) })
1011 });
1012 state.insert_extension(MediaWorkflowDelegateExt(delegate));
1013
1014 let workflows = MediaWorkflows::new("ffmpeg", "media");
1015 workflows
1016 .queue_thumbnail(&state, thumbnail_args(PathBuf::from("/rec/seg.mp4")))
1017 .await
1018 .expect("delegate path returns Ok");
1019
1020 assert_eq!(calls.load(Ordering::SeqCst), 1);
1021 assert!(matches!(
1022 seen.lock().expect("lock").as_ref(),
1023 Some(MediaWorkflowRequest::Thumbnail(_))
1024 ));
1025 }
1026
1027 #[tokio::test]
1028 async fn dispatch_enqueues_builtin_job_when_no_delegate() {
1029 let _guard = autumn_web::job::global_job_runtime_test_lock().lock().await;
1030 autumn_web::job::clear_global_job_client();
1031
1032 let state = AppState::for_test();
1033 let workflows = MediaWorkflows::new("ffmpeg", "media");
1034 let err = workflows
1037 .queue_thumbnail(&state, thumbnail_args(PathBuf::from("/rec/seg.mp4")))
1038 .await
1039 .expect_err("enqueue branch should be taken without a delegate");
1040 assert!(
1041 err.to_string().contains("job runtime is not initialized"),
1042 "unexpected error: {err}"
1043 );
1044 }
1045
1046 #[tokio::test]
1047 async fn job_reports_failure_to_sink_when_source_missing() {
1048 let temp = tempfile::tempdir().expect("tempdir");
1049 let state = AppState::for_test();
1050 state.insert_extension(local_storage(temp.path()));
1051 state.insert_extension(MediaWorkflows::new("ffmpeg", "media"));
1052 let sink = Arc::new(CountingSink::new());
1053 state.insert_extension(MediaArtifactSinkExt(sink.clone()));
1054
1055 let args = thumbnail_args(temp.path().join("does-not-exist.mp4"));
1058 let result = extract_thumbnail(state, args).await;
1059
1060 assert!(result.is_err(), "missing source must fail the job");
1061 assert_eq!(sink.failed.load(Ordering::SeqCst), 1);
1062 assert_eq!(sink.completed.load(Ordering::SeqCst), 0);
1063 }
1064
1065 #[tokio::test]
1066 async fn persist_and_cleanup_describes_the_stored_file() {
1067 let temp = tempfile::tempdir().expect("tempdir");
1068 let storage = local_storage(temp.path());
1069 let (output_path, ephemeral) =
1071 staged_output(&storage, "clips/7.mp4", "mp4").expect("normal key");
1072 std::fs::create_dir_all(output_path.parent().expect("parent")).expect("mkdir");
1073 std::fs::write(&output_path, b"encoded-bytes").expect("write");
1074
1075 let file = persist_and_cleanup(
1076 &storage,
1077 "clips/7.mp4",
1078 &output_path,
1079 "video/mp4",
1080 13,
1081 ephemeral,
1082 )
1083 .await
1084 .expect("persist");
1085
1086 assert_eq!(file.key, "clips/7.mp4");
1087 assert_eq!(file.content_type, "video/mp4");
1088 assert_eq!(file.bytes, 13);
1089 assert_eq!(file.public_url, "/media/clips/7.mp4");
1090 assert!(
1091 std::path::Path::new(&file.storage_uri).exists(),
1092 "local backend keeps the served file in place"
1093 );
1094 }
1095
1096 fn room_composite_args(sources: Vec<PathBuf>) -> RoomCompositeJobArgs {
1097 RoomCompositeJobArgs {
1098 source_paths: sources,
1099 output_key: "rooms/room-7.mp4".to_owned(),
1100 content_type: "video/mp4".to_owned(),
1101 workflow_id: "wf-room".to_owned(),
1102 source_id: Some("room-7".to_owned()),
1103 }
1104 }
1105
1106 #[test]
1107 fn room_composite_args_round_trip_through_json() {
1108 let args = room_composite_args(vec![
1109 PathBuf::from("/rec/a.mp4"),
1110 PathBuf::from("/rec/b.mp4"),
1111 ]);
1112 let back: RoomCompositeJobArgs =
1113 serde_json::from_str(&serde_json::to_string(&args).expect("ser")).expect("de");
1114 assert_eq!(args, back);
1115 }
1116
1117 #[tokio::test]
1118 async fn dispatch_routes_room_composite_to_delegate() {
1119 let state = AppState::for_test();
1120 let seen = Arc::new(std::sync::Mutex::new(None::<MediaWorkflowRequest>));
1121 let seen_c = seen.clone();
1122 let delegate: MediaWorkflowDelegate = Arc::new(move |_state, request| {
1123 *seen_c.lock().expect("lock") = Some(request);
1124 Box::pin(async { Ok(()) })
1125 });
1126 state.insert_extension(MediaWorkflowDelegateExt(delegate));
1127
1128 let workflows = MediaWorkflows::new("ffmpeg", "media");
1129 workflows
1130 .queue_room_composite(
1131 &state,
1132 room_composite_args(vec![
1133 PathBuf::from("/rec/a.mp4"),
1134 PathBuf::from("/rec/b.mp4"),
1135 ]),
1136 )
1137 .await
1138 .expect("delegate path returns Ok");
1139 assert!(matches!(
1140 seen.lock().expect("lock").as_ref(),
1141 Some(MediaWorkflowRequest::RoomComposite(_))
1142 ));
1143 }
1144
1145 #[tokio::test]
1146 async fn room_composite_job_reports_failure_to_sink_when_source_missing() {
1147 let temp = tempfile::tempdir().expect("tempdir");
1148 let state = AppState::for_test();
1149 state.insert_extension(local_storage(temp.path()));
1150 state.insert_extension(MediaWorkflows::new("ffmpeg", "media"));
1151 let sink = Arc::new(CountingSink::new());
1152 state.insert_extension(MediaArtifactSinkExt(sink.clone()));
1153
1154 let args = room_composite_args(vec![
1157 temp.path().join("missing-a.mp4"),
1158 temp.path().join("missing-b.mp4"),
1159 ]);
1160 let result = compose_room_recording(state, args).await;
1161
1162 assert!(result.is_err(), "missing sources must fail the job");
1163 assert_eq!(sink.failed.load(Ordering::SeqCst), 1);
1164 assert_eq!(sink.completed.load(Ordering::SeqCst), 0);
1165 }
1166
1167 #[tokio::test]
1168 async fn room_composite_rejects_more_than_six_paths_before_probing() {
1169 let temp = tempfile::tempdir().expect("tempdir");
1170 let storage = local_storage(temp.path());
1171 let sources: Vec<PathBuf> = (0..7)
1176 .map(|i| temp.path().join(format!("missing-{i}.mp4")))
1177 .collect();
1178 let args = room_composite_args(sources);
1179 let result = run_room_composite("/nonexistent/ffmpeg-bin", &storage, args).await;
1180 assert!(
1181 matches!(
1182 result,
1183 Err(MediaError::FfmpegSourceMissing { ref path })
1184 if path.contains("at most six")
1185 ),
1186 "a >6-path job must be rejected by the input-count cap before probing, got {result:?}"
1187 );
1188 }
1189
1190 #[test]
1191 fn finalize_args_carry_both_sub_requests() {
1192 let args = FinalizeRecordingJobArgs {
1193 thumbnail: thumbnail_args(PathBuf::from("/rec/seg.mp4")),
1194 preview: PreviewJobArgs {
1195 source_paths: vec![PathBuf::from("/rec/seg.mp4")],
1196 sprite_key: "previews/7.jpg".to_owned(),
1197 vtt_key: "previews/7.vtt".to_owned(),
1198 frame_count: 12,
1199 workflow_id: "wf-final".to_owned(),
1200 source_id: Some("stream-7".to_owned()),
1201 },
1202 };
1203 let back: FinalizeRecordingJobArgs =
1204 serde_json::from_str(&serde_json::to_string(&args).expect("ser")).expect("de");
1205 assert_eq!(args, back);
1206 }
1207}