Skip to main content

rff/
transcode.rs

1//! Transcoding — the engine side of `ffmpeg`.
2//!
3//! The pipeline is the classic FFmpeg shape:
4//!
5//! ```text
6//!   demuxer ─▶ decoder ─▶ [filters] ─▶ encoder ─▶ muxer
7//!   (input)    (raw frames pass through the middle)      (output)
8//! ```
9//!
10//! [`TranscodeSpec`] is the declarative description of a job — inputs, an
11//! output, and the codecs to use. [`run`] resolves that spec against an
12//! [`Engine`] (finding the demuxer, decoders, encoders, and muxer) and then
13//! drives the loop to completion.
14//!
15//! Each input stream is either **transcoded** (decode → re-encode) when an
16//! output codec is requested for its media type, or **stream-copied**
17//! (remuxed packet-for-packet) when none is — the same `-c:v copy` distinction
18//! FFmpeg draws. Video runs through the `-vf` filter graph (scale/crop); audio
19//! is automatically resampled to a rate the target encoder accepts (FFmpeg's
20//! implicit `aresample`).
21
22use std::io::Read;
23use std::path::PathBuf;
24
25use rff_codec::{CodecParams, Decoder, Encoder};
26use rff_core::{
27    AudioFrame, CodecId, ColorRange, Dictionary, Error, Frame, MediaType, Packet, PixelFormat,
28    Result, SampleFormat, VideoFrame,
29};
30use rff_filter::{FilterChain, FilterComplex};
31
32use rff_format::{Muxer, Stream};
33use rff_resample::Resampler;
34
35use crate::Engine;
36
37/// `RFF_PHASES=1`: wall time per phase of the transcode loop, on stderr at the
38/// end of [`run`] -- the outside-the-codec view (demux, decode, the conform
39/// chain, encode, mux) that a codec's own stage profiler cannot see. A gap
40/// between a codec's core throughput and the CLI's is plumbing, and this is
41/// what locates it (codec-memory-copies step 0). Off by default: one cached
42/// flag test per call, and `Instant` is never touched unless it is set.
43mod phases {
44    use std::sync::atomic::{AtomicU64, Ordering::Relaxed};
45    use std::sync::OnceLock;
46
47    pub static OPEN: AtomicU64 = AtomicU64::new(0);
48    pub static DEMUX: AtomicU64 = AtomicU64::new(0);
49    pub static DECODE: AtomicU64 = AtomicU64::new(0);
50    pub static CONFORM: AtomicU64 = AtomicU64::new(0);
51    pub static ENCODE: AtomicU64 = AtomicU64::new(0);
52    pub static MUX: AtomicU64 = AtomicU64::new(0);
53
54    pub fn on() -> bool {
55        static ON: OnceLock<bool> = OnceLock::new();
56        *ON.get_or_init(|| std::env::var_os("RFF_PHASES").is_some())
57    }
58
59    /// Run `f`, charging its wall time to `acc` when phases are on.
60    pub fn time<T>(acc: &AtomicU64, f: impl FnOnce() -> T) -> T {
61        if !on() {
62            return f();
63        }
64        let t = std::time::Instant::now();
65        let r = f();
66        acc.fetch_add(t.elapsed().as_nanos() as u64, Relaxed);
67        r
68    }
69
70    pub fn report(total: std::time::Duration) {
71        let total_ns = total.as_nanos().max(1) as f64;
72        eprintln!("--- rff phases (total {:.1} ms) ---", total_ns / 1e6);
73        let mut sum = 0u64;
74        for (name, acc) in [
75            ("open", &OPEN),
76            ("demux", &DEMUX),
77            ("decode", &DECODE),
78            ("conform", &CONFORM),
79            ("encode", &ENCODE),
80            ("mux", &MUX),
81        ] {
82            let ns = acc.swap(0, Relaxed);
83            sum += ns;
84            eprintln!(
85                "  {name:<8} {:>9.1} ms  {:>5.1}%",
86                ns as f64 / 1e6,
87                100.0 * ns as f64 / total_ns
88            );
89        }
90        let rest = total_ns - sum as f64;
91        eprintln!(
92            "  {:<8} {:>9.1} ms  {:>5.1}%",
93            "other",
94            rest / 1e6,
95            100.0 * rest / total_ns
96        );
97    }
98}
99
100/// Codecs whose decoded output is inherently full-range (they decode to RGB, or
101/// are defined on 0-255 samples). JPEG is the canonical case: the standard has
102/// no limited-range mode, which is why FFmpeg calls its pixel formats `yuvj*`.
103fn is_full_range_codec(codec: CodecId) -> bool {
104    matches!(
105        codec,
106        CodecId::Jpeg | CodecId::Png | CodecId::Gif | CodecId::Webp
107    )
108}
109
110/// What range this stream's decoded frames are actually in.
111///
112/// An `Unspecified` stream is not "unknown, do nothing" — every consumer has to
113/// pick something, so pick it here, once, explicitly:
114/// full-range for codecs that are defined that way, limited-range otherwise
115/// (the video convention, and what an untagged y4m means).
116fn effective_color_range(stream: &Stream) -> ColorRange {
117    match stream.color_range {
118        ColorRange::Unspecified if is_full_range_codec(stream.codec_id) => ColorRange::Full,
119        ColorRange::Unspecified => ColorRange::Limited,
120        explicit => explicit,
121    }
122}
123
124/// One input file for a job.
125#[derive(Debug, Clone)]
126pub struct InputSpec {
127    pub path: PathBuf,
128    /// Force a container format instead of guessing from the extension
129    /// (`ffmpeg -f <fmt> -i ...`).
130    pub format: Option<String>,
131}
132
133/// Per-stream codec selection for the output.
134#[derive(Debug, Clone)]
135pub struct StreamCodec {
136    pub codec: CodecId,
137    /// Codec options (`-b:v 2M`, `-crf 23`, ...).
138    pub options: Dictionary,
139    /// Output sample format pinned by the codec NAME (`-c:a pcm_s16le`) or by
140    /// `-sample_fmt`. `None` = take whatever the decoder emits. Without this,
141    /// raw-PCM output silently ignored the format half of its own codec name.
142    pub sample_format: Option<SampleFormat>,
143}
144
145/// Which input stream(s) a `-map` entry selects.
146#[derive(Debug, Clone)]
147pub enum MapSelector {
148    /// All streams of the input (`-map 0`).
149    All,
150    /// All streams of a media kind (`-map 0:v` / `0:a`).
151    Kind(MediaType),
152    /// One stream by index within the input (`-map 0:2`).
153    Index(usize),
154}
155
156/// One `-map` entry: pick stream(s) from input `input`.
157#[derive(Debug, Clone)]
158pub struct MapSpec {
159    pub input: usize,
160    pub selector: MapSelector,
161}
162
163/// The output file for a job.
164#[derive(Debug, Clone, Default)]
165pub struct OutputSpec {
166    pub path: PathBuf,
167    /// Force a container format instead of guessing from the extension
168    /// (`ffmpeg -f <fmt> ...`).
169    pub format: Option<String>,
170    /// Video codec for the output, if a video stream is produced.
171    pub video_codec: Option<StreamCodec>,
172    /// Audio codec for the output, if an audio stream is produced.
173    pub audio_codec: Option<StreamCodec>,
174    /// Subtitle codec (`-c:s subrip|webvtt`). Text-subtitle packets share one
175    /// contract (plain text + ms timing), so this relabels the stream for the
176    /// muxer rather than re-encoding anything.
177    pub subtitle_codec: Option<CodecId>,
178    /// Video filter graph (`-vf`), e.g. `scale=320:240,crop=...`. Applied to
179    /// decoded video frames before re-encoding (transcode streams only).
180    pub video_filters: Option<String>,
181    /// Multi-input filter graph (`-filter_complex`). Currently models `overlay`:
182    /// the last input is composited over input #0's video.
183    pub filter_complex: Option<String>,
184    /// Explicit stream selection (`-map`). Empty = default (all video + audio,
185    /// in input/stream order).
186    pub maps: Vec<MapSpec>,
187    /// Overwrite the output if it exists (`-y`); otherwise fail (`-n`).
188    pub overwrite: bool,
189    /// Stop after this many VIDEO frames (`-frames:v N`). `None` = whole input.
190    ///
191    /// This is a measurement-critical option: without it a harness that thinks
192    /// it is encoding a 50-frame prefix silently encodes the whole clip, and any
193    /// rate-vs-quality pairing computed against a prefix is then wrong.
194    pub max_video_frames: Option<u64>,
195    /// Output trim window start, in seconds (`-ss`). Decoded frames (or copied
196    /// packets) before this point are dropped; timestamps are shifted so the
197    /// output starts at zero.
198    pub trim_start: Option<f64>,
199    /// Output trim window end, in seconds (`-to`, or `-ss` + `-t`). Exclusive.
200    pub trim_end: Option<f64>,
201    /// Constant output frame rate (`-r`), as `(num, den)` — e.g. `(30000, 1001)`.
202    /// Frames are duplicated/dropped to hit it (FFmpeg's `fps` filter).
203    pub frame_rate: Option<(u32, u32)>,
204    /// Target audio sample rate (`-ar`). The encoder's accepted-rate list still
205    /// wins if it cannot take this exact rate (nearest accepted is used).
206    pub audio_rate: Option<u32>,
207    /// Target audio channel count (`-ac`): 1↔2 downmix/upmix.
208    pub audio_channels: Option<u16>,
209    /// Audio filter chain (`-af` / `-filter:a`), e.g. `volume=-6dB,atrim=...`.
210    pub audio_filters: Option<String>,
211    /// Output metadata (`-metadata title=...`), handed to muxers that carry it.
212    pub metadata: Dictionary,
213    /// Muxer options (`-hls_time 6` → `{"hls_time": "6"}`), for formats that
214    /// take them (HLS/DASH segmenting).
215    pub format_options: Dictionary,
216}
217
218/// A complete, declarative transcoding job.
219#[derive(Debug, Clone, Default)]
220pub struct TranscodeSpec {
221    pub inputs: Vec<InputSpec>,
222    pub outputs: Vec<OutputSpec>,
223}
224
225/// A successful run's summary (frames/packets moved, etc.). Fields will grow as
226/// the pipeline does.
227#[derive(Debug, Clone, Default)]
228pub struct TranscodeReport {
229    pub packets_written: u64,
230    pub frames_decoded: u64,
231}
232
233/// Output trim window (`-ss` / `-t` / `-to`), in seconds.
234#[derive(Clone, Copy)]
235struct Trim {
236    start: f64,
237    end: Option<f64>,
238}
239
240impl Trim {
241    fn from_spec(o: &OutputSpec) -> Option<Trim> {
242        if o.trim_start.is_none() && o.trim_end.is_none() {
243            return None;
244        }
245        Some(Trim {
246            start: o.trim_start.unwrap_or(0.0).max(0.0),
247            end: o.trim_end,
248        })
249    }
250
251    fn keeps(&self, t: f64) -> bool {
252        t >= self.start - 1e-9 && self.end.map_or(true, |e| t < e - 1e-9)
253    }
254}
255
256/// CFR conversion state (`-r`): input frames are snapped onto a fixed
257/// `num/den` fps grid, duplicating the previous frame across gaps and dropping
258/// frames that land on an already-filled slot. Emitted frames carry their slot
259/// index as pts (time base `den/num`).
260struct FpsConv {
261    num: u32,
262    den: u32,
263    next_slot: i64,
264    last: Option<VideoFrame>,
265}
266
267impl FpsConv {
268    fn new(num: u32, den: u32) -> FpsConv {
269        FpsConv {
270            num: num.max(1),
271            den: den.max(1),
272            next_slot: 0,
273            last: None,
274        }
275    }
276
277    fn slot_time(&self, slot: i64) -> f64 {
278        slot as f64 * self.den as f64 / self.num as f64
279    }
280
281    /// Feed one input frame at time `t` (seconds); returns the frames due
282    /// strictly before `t`, each stamped with its slot pts.
283    fn push(&mut self, frame: VideoFrame, t: f64) -> Vec<VideoFrame> {
284        let mut out = Vec::new();
285        while let Some(last) = &self.last {
286            if self.slot_time(self.next_slot) < t - 1e-9 {
287                let mut f = last.clone();
288                f.pts = Some(self.next_slot);
289                out.push(f);
290                self.next_slot += 1;
291            } else {
292                break;
293            }
294        }
295        self.last = Some(frame);
296        out
297    }
298
299    /// End of stream: emit the held frame in its slot.
300    fn finish(&mut self) -> Option<VideoFrame> {
301        self.last.take().map(|mut f| {
302            f.pts = Some(self.next_slot);
303            self.next_slot += 1;
304            f
305        })
306    }
307}
308
309/// What to do with one input stream on its way to the output.
310enum StreamOp {
311    /// Not selected (or a media type we can't route): drop its packets.
312    Skip,
313    /// Remux packets unchanged into the output.
314    Copy(CopyOp),
315    /// Decode, filter, re-encode.
316    Transcode(Box<TranscodeOp>),
317}
318
319/// Stream-copy: forward packets, applying the trim window at packet level.
320/// Video cuts on the first keyframe inside the window (a copy can't split a
321/// GOP); timestamps shift so the output starts at zero.
322struct CopyOp {
323    out_index: usize,
324    is_video: bool,
325    /// Trim window in stream ticks (`None` = no trimming).
326    start_ticks: i64,
327    end_ticks: Option<i64>,
328    trimming: bool,
329    /// Video: set once the first in-window keyframe has passed.
330    started: bool,
331}
332
333impl CopyOp {
334    /// Apply trim/shift; `None` = drop the packet.
335    fn process(&mut self, mut packet: Packet) -> Option<Packet> {
336        if self.trimming {
337            let ts = packet.pts.or(packet.dts).unwrap_or(0);
338            if ts < self.start_ticks {
339                return None;
340            }
341            if let Some(end) = self.end_ticks {
342                if ts >= end {
343                    return None;
344                }
345            }
346            if self.is_video && !self.started {
347                if !packet.flags.keyframe {
348                    return None; // wait for a clean random-access point
349                }
350                self.started = true;
351            }
352            packet.pts = packet.pts.map(|p| p - self.start_ticks);
353            packet.dts = packet.dts.map(|d| d - self.start_ticks);
354        }
355        packet.stream_index = self.out_index;
356        Some(packet)
357    }
358}
359
360/// Decode → trim → fps → filters/overlay → audio conform → encode → mux.
361struct TranscodeOp {
362    decoder: Box<dyn Decoder>,
363    encoder: Box<dyn Encoder>,
364    filters: FilterChain,
365    /// `-filter_complex` overlay: a pre-decoded frame composited onto each
366    /// of this stream's frames at `(x, y)` (after `filters`). Video only.
367    overlay: Option<(VideoFrame, u32, u32)>,
368    /// The input stream's time base (frame pts are in these ticks).
369    in_time_base: rff_core::Rational,
370    is_video: bool,
371    /// Output trim window; pts are shifted by `shift_ticks` after it.
372    trim: Option<Trim>,
373    shift_ticks: i64,
374    /// Fallback clock for trimming audio whose frames carry no pts.
375    audio_clock_samples: u64,
376    /// `-r`: CFR duplicate/drop stage.
377    fps: Option<FpsConv>,
378    /// `-af`: the audio filter chain (volume/atrim/...).
379    audio_chain: rff_filter::AudioFilterChain,
380    /// `-ac`: mix to this many channels before everything else (0 = keep).
381    target_channels: u16,
382    /// Sample rate the encoder needs (0 = no audio resampling required).
383    target_rate: u32,
384    /// Sample format the output pins (`-c:a pcm_s16le`), if any.
385    target_sample_format: Option<SampleFormat>,
386    /// Lazily built once the first audio frame reveals the input rate.
387    resampler: Option<Resampler>,
388    /// Pixel formats the encoder accepts (`None` = anything).
389    accepted_formats: Option<Vec<PixelFormat>>,
390    /// Lazily built conversion into an accepted format.
391    pixel_converter: Option<FilterChain>,
392    /// Range of the frames reaching the encoder, for that conversion.
393    source_range: ColorRange,
394    out_index: usize,
395    /// `-frames:v` limit for this output, and how many we have sent.
396    max_video_frames: Option<u64>,
397    video_frames_sent: u64,
398    /// pts of video frames handed to the encoder, FIFO. Video encoders that
399    /// don't timestamp their packets (VP9, H.264) get these stamped on in
400    /// [`TranscodeOp::drain`] — without it every Matroska block lands at t=0.
401    pending_pts: std::collections::VecDeque<Option<i64>>,
402}
403
404/// Composite the `-filter_complex` overlay onto a video frame, if one is set.
405/// Audio frames and the no-overlay case pass through untouched.
406fn apply_overlay(overlay: &Option<(VideoFrame, u32, u32)>, frame: Frame) -> Result<Frame> {
407    match (overlay, frame) {
408        (Some((over, x, y)), Frame::Video(v)) => {
409            Ok(Frame::Video(rff_filter::overlay(v, over, *x, *y)?))
410        }
411        (_, frame) => Ok(frame),
412    }
413}
414
415/// Apply a video filter chain to a frame. Filters are video-only; audio passes
416/// through untouched, and an empty chain is a no-op.
417fn apply_filters(filters: &mut FilterChain, frame: Frame) -> Result<Frame> {
418    if filters.is_empty() {
419        return Ok(frame);
420    }
421    match frame {
422        Frame::Video(v) => Ok(Frame::Video(filters.apply(v)?)),
423        other => Ok(other),
424    }
425}
426
427/// Pick the accepted rate closest to `target`.
428fn nearest_rate(rates: &[u32], target: u32) -> u32 {
429    rates
430        .iter()
431        .copied()
432        .min_by_key(|r| (*r as i64 - target as i64).abs())
433        .unwrap_or(target)
434}
435
436/// Read interleaved `s16`/`f32` plane 0 of an audio frame into `f32` samples.
437fn audio_to_f32(af: &AudioFrame) -> Result<Vec<f32>> {
438    match af.format {
439        SampleFormat::F32 => Ok(af.planes[0]
440            .chunks_exact(4)
441            .map(|b| f32::from_le_bytes([b[0], b[1], b[2], b[3]]))
442            .collect()),
443        SampleFormat::S16 => Ok(af.planes[0]
444            .chunks_exact(2)
445            .map(|b| i16::from_le_bytes([b[0], b[1]]) as f32 / 32768.0)
446            .collect()),
447        other => Err(Error::unsupported(format!(
448            "resample: sample format `{}` (need interleaved s16/f32)",
449            other.name()
450        ))),
451    }
452}
453
454/// `f32` samples as little-endian bytes, in one sized pass. The
455/// `iter().flat_map(to_le_bytes).collect()` this replaces is the pattern LLVM
456/// handles worst -- an iterator of tiny arrays flattened into a growing `Vec`.
457fn f32_le_bytes(samples: &[f32]) -> Vec<u8> {
458    let mut bytes = vec![0u8; samples.len() * 4];
459    for (d, s) in bytes.chunks_exact_mut(4).zip(samples) {
460        d.copy_from_slice(&s.to_le_bytes());
461    }
462    bytes
463}
464
465/// The usual f32 -> s16 rule: round to nearest, then clamp. Truncation would
466/// bias every sample toward zero.
467fn f32_to_s16(s: f32) -> i16 {
468    (s * 32768.0).round().clamp(-32768.0, 32767.0) as i16
469}
470
471/// Wrap interleaved `f32` samples as an `f32` [`AudioFrame`].
472fn f32_frame(samples: Vec<f32>, rate: u32, channels: u16, pts: Option<i64>) -> Frame {
473    let bytes = f32_le_bytes(&samples);
474    Frame::Audio(AudioFrame {
475        sample_rate: rate,
476        channels,
477        format: SampleFormat::F32,
478        samples: samples.len() / channels.max(1) as usize,
479        planes: vec![bytes],
480        pts,
481    })
482}
483
484/// Convert an audio frame to `target` sample format, if one is pinned and the
485/// frame is not already in it. The audio analogue of `conform_video`: raw PCM
486/// output declares its format in the container header, so the frames handed to
487/// the muxer must actually BE that format, not merely be labelled it.
488fn conform_sample_format(target: Option<SampleFormat>, frame: Frame) -> Result<Frame> {
489    let Some(target) = target else {
490        return Ok(frame);
491    };
492    let Frame::Audio(af) = frame else {
493        return Ok(frame);
494    };
495    if af.format == target {
496        return Ok(Frame::Audio(af));
497    }
498    // Bytes to bytes in ONE pass. This used to decode the whole plane into a
499    // `Vec<f32>` and then re-encode it through `flat_map(..).collect()` -- an
500    // extra full-frame copy (a WAV's worth, when the frame is a whole file) and
501    // the iterator shape LLVM vectorises worst. The arithmetic per sample is
502    // the same expression, so the output is byte-identical.
503    let src = &af.planes[0];
504    let plane = match (af.format, target) {
505        (SampleFormat::F32, SampleFormat::S16) => {
506            let mut out = vec![0u8; src.len() / 4 * 2];
507            for (d, b) in out.chunks_exact_mut(2).zip(src.chunks_exact(4)) {
508                let s = f32::from_le_bytes([b[0], b[1], b[2], b[3]]);
509                d.copy_from_slice(&f32_to_s16(s).to_le_bytes());
510            }
511            out
512        }
513        (SampleFormat::S16, SampleFormat::F32) => {
514            let mut out = vec![0u8; src.len() / 2 * 4];
515            for (d, b) in out.chunks_exact_mut(4).zip(src.chunks_exact(2)) {
516                let s = i16::from_le_bytes([b[0], b[1]]) as f32 / 32768.0;
517                d.copy_from_slice(&s.to_le_bytes());
518            }
519            out
520        }
521        _ => {
522            // Not a supported pair: report the source format first, as before.
523            audio_to_f32(&af)?;
524            return Err(Error::unsupported(format!(
525                "sample format conversion to `{}` (only interleaved s16/f32)",
526                target.name()
527            )));
528        }
529    };
530    let planes = vec![plane];
531    Ok(Frame::Audio(AudioFrame {
532        sample_rate: af.sample_rate,
533        channels: af.channels,
534        format: target,
535        samples: af.samples,
536        planes,
537        pts: af.pts,
538    }))
539}
540
541/// Resample an audio frame to `target_rate` if needed (the resampler is built
542/// lazily from the first frame's rate/channels). `target_rate == 0` is a no-op.
543fn conform_audio(
544    resampler: &mut Option<Resampler>,
545    target_rate: u32,
546    frame: Frame,
547) -> Result<Frame> {
548    if target_rate == 0 {
549        return Ok(frame);
550    }
551    let Frame::Audio(af) = frame else {
552        return Ok(frame);
553    };
554    if af.sample_rate == target_rate {
555        return Ok(Frame::Audio(af));
556    }
557    let rs =
558        resampler.get_or_insert_with(|| Resampler::new(af.sample_rate, target_rate, af.channels));
559    let out = rs.process(&audio_to_f32(&af)?);
560    let pts = af
561        .pts
562        .map(|p| p * target_rate as i64 / af.sample_rate.max(1) as i64);
563    Ok(f32_frame(out, target_rate, af.channels, pts))
564}
565
566/// Convert a video frame into a pixel format the encoder accepts, if it isn't
567/// already in one. The converting chain is built lazily from the first frame
568/// that needs it, mirroring the audio resampler above.
569///
570/// This is what lets a decoder emit its cheapest native layout: the JPEG decoder
571/// hands out planar Y'CbCr, and a PNG encoder downstream still gets the RGB it
572/// requires because this inserts the conversion.
573fn conform_video(
574    converter: &mut Option<FilterChain>,
575    accepted: &Option<Vec<PixelFormat>>,
576    source_range: ColorRange,
577    frame: Frame,
578) -> Result<Frame> {
579    let Some(accepted) = accepted else {
580        return Ok(frame);
581    };
582    let Frame::Video(vf) = frame else {
583        return Ok(frame);
584    };
585    if accepted.is_empty() || accepted.contains(&vf.format) {
586        return Ok(Frame::Video(vf));
587    }
588    if converter.is_none() {
589        // First choice the encoder listed that we can actually convert to.
590        let target = accepted
591            .iter()
592            .find(|f| matches!(f, PixelFormat::Rgb24 | PixelFormat::Yuv420p))
593            .ok_or_else(|| {
594                Error::unsupported(format!(
595                    "no conversion from `{}` to any format this encoder accepts",
596                    vf.format.name()
597                ))
598            })?;
599        // Pin the range: `spec.limited` describes the Y'CbCr side either way,
600        // and guessing it wrong is a silent several-dB error.
601        let range = match source_range {
602            ColorRange::Limited => "limited",
603            _ => "full",
604        };
605        *converter = Some(FilterChain::parse(&format!(
606            "format={}:bt601:{}",
607            target.name(),
608            range
609        ))?);
610    }
611    let chain = converter.as_mut().expect("just built");
612    Ok(Frame::Video(chain.apply(vf)?))
613}
614
615/// Mix an audio frame to `target` channels (0 = keep). Stereo↔mono only: a
616/// downmix averages pairs, an upmix duplicates the channel.
617fn conform_channels(target: u16, frame: Frame) -> Result<Frame> {
618    if target == 0 {
619        return Ok(frame);
620    }
621    let Frame::Audio(af) = frame else {
622        return Ok(frame);
623    };
624    if af.channels == target {
625        return Ok(Frame::Audio(af));
626    }
627    let samples = audio_to_f32(&af)?;
628    let mixed: Vec<f32> = match (af.channels, target) {
629        (2, 1) => samples
630            .chunks_exact(2)
631            .map(|p| 0.5 * (p[0] + p[1]))
632            .collect(),
633        (1, 2) => samples.iter().flat_map(|s| [*s, *s]).collect(),
634        (from, to) => {
635            return Err(Error::unsupported(format!(
636                "-ac: only 1<->2 channel mixing supported (input {from} ch, requested {to})"
637            )))
638        }
639    };
640    Ok(f32_frame(mixed, af.sample_rate, target, af.pts))
641}
642
643impl TranscodeOp {
644    /// A frame's start time in seconds (input timeline). Audio without pts
645    /// falls back to a running sample clock; video without pts cannot be
646    /// placed and errors — only called when trim/fps actually need the time.
647    fn frame_time(&self, frame: &Frame) -> Result<f64> {
648        let tb = self.in_time_base;
649        let tick = if tb.num > 0 && tb.den > 0 {
650            tb.num as f64 / tb.den as f64
651        } else {
652            0.001
653        };
654        match frame.pts() {
655            Some(p) => Ok(p as f64 * tick),
656            None => match frame {
657                Frame::Audio(af) => {
658                    Ok(self.audio_clock_samples as f64 / af.sample_rate.max(1) as f64)
659                }
660                Frame::Video(_) => Err(Error::unsupported(
661                    "-ss/-t/-to/-r need timestamps and this video stream has none",
662                )),
663            },
664        }
665    }
666
667    /// Decoded-frame entry point: trim window, timestamp shift, CFR stage,
668    /// then the per-frame pipeline in [`TranscodeOp::emit`].
669    fn handle_frame(
670        &mut self,
671        frame: Frame,
672        muxer: &mut dyn Muxer,
673        report: &mut TranscodeReport,
674    ) -> Result<()> {
675        report.frames_decoded += 1;
676        let needs_time = self.trim.is_some() || (self.is_video && self.fps.is_some());
677        let t = if needs_time {
678            Some(self.frame_time(&frame)?)
679        } else {
680            None
681        };
682        // The sample clock tracks ALL input audio, kept or dropped.
683        if let Frame::Audio(af) = &frame {
684            self.audio_clock_samples += af.samples as u64;
685        }
686        let mut frame = frame;
687        if let (Some(trim), Some(t)) = (self.trim, t) {
688            match frame {
689                // Audio frames can be arbitrarily long (a WAV decodes as one
690                // frame), so the window slices samples instead of keep/drop.
691                Frame::Audio(af) => {
692                    match rff_filter::trim_audio_frame(af, Some(t), Some(trim.start), trim.end)? {
693                        Some(af) => frame = Frame::Audio(af),
694                        None => return Ok(()),
695                    }
696                }
697                Frame::Video(_) => {
698                    if !trim.keeps(t) {
699                        return Ok(());
700                    }
701                }
702            }
703        }
704        // Shift timestamps so the output timeline starts at zero.
705        if self.shift_ticks != 0 {
706            match &mut frame {
707                Frame::Video(v) => v.pts = v.pts.map(|p| p - self.shift_ticks),
708                Frame::Audio(a) => a.pts = a.pts.map(|p| p - self.shift_ticks),
709            }
710        }
711        match frame {
712            Frame::Video(v) if self.fps.is_some() => {
713                let t_out = t.expect("fps requires time") - self.trim.map_or(0.0, |w| w.start);
714                let mut fps = self.fps.take().expect("checked");
715                let due = fps.push(v, t_out);
716                self.fps = Some(fps);
717                for f in due {
718                    self.emit(Frame::Video(f), muxer, report)?;
719                }
720                Ok(())
721            }
722            other => self.emit(other, muxer, report),
723        }
724    }
725
726    /// Post-fps pipeline: `-frames:v` gate, filters, overlay, audio conforms,
727    /// encode, drain.
728    fn emit(
729        &mut self,
730        frame: Frame,
731        muxer: &mut dyn Muxer,
732        report: &mut TranscodeReport,
733    ) -> Result<()> {
734        if let Some(limit) = self.max_video_frames {
735            if matches!(frame, Frame::Video(_)) {
736                if self.video_frames_sent >= limit {
737                    return Ok(());
738                }
739                self.video_frames_sent += 1;
740            }
741        }
742        let Some(frame) = phases::time(&phases::CONFORM, || self.conform(frame))? else {
743            return Ok(()); // atrim consumed the whole frame
744        };
745        if self.is_video {
746            self.pending_pts.push_back(frame.pts());
747        }
748        phases::time(&phases::ENCODE, || self.encoder.send_frame(&frame))?;
749        self.drain(muxer, report)
750    }
751
752    /// The per-frame conform chain between decode and encode: filters, overlay,
753    /// channel/rate/sample-format/pixel conforms. `None` = atrim dropped it.
754    fn conform(&mut self, frame: Frame) -> Result<Option<Frame>> {
755        let frame = apply_filters(&mut self.filters, frame)?;
756        let frame = apply_overlay(&self.overlay, frame)?;
757        let frame = conform_channels(self.target_channels, frame)?;
758        let frame = match frame {
759            Frame::Audio(af) if !self.audio_chain.is_empty() => {
760                let tb = self.in_time_base;
761                let t = af
762                    .pts
763                    .map(|p| p as f64 * tb.num as f64 / tb.den.max(1) as f64);
764                match self.audio_chain.apply(af, t)? {
765                    Some(af) => Frame::Audio(af),
766                    None => return Ok(None),
767                }
768            }
769            other => other,
770        };
771        let frame = conform_audio(&mut self.resampler, self.target_rate, frame)?;
772        let frame = conform_sample_format(self.target_sample_format, frame)?;
773        let frame = conform_video(
774            &mut self.pixel_converter,
775            &self.accepted_formats,
776            self.source_range,
777            frame,
778        )?;
779        Ok(Some(frame))
780    }
781
782    /// Pull every ready packet out of the encoder and mux it. Video encoders
783    /// that don't timestamp their packets (VP9, H.264 emit `pts: None`) get the
784    /// corresponding source frame's pts stamped on, FIFO — without this every
785    /// Matroska block would land at t=0.
786    fn drain(&mut self, muxer: &mut dyn Muxer, report: &mut TranscodeReport) -> Result<()> {
787        loop {
788            match phases::time(&phases::ENCODE, || self.encoder.receive_packet()) {
789                Ok(mut packet) => {
790                    if self.is_video {
791                        let queued = self.pending_pts.pop_front().flatten();
792                        if packet.pts.is_none() {
793                            packet.pts = queued;
794                        }
795                    }
796                    packet.stream_index = self.out_index;
797                    phases::time(&phases::MUX, || muxer.write_packet(&packet))?;
798                    report.packets_written += 1;
799                }
800                Err(Error::Again) | Err(Error::Eof) => break,
801                Err(e) => return Err(e),
802            }
803        }
804        Ok(())
805    }
806
807    /// End of input: flush the decoder, the CFR stage's held frame, the
808    /// resampler's FIR tail, then the encoder.
809    fn finish(&mut self, muxer: &mut dyn Muxer, report: &mut TranscodeReport) -> Result<()> {
810        self.decoder.flush();
811        loop {
812            match phases::time(&phases::DECODE, || self.decoder.receive_frame()) {
813                Ok(frame) => self.handle_frame(frame, muxer, report)?,
814                Err(Error::Again) | Err(Error::Eof) => break,
815                Err(e) => return Err(e),
816            }
817        }
818        if let Some(mut fps) = self.fps.take() {
819            let held = fps.finish();
820            self.fps = Some(fps);
821            if let Some(f) = held {
822                self.emit(Frame::Video(f), muxer, report)?;
823            }
824        }
825        if let Some(rs) = &mut self.resampler {
826            let tail = rs.finish();
827            if !tail.is_empty() {
828                let frame = f32_frame(tail, rs.out_rate(), rs.channels(), None);
829                let frame = conform_sample_format(self.target_sample_format, frame)?;
830                self.encoder.send_frame(&frame)?;
831                self.drain(muxer, report)?;
832            }
833        }
834        phases::time(&phases::ENCODE, || self.encoder.flush());
835        self.drain(muxer, report)
836    }
837}
838
839/// Resolve and run a transcode job against `engine`.
840///
841/// Resolution (formats, decoders, encoders) happens up front so failures are
842/// reported before any output file is touched; then the demux→decode→encode→mux
843/// loop runs to completion.
844pub fn run(engine: &Engine, spec: &TranscodeSpec) -> Result<TranscodeReport> {
845    if spec.inputs.is_empty() {
846        return Err(Error::Option("no input files specified".into()));
847    }
848    if spec.outputs.is_empty() {
849        return Err(Error::Option("no output file specified".into()));
850    }
851    if spec.outputs.len() > 1 {
852        return Err(Error::unsupported("multiple outputs are not supported yet"));
853    }
854    let output = &spec.outputs[0];
855
856    // -filter_complex overlay: the last input is composited over input #0's
857    // video. Resolve it up front so stream selection can exclude that input.
858    let overlay_xy = output
859        .filter_complex
860        .as_deref()
861        .map(FilterComplex::parse)
862        .transpose()?
863        .and_then(|fc| fc.overlay);
864    let overlay_input = match overlay_xy {
865        Some(_) if spec.inputs.len() >= 2 => Some(spec.inputs.len() - 1),
866        Some(_) => {
867            return Err(Error::Option(
868                "filter_complex overlay needs a second input (the overlay image/video)".into(),
869            ))
870        }
871        None => None,
872    };
873
874    // --- open every input demuxer and read its streams ---
875    // (`open` is a phase of its own: a container read whole at open -- WAV, the
876    // MP3 demuxer -- does all of its I/O here, before the first packet.)
877    let t_start = phases::on().then(std::time::Instant::now);
878    let mut demuxers: Vec<Box<dyn rff_format::Demuxer>> = Vec::new();
879    let mut input_streams: Vec<Vec<Stream>> = Vec::new();
880    for input in &spec.inputs {
881        let (demuxer, streams) = phases::time(&phases::OPEN, || -> Result<_> {
882            let (in_format, reader) = open_input(engine, input)?;
883            let mut demuxer = engine.formats.open_demuxer(&in_format, reader)?;
884            let streams = demuxer.read_header()?;
885            Ok((demuxer, streams))
886        })?;
887        input_streams.push(streams);
888        demuxers.push(demuxer);
889    }
890
891    // --- select which (input, stream) pairs go to the output, in order ---
892    let mut selection = select_streams(&input_streams, output)?;
893    // The overlay input is consumed by the filter, not muxed as its own stream.
894    if let Some(oin) = overlay_input {
895        selection.retain(|(inp, _)| *inp != oin);
896    }
897    if selection.is_empty() {
898        return Err(Error::unsupported("no streams selected for the output"));
899    }
900
901    // Single-codec containers can't stream-copy a foreign codec, so an unset
902    // `-c:v` must mean TRANSCODE to the container's one codec, not copy (which
903    // the muxer would reject). y4m additionally can't carry anything compressed.
904    // Mirrors ffmpeg picking the muxer's default codec (`out.webp` → webp).
905    // When the source already IS that codec, `None` stays: stream-copy wins.
906    let mut output_owned;
907    let output = if output.video_codec.is_none() {
908        let format_name = resolve_output_format(engine, output).ok();
909        // y4m keeps its unconditional default: it can't carry anything
910        // compressed, so even rawvideo input goes through the decode path.
911        let required = match format_name.as_deref() {
912            Some("yuv4mpegpipe") => Some((CodecId::RawVideo, true)),
913            Some("webp") => Some((CodecId::Webp, false)),
914            Some("png") => Some((CodecId::Png, false)),
915            Some("jpeg") => Some((CodecId::Jpeg, false)),
916            Some("gif") => Some((CodecId::Gif, false)),
917            Some("avif") => Some((CodecId::Avif, false)),
918            Some("jpegxl") => Some((CodecId::Jxl, false)),
919            _ => None,
920        };
921        let apply = match required {
922            Some((_, true)) => true,
923            // Image containers: only when the source codec differs — a matching
924            // source keeps `None` and takes the stream-copy fast path.
925            Some((codec, false)) => !selection.iter().any(|&(inp, local)| {
926                let s = &input_streams[inp][local];
927                s.media_type == MediaType::Video && s.codec_id == codec
928            }),
929            None => false,
930        };
931        if apply {
932            output_owned = output.clone();
933            output_owned.video_codec = Some(StreamCodec {
934                codec: required.expect("apply implies required").0,
935                options: Dictionary::default(),
936                sample_format: None, // video: no audio sample format to pin
937            });
938            &output_owned
939        } else {
940            output
941        }
942    } else {
943        output
944    };
945
946    // Per-input op tables (Skip for unselected streams) + ordered output streams.
947    let mut per_input_ops: Vec<Vec<StreamOp>> = input_streams
948        .iter()
949        .map(|s| (0..s.len()).map(|_| StreamOp::Skip).collect())
950        .collect();
951    let mut out_streams: Vec<Stream> = Vec::new();
952    for (out_index, &(inp, local)) in selection.iter().enumerate() {
953        let (op, os) = build_op(engine, &input_streams[inp][local], output, out_index)?;
954        per_input_ops[inp][local] = op;
955        out_streams.push(os);
956    }
957
958    // --- filter_complex overlay: pre-decode the overlay frame and hand it to
959    // input #0's video transcode op (which composites it onto every frame) ---
960    if let (Some((x, y)), Some(oin)) = (overlay_xy, overlay_input) {
961        let over = decode_overlay_frame(engine, &mut *demuxers[oin], &input_streams[oin])?;
962        let vidx = input_streams[0]
963            .iter()
964            .position(|s| s.media_type == MediaType::Video)
965            .ok_or_else(|| Error::Option("filter_complex overlay: input #0 has no video".into()))?;
966        match &mut per_input_ops[0][vidx] {
967            StreamOp::Transcode(op) => op.overlay = Some((over, x, y)),
968            _ => {
969                return Err(Error::unsupported(
970                    "filter_complex overlay needs input #0's video re-encoded — pass -c:v",
971                ))
972            }
973        }
974    }
975
976    // --- pick the muxer from the registry. Path-based formats (HLS/DASH: a
977    // playlist plus many segment files) are built with the output path; every
978    // other format writes one byte sink through rff-io (file, pipe, udp).
979    let out_format = resolve_output_format(engine, output)?;
980    let muxer_path_factory = engine
981        .formats
982        .by_name(&out_format)
983        .filter(|fmt| fmt.can_mux())
984        .ok_or_else(|| Error::MuxerNotFound(out_format.clone()))?
985        .muxer_path;
986
987    // --- refuse to clobber an existing file unless -y was given (pipes and
988    // sockets have nothing to clobber) ---
989    let out_path_str = output.path.to_str().unwrap_or_default();
990    let is_sink_stream = rff_io::is_pipe(out_path_str)
991        || rff_io::is_udp(out_path_str)
992        || rff_io::is_rtmp(out_path_str);
993    if !is_sink_stream && !output.overwrite && output.path.exists() {
994        return Err(Error::Option(format!(
995            "{} already exists (pass -y to overwrite)",
996            output.path.display()
997        )));
998    }
999    let mut muxer: Box<dyn Muxer> = match muxer_path_factory {
1000        Some(factory) => factory(&output.path, &output.format_options)?,
1001        None => engine
1002            .formats
1003            .open_muxer(&out_format, rff_io::create(out_path_str)?)?,
1004    };
1005    if !output.metadata.is_empty() {
1006        muxer.set_metadata(&output.metadata);
1007    }
1008    muxer.write_header(&out_streams)?;
1009
1010    let mut report = TranscodeReport::default();
1011
1012    // --- drive each input through its plan into the shared muxer ---
1013    for (demuxer, ops) in demuxers.iter_mut().zip(per_input_ops.iter_mut()) {
1014        loop {
1015            match phases::time(&phases::DEMUX, || demuxer.read_packet()) {
1016                Ok(packet) => process_packet(packet, ops, &mut *muxer, &mut report)?,
1017                Err(Error::Eof) => break,
1018                Err(e) => return Err(e),
1019            }
1020        }
1021        flush_streams(ops, &mut *muxer, &mut report)?;
1022    }
1023    phases::time(&phases::MUX, || muxer.write_trailer())?;
1024    if let Some(t) = t_start {
1025        phases::report(t.elapsed());
1026    }
1027
1028    Ok(report)
1029}
1030
1031/// Resolve which `(input_index, stream_index)` pairs go to the output, in
1032/// output order. With no `-map`, defaults to every video + audio stream across
1033/// all inputs (input order, then stream order).
1034fn select_streams(inputs: &[Vec<Stream>], output: &OutputSpec) -> Result<Vec<(usize, usize)>> {
1035    let mut selection = Vec::new();
1036    if output.maps.is_empty() {
1037        for (ii, streams) in inputs.iter().enumerate() {
1038            for (i, s) in streams.iter().enumerate() {
1039                if matches!(
1040                    s.media_type,
1041                    MediaType::Video | MediaType::Audio | MediaType::Subtitle
1042                ) {
1043                    selection.push((ii, i));
1044                }
1045            }
1046        }
1047    } else {
1048        for map in &output.maps {
1049            let streams = inputs
1050                .get(map.input)
1051                .ok_or_else(|| Error::Option(format!("-map: no input #{}", map.input)))?;
1052            for (i, s) in streams.iter().enumerate() {
1053                let hit = match &map.selector {
1054                    MapSelector::All => true,
1055                    MapSelector::Kind(k) => s.media_type == *k,
1056                    MapSelector::Index(idx) => i == *idx,
1057                };
1058                if hit {
1059                    selection.push((map.input, i));
1060                }
1061            }
1062        }
1063    }
1064    Ok(selection)
1065}
1066
1067/// Build the [`StreamOp`] (transcode or copy) and matching output [`Stream`] for
1068/// one selected input stream at output position `out_index`.
1069fn build_op(
1070    engine: &Engine,
1071    stream: &Stream,
1072    output: &OutputSpec,
1073    out_index: usize,
1074) -> Result<(StreamOp, Stream)> {
1075    let requested = match stream.media_type {
1076        MediaType::Video => output.video_codec.as_ref(),
1077        MediaType::Audio => output.audio_codec.as_ref(),
1078        _ => None,
1079    };
1080    match requested {
1081        // Transcode: decode the input codec, re-encode to the requested one.
1082        Some(target) => {
1083            let mut decoder = engine.codecs.find_decoder(stream.codec_id)?;
1084            decoder.configure(&codec_params(stream))?;
1085            let mut encoder = engine.codecs.find_encoder(target.codec)?;
1086            encoder.configure(&target.options)?; // rate control: -crf / -preset / -b
1087                                                 // Video filter graph (`-vf`); applies to video streams only.
1088            let mut filters = if stream.media_type == MediaType::Video {
1089                FilterChain::parse(output.video_filters.as_deref().unwrap_or(""))?
1090            } else {
1091                FilterChain::default()
1092            };
1093            // Colour range has to be reconciled before any filter runs. JPEG (and
1094            // the other image codecs) are defined on full-range samples, so
1095            // handing them limited-range video encodes the wrong signal — it
1096            // measured 28.35 dB where it should have been 36.15 dB, a deficit
1097            // that looked exactly like poor encoder quality. Convert once, up
1098            // front, then tell the rest of the chain what it is now looking at.
1099            let mut source_range = ColorRange::Unspecified;
1100            if stream.media_type == MediaType::Video {
1101                source_range = effective_color_range(stream);
1102                if is_full_range_codec(target.codec) && source_range == ColorRange::Limited {
1103                    filters.prepend_range_conversion(true);
1104                    source_range = ColorRange::Full;
1105                }
1106                filters.set_source_color_range(source_range);
1107            }
1108            let (out_w, out_h) = filters.output_dims(stream.width, stream.height);
1109            let is_video = stream.media_type == MediaType::Video;
1110            let is_audio = stream.media_type == MediaType::Audio;
1111
1112            // Audio filter chain (`-af`); audio streams only.
1113            let audio_chain = if is_audio {
1114                rff_filter::AudioFilterChain::parse(output.audio_filters.as_deref().unwrap_or(""))?
1115            } else {
1116                rff_filter::AudioFilterChain::default()
1117            };
1118
1119            // `-ac`: validate up front — only mono/stereo mixing exists.
1120            let target_channels = match output.audio_channels {
1121                Some(c) if is_audio => {
1122                    if !(1..=2).contains(&c) {
1123                        return Err(Error::unsupported(format!(
1124                            "-ac {c}: only 1 or 2 channels supported"
1125                        )));
1126                    }
1127                    c
1128                }
1129                _ => 0,
1130            };
1131
1132            // Output sample rate: `-ar` / `aresample=` wins, else the input
1133            // rate; either way the encoder's accepted-rate list has the last
1134            // word (nearest accepted).
1135            let mut target_rate = 0;
1136            let mut out_rate = stream.sample_rate;
1137            if is_audio && stream.sample_rate > 0 {
1138                let desired = output
1139                    .audio_rate
1140                    .or(audio_chain.resample_target())
1141                    .unwrap_or(stream.sample_rate);
1142                let desired = match encoder.accepted_sample_rates() {
1143                    Some(rates) if !rates.contains(&desired) => nearest_rate(&rates, desired),
1144                    _ => desired,
1145                };
1146                if desired != stream.sample_rate {
1147                    target_rate = desired;
1148                }
1149                out_rate = desired;
1150            }
1151
1152            // Trim window and the tick shift that re-zeroes the output timeline.
1153            let trim = Trim::from_spec(output);
1154            let tb = stream.time_base;
1155            let shift_ticks = trim
1156                .map(|t| (t.start * tb.den as f64 / tb.num.max(1) as f64).round() as i64)
1157                .unwrap_or(0);
1158
1159            // `-r`: CFR stage; the output stream ticks in 1/fps units.
1160            let fps = match output.frame_rate {
1161                Some((num, den)) if is_video => Some(FpsConv::new(num, den)),
1162                _ => None,
1163            };
1164
1165            let mut accepted_formats = encoder.accepted_pixel_formats();
1166
1167            let mut os = Stream::new(out_index, target.codec);
1168            os.media_type = stream.media_type;
1169            os.time_base = stream.time_base;
1170            os.width = out_w;
1171            os.height = out_h;
1172            os.pixel_format = stream.pixel_format;
1173            // Raw output declares its pixel format in the container header,
1174            // which the muxer writes before a single frame has been seen. So the
1175            // format cannot be discovered later — pin it now and force frames to
1176            // match, or the header ends up describing data it does not have.
1177            // (A 4:4:4 JPEG decoded straight to planes under a `C420mpeg2`
1178            // header is silent corruption, not a mislabel.)
1179            if target.codec == CodecId::RawVideo && stream.media_type == MediaType::Video {
1180                let pinned = os.pixel_format.unwrap_or(PixelFormat::Yuv420p);
1181                os.pixel_format = Some(pinned);
1182                accepted_formats = Some(vec![pinned]);
1183            }
1184            // Colour range: a `format=` filter that converts to Y'CbCr decides
1185            // it; otherwise it rides through from the input. Decoders whose
1186            // native output is RGB (JPEG, PNG, GIF, WebP) are full-range by
1187            // definition, so an untagged input from one of those is Full, not
1188            // "unknown" — leaving it unspecified makes the muxer label the
1189            // samples limited-range and every reader then rescales them.
1190            // A colour conversion in the chain decides the output range;
1191            // otherwise it is whatever we reconciled the source to above.
1192            os.color_range = filters.output_color_range().unwrap_or(source_range);
1193            os.sample_rate = out_rate;
1194            os.channels = if target_channels > 0 {
1195                target_channels
1196            } else {
1197                stream.channels
1198            };
1199            // A CFR conversion re-times the stream: pts become slot indices.
1200            if let Some((num, den)) = output.frame_rate {
1201                if is_video {
1202                    os.time_base = rff_core::Rational::new(den as i32, num as i32);
1203                }
1204            }
1205            // A format pinned by the output codec NAME (`-c:a pcm_s16le`) wins:
1206            // it is an explicit request, and the muxer writes it into the
1207            // container header before any frame is seen. Otherwise compressed
1208            // decoders (AAC/Opus/Vorbis/FLAC/MP3) emit f32, so default to that
1209            // when the input doesn't declare a format.
1210            let target_sample_format = target.sample_format;
1211            os.sample_format = target_sample_format
1212                .or(stream.sample_format)
1213                .or(Some(SampleFormat::F32));
1214            // The muxer writes os.sample_format into the container header
1215            // BEFORE any frame is seen, so every frame must be conformed to
1216            // that committed format — decoders may legitimately emit either
1217            // layout (the FLAC decoder emits native s16 for 16-bit streams).
1218            let target_sample_format = target_sample_format.or(os.sample_format);
1219            // Audio encoders timestamp packets in per-channel samples, so the
1220            // output stream's time base is 1/sample_rate.
1221            if stream.media_type == MediaType::Audio && out_rate > 0 {
1222                os.time_base = rff_core::Rational::new(1, out_rate as i32);
1223            }
1224            Ok((
1225                StreamOp::Transcode(Box::new(TranscodeOp {
1226                    decoder,
1227                    encoder,
1228                    filters,
1229                    overlay: None,
1230                    in_time_base: stream.time_base,
1231                    is_video,
1232                    trim,
1233                    shift_ticks,
1234                    audio_clock_samples: 0,
1235                    fps,
1236                    audio_chain,
1237                    target_channels,
1238                    target_rate,
1239                    target_sample_format,
1240                    resampler: None,
1241                    accepted_formats,
1242                    pixel_converter: None,
1243                    source_range,
1244                    out_index,
1245                    max_video_frames: output.max_video_frames,
1246                    video_frames_sent: 0,
1247                    pending_pts: std::collections::VecDeque::new(),
1248                })),
1249                os,
1250            ))
1251        }
1252        // Stream copy: carry the same codec/packets through unchanged (the trim
1253        // window still applies, cutting video on keyframes).
1254        None => {
1255            let mut os = stream.clone();
1256            os.index = out_index;
1257            // `-c:s`: text subtitles share one packet contract, so choosing a
1258            // subtitle codec relabels the stream (SubRip ↔ WebVTT) in place.
1259            if stream.media_type == MediaType::Subtitle {
1260                if let Some(target) = output.subtitle_codec {
1261                    if target.media_type() != MediaType::Subtitle {
1262                        return Err(Error::unsupported(format!(
1263                            "-c:s: `{}` is not a subtitle codec",
1264                            target.name()
1265                        )));
1266                    }
1267                    os.codec_id = target;
1268                }
1269            }
1270            let trim = Trim::from_spec(output);
1271            let tb = stream.time_base;
1272            let to_ticks = |s: f64| (s * tb.den as f64 / tb.num.max(1) as f64).round() as i64;
1273            Ok((
1274                StreamOp::Copy(CopyOp {
1275                    out_index,
1276                    is_video: stream.media_type == MediaType::Video,
1277                    start_ticks: trim.map(|t| to_ticks(t.start)).unwrap_or(0),
1278                    end_ticks: trim.and_then(|t| t.end).map(to_ticks),
1279                    trimming: trim.is_some(),
1280                    started: false,
1281                }),
1282                os,
1283            ))
1284        }
1285    }
1286}
1287
1288/// Decode the first video frame of the overlay input and convert it to 4:2:0,
1289/// ready to composite onto the base's YUV frames.
1290fn decode_overlay_frame(
1291    engine: &Engine,
1292    demuxer: &mut dyn rff_format::Demuxer,
1293    streams: &[Stream],
1294) -> Result<VideoFrame> {
1295    let vidx = streams
1296        .iter()
1297        .position(|s| s.media_type == MediaType::Video)
1298        .ok_or_else(|| {
1299            Error::Option("filter_complex overlay: overlay input has no video".into())
1300        })?;
1301    let mut decoder = engine.codecs.find_decoder(streams[vidx].codec_id)?;
1302    decoder.configure(&codec_params(&streams[vidx]))?;
1303    let mut to_yuv = FilterChain::parse("format=yuv420p")?;
1304    let mut got_eof = false;
1305    loop {
1306        let frame = match demuxer.read_packet() {
1307            Ok(pkt) if pkt.stream_index as usize != vidx => continue,
1308            Ok(pkt) => {
1309                decoder.send_packet(&pkt)?;
1310                decoder.receive_frame()
1311            }
1312            Err(Error::Eof) if !got_eof => {
1313                got_eof = true;
1314                decoder.flush();
1315                decoder.receive_frame()
1316            }
1317            Err(e) => return Err(e),
1318        };
1319        match frame {
1320            Ok(Frame::Video(v)) => return to_yuv.apply(v),
1321            Ok(_) => continue,
1322            Err(Error::Again) if !got_eof => continue,
1323            Err(Error::Again) | Err(Error::Eof) => {
1324                return Err(Error::Option(
1325                    "filter_complex overlay: no decodable frame in the overlay input".into(),
1326                ))
1327            }
1328            Err(e) => return Err(e),
1329        }
1330    }
1331}
1332
1333/// Build the decoder configuration from a demuxed input stream.
1334fn codec_params(s: &Stream) -> CodecParams {
1335    CodecParams {
1336        codec_id: s.codec_id,
1337        width: s.width,
1338        height: s.height,
1339        pixel_format: s.pixel_format,
1340        sample_rate: s.sample_rate,
1341        channels: s.channels,
1342        sample_format: s.sample_format,
1343        extradata: s.extradata.clone(),
1344    }
1345}
1346
1347/// Route one demuxed packet through its stream's plan.
1348fn process_packet(
1349    packet: Packet,
1350    ops: &mut [StreamOp],
1351    muxer: &mut dyn Muxer,
1352    report: &mut TranscodeReport,
1353) -> Result<()> {
1354    let Some(op) = ops.get_mut(packet.stream_index) else {
1355        return Ok(()); // packet for a stream we didn't plan — drop it
1356    };
1357    match op {
1358        StreamOp::Skip => Ok(()),
1359        StreamOp::Copy(copy) => {
1360            if let Some(packet) = copy.process(packet) {
1361                muxer.write_packet(&packet)?;
1362                report.packets_written += 1;
1363            }
1364            Ok(())
1365        }
1366        StreamOp::Transcode(op) => {
1367            phases::time(&phases::DECODE, || op.decoder.send_packet(&packet))?;
1368            loop {
1369                match phases::time(&phases::DECODE, || op.decoder.receive_frame()) {
1370                    Ok(frame) => op.handle_frame(frame, muxer, report)?,
1371                    Err(Error::Again) | Err(Error::Eof) => break,
1372                    Err(e) => return Err(e),
1373                }
1374            }
1375            Ok(())
1376        }
1377    }
1378}
1379
1380/// At end of input, flush each transcoded stream's decoder, filter state, and
1381/// encoder, writing out anything still buffered.
1382fn flush_streams(
1383    ops: &mut [StreamOp],
1384    muxer: &mut dyn Muxer,
1385    report: &mut TranscodeReport,
1386) -> Result<()> {
1387    for op in ops.iter_mut() {
1388        if let StreamOp::Transcode(op) = op {
1389            op.finish(muxer, report)?;
1390        }
1391    }
1392    Ok(())
1393}
1394
1395/// Open an input as a streaming reader and decide its container format —
1396/// local file or `http://` URL. Delegates to the shared [`crate::probe`] opener
1397/// so ffmpeg and ffprobe resolve inputs identically.
1398fn open_input(engine: &Engine, input: &InputSpec) -> Result<(String, Box<dyn Read + Send>)> {
1399    let path = input
1400        .path
1401        .to_str()
1402        .ok_or_else(|| Error::Option("input path is not valid UTF-8".into()))?;
1403    crate::probe::open_source(engine, path, input.format.as_deref())
1404}
1405
1406/// Decide which container to mux an output as: explicit `-f`, else by
1407/// extension. An `rtmp://` target implies FLV — that is the payload RTMP
1408/// carries (matching FFmpeg's default for rtmp outputs).
1409fn resolve_output_format(engine: &Engine, output: &OutputSpec) -> Result<String> {
1410    if let Some(forced) = &output.format {
1411        return Ok(forced.clone());
1412    }
1413    if output.path.to_str().is_some_and(rff_io::is_rtmp) {
1414        return Ok("flv".to_string());
1415    }
1416    let ext = output
1417        .path
1418        .extension()
1419        .and_then(|e| e.to_str())
1420        .unwrap_or_default();
1421    engine
1422        .formats
1423        .by_extension(ext)
1424        .map(|f| f.name.to_string())
1425        .ok_or_else(|| {
1426            Error::MuxerNotFound(format!(
1427                "{} (no format for this extension — pass -f)",
1428                output.path.display()
1429            ))
1430        })
1431}
1432
1433#[cfg(test)]
1434mod sample_format_tests {
1435    use super::*;
1436
1437    fn f32_af(samples: &[f32]) -> Frame {
1438        f32_frame(samples.to_vec(), 44_100, 1, Some(0))
1439    }
1440
1441    /// `-c:a pcm_s16le` used to be silently ignored on decode: the codec NAME
1442    /// carries the format but `CodecId` does not, so raw output kept whatever
1443    /// the decoder emitted (f32), writing double the bytes under an s16 request.
1444    #[test]
1445    fn pinned_format_converts_f32_to_s16() {
1446        let src = [0.0f32, 0.5, -0.5, 1.0, -1.0];
1447        let out = conform_sample_format(Some(SampleFormat::S16), f32_af(&src)).unwrap();
1448        let Frame::Audio(af) = out else {
1449            unreachable!()
1450        };
1451        assert_eq!(af.format, SampleFormat::S16);
1452        assert_eq!(af.planes[0].len(), src.len() * 2, "s16 is 2 bytes/sample");
1453        let got: Vec<i16> = af.planes[0]
1454            .chunks_exact(2)
1455            .map(|b| i16::from_le_bytes([b[0], b[1]]))
1456            .collect();
1457        // Round-to-nearest, and full scale must CLAMP rather than wrap: 1.0 maps
1458        // to 32767, not to -32768 via overflow.
1459        assert_eq!(got, vec![0, 16384, -16384, 32767, -32768]);
1460    }
1461
1462    /// No pin, or a frame already in the target format, must pass through
1463    /// untouched — the conversion is not allowed to cost anything when idle.
1464    #[test]
1465    fn unpinned_or_matching_format_is_a_passthrough() {
1466        let src = [0.25f32, -0.25];
1467        let Frame::Audio(a) = conform_sample_format(None, f32_af(&src)).unwrap() else {
1468            unreachable!()
1469        };
1470        assert_eq!(a.format, SampleFormat::F32);
1471        let Frame::Audio(b) = conform_sample_format(Some(SampleFormat::F32), f32_af(&src)).unwrap()
1472        else {
1473            unreachable!()
1474        };
1475        assert_eq!(b.format, SampleFormat::F32);
1476        assert_eq!(b.planes[0], a.planes[0]);
1477    }
1478
1479    /// The codec name is the only place the format lives for raw PCM.
1480    #[test]
1481    fn codec_name_pins_the_sample_format() {
1482        assert_eq!(
1483            CodecId::sample_format_from_name("pcm_s16le"),
1484            Some(SampleFormat::S16)
1485        );
1486        assert_eq!(
1487            CodecId::sample_format_from_name("pcm_f32le"),
1488            Some(SampleFormat::F32)
1489        );
1490        // Bare `pcm` pins nothing, and compressed codecs never do.
1491        assert_eq!(CodecId::sample_format_from_name("pcm"), None);
1492        assert_eq!(CodecId::sample_format_from_name("mp3"), None);
1493    }
1494}