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