Skip to main content

autumn_media_plugin/
workflows.rs

1//! Generic, harvest-free media workflows built on autumn-web's `#[job]` system.
2//!
3//! Each pipeline Arroyo drove through Autumn Harvest activities — poster
4//! extraction, seek-preview sprite + `WebVTT`, and clip/highlight transcode —
5//! is generalized here into a durable background job that:
6//!
7//! 1. resolves [`MediaStorage`](crate::storage::MediaStorage) and the `FFmpeg`
8//!    binary from `AppState`,
9//! 2. runs the matching [`crate::encode`] command **off** the async runtime via
10//!    [`tokio::task::spawn_blocking`],
11//! 3. persists the output through `MediaStorage`, and
12//! 4. hands a [`MediaArtifact`] to the app-supplied
13//!    [`MediaArtifactSink`](crate::sink::MediaArtifactSink).
14//!
15//! # The extension seam
16//!
17//! Dispatch flows through [`MediaWorkflows`], whose `queue_*` methods mirror
18//! autumn-web's outbound-webhook seam exactly: if a
19//! [`MediaWorkflowDelegateExt`] is installed on `AppState`, the request is
20//! handed to that runtime delegate (an external Harvest adapter, maintained
21//! outside this workspace); otherwise the **built-in** `#[job]` default is
22//! enqueued. There is **no** `autumn-harvest` dependency here — the `#[job]`
23//! path is the default and the delegate is the override.
24
25use 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// ── Job argument structs ─────────────────────────────────────────────────────
46
47/// Arguments for the poster/thumbnail extraction job.
48#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
49pub struct ThumbnailJobArgs {
50    /// Recording segment(s) to sample the poster frame from. Multiple segments
51    /// are concatenated first (see [`FfmpegPosterCommand`]).
52    pub source_paths: Vec<PathBuf>,
53    /// Caller-relative storage key for the poster JPEG.
54    pub output_key: String,
55    /// Seconds back from end-of-file to sample the poster frame.
56    pub tail_offset_seconds: u32,
57    /// Caller-supplied workflow id echoed back to the sink.
58    pub workflow_id: String,
59    /// Caller-supplied source identifier (e.g. a broadcast id) echoed back.
60    #[serde(default)]
61    pub source_id: Option<String>,
62}
63
64/// Arguments for the seek-preview sprite + `WebVTT` job.
65#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
66pub struct PreviewJobArgs {
67    /// Recording segment(s) to sample preview frames from.
68    pub source_paths: Vec<PathBuf>,
69    /// Caller-relative storage key for the sprite-mosaic JPEG.
70    pub sprite_key: String,
71    /// Caller-relative storage key for the companion `WebVTT` track.
72    pub vtt_key: String,
73    /// Total number of preview frames to sample and tile.
74    pub frame_count: u32,
75    /// Caller-supplied workflow id echoed back to the sink.
76    pub workflow_id: String,
77    /// Caller-supplied source identifier echoed back.
78    #[serde(default)]
79    pub source_id: Option<String>,
80}
81
82/// Arguments for the transcode-window (clip/highlight) job.
83#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
84pub struct TranscodeJobArgs {
85    /// Recording to transcode a window of.
86    pub source_path: PathBuf,
87    /// Caller-relative storage key for the encoded MP4.
88    pub output_key: String,
89    /// Start offset (seconds) into the source.
90    pub start_seconds: u32,
91    /// Length (seconds) of the window to encode.
92    pub duration_seconds: u32,
93    /// MIME content type to persist the output with.
94    pub content_type: String,
95    /// Caller-supplied workflow id echoed back to the sink.
96    pub workflow_id: String,
97    /// Caller-supplied source identifier echoed back.
98    #[serde(default)]
99    pub source_id: Option<String>,
100}
101
102/// Arguments for the room-composite job, which tiles every participant's
103/// recording into one composited MP4 (see [`FfmpegRoomCompositeCommand`]).
104#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
105pub struct RoomCompositeJobArgs {
106    /// One recording per room participant (`2..=6`), tiled into the grid.
107    pub source_paths: Vec<PathBuf>,
108    /// Caller-relative storage key for the composited MP4.
109    pub output_key: String,
110    /// MIME content type to persist the output with.
111    pub content_type: String,
112    /// Caller-supplied workflow id echoed back to the sink.
113    pub workflow_id: String,
114    /// Caller-supplied source identifier (e.g. a room id) echoed back.
115    #[serde(default)]
116    pub source_id: Option<String>,
117}
118
119/// Arguments for the recording-finalize orchestration job, which fans a
120/// finished recording out into a poster and a seek-preview.
121#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
122pub struct FinalizeRecordingJobArgs {
123    /// The poster job to enqueue for the finished recording.
124    pub thumbnail: ThumbnailJobArgs,
125    /// The seek-preview job to enqueue for the finished recording.
126    pub preview: PreviewJobArgs,
127}
128
129// ── Built-in `#[job]` handlers (the default engine) ──────────────────────────
130
131// `unique_by = "workflow_id"` + a TTL window make a re-enqueue of the same
132// workflow_id a coalesced no-op (returns `Ok(())`, spawns no second job). This
133// is what keeps `finalize_recording`'s partial-failure retry idempotent: if the
134// thumbnail enqueue succeeds but the preview enqueue then fails, the retried
135// orchestration re-enqueues the thumbnail as a dedup no-op (only the missing
136// preview is filled), so the app-supplied `MediaArtifactSink::on_completed` is
137// not invoked twice for the same artifact via that path. The TTL (5 min) simply
138// has to outlast the finalize retry envelope (3 attempts, 1s exponential
139// backoff); it is keyed on the caller's own `workflow_id`, so it only ever
140// coalesces work the caller already labelled as one logical workflow.
141#[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// See `extract_thumbnail`: the same workflow_id-keyed TTL dedup makes the
158// finalize retry re-enqueue of the preview a coalesced no-op.
159#[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    // Orchestration: dispatch the two derived-artifact jobs through the same
209    // seam, so an installed delegate governs the sub-work too and each gets its
210    // own durable retry envelope.
211    //
212    // Partial-failure retry safety: if `queue_thumbnail` succeeds but
213    // `queue_preview` then fails, this job returns `Err` and is retried. The two
214    // sub-jobs are `unique`-keyed on their `workflow_id` (see `extract_thumbnail`
215    // / `extract_preview`), so the retry re-enqueues the already-queued thumbnail
216    // as a coalesced no-op and only fills the missing preview — no duplicate
217    // sub-job, and therefore no duplicate `on_completed` for the same artifact
218    // via this orchestration path. (The durable engine is still at-least-once, so
219    // the app-side sink must remain idempotent by `workflow_id` + `kind` for the
220    // general worker-crash-after-persist case, which no enqueue key can remove.)
221    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/// The [`JobInfo`] set for the built-in media jobs, with each job's queue
230/// overridden to `queue`.
231///
232/// The `#[job(queue = "media")]` literal is a compile-time default; the plugin
233/// lets an application override [`crate::MediaPlugin::queue`], so registration
234/// rewrites `JobInfo.queue` here — the enqueue chokepoint routes by the
235/// registered `JobInfo`, so this is what makes the override take effect (the
236/// same technique autumn-web's outbound-webhook plugin uses).
237#[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
254// ── The dispatch seam ────────────────────────────────────────────────────────
255
256/// A runtime delegation callback bridging the generic media workflow layer to
257/// an external durable engine (e.g. an Autumn Harvest adapter).
258///
259/// When a [`MediaWorkflowDelegateExt`] is present on `AppState`, every
260/// [`MediaWorkflows`] `queue_*` call routes through it instead of enqueuing the
261/// built-in `#[job]`. Mirrors autumn-web's `WebhookDelegate`.
262pub 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/// `AppState` extension carrying the runtime delegation hook. Present → the
272/// delegate overrides the built-in `#[job]`; absent → the `#[job]` default runs.
273#[derive(Clone)]
274pub struct MediaWorkflowDelegateExt(pub MediaWorkflowDelegate);
275
276/// A media-workflow request, as handed to a [`MediaWorkflowDelegate`].
277///
278/// Variants are boxed so the enum stays small regardless of the payload sizes.
279#[derive(Clone, Debug, PartialEq, Eq)]
280pub enum MediaWorkflowRequest {
281    /// A poster/thumbnail extraction request.
282    Thumbnail(Box<ThumbnailJobArgs>),
283    /// A seek-preview sprite + `WebVTT` request.
284    Preview(Box<PreviewJobArgs>),
285    /// A transcode-window request.
286    Transcode(Box<TranscodeJobArgs>),
287    /// A room-composite request.
288    RoomComposite(Box<RoomCompositeJobArgs>),
289    /// A recording-finalize orchestration request.
290    Finalize(Box<FinalizeRecordingJobArgs>),
291}
292
293/// The service applications call to queue media work.
294///
295/// Installed on `AppState` by [`crate::MediaPlugin`]. Each `queue_*` method
296/// runs the extension-seam branch: delegate if present, else enqueue the
297/// built-in `#[job]`.
298#[derive(Clone, Debug)]
299pub struct MediaWorkflows {
300    ffmpeg_bin: String,
301    queue: String,
302}
303
304impl MediaWorkflows {
305    /// Create a workflow service bound to an `FFmpeg` binary and job queue.
306    #[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    /// The configured `FFmpeg` binary path the built-in jobs shell out to.
315    #[must_use]
316    pub fn ffmpeg_bin(&self) -> &str {
317        &self.ffmpeg_bin
318    }
319
320    /// The job queue the built-in media jobs are registered on.
321    #[must_use]
322    pub fn queue(&self) -> &str {
323        &self.queue
324    }
325
326    /// Queue a poster/thumbnail extraction.
327    ///
328    /// # Errors
329    ///
330    /// Returns any error from the runtime delegate or the fallback enqueue.
331    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    /// Queue a seek-preview sprite + `WebVTT` build.
341    ///
342    /// # Errors
343    ///
344    /// Returns any error from the runtime delegate or the fallback enqueue.
345    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    /// Queue a transcode-window (clip/highlight) encode.
351    ///
352    /// # Errors
353    ///
354    /// Returns any error from the runtime delegate or the fallback enqueue.
355    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    /// Queue a room-composite encode (tile every participant recording into one
365    /// composited MP4).
366    ///
367    /// # Errors
368    ///
369    /// Returns any error from the runtime delegate or the fallback enqueue.
370    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    /// Queue a recording-finalize orchestration (poster + seek-preview).
380    ///
381    /// # Errors
382    ///
383    /// Returns any error from the runtime delegate or the fallback enqueue.
384    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    /// The extension-seam branch, identical in shape to autumn-web's outbound
394    /// webhook `dispatch`.
395    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
413// ── Internal run helpers (shared by the jobs) ────────────────────────────────
414
415/// Resolve the storage backend and `FFmpeg` binary the jobs need from state.
416fn 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
427/// Deliver a workflow result to the app-supplied sink, mapping errors for the
428/// durable job engine.
429async 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            // Return the error so the durable job engine retries per the job's
459            // max_attempts/backoff.
460            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    // The WebVTT cues point at the sprite's *public* URL, so build it only once
528    // the sprite has been persisted and its URL is known.
529    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    // Reject a malformed job (fewer than two / more than six source paths)
608    // against the same `2..=6` band `FfmpegRoomCompositeCommand::run()` enforces
609    // *before* spawning any `ffprobe`, so a bad queued job can never fan out one
610    // probe child process per supplied path only to be rejected afterwards.
611    validate_room_composite_input_count(source_paths.len())?;
612    let (output_path, ephemeral) = staged_output(storage, &output_key, "mp4")?;
613    // Detect, per input, whether it carries an audio stream so the composite
614    // mixes only the inputs that actually have audio. Probing is async I/O and
615    // lives here in the caller; `FfmpegRoomCompositeCommand::args()` stays a pure
616    // function of the resulting flags. See `probe_has_audio` for the fail-safe
617    // policy (a probe failure degrades to "no audio", never a crash).
618    let ffprobe_bin = resolve_ffprobe_bin(ffmpeg_bin);
619    // Probe every input concurrently rather than serializing one `ffprobe`
620    // spawn after another. The probes are independent async I/O; awaiting the
621    // handles in spawn order preserves input order so `audio_present[i]` still
622    // aligns with `source_paths[i]`. `probe_has_audio` is itself fail-safe (a
623    // probe error yields `false`), and a task-join failure (e.g. a panic)
624    // degrades to `false` the same way, so a probe outage never aborts the job.
625    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
663/// Resolve the `ffprobe` binary from the configured `ffmpeg` binary path.
664///
665/// Prefers a sibling `ffprobe` next to the configured `ffmpeg` (they ship
666/// together), i.e. the same directory with the file name swapped. When the
667/// `ffmpeg` path carries no directory component (a bare `ffmpeg` resolved via
668/// `PATH`) or the sibling does not exist, falls back to plain `ffprobe` on
669/// `PATH`.
670fn 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
682/// Probe whether `path` carries at least one audio stream.
683///
684/// Runs `ffprobe -v error -select_streams a -show_entries stream=index -of
685/// csv=p=0 <file>` and treats non-empty stdout as "has audio".
686///
687/// **Fail-safe policy:** any probe failure (spawn error, non-zero exit, or a
688/// path with no audio) resolves to `false` ("assume no audio"). This is the
689/// only direction that **cannot crash the composite job**: a `false` merely
690/// drops that input from the `amix`, so at worst a probe outage yields a silent
691/// (but successful) grid. Returning `true` on failure would be unsafe — if the
692/// input truly had no audio, the filtergraph would reference a missing `[i:a]`
693/// stream and `FFmpeg` would fail, which is exactly the crash this replaces.
694async 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
715/// Run a blocking encode closure off the async runtime.
716async 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
727/// Resolve the on-disk path an encode command should write its output to.
728///
729/// The **local** backend serves objects straight from its root, so the output
730/// is written to its final served location (`root/{object_key}`) and never
731/// cleaned up. The **S3** backend stages to a unique temp file that is uploaded
732/// and then removed — so the returned `bool` reports whether the path is
733/// ephemeral.
734///
735/// # Errors
736///
737/// Returns [`MediaError::ArtifactWrite`] when a local output key would escape
738/// the storage root (see [`join_within_root`]). The S3 arm cannot fail (it
739/// stages under [`std::env::temp_dir`]).
740fn staged_output(
741    storage: &MediaStorage,
742    key: &str,
743    ext: &str,
744) -> Result<(PathBuf, bool), MediaError> {
745    match storage {
746        MediaStorage::Local { root, .. } => {
747            // `object_key` only slash-trims a local key — it does not reject a
748            // `..`/absolute segment — so guard the join against traversal before
749            // an encode writes to `root/{object_key}`.
750            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
760/// Join an already-slash-trimmed caller key onto the local backend `root`,
761/// rejecting any key that could escape it.
762///
763/// Slice-1's key resolution ([`MediaStorage::object_key`]) only slash-trims a
764/// local key; its dot-segment handling is URL-only (`encode_key_for_url`), so
765/// nothing upstream stops a `..`/absolute/drive-prefix segment from reaching the
766/// on-disk join here — and the local encode output is written straight to
767/// `root/{object_key}`. This guard rejects such keys before the join (defense in
768/// depth), keeping every staged local output inside `root`. Only
769/// [`std::path::Component::Normal`] segments are allowed; a `ParentDir` (`..`),
770/// a `RootDir`/`Prefix` (absolute), a leading `CurDir` (`.`), or an
771/// empty/dot-only key is rejected.
772fn 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        // Empty or dot-only key: nothing legitimate to write under `root`.
784        return Err(rejected_output_key(key));
785    }
786    Ok(root.join(relative))
787}
788
789/// Build the traversal-rejection error for an unsafe local output key, reusing
790/// [`MediaError::ArtifactWrite`] (the crate's "could not write this artifact
791/// path" error) rather than introducing a new variant.
792fn 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
803/// Persist a staged output under `key`, cleaning up an ephemeral (S3) staging
804/// file afterwards, and describe the stored file.
805async 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        // Best-effort: the upload already succeeded or failed; the temp file is
818        // never the source of truth.
819        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
831/// Write text (the `WebVTT` track) to a staging path, creating parent dirs.
832async 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        // The thumbnail and preview jobs are `unique` on `workflow_id` with a TTL
900        // window, so a `finalize_recording` partial-failure retry coalesces the
901        // already-enqueued sub-job instead of spawning a duplicate. The transcode
902        // and orchestration jobs carry no dedup.
903        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        // A `..` segment would escape the media root — must be rejected, not
978        // joined into `/srv/media/../../etc/...`. (A plain absolute key like
979        // `/etc/passwd` is *not* traversal here: `object_key` slash-trims it to
980        // the benign in-root relative key `etc/passwd`, so it is not in this set.)
981        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        // A normal nested key still resolves inside the root.
996        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        // No delegate installed → dispatch takes the built-in enqueue branch,
1035        // which errors because no job runtime is initialized in this unit test.
1036        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        // A source path that does not exist → the poster command fails before it
1056        // ever spawns FFmpeg (require_sources), so this test needs no binary.
1057        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        // Stage a real "encoded" file at the served location.
1070        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        // Two source paths that do not exist → require_sources fails before
1155        // FFmpeg is ever spawned, so this test needs no binary.
1156        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        // Seven paths (over the 2..=6 mesh-room band). A bogus ffmpeg bin means
1172        // its sibling `ffprobe` cannot resolve either, so if this reached the
1173        // probe loop it would spawn seven child processes; the input-count guard
1174        // must reject first and return the same `2..=6` cap error `run()` uses.
1175        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}