Skip to main content

alpha2_native/
video_encode.rs

1//! Video-only encode sink.
2
3use std::fs;
4use std::path::{Path, PathBuf};
5
6use crate::capabilities::{
7    NativeVideoCodec, NativeVideoEncoderSelection, VideoEncoderOpenRequest, open_video_encoder,
8};
9use crate::convert::{
10    codec_thread_budget, configure_rgba_to_yuv, encode_adapter_error, encode_adapter_error_at,
11    tick_base,
12};
13use crate::encode::{EncodeStageTimings, timed};
14use crate::partial::PartialOutput;
15use alpha2_ffmpeg_bridge::OutputFile;
16use alpha2_ffmpeg_bridge::Yuv420pFrameBuffer;
17use alpha2_media::{
18    CancelToken, ColorMatrix, ColorMetadata, Container, DecodedVideoFrame, EncodeError,
19    EncodeEvent, EncodeSink, Orientation, PixelFormat, SampleAspectRatio, VideoRateControl,
20};
21use alpha2_time::{FrameRate, MediaTime};
22use rsmpeg::avcodec::AVCodecContext;
23use rsmpeg::avutil::{AVFrameWithImage, AVImage, AVRational, av_rescale_q, ra};
24use rsmpeg::error::RsmpegError;
25use rsmpeg::ffi;
26use rsmpeg::swscale::SwsContext;
27
28/// Configuration for deterministic native video encoding.
29#[derive(Copy, Clone, Debug, PartialEq)]
30pub struct VideoEncodeConfig {
31    /// Encoded frame width; 4:2:0 codecs require an even value.
32    pub width: u32,
33    /// Encoded frame height; 4:2:0 codecs require an even value.
34    pub height: u32,
35    /// Constant output frame rate.
36    pub frame_rate: FrameRate,
37    /// SDR output color tags.
38    pub color: ColorMetadata,
39    /// Resolved rate control. A sink encodes from these numbers and never
40    /// from a quality level; the host resolves the level before this point.
41    pub rate_control: VideoRateControl,
42    /// Video codec selected by the downstream host.
43    pub codec: NativeVideoCodec,
44    /// How the pictures are shown, which the container says beside them.
45    pub display: VideoDisplay,
46    /// The file format the output is written in, which must offer `codec`.
47    pub container: Container,
48}
49
50/// How encoded pictures are shown: what a container records beside them
51/// rather than in them.
52#[derive(Copy, Clone, Debug, Default, Eq, PartialEq)]
53pub struct VideoDisplay {
54    /// Turned and mirrored as this says (MP4's `tkhd` display matrix).
55    pub orientation: Orientation,
56    /// Each pixel this wide for its height (the bitstream's and the
57    /// container's sample aspect ratio).
58    pub sample_aspect_ratio: SampleAspectRatio,
59}
60
61/// Refuses an output this sink cannot write, saying which condition fails.
62pub(crate) fn check_video_config(config: &VideoEncodeConfig) -> Result<(), EncodeError> {
63    let refuse = |reason: String| Err(EncodeError::Adapter(reason));
64    if !config.container.offers_video(config.codec.model()) {
65        return refuse(format!(
66            "{} does not offer video codec {}",
67            config.container,
68            config.codec.as_str()
69        ));
70    }
71    if config.width == 0
72        || config.height == 0
73        || !config.width.is_multiple_of(2)
74        || !config.height.is_multiple_of(2)
75    {
76        return refuse(format!(
77            "a {}x{} output is refused: 4:2:0 video needs an even, non-zero width and height",
78            config.width, config.height
79        ));
80    }
81    if config.rate_control.bitrate == 0 {
82        return refuse("the output bitrate must be positive".into());
83    }
84    if config.color.is_hdr() {
85        return refuse(format!(
86            "the output is HDR ({:?}), and native encoding writes SDR only",
87            config.color.transfer
88        ));
89    }
90    if config.color.matrix == ColorMatrix::Rgb {
91        return refuse(
92            "the output is tagged as RGB, and 4:2:0 video is Y'CbCr: tag the matrix it is encoded with"
93                .into(),
94        );
95    }
96    let sar = config.display.sample_aspect_ratio;
97    if sar.num == 0 || sar.den == 0 || i32::try_from(sar.num.max(sar.den)).is_err() {
98        return refuse(format!(
99            "the pixel aspect ratio {}:{} is out of range",
100            sar.num, sar.den
101        ));
102    }
103    Ok(())
104}
105
106/// Writes what `container` says beside an encoder's pictures on its stream:
107/// the display matrix, the pixel aspect ratio, and the codec tag.
108pub(crate) fn describe_output_stream(
109    stream: &mut rsmpeg::avformat::AVStreamMut<'_>,
110    encoder: &AVCodecContext,
111    display: VideoDisplay,
112    container: Container,
113) -> Result<(), EncodeError> {
114    let sar = display.sample_aspect_ratio;
115    if sar != SampleAspectRatio::default() {
116        stream.set_sample_aspect_ratio(ra(
117            i32::try_from(sar.num).unwrap_or(1),
118            i32::try_from(sar.den).unwrap_or(1),
119        ));
120    }
121    if let Some(matrix) = crate::convert::display_of(display.orientation) {
122        alpha2_ffmpeg_bridge::set_stream_display_matrix(stream, matrix)
123            .map_err(encode_adapter_error)?;
124    }
125    let tag = crate::convert::container_codec_tag(
126        container,
127        encoder.codec_id,
128        stream.codecpar().codec_tag,
129        false,
130    );
131    alpha2_ffmpeg_bridge::set_stream_codec_tag(stream, tag);
132    Ok(())
133}
134
135/// Transactional `FFmpeg` video encoder and muxer.
136///
137/// Frames are encoded into a sibling partial file. Only a successful encoder
138/// drain and trailer write renames it to the requested destination.
139pub struct NativeVideoEncodeSink {
140    pub(crate) final_path: PathBuf,
141    pub(crate) partial_path: PathBuf,
142    /// Held until the partial file is renamed into place or removed.
143    pub(crate) _partial_lock: crate::partial::PartialLock,
144    pub(crate) output: Option<OutputFile>,
145    pub(crate) encoder: AVCodecContext,
146    pub(crate) stream_index: i32,
147    pub(crate) encoder_time_base: AVRational,
148    /// One frame in `encoder_time_base`.
149    pub(crate) frame_units: i64,
150    pub(crate) stream_time_base: AVRational,
151    pub(crate) scaler: SwsContext,
152    /// Turns decoded YUV of any layout into the encoder's 4:2:0.
153    pub(crate) yuv_input: crate::convert::Yuv420pConverter,
154    pub(crate) width: i32,
155    pub(crate) height: i32,
156    pub(crate) last_pts: Option<i64>,
157    pub(crate) complete: bool,
158    pub(crate) selection: NativeVideoEncoderSelection,
159    pub(crate) stages: EncodeStageTimings,
160}
161
162impl NativeVideoEncodeSink {
163    /// Creates a sink that encodes `config.codec` into a `config.container` file at `path`.
164    ///
165    /// # Errors
166    /// Returns an adapter failure for invalid dimensions, paths, codecs, or muxers.
167    pub fn create(path: impl AsRef<Path>, config: VideoEncodeConfig) -> Result<Self, EncodeError> {
168        Self::create_selected(path, config, None, &codec_thread_budget)
169    }
170
171    /// Creates a concrete encoder selected by its `FFmpeg` short name.
172    ///
173    /// # Errors
174    /// Returns an adapter failure when the name is unavailable, implements a
175    /// different codec, or the remaining configuration is invalid.
176    pub fn create_with_encoder(
177        path: impl AsRef<Path>,
178        config: VideoEncodeConfig,
179        encoder_name: &str,
180    ) -> Result<Self, EncodeError> {
181        Self::create_selected(path, config, Some(encoder_name), &codec_thread_budget)
182    }
183
184    /// Creates an encoder whose software thread count the caller decides.
185    ///
186    /// `software_threads` is asked, at most once, only when a candidate that
187    /// runs on the CPU is opened: the caller sizes the rest of its work from
188    /// [`NativeVideoEncoderSelection`] afterwards, so a hardware candidate that
189    /// fails to open and falls through to software still gets a software
190    /// encoder's share of the CPU.
191    ///
192    /// `encoder_name` is the concrete encoder the caller pinned. `None` lets
193    /// the candidates for the codec be tried in preference order, which is
194    /// what makes the open-time fallback reachable: a caller that names an
195    /// encoder gets that one or an error.
196    ///
197    /// # Errors
198    /// Returns an adapter failure when no candidate opens or the output
199    /// configuration is invalid.
200    pub fn create_with_encoder_threads(
201        path: impl AsRef<Path>,
202        config: VideoEncodeConfig,
203        encoder_name: Option<&str>,
204        software_threads: &dyn Fn() -> i64,
205    ) -> Result<Self, EncodeError> {
206        Self::create_selected(path, config, encoder_name, software_threads)
207    }
208
209    #[allow(clippy::too_many_lines)]
210    pub(crate) fn create_selected(
211        path: impl AsRef<Path>,
212        config: VideoEncodeConfig,
213        encoder_name: Option<&str>,
214        software_threads: &dyn Fn() -> i64,
215    ) -> Result<Self, EncodeError> {
216        check_video_config(&config)?;
217        let final_path = path.as_ref().to_owned();
218        let partial = PartialOutput::claim(&final_path)?;
219        let partial_path = partial.path().to_owned();
220        let mut output = partial.open_muxer(config.container)?;
221        let width = i32::try_from(config.width)
222            .map_err(|_| EncodeError::Adapter("width is out of range".into()))?;
223        let height = i32::try_from(config.height)
224            .map_err(|_| EncodeError::Adapter("height is out of range".into()))?;
225        let (encoder, selection) = open_video_encoder(&VideoEncoderOpenRequest {
226            config: &config,
227            requested_name: encoder_name,
228            threads: crate::capabilities::SoftwareEncoderThreads::new(software_threads),
229            global_header: output.oformat().flags
230                & i32::try_from(ffi::AVFMT_GLOBALHEADER).unwrap_or(0)
231                != 0,
232            width,
233            height,
234        })?;
235        // The frame length, or the finest unit an encoder that cannot hold it takes.
236        let encoder_time_base = encoder.time_base;
237        let mut stream_time_base;
238        let stream_index;
239        {
240            let mut stream = output.new_stream();
241            stream_time_base = encoder_time_base;
242            stream.set_time_base(stream_time_base);
243            let parameters = encoder.extract_codecpar();
244            stream.set_codecpar(parameters);
245            describe_output_stream(&mut stream, &encoder, config.display, config.container)?;
246            stream_index = stream.index;
247        }
248        output
249            .write_header(&mut crate::convert::muxer_header_options(config.container))
250            .map_err(encode_adapter_error)?;
251        stream_time_base = output.streams()[usize::try_from(stream_index).unwrap_or(0)].time_base;
252        let mut scaler = SwsContext::get_context(
253            width,
254            height,
255            ffi::AV_PIX_FMT_RGBA,
256            width,
257            height,
258            ffi::AV_PIX_FMT_YUV420P,
259            ffi::SWS_BILINEAR,
260            None,
261            None,
262            None,
263        )
264        .ok_or_else(|| EncodeError::Adapter("cannot create RGBA to YUV scaler".into()))?;
265        configure_rgba_to_yuv(&mut scaler, config.color).map_err(encode_adapter_error)?;
266        let partial_lock = partial.keep();
267        Ok(Self {
268            final_path,
269            partial_path,
270            _partial_lock: partial_lock,
271            output: Some(output),
272            encoder,
273            stream_index,
274            encoder_time_base,
275            frame_units: crate::mux::frame_units(config.frame_rate, encoder_time_base),
276            stream_time_base,
277            scaler,
278            yuv_input: crate::convert::Yuv420pConverter::new(config.color),
279            width,
280            height,
281            last_pts: None,
282            complete: false,
283            selection,
284            stages: EncodeStageTimings::default(),
285        })
286    }
287
288    /// Where this sink's time has gone so far.
289    #[must_use]
290    pub fn stage_timings(&self) -> EncodeStageTimings {
291        self.stages
292    }
293
294    /// The encoder this sink opened, and the candidates it passed over.
295    #[must_use]
296    pub fn selection(&self) -> &NativeVideoEncoderSelection {
297        &self.selection
298    }
299
300    pub(crate) fn drain_packets(&mut self, flushing: bool) -> Result<(), EncodeError> {
301        loop {
302            let received = timed(&mut self.stages.video_receive_packet, || {
303                self.encoder.receive_packet()
304            });
305            match received {
306                Ok(mut packet) => {
307                    packet.set_stream_index(self.stream_index);
308                    crate::mux::fill_frame_duration(&mut packet, self.frame_units);
309                    packet.rescale_ts(self.encoder_time_base, self.stream_time_base);
310                    let output = self
311                        .output
312                        .as_mut()
313                        .ok_or_else(|| EncodeError::Adapter("muxer is already closed".into()))?;
314                    timed(&mut self.stages.video_mux_write, || {
315                        output.interleaved_write_frame(&mut packet)
316                    })
317                    .map_err(|error| encode_adapter_error_at("packet write failed", error))?;
318                }
319                Err(RsmpegError::EncoderDrainError) if !flushing => return Ok(()),
320                Err(RsmpegError::EncoderFlushedError | RsmpegError::EncoderDrainError)
321                    if flushing =>
322                {
323                    return Ok(());
324                }
325                Err(error) => {
326                    return Err(encode_adapter_error_at(
327                        "video encoder packet drain failed",
328                        error,
329                    ));
330                }
331            }
332        }
333    }
334
335    /// Encodes a frame the caller already packed, without copying it.
336    ///
337    /// An [`EncodeEvent::Video`] written to the sink takes engine planes and
338    /// allocates an `AVFrame` to copy them into, on this thread: the encode
339    /// worker, which is the saturated one. A caller that packed its own output
340    /// straight into an encoder frame hands that frame over instead.
341    ///
342    /// # Errors
343    /// Returns an error for a frame sized differently from this encoder, a
344    /// non-monotonic timestamp, or a codec failure.
345    pub fn write_prepared_video(
346        &mut self,
347        frame: &mut Yuv420pFrameBuffer,
348        pts: MediaTime,
349    ) -> Result<(), EncodeError> {
350        let (width, height) = frame.size();
351        if width != usize::try_from(self.width).unwrap_or(0)
352            || height != usize::try_from(self.height).unwrap_or(0)
353        {
354            return Err(EncodeError::Adapter(
355                "encoder input dimensions do not match its configuration".into(),
356            ));
357        }
358        let pts = av_rescale_q(pts.ticks(), tick_base(), self.encoder_time_base);
359        if self.last_pts.is_some_and(|previous| pts <= previous) {
360            return Err(EncodeError::InvalidTimestamp);
361        }
362        frame.set_presentation(pts, self.encoder_time_base);
363        timed(&mut self.stages.video_send_frame, || {
364            self.encoder.send_frame(Some(frame.frame()))
365        })
366        .map_err(|error| {
367            encode_adapter_error_at("video encoder rejected a YUV420P frame", error)
368        })?;
369        self.last_pts = Some(pts);
370        self.drain_packets(false)
371    }
372
373    pub(crate) fn write_video(&mut self, frame: &DecodedVideoFrame) -> Result<(), EncodeError> {
374        if frame.visible_rect.width != u32::try_from(self.width).unwrap_or(0)
375            || frame.visible_rect.height != u32::try_from(self.height).unwrap_or(0)
376        {
377            return Err(EncodeError::Adapter(
378                "encoder input dimensions do not match its configuration".into(),
379            ));
380        }
381        let pts = av_rescale_q(frame.pts.ticks(), tick_base(), self.encoder_time_base);
382        if self.last_pts.is_some_and(|previous| pts <= previous) {
383            return Err(EncodeError::InvalidTimestamp);
384        }
385        if let Some(mut yuv) = self.yuv_input.convert(frame, self.width, self.height)? {
386            yuv.set_pts(pts);
387            yuv.set_time_base(self.encoder_time_base);
388            timed(&mut self.stages.video_send_frame, || {
389                self.encoder.send_frame(Some(&yuv))
390            })
391            .map_err(|error| {
392                encode_adapter_error_at("video encoder rejected a YUV420P frame", error)
393            })?;
394            self.last_pts = Some(pts);
395            return self.drain_packets(false);
396        }
397        if frame.pixel_format != PixelFormat::Rgba8 || frame.planes.len() != 1 {
398            return Err(EncodeError::Adapter(
399                "encoder expects YUV planes or one tightly packed RGBA8 plane".into(),
400            ));
401        }
402        let plane = &frame.planes[0];
403        let expected = usize::try_from(self.width)
404            .ok()
405            .and_then(|width| {
406                usize::try_from(self.height)
407                    .ok()
408                    .and_then(|height| width.checked_mul(height))
409            })
410            .and_then(|pixels| pixels.checked_mul(4))
411            .ok_or_else(|| EncodeError::Adapter("frame dimensions overflow".into()))?;
412        if plane.bytes.len() != expected
413            || plane.stride != u32::try_from(self.width).unwrap_or(0) * 4
414        {
415            return Err(EncodeError::Adapter(
416                "RGBA plane is not tightly packed".into(),
417            ));
418        }
419        // No alpha in the output, so premultiplied RGB is kept: frames arrive opaque over the
420        // project background, and dividing alpha out would show a translucent layer at full
421        // strength.
422        let rgba = alpha2_ffmpeg_bridge::rgba_frame(&plane.bytes, self.width, self.height)
423            .map_err(encode_adapter_error)?;
424        let yuv_image = AVImage::new(ffi::AV_PIX_FMT_YUV420P, self.width, self.height, 1)
425            .ok_or_else(|| EncodeError::Adapter("cannot allocate YUV input".into()))?;
426        let mut yuv = AVFrameWithImage::new(yuv_image);
427        self.scaler
428            .scale_frame(&rgba, 0, self.height, &mut yuv)
429            .map_err(encode_adapter_error)?;
430        yuv.set_pts(pts);
431        yuv.set_time_base(self.encoder_time_base);
432        timed(&mut self.stages.video_send_frame, || {
433            self.encoder.send_frame(Some(&yuv))
434        })
435        .map_err(|error| {
436            encode_adapter_error_at("video encoder rejected a converted YUV420P frame", error)
437        })?;
438        self.last_pts = Some(pts);
439        self.drain_packets(false)
440    }
441}
442
443impl EncodeSink for NativeVideoEncodeSink {
444    fn write(&mut self, event: EncodeEvent, cancel: &CancelToken) -> Result<(), EncodeError> {
445        if cancel.is_cancelled() {
446            self.abort()?;
447            return Err(EncodeError::Cancelled);
448        }
449        match event {
450            EncodeEvent::Video(frame) => self.write_video(&frame),
451            EncodeEvent::Audio(_) => Err(EncodeError::Adapter(
452                "this sink was configured for video-only output".into(),
453            )),
454        }
455    }
456
457    fn finish(&mut self, cancel: &CancelToken) -> Result<(), EncodeError> {
458        if cancel.is_cancelled() {
459            self.abort()?;
460            return Err(EncodeError::Cancelled);
461        }
462        timed(&mut self.stages.video_send_frame, || {
463            self.encoder.send_frame(None)
464        })
465        .map_err(encode_adapter_error)?;
466        self.drain_packets(true)?;
467        crate::encode::beat_encode_call();
468        self.output
469            .as_mut()
470            .ok_or_else(|| EncodeError::Adapter("muxer is already closed".into()))?
471            .write_trailer()
472            .map_err(encode_adapter_error)?;
473        // A write error reported only at close fails the export here, before the rename.
474        if let Some(output) = self.output.take() {
475            crate::encode::beat_encode_call();
476            output
477                .close()
478                .map_err(|error| encode_adapter_error_at("closing the output", error))?;
479        }
480        crate::partial::publish(&self.partial_path, &self.final_path)?;
481        self.complete = true;
482        Ok(())
483    }
484
485    fn abort(&mut self) -> Result<(), EncodeError> {
486        drop(self.output.take());
487        match fs::remove_file(&self.partial_path) {
488            Ok(()) => Ok(()),
489            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
490            Err(error) => Err(EncodeError::Adapter(error.to_string())),
491        }
492    }
493}
494
495impl Drop for NativeVideoEncodeSink {
496    fn drop(&mut self) {
497        if !self.complete {
498            drop(self.output.take());
499            let _ = fs::remove_file(&self.partial_path);
500        }
501    }
502}