Skip to main content

alpha2_native/
passthrough.rs

1//! Copying a source's compressed video packets into an export unchanged.
2//!
3//! The project side of the decision (one untouched clip covering the range)
4//! is the engine's ([`alpha2_engine::passthrough_plan`] in the host). This is
5//! the file side: whether the packets under that range decode, on their own,
6//! into exactly the frames rendering would show, and then the copy itself.
7//!
8//! The test is on timestamps, not on flags. The copy takes one contiguous run
9//! of video packets in decode order. It must start at an IDR access unit, so
10//! nothing in it refers to a picture before it (a keyframe flag alone does not
11//! promise that), and the presentation times in the run must be exactly the
12//! export's frame times, one packet each. A B picture that needs a reference
13//! outside the range puts that reference inside the run with a presentation
14//! time outside it, and the run is refused; so is a stream whose frames are off
15//! the export's grid, a variable rate, or a duplicate. What passes is a
16//! self-contained stretch of the stream whose decoded frames are the rendered
17//! ones, which is the only thing a copy may claim.
18//!
19//! Several such runs (clips cut one after another) join into one output
20//! ([`PacketCopy::join`]) when one MP4 sample description fits them all and
21//! their decode times keep increasing across each join; each run starts at an
22//! IDR picture, so a decoder restarts there.
23
24use std::path::{Path, PathBuf};
25
26use alpha2_ffmpeg_bridge::DemuxInput;
27use alpha2_media::{
28    CancelToken, ChromaLocation, ColorMetadata, EncodeError, StreamKind, YuvEncoding,
29};
30use alpha2_time::MediaTime;
31use rsmpeg::avutil::{AVRational, av_rescale_q};
32use rsmpeg::ffi;
33
34use crate::NativeVideoCodec;
35use crate::convert::{encode_adapter_error, encode_adapter_error_at, tick_base};
36use crate::mux::{AudioStream, Muxer, VideoStream};
37
38/// The stream an export copies from and what it must match.
39#[derive(Clone, Debug)]
40pub struct PacketCopyRequest<'a> {
41    /// The source file.
42    pub source: &'a Path,
43    /// Each output frame's source time, in order: what rendering would show.
44    pub frame_times: &'a [MediaTime],
45    /// The output size, which the stream must already have.
46    pub width: u32,
47    /// See `width`.
48    pub height: u32,
49    /// The codec the export asked for, which the stream must already be.
50    pub codec: NativeVideoCodec,
51    /// The file format the export writes, which must offer the copied
52    /// streams' codecs.
53    pub container: alpha2_media::Container,
54    /// The color the output is tagged with when it renders; a copy keeps the
55    /// source's tags, so they must already say this.
56    pub color: ColorMetadata,
57    /// Where this run's first frame lands in the output: zero for a single
58    /// copy, a later segment's start in a join.
59    pub output_start: MediaTime,
60    /// The mix is the source's own audio, untouched: copy it too where that
61    /// can be exact, which is over the whole file. Where it cannot, the plan
62    /// says why ([`PacketCopyPlan::audio_copy_refusal`]) and the caller
63    /// encodes the mix instead; the video copy stands either way.
64    pub audio: Option<PacketCopyAudio>,
65}
66
67/// What a copied audio stream must match.
68#[derive(Copy, Clone, Debug, Eq, PartialEq)]
69pub struct PacketCopyAudio {
70    /// The project's sample rate.
71    pub sample_rate: u32,
72    /// The export's channel count.
73    pub channels: u32,
74}
75
76/// A copy that can be made, and what it copies.
77#[derive(Clone, Debug)]
78pub struct PacketCopyPlan {
79    source: PathBuf,
80    video_stream: usize,
81    audio_stream: Option<usize>,
82    /// Why audio the request offered to copy is not copied.
83    audio_refusal: Option<String>,
84    /// The copied run, as positions in the video stream's decode order.
85    first_packet: u64,
86    last_packet: u64,
87    /// The run's first packet's timestamps, in the video stream's time base:
88    /// what a writer that seeks to it recognizes it by.
89    first_timestamps: (i64, i64),
90    /// The first output frame's presentation time, in the video stream's
91    /// time base: it lands at `offset` in the output.
92    origin: i64,
93    /// `output_start`, in the video stream's time base.
94    offset: i64,
95    video_time_base: AVRational,
96    /// What an MP4 sample description records of the stream: runs that join
97    /// must agree on all of it.
98    contract: StreamContract,
99    /// The run's first and last decode times in the output, in ticks.
100    decode_span: (i64, i64),
101}
102
103/// The part of a video stream a single MP4 sample description fixes.
104#[derive(Clone, Debug, Eq, PartialEq)]
105struct StreamContract {
106    codec_id: ffi::AVCodecID,
107    width: i32,
108    height: i32,
109    format: i32,
110    /// The parameter sets.
111    extradata: Vec<u8>,
112    time_base: (i32, i32),
113}
114
115/// One or more copy runs written back to back into one output.
116#[derive(Clone, Debug)]
117pub struct PacketCopy {
118    segments: Vec<PacketCopyPlan>,
119}
120
121impl PacketCopy {
122    /// Joins runs, in output order, into one copy.
123    ///
124    /// Each run is already a self-contained stretch of its stream starting
125    /// at an IDR picture, so a decoder restarts at every join. What a join
126    /// adds is that one sample description must describe every run (the
127    /// same codec, size, pixel format, parameter sets, and time base), and
128    /// that decode times keep increasing across it, which fails when the
129    /// runs reorder pictures differently.
130    ///
131    /// # Errors
132    /// Names the join that breaks either, or a run that copies audio in a
133    /// join (copied audio travels only with one whole file).
134    pub fn join(segments: Vec<PacketCopyPlan>) -> Result<Self, String> {
135        let first = segments
136            .first()
137            .ok_or_else(|| "a copy needs at least one run".to_owned())?;
138        if segments.len() > 1 && segments.iter().any(PacketCopyPlan::copies_audio) {
139            return Err("copied audio travels only with one whole file".to_owned());
140        }
141        for (index, pair) in segments.windows(2).enumerate() {
142            if pair[1].contract != first.contract {
143                return Err(format!(
144                    "the source of run {} is not encoded like the first run's (codec, size, pixel format, parameter sets, or time base differ), and one MP4 sample description must fit both",
145                    index + 2
146                ));
147            }
148            if pair[1].decode_span.0 <= pair[0].decode_span.1 {
149                return Err(format!(
150                    "at the join into run {}, decode times go back: the runs reorder pictures differently",
151                    index + 2
152                ));
153            }
154        }
155        Ok(Self { segments })
156    }
157
158    /// Video packets the copy writes: one per output frame.
159    #[must_use]
160    pub fn video_packets(&self) -> u64 {
161        self.segments
162            .iter()
163            .map(PacketCopyPlan::video_packets)
164            .sum()
165    }
166
167    /// Runs joined.
168    #[must_use]
169    pub fn segments(&self) -> usize {
170        self.segments.len()
171    }
172
173    /// Whether the source's audio is copied too.
174    #[must_use]
175    pub fn copies_audio(&self) -> bool {
176        matches!(&self.segments[..], [only] if only.copies_audio())
177    }
178
179    /// Why the source's audio, which the request offered to copy, is not.
180    #[must_use]
181    pub fn audio_copy_refusal(&self) -> Option<&str> {
182        self.segments
183            .first()
184            .and_then(PacketCopyPlan::audio_copy_refusal)
185    }
186}
187
188impl PacketCopyPlan {
189    /// Video packets the copy writes: one per output frame.
190    #[must_use]
191    pub const fn video_packets(&self) -> u64 {
192        self.last_packet - self.first_packet + 1
193    }
194
195    /// Whether the source's audio is copied too.
196    #[must_use]
197    pub const fn copies_audio(&self) -> bool {
198        self.audio_stream.is_some()
199    }
200
201    /// Why the source's audio, which the request offered to copy, is not.
202    #[must_use]
203    pub fn audio_copy_refusal(&self) -> Option<&str> {
204        self.audio_refusal.as_deref()
205    }
206}
207
208/// The export's mixed audio, block by block, for a copy that encodes it.
209pub trait AudioBlockFeed {
210    /// The next block, starting where the previous one ended, at sample zero
211    /// of the output; `None` once the export's audio is complete.
212    ///
213    /// # Errors
214    /// Says why the mix could not be produced.
215    fn next_block(&mut self) -> Result<Option<alpha2_media::AudioBlock>, String>;
216}
217
218/// The audio a packet copy writes beside its video.
219pub enum CopyAudio<'a> {
220    /// None.
221    None,
222    /// The plan's copied audio packets; see [`PacketCopyPlan::copies_audio`].
223    Source,
224    /// The export's mix, encoded.
225    Encode {
226        /// The encoder's settings.
227        config: crate::AudioStreamConfig,
228        /// Where the mix comes from.
229        feed: &'a mut dyn AudioBlockFeed,
230    },
231}
232
233/// What a copy wrote.
234#[derive(Copy, Clone, Debug, Default, Eq, PartialEq)]
235pub struct PacketCopyReport {
236    /// Video packets written.
237    pub video_packets: u64,
238    /// Audio packets copied.
239    pub audio_packets: u64,
240    /// Audio sample frames encoded from the mix.
241    pub encoded_audio_frames: u64,
242    /// Payload bytes written, both streams.
243    pub bytes: u64,
244}
245
246/// One video packet as the scan saw it.
247#[derive(Clone)]
248struct ScannedPacket {
249    pts: i64,
250    dts: i64,
251    idr: bool,
252    /// The stream's sample description changes here
253    /// (`AV_PKT_DATA_NEW_EXTRADATA`).
254    new_description: bool,
255}
256
257/// Decides whether `request` can be served by copying packets.
258///
259/// A host planning several runs of one export uses one
260/// [`PacketCopyPlanner`], which reads each source once.
261///
262/// # Errors
263/// Names why not: the container, codec, size, color, or frame timing that a
264/// copy would not reproduce, or a run that does not start at an IDR picture.
265pub fn plan_packet_copy(request: &PacketCopyRequest<'_>) -> Result<PacketCopyPlan, String> {
266    PacketCopyPlanner::new(CancelToken::default()).plan(request)
267}
268
269/// Plans the runs of one export, reading each source file once however many
270/// runs it serves, and stopping when the export is cancelled.
271pub struct PacketCopyPlanner {
272    cancel: CancelToken,
273    sources: std::collections::HashMap<PathBuf, SourceFacts>,
274}
275
276/// What a plan learns of one source file, each part read when first needed.
277#[derive(Default)]
278struct SourceFacts {
279    probe: Option<Result<crate::ProbeResult, String>>,
280    video: Option<Result<VideoParameters, String>>,
281    scan: Option<Result<std::rc::Rc<Scan>, String>>,
282}
283
284/// The selected video stream's codec parameters a copy depends on.
285#[derive(Clone)]
286struct VideoParameters {
287    codec_id: ffi::AVCodecID,
288    format: i32,
289    field_order: ffi::AVFieldOrder,
290    sample_aspect_ratio: AVRational,
291    extradata: Vec<u8>,
292    time_base: AVRational,
293    width: i32,
294    height: i32,
295    tags: (
296        ffi::AVColorPrimaries,
297        ffi::AVColorTransferCharacteristic,
298        ffi::AVColorSpace,
299        ffi::AVColorRange,
300    ),
301}
302
303impl PacketCopyPlanner {
304    /// A planner that gives up as soon as `cancel` is set.
305    #[must_use]
306    pub fn new(cancel: CancelToken) -> Self {
307        Self {
308            cancel,
309            sources: std::collections::HashMap::new(),
310        }
311    }
312
313    /// [`plan_packet_copy`], from what this planner has read of the source.
314    ///
315    /// # Errors
316    /// As [`plan_packet_copy`], and a cancel.
317    pub fn plan(&mut self, request: &PacketCopyRequest<'_>) -> Result<PacketCopyPlan, String> {
318        if request.frame_times.is_empty() {
319            return Err("the range has no frames".to_owned());
320        }
321        let probed = self.probe(request.source)?;
322        let (video_index, audio_stream) = check_metadata(&probed, request)?;
323        let parameters = self.video(request.source, video_index)?;
324        let (length_size, contract) = stream_framing(request, &parameters)?;
325        let time_base = parameters.time_base;
326        let expected = request
327            .frame_times
328            .iter()
329            .map(|time| av_rescale_q(time.ticks(), tick_base(), time_base))
330            .collect::<Vec<_>>();
331        // Exact frame identity: the ticks each expected pts converts back to must be the frame time
332        // itself, or the grid does not land on the stream's time base.
333        if expected
334            .iter()
335            .zip(request.frame_times)
336            .any(|(pts, time)| av_rescale_q(*pts, time_base, tick_base()) != time.ticks())
337        {
338            return Err(
339                "the export's frame times do not land on the source's time base".to_owned(),
340            );
341        }
342        let scanned = self.scan(
343            request.source,
344            video_index,
345            probed.selected_audio,
346            request.codec,
347            length_size,
348        )?;
349        plan_run(
350            request,
351            &scanned,
352            &expected,
353            video_index,
354            audio_stream,
355            contract,
356        )
357    }
358
359    fn facts(&mut self, source: &Path) -> &mut SourceFacts {
360        self.sources.entry(source.to_owned()).or_default()
361    }
362
363    fn probe(&mut self, source: &Path) -> Result<crate::ProbeResult, String> {
364        self.facts(source)
365            .probe
366            .get_or_insert_with(|| {
367                crate::probe(source).map_err(|error| format!("probe failed: {error}"))
368            })
369            .clone()
370    }
371
372    fn video(&mut self, source: &Path, index: usize) -> Result<VideoParameters, String> {
373        self.facts(source)
374            .video
375            .get_or_insert_with(|| video_parameters(source, index))
376            .clone()
377    }
378
379    fn scan(
380        &mut self,
381        source: &Path,
382        index: usize,
383        audio: Option<usize>,
384        codec: NativeVideoCodec,
385        length_size: usize,
386    ) -> Result<std::rc::Rc<Scan>, String> {
387        let cancel = self.cancel.clone();
388        self.facts(source)
389            .scan
390            .get_or_insert_with(|| {
391                scan(source, index, codec, length_size, audio, &cancel).map(std::rc::Rc::new)
392            })
393            .clone()
394    }
395}
396
397/// The plan of one run, from its source's scan.
398fn plan_run(
399    request: &PacketCopyRequest<'_>,
400    scanned: &Scan,
401    expected: &[i64],
402    video_index: usize,
403    audio_stream: Option<Result<usize, String>>,
404    contract: StreamContract,
405) -> Result<PacketCopyPlan, String> {
406    let time_base = AVRational {
407        num: contract.time_base.0,
408        den: contract.time_base.1,
409    };
410    let packets = &scanned.video;
411    let first = packets
412        .iter()
413        .position(|packet| packet.pts == expected[0])
414        .ok_or_else(|| "no source frame is at the range's first frame time".to_owned())?;
415    let last = first + expected.len() - 1;
416    let run = packets
417        .get(first..=last)
418        .ok_or_else(|| "the source ends before the range does".to_owned())?;
419    if !run[0].idr {
420        return Err(
421            "the range does not start at an IDR picture, so its first frames may refer to earlier ones"
422                .to_owned(),
423        );
424    }
425    // The stream's parameter sets are its first sample description's; a run under another is
426    // decoded with the wrong ones.
427    if packets[1..=last]
428        .iter()
429        .any(|packet| packet.new_description)
430    {
431        return Err(
432            "the source changes its sample description (its parameter sets) before or inside the range, and a copy carries only the first"
433                .to_owned(),
434        );
435    }
436    let mut presented = run.iter().map(|packet| packet.pts).collect::<Vec<_>>();
437    presented.sort_unstable();
438    if presented != expected {
439        return Err(
440            "the packets under the range are not exactly its frames: the source is off the export's frame grid, or a picture in it depends on one outside it"
441                .to_owned(),
442        );
443    }
444    // Anywhere but the whole file the boundary audio packets straddle the range, and cutting them
445    // is not a copy.
446    let (audio_stream, audio_refusal) = match audio_stream {
447        Some(Ok(_)) if first != 0 || last + 1 != packets.len() => (
448            None,
449            Some("audio is copied only with the whole of its video".to_owned()),
450        ),
451        Some(Ok(index)) => match check_audio_span(scanned.audio, expected[0], time_base, request) {
452            Ok(()) => (Some(index), None),
453            Err(reason) => (None, Some(reason)),
454        },
455        Some(Err(reason)) => (None, Some(reason)),
456        None => (None, None),
457    };
458    let offset = av_rescale_q(request.output_start.ticks(), tick_base(), time_base);
459    let output_decode = |dts: i64| av_rescale_q(dts - expected[0] + offset, time_base, tick_base());
460    let decode_span = (
461        output_decode(run.iter().map(|packet| packet.dts).min().unwrap_or(0)),
462        output_decode(run.iter().map(|packet| packet.dts).max().unwrap_or(0)),
463    );
464    Ok(PacketCopyPlan {
465        source: request.source.to_owned(),
466        video_stream: video_index,
467        audio_stream,
468        audio_refusal,
469        first_packet: u64::try_from(first).unwrap_or(u64::MAX),
470        last_packet: u64::try_from(last).unwrap_or(u64::MAX),
471        first_timestamps: (run[0].pts, run[0].dts),
472        origin: expected[0],
473        offset,
474        video_time_base: time_base,
475        contract,
476        decode_span,
477    })
478}
479
480/// The container and stream metadata a copy keeps: the video stream, and
481/// the audio stream when the request copies audio.
482fn check_metadata(
483    probed: &crate::ProbeResult,
484    request: &PacketCopyRequest<'_>,
485) -> Result<(usize, Option<Result<usize, String>>), String> {
486    if !probed
487        .format
488        .split(',')
489        .any(|name| matches!(name, "mov" | "mp4"))
490    {
491        return Err(format!(
492            "the source is {}, and only MP4/MOV sources are copied",
493            probed.format
494        ));
495    }
496    if !request.container.offers_video(request.codec.model()) {
497        return Err(format!(
498            "the source's {} is not offered in {}",
499            request.codec.as_str(),
500            request.container
501        ));
502    }
503    let video_index = probed
504        .selected_video
505        .ok_or_else(|| "the source has no video stream".to_owned())?;
506    check_picture(
507        probed,
508        video_index,
509        (request.width, request.height),
510        request.color,
511    )?;
512    let audio_stream = request
513        .audio
514        .map(|audio| copyable_audio(probed, audio, request.container));
515
516    Ok((video_index, audio_stream))
517}
518
519/// Whether the video stream's pictures, shown as they are, already are the
520/// output's: its size, upright with square pixels, tagged with its color.
521fn check_picture(
522    probed: &crate::ProbeResult,
523    video_index: usize,
524    (width, height): (u32, u32),
525    color: ColorMetadata,
526) -> Result<(), String> {
527    let video = probed
528        .streams
529        .iter()
530        .find(|stream| stream.index == video_index)
531        .ok_or_else(|| "the source's video stream is missing".to_owned())?;
532    if video.dimensions != Some((width, height)) {
533        return Err(format!(
534            "the source is {:?}, not the {width}x{height} output",
535            video.dimensions
536        ));
537    }
538    if video.orientation.mirror {
539        return Err(
540            "the source mirrors the picture in its display matrix, which a render applies"
541                .to_owned(),
542        );
543    }
544    if video.orientation.rotation != alpha2_media::Rotation::Deg0 {
545        return Err("the source is rotated".to_owned());
546    }
547    let aspect = video.sample_aspect_ratio;
548    if aspect != alpha2_media::SampleAspectRatio::default() {
549        return Err(format!(
550            "the source shows {}:{} pixels, which a render makes square",
551            aspect.num, aspect.den
552        ));
553    }
554    if video.color != Some(color) {
555        return Err(format!(
556            "the source is tagged {:?}, and the output must be tagged {color:?}",
557            video.color
558        ));
559    }
560    Ok(())
561}
562
563/// Whether the codec parameters a copy carries into the output show its
564/// pictures as a render does. A render shows the pixel aspect ratio
565/// [`check_picture`] reads: the container's declaration, which overrides the
566/// codec's. A copy carries the codec's (in the bitstream, and in the `pasp`
567/// box the output then writes), so a codec ratio a container overrides to
568/// square pixels would show otherwise. A YUV transcode encodes its own
569/// parameters, so only a copy asks this.
570fn check_carried_display(aspect: AVRational) -> Result<(), String> {
571    if aspect.num > 0 && aspect.den > 0 && aspect.num != aspect.den {
572        return Err(format!(
573            "the source's codec declares {}:{} pixels, which a copy keeps and its container overrides to square",
574            aspect.num, aspect.den
575        ));
576    }
577    Ok(())
578}
579
580/// Whether the stream's pictures are progressive 8-bit 4:2:0 in one of
581/// `formats`.
582fn check_sampling(
583    format: i32,
584    field_order: ffi::AVFieldOrder,
585    formats: &[ffi::AVPixelFormat],
586) -> Result<(), String> {
587    if !formats.contains(&format) {
588        return Err("the source is not 8-bit 4:2:0".to_owned());
589    }
590    if !matches!(
591        field_order,
592        ffi::AV_FIELD_PROGRESSIVE | ffi::AV_FIELD_UNKNOWN
593    ) {
594        return Err("the source is interlaced".to_owned());
595    }
596    Ok(())
597}
598
599/// Decides whether `source`'s decoded pictures can become a
600/// `width`x`height` output's frames as they are, with no conversion: the
601/// file side of a YUV transcode, whose project side is the same untouched
602/// tiling a copy needs.
603///
604/// Unlike a copy, any container and codec qualify, and the frame grid is
605/// free: the decoder picks each frame's picture exactly as it does for a
606/// render, so only what the render would have changed (size, orientation,
607/// pixel shape, color, sampling, and where the chroma samples sit) decides.
608/// The output is encoded as `encoding`.
609///
610/// # Errors
611/// Names what the render would have changed.
612pub fn check_yuv_transcode(
613    source: &Path,
614    width: u32,
615    height: u32,
616    encoding: YuvEncoding,
617) -> Result<(), String> {
618    let input =
619        crate::decode::open_input(source).map_err(|error| format!("probe failed: {error}"))?;
620    let probed = crate::decode::probe_context(&input, source)
621        .map_err(|error| format!("probe failed: {error}"))?;
622    let video_index = probed
623        .selected_video
624        .ok_or_else(|| "the source has no video stream".to_owned())?;
625    check_picture(&probed, video_index, (width, height), encoding.color)?;
626    let parameters = input.streams()[video_index].codecpar();
627    check_sampling(
628        parameters.format,
629        parameters.field_order,
630        &[ffi::AV_PIX_FMT_YUV420P, ffi::AV_PIX_FMT_NV12],
631    )?;
632    let siting =
633        crate::convert::decoded_chroma_location(parameters.codec_id, parameters.chroma_location);
634    if !same_chroma_siting(siting, encoding.chroma) {
635        return Err(format!(
636            "the source's chroma sits {siting:?}, and the output's must sit {:?}",
637            encoding.chroma
638        ));
639    }
640    Ok(())
641}
642
643/// Whether chroma sited at `source` is where `output` puts it, reading an
644/// unspecified location as centred, as import does.
645#[must_use]
646pub fn same_chroma_siting(source: ChromaLocation, output: ChromaLocation) -> bool {
647    let centred = |location| {
648        if location == ChromaLocation::Unspecified {
649            ChromaLocation::Center
650        } else {
651            location
652        }
653    };
654    centred(source) == centred(output)
655}
656
657/// The video stream's codec parameters, read from the file.
658fn video_parameters(source: &Path, index: usize) -> Result<VideoParameters, String> {
659    let input = open(source)?;
660    let stream = input
661        .streams()
662        .get(index)
663        .ok_or_else(|| "the source's video stream is missing".to_owned())?;
664    let parameters = stream.codecpar();
665    Ok(VideoParameters {
666        codec_id: parameters.codec_id,
667        format: parameters.format,
668        field_order: parameters.field_order,
669        sample_aspect_ratio: parameters.sample_aspect_ratio,
670        extradata: alpha2_ffmpeg_bridge::codec_parameters_extradata(&parameters),
671        time_base: stream.time_base,
672        width: parameters.width,
673        height: parameters.height,
674        tags: (
675            parameters.color_primaries,
676            parameters.color_trc,
677            parameters.color_space,
678            parameters.color_range,
679        ),
680    })
681}
682
683/// The video stream's NAL framing and sample description, after the checks
684/// only the codec parameters answer.
685fn stream_framing(
686    request: &PacketCopyRequest<'_>,
687    parameters: &VideoParameters,
688) -> Result<(usize, StreamContract), String> {
689    if parameters.codec_id != request.codec.codec_id() {
690        return Err(format!(
691            "the source's video is not {}",
692            request.codec.as_str()
693        ));
694    }
695    check_sampling(
696        parameters.format,
697        parameters.field_order,
698        &[ffi::AV_PIX_FMT_YUV420P],
699    )?;
700    check_carried_display(parameters.sample_aspect_ratio)?;
701    // A copy keeps the stream's own tags, so they must be the output's exactly, not a summary that
702    // reads an unspecified one as BT.709.
703    if parameters.tags != crate::convert::color_tags(request.color) {
704        return Err(format!(
705            "the source is tagged primaries {}, transfer {}, matrix {}, range {} (FFmpeg's numbers), and the output must be tagged {:?}",
706            parameters.tags.0,
707            parameters.tags.1,
708            parameters.tags.2,
709            parameters.tags.3,
710            request.color
711        ));
712    }
713    let length_size = nal_length_size(request.codec, &parameters.extradata)?;
714    Ok((
715        length_size,
716        StreamContract {
717            codec_id: parameters.codec_id,
718            width: parameters.width,
719            height: parameters.height,
720            format: parameters.format,
721            extradata: parameters.extradata.clone(),
722            time_base: (parameters.time_base.num, parameters.time_base.den),
723        },
724    ))
725}
726
727/// The source's audio stream, when it is what the export would mix.
728fn copyable_audio(
729    probed: &crate::ProbeResult,
730    audio: PacketCopyAudio,
731    container: alpha2_media::Container,
732) -> Result<usize, String> {
733    let index = probed
734        .selected_audio
735        .ok_or_else(|| "the source has no audio stream to copy".to_owned())?;
736    let stream = probed
737        .streams
738        .iter()
739        .find(|stream| stream.index == index && stream.kind == StreamKind::Audio)
740        .ok_or_else(|| "the source's audio stream is missing".to_owned())?;
741    if stream.codec != "aac" {
742        return Err(format!("the source's audio is {}, not AAC", stream.codec));
743    }
744    if !container.offers_audio(alpha2_media::AudioCodec::Aac) {
745        return Err(format!("the source's aac is not offered in {container}"));
746    }
747    if stream.sample_rate != Some(audio.sample_rate) || stream.channels != Some(audio.channels) {
748        return Err(format!(
749            "the source's audio is {:?} Hz x {:?}, not the export's {} Hz x {}",
750            stream.sample_rate, stream.channels, audio.sample_rate, audio.channels
751        ));
752    }
753    Ok(index)
754}
755
756fn open(path: &Path) -> Result<DemuxInput, String> {
757    crate::decode::open_input(path).map_err(|error| format!("cannot open the source: {error}"))
758}
759
760/// The byte width of each NAL unit's length prefix, from the sample
761/// description. Only length-prefixed (MP4) streams are copied: an Annex B
762/// stream would need its framing rewritten, which is not a copy.
763pub(crate) fn nal_length_size(codec: NativeVideoCodec, extradata: &[u8]) -> Result<usize, String> {
764    let annex_b = || "the source's parameter sets are not in MP4 form".to_owned();
765    match codec {
766        NativeVideoCodec::H264 if extradata.len() >= 7 && extradata[0] == 1 => {
767            Ok(usize::from(extradata[4] & 0b11) + 1)
768        }
769        NativeVideoCodec::Hevc if extradata.len() >= 23 && extradata[0] == 1 => {
770            Ok(usize::from(extradata[21] & 0b11) + 1)
771        }
772        NativeVideoCodec::H264 | NativeVideoCodec::Hevc => Err(annex_b()),
773        other => Err(format!(
774            "{} is not copied; only H.264 and HEVC are",
775            other.as_str()
776        )),
777    }
778}
779
780/// Whether a length-prefixed access unit holds an IDR picture.
781pub(crate) fn is_idr(codec: NativeVideoCodec, length_size: usize, data: &[u8]) -> bool {
782    let mut at = 0_usize;
783    while at + length_size < data.len() {
784        let length = data[at..at + length_size]
785            .iter()
786            .fold(0_usize, |value, byte| (value << 8) | usize::from(*byte));
787        let Some(&header) = data.get(at + length_size) else {
788            return false;
789        };
790        let idr = match codec {
791            NativeVideoCodec::H264 => header & 0x1f == 5,
792            // IDR_W_RADL and IDR_N_LP. A CRA may be followed by pictures that refer to the previous
793            // GOP, so it does not count.
794            NativeVideoCodec::Hevc => matches!((header >> 1) & 0x3f, 19 | 20),
795            _ => false,
796        };
797        if idr {
798            return true;
799        }
800        at = at.saturating_add(length_size).saturating_add(length);
801    }
802    false
803}
804
805/// What a plan reads of a source's packets.
806struct Scan {
807    /// Every video packet, in decode order.
808    video: Vec<ScannedPacket>,
809    /// The span of the audio packets a copy would carry, with their time
810    /// base; `None` when the source has no audio stream or no timestamped
811    /// audio packet.
812    audio: Option<AudioSpan>,
813}
814
815/// The timestamps a copy would carry of the source's audio.
816#[derive(Clone, Copy)]
817struct AudioSpan {
818    first: i64,
819    last: i64,
820    /// Where the last packet ends: the latest presentation time plus its
821    /// duration, and that duration.
822    end: i64,
823    last_duration: i64,
824    time_base: AVRational,
825}
826
827/// Reads the source through once: every packet of video stream `index`,
828/// and the timestamps of audio stream `audio`'s. Every other stream is
829/// skipped by the demuxer, and `cancel` stops it between packets.
830fn scan(
831    path: &Path,
832    index: usize,
833    codec: NativeVideoCodec,
834    length_size: usize,
835    audio: Option<usize>,
836    cancel: &CancelToken,
837) -> Result<Scan, String> {
838    let mut input = open(path)?;
839    crate::decode::interrupt_on_cancel(&input, cancel);
840    input.keep_only(
841        &[Some(index), audio]
842            .into_iter()
843            .flatten()
844            .collect::<Vec<_>>(),
845    );
846    let audio_time_base =
847        audio.and_then(|audio| input.streams().get(audio).map(|stream| stream.time_base));
848    let mut packets = Vec::new();
849    let mut audio_span: Option<(i64, i64, i64, i64)> = None;
850    let cancelled = || "the export was cancelled while its copy was planned".to_owned();
851    loop {
852        if cancel.is_cancelled() {
853            return Err(cancelled());
854        }
855        let packet = match input.read_packet() {
856            Ok(Some(packet)) => packet,
857            Ok(None) => break,
858            Err(_) if cancel.is_cancelled() => return Err(cancelled()),
859            Err(error) => return Err(format!("cannot read the source: {error}")),
860        };
861        // A watcher outside (a host's) sees the scan alive, not blocked, packet by packet.
862        crate::recovery::report_decode_progress();
863        let stream = usize::try_from(packet.stream_index).ok();
864        if stream.is_some() && stream == audio {
865            for time in [packet.pts, packet.dts] {
866                if time != ffi::AV_NOPTS_VALUE {
867                    let span = audio_span.get_or_insert((time, time, time, 0));
868                    span.0 = span.0.min(time);
869                    span.1 = span.1.max(time);
870                }
871            }
872            if packet.pts != ffi::AV_NOPTS_VALUE {
873                let end = packet.pts.saturating_add(packet.duration.max(0));
874                if let Some(span) = audio_span.as_mut()
875                    && end >= span.2
876                {
877                    span.2 = end;
878                    span.3 = packet.duration.max(0);
879                }
880            }
881            continue;
882        }
883        if stream != Some(index) {
884            continue;
885        }
886        if packet.pts == ffi::AV_NOPTS_VALUE {
887            return Err("a source video packet has no presentation time".to_owned());
888        }
889        let key = packet.flags & i32::try_from(ffi::AV_PKT_FLAG_KEY).unwrap_or(0) != 0;
890        packets.push(ScannedPacket {
891            pts: packet.pts,
892            dts: if packet.dts == ffi::AV_NOPTS_VALUE {
893                packet.pts
894            } else {
895                packet.dts
896            },
897            idr: key
898                && is_idr(
899                    codec,
900                    length_size,
901                    &alpha2_ffmpeg_bridge::packet_bytes(&packet),
902                ),
903            new_description: alpha2_ffmpeg_bridge::packet_has_side_data(
904                &packet,
905                ffi::AV_PKT_DATA_NEW_EXTRADATA,
906            ),
907        });
908    }
909    Ok(Scan {
910        video: packets,
911        audio: audio_span.zip(audio_time_base).map(
912            |((first, last, end, last_duration), time_base)| AudioSpan {
913                first,
914                last,
915                end,
916                last_duration,
917                time_base,
918            },
919        ),
920    })
921}
922
923/// How far outside the copied video's span copied audio may reach, in
924/// ticks: more than an AAC encoder's priming before it and the last video
925/// frame's duration after it, together.
926const COPIED_AUDIO_SLACK: i64 = alpha2_time::TICKS_PER_SECOND;
927
928/// Whether the audio a copy would carry lands where the render's mix does:
929/// every timestamp, moved by the shift that puts the video's first frame at
930/// its place in the output, within the output's video, give or take
931/// [`COPIED_AUDIO_SLACK`]. The timestamps are the file's own, unchecked by
932/// the demuxer (a fragment's `tfdt` is any 64-bit value), so the shift is
933/// checked too: audio that cannot be moved, or would play elsewhere, is not
934/// the mix.
935fn check_audio_span(
936    span: Option<AudioSpan>,
937    origin: i64,
938    video_time_base: AVRational,
939    request: &PacketCopyRequest<'_>,
940) -> Result<(), String> {
941    let Some(span) = span else {
942        return Ok(());
943    };
944    let out_of_range = || {
945        "the source's audio timestamps lie outside its video, so copying them would not play the render's mix".to_owned()
946    };
947    let output_start = request.output_start.ticks();
948    let video_ticks = request
949        .frame_times
950        .last()
951        .map_or(0, |last| last.ticks() - request.frame_times[0].ticks());
952    let earliest = output_start.saturating_sub(COPIED_AUDIO_SLACK);
953    let latest = output_start
954        .saturating_add(video_ticks)
955        .saturating_add(COPIED_AUDIO_SLACK);
956    let shift_by = av_rescale_q(
957        origin - av_rescale_q(output_start, tick_base(), video_time_base),
958        video_time_base,
959        span.time_base,
960    );
961    for time in [span.first, span.last] {
962        let moved = time.checked_sub(shift_by).ok_or_else(out_of_range)?;
963        let ticks = av_rescale_q(moved, span.time_base, tick_base());
964        if ticks == ffi::AV_NOPTS_VALUE || !(earliest..=latest).contains(&ticks) {
965            return Err(out_of_range());
966        }
967    }
968    // A render mixes exactly the range, so the copied audio may not run on past the last frame's
969    // end by more than its own last packet, which a frame cannot be cut from.
970    let frames = i64::try_from(request.frame_times.len()).unwrap_or(i64::MAX);
971    let frame_ticks = if frames > 1 {
972        video_ticks / (frames - 1)
973    } else {
974        0
975    };
976    let video_end = output_start
977        .saturating_add(video_ticks)
978        .saturating_add(frame_ticks);
979    let end = av_rescale_q(
980        span.end.checked_sub(shift_by).ok_or_else(out_of_range)?,
981        span.time_base,
982        tick_base(),
983    );
984    let last_packet = av_rescale_q(span.last_duration, span.time_base, tick_base());
985    if end > video_end.saturating_add(last_packet) {
986        return Err(format!(
987            "the source's audio outlasts its video by {} ms, and a render mixes only the range",
988            (end - video_end) * 1_000 / alpha2_time::TICKS_PER_SECOND
989        ));
990    }
991    Ok(())
992}
993
994/// Writes `copy`'s runs, back to back, into a new `container` file at
995/// `output`, with `audio`. The runs were planned for `container`
996/// ([`PacketCopyRequest::container`]).
997///
998/// The output is transactional, as every export's is: it is written beside
999/// `output` and renamed into place only when complete, and a failure or a
1000/// cancel removes it. Encoded audio is interleaved with the video as it is
1001/// written, a little ahead of it, so neither stream piles up in the muxer.
1002/// `progress` is called with the video packets written.
1003///
1004/// # Errors
1005/// Returns an adapter failure for an existing output, a partial a running export holds, a read
1006/// or write failure, a failed mix, audio the plan does not copy, or a cancel.
1007pub fn copy_packets(
1008    copy: &PacketCopy,
1009    output: &Path,
1010    container: alpha2_media::Container,
1011    audio: CopyAudio<'_>,
1012    cancel: &CancelToken,
1013    progress: &mut dyn FnMut(u64),
1014) -> Result<PacketCopyReport, EncodeError> {
1015    let first = &copy.segments[0];
1016    let input = crate::decode::open_input(&first.source).map_err(encode_adapter_error)?;
1017    let video_stream = {
1018        let source = &input.streams()[first.video_stream];
1019        VideoStream {
1020            parameters: source.codecpar().clone(),
1021            time_base: source.time_base,
1022            copied: true,
1023        }
1024    };
1025    let audio_stream = match &audio {
1026        CopyAudio::None => None,
1027        CopyAudio::Source => {
1028            if !copy.copies_audio() {
1029                return Err(EncodeError::Adapter("the plan copies no audio".into()));
1030            }
1031            let index = first.audio_stream.unwrap_or(0);
1032            let source = &input.streams()[index];
1033            Some(AudioStream::Copy {
1034                parameters: source.codecpar().clone(),
1035                time_base: source.time_base,
1036            })
1037        }
1038        CopyAudio::Encode { config, .. } => Some(AudioStream::Encode(*config)),
1039    };
1040    drop(input);
1041    let copied_audio = matches!(audio, CopyAudio::Source);
1042    let mut writer = RunWriter {
1043        muxer: Muxer::open_streams(output, container, video_stream, audio_stream)?,
1044        mix: match audio {
1045            CopyAudio::Encode { feed, .. } => Some(MixWriter {
1046                feed,
1047                covered: 0,
1048                done: false,
1049            }),
1050            CopyAudio::None | CopyAudio::Source => None,
1051        },
1052        stages: crate::EncodeStageTimings::default(),
1053        report: PacketCopyReport::default(),
1054    };
1055    for segment in &copy.segments {
1056        writer.write_run(segment, copied_audio, cancel, progress)?;
1057    }
1058    if writer.report.video_packets != copy.video_packets() {
1059        return Err(EncodeError::Adapter(format!(
1060            "the copy wrote {} video packets of the planned {}",
1061            writer.report.video_packets,
1062            copy.video_packets()
1063        )));
1064    }
1065    let RunWriter {
1066        mut muxer,
1067        mut mix,
1068        mut stages,
1069        mut report,
1070    } = writer;
1071    if let Some(mix) = mix.as_mut() {
1072        mix.write_until(i64::MAX, &mut muxer, &mut stages, &mut report)?;
1073    }
1074    muxer.finish_audio()?;
1075    muxer.write_ready_audio(true, &mut stages)?;
1076    muxer.close()?;
1077    Ok(report)
1078}
1079
1080/// The output a copy writes its runs into, and the mix it interleaves.
1081struct RunWriter<'a> {
1082    muxer: Muxer,
1083    mix: Option<MixWriter<'a>>,
1084    stages: crate::EncodeStageTimings,
1085    report: PacketCopyReport,
1086}
1087
1088impl RunWriter<'_> {
1089    /// Copies one run's packets, and its source's audio packets when
1090    /// `copied_audio` (only ever for a single whole file).
1091    fn write_run(
1092        &mut self,
1093        run: &PacketCopyPlan,
1094        copied_audio: bool,
1095        cancel: &CancelToken,
1096        progress: &mut dyn FnMut(u64),
1097    ) -> Result<(), EncodeError> {
1098        let mut input = crate::decode::open_input(&run.source).map_err(encode_adapter_error)?;
1099        crate::decode::interrupt_on_cancel(&input, cancel);
1100        let audio_stream = copied_audio.then_some(run.audio_stream).flatten();
1101        input.keep_only(
1102            &[Some(run.video_stream), audio_stream]
1103                .into_iter()
1104                .flatten()
1105                .collect::<Vec<_>>(),
1106        );
1107        let shift_by = run.origin - run.offset;
1108        // A run after the first packet is sought to (its first packet is an indexed IDR). Copied
1109        // audio only travels with a whole file, which starts at the first packet.
1110        let mut video_position = if run.first_packet > 0 {
1111            let stream = i32::try_from(run.video_stream)
1112                .map_err(|_| EncodeError::Adapter("video stream index is out of range".into()))?;
1113            input
1114                .seek(
1115                    stream,
1116                    run.first_timestamps.1,
1117                    i32::try_from(ffi::AVSEEK_FLAG_BACKWARD).unwrap_or(0),
1118                )
1119                .map_err(|error| {
1120                    if cancel.is_cancelled() {
1121                        EncodeError::Cancelled
1122                    } else {
1123                        encode_adapter_error_at("seeking to the run", error)
1124                    }
1125                })?;
1126            None
1127        } else {
1128            Some(0_u64)
1129        };
1130        while let Some(mut packet) = input.read_packet().map_err(|error| {
1131            if cancel.is_cancelled() {
1132                EncodeError::Cancelled
1133            } else {
1134                encode_adapter_error(error)
1135            }
1136        })? {
1137            if cancel.is_cancelled() {
1138                return Err(EncodeError::Cancelled);
1139            }
1140            let stream = usize::try_from(packet.stream_index).ok();
1141            if stream == Some(run.video_stream) {
1142                let position = match video_position {
1143                    Some(position) => position,
1144                    None if (packet.pts, packet.dts) == run.first_timestamps => run.first_packet,
1145                    None => continue,
1146                };
1147                video_position = Some(position + 1);
1148                if position < run.first_packet {
1149                    continue;
1150                }
1151                if position > run.last_packet {
1152                    // Copied audio only travels with the whole video.
1153                    break;
1154                }
1155                packet.set_pts(shift(packet.pts, shift_by)?);
1156                packet.set_dts(shift(packet.dts, shift_by)?);
1157                packet.set_pos(-1);
1158                if let Some(mix) = self.mix.as_mut() {
1159                    let at = av_rescale_q(packet.dts, run.video_time_base, tick_base());
1160                    mix.write_until(
1161                        at.saturating_add(MIX_LEAD),
1162                        &mut self.muxer,
1163                        &mut self.stages,
1164                        &mut self.report,
1165                    )?;
1166                }
1167                self.report.bytes = self
1168                    .report
1169                    .bytes
1170                    .saturating_add(u64::try_from(packet.size).unwrap_or(0));
1171                self.muxer.write_video(&mut packet, run.video_time_base)?;
1172                self.report.video_packets += 1;
1173                progress(self.report.video_packets);
1174            } else if stream.is_some() && stream == audio_stream {
1175                let source_base = input.streams()[run.audio_stream.unwrap_or(0)].time_base;
1176                // The same zero as the video's; audio keeps its own offset from it, priming
1177                // included.
1178                let origin = av_rescale_q(shift_by, run.video_time_base, source_base);
1179                packet.set_pts(shift(packet.pts, origin)?);
1180                packet.set_dts(shift(packet.dts, origin)?);
1181                packet.set_pos(-1);
1182                self.report.bytes = self
1183                    .report
1184                    .bytes
1185                    .saturating_add(u64::try_from(packet.size).unwrap_or(0));
1186                self.muxer.write_copied_audio(&mut packet)?;
1187                self.report.audio_packets += 1;
1188            }
1189        }
1190        Ok(())
1191    }
1192}
1193
1194/// How far ahead of the video the encoded mix is written, in ticks: enough
1195/// that the muxer always has audio to interleave, little enough that neither
1196/// stream piles up in it.
1197const MIX_LEAD: i64 = alpha2_time::TICKS_PER_SECOND / 10;
1198
1199/// The export's mix, written into the muxer as the video advances.
1200struct MixWriter<'a> {
1201    feed: &'a mut dyn AudioBlockFeed,
1202    /// Output time the mix written so far reaches, in ticks.
1203    covered: i64,
1204    done: bool,
1205}
1206
1207impl MixWriter<'_> {
1208    /// Writes blocks until the mix reaches `target`, or ends.
1209    fn write_until(
1210        &mut self,
1211        target: i64,
1212        muxer: &mut Muxer,
1213        stages: &mut crate::EncodeStageTimings,
1214        report: &mut PacketCopyReport,
1215    ) -> Result<(), EncodeError> {
1216        while !self.done && self.covered < target {
1217            let Some(block) = self.feed.next_block().map_err(EncodeError::Adapter)? else {
1218                self.done = true;
1219                break;
1220            };
1221            let frames = block.channels.first().map_or(0, Vec::len);
1222            muxer.write_audio(&block, stages)?;
1223            report.encoded_audio_frames = report
1224                .encoded_audio_frames
1225                .saturating_add(u64::try_from(frames).unwrap_or(u64::MAX));
1226            self.covered = i64::try_from(report.encoded_audio_frames)
1227                .unwrap_or(i64::MAX)
1228                .saturating_mul(alpha2_time::TICKS_PER_SECOND)
1229                .div_euclid(i64::from(block.sample_rate.max(1)));
1230        }
1231        Ok(())
1232    }
1233}
1234
1235/// `value` moved back by `origin`, keeping a missing timestamp missing.
1236/// The plan checked the copied stream's timestamps; this still refuses one
1237/// that cannot be moved rather than overflowing.
1238fn shift(value: i64, origin: i64) -> Result<i64, EncodeError> {
1239    if value == ffi::AV_NOPTS_VALUE {
1240        return Ok(value);
1241    }
1242    value
1243        .checked_sub(origin)
1244        .filter(|moved| *moved != ffi::AV_NOPTS_VALUE)
1245        .ok_or_else(|| {
1246            EncodeError::Adapter(format!(
1247                "a source packet's timestamp {value} cannot be moved by {origin}"
1248            ))
1249        })
1250}