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