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