Skip to main content

alpha2_native/
video_encode.rs

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