Skip to main content

rivet/job/
mod.rs

1//! The transcode job engine.
2//!
3//! [`run_job`] takes an input buffer and an [`OutputSpec`] and drives the
4//! whole pipeline: demux → shared decode pump (decode once) → fan out to per-
5//! rung work → assemble the requested output mode. Progress is streamed
6//! through a [`ProgressSink`] as a uniform [`RungProgress`] per rung.
7//!
8//! - **SingleFile** mode: the decode pump fans frames to one per-rung worker
9//!   that scales + encodes + muxes a self-contained MP4.
10//! - **Hls** mode: the [`crate::multigpu`] orchestrator decodes once and
11//!   schedules every rung's CMAF segments across all GPUs (fair lease pool +
12//!   mid-flight helper dispatch + cross-vendor codec invariant), then this
13//!   module assembles the HLS package (audio rendition + playlists).
14
15use std::path::{Path, PathBuf};
16use std::sync::Arc;
17use std::time::{Duration, Instant};
18
19use anyhow::{Context, Result, bail};
20use bytes::Bytes;
21
22use codec::encode::EncoderConfig;
23use container::streaming::{self, DemuxHeader};
24
25use crate::decode_pump::{ClipSource, DecodePumpConfig};
26use crate::multigpu;
27use crate::progress::{JobEvent, ProgressSink, RungProgress, RungStatus};
28use crate::spec::{OutputMode, OutputSpec, Rung};
29use crate::validate::needs_chroma_downsample;
30
31mod audio;
32mod pump;
33mod run;
34mod splice;
35#[cfg(test)]
36mod tests;
37
38pub use splice::Clip;
39
40use self::audio::{PreparedAudio, prepare_audio};
41use self::pump::run_hls;
42use self::run::{run_serial_single_file, run_single_file};
43use self::splice::{trim_audio, trim_frame};
44
45/// Bounded per-rung frame channel — backpressures the decode pump.
46pub(super) const FRAME_CHANNEL_CAPACITY: usize = 8;
47
48/// The artifact one rung produced.
49#[derive(Debug)]
50pub enum RungArtifact {
51    /// A single self-contained file (MP4 bytes).
52    File(Vec<u8>),
53    /// An HLS rendition: a directory of CMAF segments + a media playlist.
54    HlsRendition {
55        dir: PathBuf,
56        relative_dir: String,
57    },
58}
59
60/// Result for one completed rung.
61#[derive(Debug)]
62pub struct RungOutput {
63    pub label: String,
64    pub width: u32,
65    pub height: u32,
66    pub frames: u64,
67    pub bytes: u64,
68    pub artifact: RungArtifact,
69}
70
71/// The full job result.
72#[derive(Debug)]
73pub struct JobOutput {
74    /// One entry per rung that completed successfully (failed rungs are
75    /// reported via the progress sink with [`RungStatus::Failed`]).
76    pub rungs: Vec<RungOutput>,
77    /// HLS mode only: the asset root directory.
78    pub hls_root: Option<PathBuf>,
79    /// HLS mode only: path to the master playlist.
80    pub master_playlist: Option<PathBuf>,
81    pub source_codec: String,
82    pub source_dims: (u32, u32),
83    pub source_frame_rate: f64,
84    /// How the audio was handled.
85    pub audio_handling: String,
86    pub elapsed: Duration,
87}
88
89/// Run a transcode job. Async — call from within a Tokio runtime.
90///
91/// For [`OutputMode::Hls`], `output_dir` is the asset root the HLS package is
92/// written under; `None` uses a fresh temp directory (returned in
93/// [`JobOutput::hls_root`]). For [`OutputMode::SingleFile`] `output_dir` is
94/// ignored (bytes are returned).
95pub async fn run_job(
96    input: Bytes,
97    spec: &OutputSpec,
98    output_dir: Option<&Path>,
99    sink: Arc<dyn ProgressSink>,
100) -> Result<JobOutput> {
101    let started = Instant::now();
102    spec.validate().context("invalid OutputSpec")?;
103
104    let (header, audio_track) = {
105        let demuxer = streaming::demux_streaming(&input).context("demux")?;
106        (demuxer.header().clone(), demuxer.audio().cloned())
107    };
108    let source_codec = header.codec.to_ascii_lowercase();
109    let source_dims = (header.info.width, header.info.height);
110    let source_frame_rate = header.info.frame_rate;
111
112    // `DecodePolicy::FastestGpu`: benchmark each decode-capable GPU on a short
113    // prefix of the input and resolve the policy to `SpecificGpu(fastest)`.
114    // A no-op when fewer than two candidates exist (nothing to choose). Rebinds
115    // `spec` to a clone carrying the resolved policy; everything downstream
116    // reads `spec.decode_policy.gpu_index()`.
117    let resolved_spec;
118    let spec = if spec.decode_policy.is_fastest() {
119        let candidates = codec::decode::decode_capable_gpu_indices(&source_codec);
120        if candidates.len() > 1 {
121            match crate::decode_pump::fastest_decode_gpu(
122                &source_codec,
123                &header.info,
124                &input,
125                &candidates,
126                crate::decode_pump::DECODE_BENCH_FRAMES,
127            ) {
128                Some(gpu) => {
129                    let mut s = spec.clone();
130                    s.decode_policy = crate::spec::DecodePolicy::SpecificGpu(gpu);
131                    resolved_spec = s;
132                    &resolved_spec
133                }
134                None => spec,
135            }
136        } else {
137            tracing::info!(
138                candidates = candidates.len(),
139                "decode-with-fastest: fewer than two decode-capable GPUs; nothing to benchmark"
140            );
141            spec
142        }
143    } else {
144        spec
145    };
146
147    sink.on_event(JobEvent::Started { rungs: spec.rungs.len() });
148    sink.on_event(JobEvent::Probed {
149        codec: source_codec.clone(),
150        width: header.info.width,
151        height: header.info.height,
152        frame_rate: header.info.frame_rate,
153        audio_codec: audio_track.as_ref().map(|t| t.codec.to_ascii_lowercase()),
154    });
155
156    let frame_rate = {
157        let mut fr = if header.info.frame_rate > 0.0 { header.info.frame_rate } else { 30.0 };
158        if let Some(cap) = spec.max_frame_rate {
159            fr = fr.min(cap);
160        }
161        fr
162    };
163    let frames_total = if header.info.total_frames > 0 {
164        Some(header.info.total_frames)
165    } else {
166        None
167    };
168
169    let prepared_audio = prepare_audio(audio_track.as_ref(), spec.audio).context("preparing audio")?;
170    let audio_handling = prepared_audio
171        .as_ref()
172        .map(|a| a.handling.clone())
173        .unwrap_or_else(|| "none".to_string());
174
175    // Prepare the video filter chain once (loads any overlay images), then share
176    // the Arc with every decode pump / multi-GPU param built below.
177    let filter_chain = Arc::new(
178        codec::filter::FilterChain::prepare(&spec.filters).context("preparing video filters")?,
179    );
180
181    let (rungs, hls_root, master_playlist) = match &spec.mode {
182        OutputMode::SingleFile => {
183            let rungs = run_single_file(
184                input.clone(),
185                spec,
186                &header,
187                frame_rate,
188                frames_total,
189                prepared_audio.as_ref(),
190                Arc::clone(&filter_chain),
191                Arc::clone(&sink),
192            )
193            .await?;
194            (rungs, None, None)
195        }
196        OutputMode::Hls { segment_seconds } => {
197            run_hls(
198                input.clone(),
199                spec,
200                *segment_seconds,
201                &header,
202                frame_rate,
203                prepared_audio.as_ref(),
204                Arc::clone(&filter_chain),
205                output_dir,
206                Arc::clone(&sink),
207                // Single input: run_hls builds the (optionally trimmed) plan
208                // from spec.trim itself.
209                Vec::new(),
210                None,
211            )
212            .await?
213        }
214    };
215
216    let completed = rungs.len();
217    sink.on_event(JobEvent::Finished {
218        rungs_completed: completed,
219        rungs_failed: spec.rungs.len().saturating_sub(completed),
220    });
221
222    Ok(JobOutput {
223        rungs,
224        hls_root,
225        master_playlist,
226        source_codec,
227        source_dims,
228        source_frame_rate,
229        audio_handling,
230        elapsed: started.elapsed(),
231    })
232}
233
234/// Synchronous wrapper that builds a multi-threaded Tokio runtime.
235pub fn run_job_blocking(
236    input: &[u8],
237    spec: &OutputSpec,
238    output_dir: Option<&Path>,
239    sink: Arc<dyn ProgressSink>,
240) -> Result<JobOutput> {
241    let rt = tokio::runtime::Builder::new_multi_thread()
242        .enable_all()
243        .build()
244        .context("building Tokio runtime")?;
245    rt.block_on(run_job(Bytes::copy_from_slice(input), spec, output_dir, sink))
246}
247
248/// **Splice**: concatenate (and per-clip trim) one or more inputs into a single
249/// continuous, re-encoded MP4 per rung. Each clip is decoded with its own
250/// decoder, trimmed to its `[start, end)`, and the kept frames are fed to the
251/// shared encoder back-to-back. Because the muxer numbers output frames by
252/// count, the join is gap-free and the timeline is zero-based — no PTS
253/// rewriting. Audio is trimmed per clip and concatenated to match.
254///
255/// Output config (frame rate, color) follows the **first** clip; inputs are
256/// re-encoded to the spec's uniform output, so they may differ in codec /
257/// resolution / color. A one-clip `Vec` is a plain (optionally trimmed)
258/// transcode. Honors the spec's [`OutputMode`]: `SingleFile` writes one MP4 per
259/// rung; `Hls` writes a CMAF/HLS package (the spliced frame stream feeds the
260/// multi-GPU HLS engine, so segments are keyframe-aligned across the join).
261pub async fn run_splice_job(
262    clips: Vec<Clip>,
263    spec: &OutputSpec,
264    output_dir: Option<&Path>,
265    sink: Arc<dyn ProgressSink>,
266) -> Result<JobOutput> {
267    let started = Instant::now();
268    spec.validate().context("invalid OutputSpec")?;
269    if clips.is_empty() {
270        bail!("splice requires at least one clip");
271    }
272
273    // Probe each clip + prepare its audio. The first clip drives output config.
274    struct ClipPrep {
275        header: DemuxHeader,
276        audio: Option<PreparedAudio>,
277        src_audio_codec: Option<String>,
278    }
279    let mut preps = Vec::with_capacity(clips.len());
280    for (i, clip) in clips.iter().enumerate() {
281        let demuxer = streaming::demux_streaming(&clip.input)
282            .with_context(|| format!("demuxing splice clip {i}"))?;
283        let header = demuxer.header().clone();
284        let src_audio_codec = demuxer.audio().map(|t| t.codec.to_ascii_lowercase());
285        let audio = prepare_audio(demuxer.audio(), spec.audio)
286            .with_context(|| format!("preparing audio for splice clip {i}"))?;
287        preps.push(ClipPrep { header, audio, src_audio_codec });
288    }
289
290    let primary = preps[0].header.clone();
291    let source_codec = primary.codec.to_ascii_lowercase();
292    let source_dims = (primary.info.width, primary.info.height);
293    let source_frame_rate = primary.info.frame_rate;
294    let frame_rate = {
295        let mut fr = if primary.info.frame_rate > 0.0 { primary.info.frame_rate } else { 30.0 };
296        if let Some(cap) = spec.max_frame_rate {
297            fr = fr.min(cap);
298        }
299        fr
300    };
301
302    sink.on_event(JobEvent::Started { rungs: spec.rungs.len() });
303    sink.on_event(JobEvent::Probed {
304        codec: source_codec.clone(),
305        width: primary.info.width,
306        height: primary.info.height,
307        frame_rate: primary.info.frame_rate,
308        audio_codec: preps[0].src_audio_codec.clone(),
309    });
310
311    // Concat re-encodes every clip to one uniform output that follows the FIRST
312    // clip. Resolution differences are handled (each frame is scaled to the
313    // rung), but frame rate is NOT converted — a clip with a different fps keeps
314    // its frames and is timed at the output rate, which shifts its playback
315    // speed. Warn so the operator can pre-normalise fps if that matters.
316    for (i, prep) in preps.iter().enumerate().skip(1) {
317        let dims = (prep.header.info.width, prep.header.info.height);
318        let fps = prep.header.info.frame_rate;
319        let fps_differs = fps > 0.0
320            && primary.info.frame_rate > 0.0
321            && (fps - primary.info.frame_rate).abs() > 0.5;
322        if dims != source_dims || fps_differs {
323            tracing::warn!(
324                clip_index = i,
325                clip = %format!("{}x{} @ {:.3} fps", dims.0, dims.1, fps),
326                output = %format!(
327                    "{}x{} @ {:.3} fps",
328                    source_dims.0, source_dims.1, primary.info.frame_rate
329                ),
330                fps_differs,
331                "splice clip differs from the first clip: resolution is scaled to \
332                 the output; frame rate is NOT converted (a differing fps shifts \
333                 this clip's timing)"
334            );
335        }
336    }
337
338    let filter_chain = Arc::new(
339        codec::filter::FilterChain::prepare(&spec.filters).context("preparing video filters")?,
340    );
341    let encode_gpu = multigpu::serial_gpu_for_policy(spec.encode_policy);
342    // `--decode-with-fastest`: benchmark decode-capable GPUs on the first clip
343    // and prefer the quickest for the pump (the same decode GPU is used for
344    // every clip). Falls through to the explicit override / policy GPU.
345    let fastest_decode = if spec.decode_policy.is_fastest() {
346        let candidates = codec::decode::decode_capable_gpu_indices(&primary.codec);
347        if candidates.len() > 1 {
348            crate::decode_pump::fastest_decode_gpu(
349                &primary.codec,
350                &primary.info,
351                &clips[0].input,
352                &candidates,
353                crate::decode_pump::DECODE_BENCH_FRAMES,
354            )
355        } else {
356            None
357        }
358    } else {
359        None
360    };
361    let decode_gpu = spec.decode_policy.gpu_index().or(fastest_decode).or(encode_gpu);
362    let (output_color_metadata, output_pixel_format) =
363        spec.resolve_output(primary.info.color_metadata, primary.info.pixel_format);
364    let base_cfg = EncoderConfig {
365        frame_rate,
366        pixel_format: output_pixel_format,
367        color_metadata: output_color_metadata,
368        gpu_index: encode_gpu,
369        codec: spec.video_codec.codec(),
370        ..EncoderConfig::default()
371    };
372
373    // One decode source per clip (own decoder cfg + trim range); concatenate the
374    // trimmed audio and sum the expected frame total across clips.
375    let mut clip_sources = Vec::with_capacity(clips.len());
376    let mut combined_audio: Option<PreparedAudio> = None;
377    let mut effective_total: u64 = 0;
378    let mut total_known = true;
379    for (clip, prep) in clips.iter().zip(preps.iter()) {
380        let cfps = if prep.header.info.frame_rate > 0.0 {
381            prep.header.info.frame_rate
382        } else {
383            frame_rate
384        };
385        let start_frame = trim_frame(clip.start, cfps).unwrap_or(0);
386        let end_frame = trim_frame(clip.end, cfps);
387        match end_frame {
388            Some(e) => effective_total += e.saturating_sub(start_frame),
389            None if prep.header.info.total_frames > 0 => {
390                effective_total += prep.header.info.total_frames.saturating_sub(start_frame)
391            }
392            None => total_known = false,
393        }
394        if let Some(a) = trim_audio(prep.audio.as_ref(), clip.start, clip.end) {
395            if let Some(c) = combined_audio.as_mut() {
396                c.extend(&a);
397            } else {
398                combined_audio = Some(a);
399            }
400        }
401        let pump_cfg = DecodePumpConfig {
402            codec_name: prep.header.codec.clone(),
403            info_for_decoder: prep.header.info.clone(),
404            source_color_metadata: prep.header.info.color_metadata,
405            source_pixel_format: prep.header.info.pixel_format,
406            needs_downsample: needs_chroma_downsample(prep.header.info.pixel_format),
407            tonemap_to_sdr: spec.tonemaps(),
408            gpu_index: decode_gpu,
409            filters: Arc::clone(&filter_chain),
410        };
411        clip_sources.push(ClipSource {
412            cfg: pump_cfg,
413            input: clip.input.clone(),
414            start_frame,
415            end_frame,
416        });
417    }
418    let effective_total = total_known.then_some(effective_total);
419    let audio_handling = combined_audio
420        .as_ref()
421        .map(|a| a.handling.clone())
422        .unwrap_or_else(|| "none".to_string());
423
424    let (rungs, hls_root, master_playlist) = match &spec.mode {
425        OutputMode::SingleFile => {
426            let rungs = run_serial_single_file(
427                clip_sources,
428                spec,
429                base_cfg,
430                frame_rate,
431                effective_total,
432                combined_audio,
433                Arc::clone(&sink),
434            )
435            .await?;
436            (rungs, None, None)
437        }
438        OutputMode::Hls { segment_seconds } => {
439            // Concat through the multi-GPU HLS engine: the spliced pump feeds the
440            // joined frame stream, segments form at keyframe boundaries on the
441            // output timeline, so the join is segment-aligned like any ladder.
442            run_hls(
443                clips[0].input.clone(),
444                spec,
445                *segment_seconds,
446                &primary,
447                frame_rate,
448                combined_audio.as_ref(),
449                Arc::clone(&filter_chain),
450                output_dir,
451                Arc::clone(&sink),
452                clip_sources,
453                effective_total,
454            )
455            .await?
456        }
457    };
458
459    let completed = rungs.len();
460    sink.on_event(JobEvent::Finished {
461        rungs_completed: completed,
462        rungs_failed: spec.rungs.len().saturating_sub(completed),
463    });
464    Ok(JobOutput {
465        rungs,
466        hls_root,
467        master_playlist,
468        source_codec,
469        source_dims,
470        source_frame_rate,
471        audio_handling,
472        elapsed: started.elapsed(),
473    })
474}
475
476/// Blocking wrapper for [`run_splice_job`].
477pub fn run_splice_job_blocking(
478    clips: Vec<Clip>,
479    spec: &OutputSpec,
480    output_dir: Option<&Path>,
481    sink: Arc<dyn ProgressSink>,
482) -> Result<JobOutput> {
483    let rt = tokio::runtime::Builder::new_multi_thread()
484        .enable_all()
485        .build()
486        .context("building Tokio runtime")?;
487    rt.block_on(run_splice_job(clips, spec, output_dir, sink))
488}
489
490// ---------------------------------------------------------------------------
491// Shared helpers used across submodules
492// ---------------------------------------------------------------------------
493
494pub(super) fn report_failed(sink: &dyn ProgressSink, rung_index: usize, rung: &Rung, message: &str) {
495    sink.on_rung(RungProgress {
496        rung_index,
497        label: rung.label.clone(),
498        width: rung.width,
499        height: rung.height,
500        status: RungStatus::Failed,
501        percent: 0.0,
502        frames_done: 0,
503        frames_total: None,
504        segments_written: 0,
505        bytes_out: 0,
506        message: Some(message.to_string()),
507    });
508}