Skip to main content

alpha2_native/
passthrough.rs

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