Skip to main content

ff_encode/video/builder/
mod.rs

1//! Video encoder builder and public API.
2//!
3//! This module provides [`VideoEncoderBuilder`] for fluent configuration and
4//! [`VideoEncoder`] for encoding video (and optionally audio) frames.
5
6use std::path::PathBuf;
7use std::time::Instant;
8
9use ff_format::{AudioFrame, VideoFrame};
10
11use super::codec_options::VideoCodecOptions;
12use super::encoder_inner::{VideoEncoderConfig, VideoEncoderInner, preset_to_string};
13use crate::{
14    AudioCodec, EncodeError, EncodeProgressCallback, HardwareEncoder, OutputContainer, Preset,
15    VideoCodec,
16};
17
18mod audio;
19mod color;
20mod meta;
21mod video;
22
23/// Builder for constructing a [`VideoEncoder`].
24///
25/// Created by calling [`VideoEncoder::create()`]. Call [`build()`](Self::build)
26/// to open the output file and prepare for encoding.
27///
28/// # Examples
29///
30/// ```ignore
31/// use ff_encode::{VideoEncoder, VideoCodec, Preset};
32///
33/// let mut encoder = VideoEncoder::create(test_out("output.mp4"))
34///     .video(1920, 1080, 30.0)
35///     .video_codec(VideoCodec::H264)
36///     .preset(Preset::Medium)
37///     .build()?;
38/// ```
39// A builder aggregates many independent output toggles; the bool count is inherent.
40#[allow(clippy::struct_excessive_bools)]
41pub struct VideoEncoderBuilder {
42    pub(crate) path: PathBuf,
43    pub(crate) container: Option<OutputContainer>,
44    pub(crate) video_width: Option<u32>,
45    pub(crate) video_height: Option<u32>,
46    pub(crate) video_fps: Option<f64>,
47    pub(crate) video_codec: VideoCodec,
48    pub(crate) video_bitrate_mode: Option<crate::BitrateMode>,
49    pub(crate) preset: Preset,
50    pub(crate) hardware_encoder: HardwareEncoder,
51    pub(crate) allow_codec_substitution: bool,
52    pub(crate) audio_sample_rate: Option<u32>,
53    pub(crate) audio_channels: Option<u32>,
54    pub(crate) audio_codec: AudioCodec,
55    pub(crate) audio_bitrate: Option<u64>,
56    pub(crate) progress_callback: Option<Box<dyn EncodeProgressCallback>>,
57    /// A caller-supplied byte sink to mux into instead of `path`.
58    ///
59    /// Set by [`VideoEncoderBuilder::output_sink`]; when present the `path` is
60    /// only what the muxer is guessed from, and nothing is written to disk.
61    pub(crate) sink: Option<Box<dyn ff_sys::IoSink>>,
62    pub(crate) two_pass: bool,
63    pub(crate) faststart: bool,
64    pub(crate) metadata: Vec<(String, String)>,
65    pub(crate) chapters: Vec<ff_format::chapter::ChapterInfo>,
66    pub(crate) subtitle_passthrough: Option<(String, usize)>,
67    pub(crate) codec_options: Option<VideoCodecOptions>,
68    /// Codec-private options set by name, applied after `codec_options`.
69    pub(crate) codec_opts: Vec<(String, String)>,
70    pub(crate) video_codec_explicit: bool,
71    pub(crate) audio_codec_explicit: bool,
72    pub(crate) pixel_format: Option<ff_format::PixelFormat>,
73    pub(crate) hdr10_metadata: Option<ff_format::Hdr10Metadata>,
74    pub(crate) color_space: Option<ff_format::ColorSpace>,
75    pub(crate) color_transfer: Option<ff_format::ColorTransfer>,
76    pub(crate) color_primaries: Option<ff_format::ColorPrimaries>,
77    /// Binary attachments: (raw data, MIME type, filename).
78    pub(crate) attachments: Vec<(Vec<u8>, String, String)>,
79}
80
81impl std::fmt::Debug for VideoEncoderBuilder {
82    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
83        f.debug_struct("VideoEncoderBuilder")
84            .field("path", &self.path)
85            .field("container", &self.container)
86            .field("video_width", &self.video_width)
87            .field("video_height", &self.video_height)
88            .field("video_fps", &self.video_fps)
89            .field("video_codec", &self.video_codec)
90            .field("video_bitrate_mode", &self.video_bitrate_mode)
91            .field("preset", &self.preset)
92            .field("hardware_encoder", &self.hardware_encoder)
93            .field("allow_codec_substitution", &self.allow_codec_substitution)
94            .field("audio_sample_rate", &self.audio_sample_rate)
95            .field("audio_channels", &self.audio_channels)
96            .field("audio_codec", &self.audio_codec)
97            .field("audio_bitrate", &self.audio_bitrate)
98            .field(
99                "progress_callback",
100                &self.progress_callback.as_ref().map(|_| "<callback>"),
101            )
102            .field("sink", &self.sink.as_ref().map(|_| "<sink>"))
103            .field("two_pass", &self.two_pass)
104            .field("faststart", &self.faststart)
105            .field("metadata", &self.metadata)
106            .field("chapters", &self.chapters)
107            .field("subtitle_passthrough", &self.subtitle_passthrough)
108            .field("codec_options", &self.codec_options)
109            .field("codec_opts", &self.codec_opts)
110            .field("video_codec_explicit", &self.video_codec_explicit)
111            .field("audio_codec_explicit", &self.audio_codec_explicit)
112            .field("pixel_format", &self.pixel_format)
113            .field("hdr10_metadata", &self.hdr10_metadata)
114            .field("color_space", &self.color_space)
115            .field("color_transfer", &self.color_transfer)
116            .field("color_primaries", &self.color_primaries)
117            .field("attachments_count", &self.attachments.len())
118            .finish()
119    }
120}
121
122impl VideoEncoderBuilder {
123    pub(crate) fn new(path: PathBuf) -> Self {
124        Self {
125            path,
126            container: None,
127            video_width: None,
128            video_height: None,
129            video_fps: None,
130            video_codec: VideoCodec::default(),
131            video_bitrate_mode: None,
132            preset: Preset::default(),
133            hardware_encoder: HardwareEncoder::default(),
134            allow_codec_substitution: false,
135            audio_sample_rate: None,
136            audio_channels: None,
137            audio_codec: AudioCodec::default(),
138            audio_bitrate: None,
139            progress_callback: None,
140            sink: None,
141            two_pass: false,
142            faststart: false,
143            metadata: Vec::new(),
144            chapters: Vec::new(),
145            subtitle_passthrough: None,
146            codec_options: None,
147            codec_opts: Vec::new(),
148            video_codec_explicit: false,
149            audio_codec_explicit: false,
150            pixel_format: None,
151            hdr10_metadata: None,
152            color_space: None,
153            color_transfer: None,
154            color_primaries: None,
155            attachments: Vec::new(),
156        }
157    }
158
159    /// Validate builder state and open the output file.
160    ///
161    /// # Errors
162    ///
163    /// Returns [`EncodeError`] if configuration is invalid, the output path
164    /// cannot be created, or no suitable encoder is found.
165    pub fn build(self) -> Result<VideoEncoder, EncodeError> {
166        let this = self.apply_container_defaults();
167        this.validate()?;
168        VideoEncoder::from_builder(this)
169    }
170
171    /// Apply container-specific codec defaults before validation.
172    ///
173    /// For `WebM` paths/containers, default to VP9 + Opus when the caller has
174    /// not explicitly chosen a codec.
175    fn apply_container_defaults(mut self) -> Self {
176        let is_webm = self
177            .path
178            .extension()
179            .and_then(|e| e.to_str())
180            .is_some_and(|e| e.eq_ignore_ascii_case("webm"))
181            || self
182                .container
183                .as_ref()
184                .is_some_and(|c| *c == OutputContainer::WebM);
185
186        if is_webm {
187            if !self.video_codec_explicit {
188                self.video_codec = VideoCodec::Vp9;
189            }
190            if !self.audio_codec_explicit {
191                self.audio_codec = AudioCodec::Opus;
192            }
193        }
194
195        let is_avi = self
196            .path
197            .extension()
198            .and_then(|e| e.to_str())
199            .is_some_and(|e| e.eq_ignore_ascii_case("avi"))
200            || self
201                .container
202                .as_ref()
203                .is_some_and(|c| *c == OutputContainer::Avi);
204
205        if is_avi {
206            if !self.video_codec_explicit {
207                self.video_codec = VideoCodec::H264;
208            }
209            if !self.audio_codec_explicit {
210                self.audio_codec = AudioCodec::Mp3;
211            }
212        }
213
214        let is_mov = self
215            .path
216            .extension()
217            .and_then(|e| e.to_str())
218            .is_some_and(|e| e.eq_ignore_ascii_case("mov"))
219            || self
220                .container
221                .as_ref()
222                .is_some_and(|c| *c == OutputContainer::Mov);
223
224        if is_mov {
225            if !self.video_codec_explicit {
226                self.video_codec = VideoCodec::H264;
227            }
228            if !self.audio_codec_explicit {
229                self.audio_codec = AudioCodec::Aac;
230            }
231        }
232
233        // Image-sequence paths contain '%' (e.g. "frames/frame%04d.png").
234        // Auto-select codec from the extension that follows the pattern.
235        let is_image_sequence = self.path.to_str().is_some_and(|s| s.contains('%'));
236        if is_image_sequence && !self.video_codec_explicit {
237            let ext = self
238                .path
239                .to_str()
240                .and_then(|s| s.rfind('.').map(|i| &s[i + 1..]))
241                .unwrap_or("");
242            if ext.eq_ignore_ascii_case("png") {
243                self.video_codec = VideoCodec::Png;
244            } else if ext.eq_ignore_ascii_case("jpg") || ext.eq_ignore_ascii_case("jpeg") {
245                self.video_codec = VideoCodec::Mjpeg;
246            }
247        }
248
249        self
250    }
251
252    fn validate(&self) -> Result<(), EncodeError> {
253        let has_video =
254            self.video_width.is_some() && self.video_height.is_some() && self.video_fps.is_some();
255        let has_audio = self.audio_sample_rate.is_some() && self.audio_channels.is_some();
256
257        if !has_video && !has_audio {
258            return Err(EncodeError::InvalidConfig {
259                reason: "At least one video or audio stream must be configured".to_string(),
260            });
261        }
262
263        if self.faststart && self.sink.is_some() {
264            // `movflags=+faststart` finalises by *reopening the output for reading*
265            // (`mov_write_trailer` -> `shift_data` -> `ff_format_shift_data`, which
266            // does `io_open(s, &read_pb, s->url, AVIO_FLAG_READ, ...)`). With a sink
267            // the bytes never reached `s->url`, so that read hits whatever is at the
268            // path -- nothing, or an unrelated file. Measured: with no such file
269            // `finish()` fails and leaves a moov-less stream in the sink; with a
270            // stale file of that name `finish()` returns `Ok` and that file's
271            // contents are copied into the caller's sink. Any muxer that relocates
272            // data by reopening `s->url` is incompatible with a custom `pb`.
273            return Err(EncodeError::InvalidConfig {
274                reason: "faststart cannot write to a caller-supplied sink".to_string(),
275            });
276        }
277
278        if self.two_pass {
279            if self.sink.is_some() {
280                // Pass 2 opens the output after pass 1 has run, so it needs an
281                // output it can open twice. A sink is moved in once and cannot be
282                // reopened, and half-supporting that would mean silently writing
283                // only the second pass.
284                return Err(EncodeError::InvalidConfig {
285                    reason: "Two-pass encoding cannot write to a caller-supplied sink".to_string(),
286                });
287            }
288            if !has_video {
289                return Err(EncodeError::InvalidConfig {
290                    reason: "Two-pass encoding requires a video stream".to_string(),
291                });
292            }
293            if has_audio {
294                return Err(EncodeError::InvalidConfig {
295                    reason:
296                        "Two-pass encoding is video-only and is incompatible with audio streams"
297                            .to_string(),
298                });
299            }
300        }
301
302        // Image-sequence paths (containing '%') do not support audio streams.
303        let is_image_sequence = self.path.to_str().is_some_and(|s| s.contains('%'));
304        if is_image_sequence && has_audio {
305            return Err(EncodeError::InvalidConfig {
306                reason: "Image sequence output does not support audio streams".to_string(),
307            });
308        }
309
310        // PNG supports odd dimensions; all other codecs require even width/height.
311        let requires_even_dims = !matches!(self.video_codec, VideoCodec::Png);
312
313        if has_video {
314            // Dimension range check (2–32768 inclusive).
315            let w = self.video_width.unwrap_or(0);
316            let h = self.video_height.unwrap_or(0);
317            if (self.video_width.is_some() || self.video_height.is_some())
318                && (!(2..=32_768).contains(&w) || !(2..=32_768).contains(&h))
319            {
320                log::warn!(
321                    "video dimensions out of range width={w} height={h} \
322                     (valid range 2–32768 per axis)"
323                );
324                return Err(EncodeError::InvalidDimensions {
325                    width: w,
326                    height: h,
327                });
328            }
329
330            if let Some(width) = self.video_width
331                && (requires_even_dims && width % 2 != 0)
332            {
333                return Err(EncodeError::InvalidConfig {
334                    reason: format!("Video width must be even, got {width}"),
335                });
336            }
337            if let Some(height) = self.video_height
338                && (requires_even_dims && height % 2 != 0)
339            {
340                return Err(EncodeError::InvalidConfig {
341                    reason: format!("Video height must be even, got {height}"),
342                });
343            }
344            if let Some(fps) = self.video_fps
345                && fps <= 0.0
346            {
347                return Err(EncodeError::InvalidConfig {
348                    reason: format!("Video FPS must be positive, got {fps}"),
349                });
350            }
351            if let Some(fps) = self.video_fps
352                && fps > 1000.0
353            {
354                log::warn!("video fps exceeds maximum fps={fps} (maximum 1000)");
355                return Err(EncodeError::InvalidConfig {
356                    reason: format!("fps {fps} exceeds maximum 1000"),
357                });
358            }
359            if let Some(crate::BitrateMode::Crf(q)) = self.video_bitrate_mode
360                && q > crate::CRF_MAX
361            {
362                return Err(EncodeError::InvalidConfig {
363                    reason: format!(
364                        "BitrateMode::Crf value must be 0-{}, got {q}",
365                        crate::CRF_MAX
366                    ),
367                });
368            }
369            if let Some(crate::BitrateMode::Vbr { target, max }) = self.video_bitrate_mode
370                && max < target
371            {
372                return Err(EncodeError::InvalidConfig {
373                    reason: format!("BitrateMode::Vbr max ({max}) must be >= target ({target})"),
374                });
375            }
376
377            // Bitrate ceiling: 800 Mbps (800_000_000 bps).
378            let effective_bitrate: Option<u64> = match self.video_bitrate_mode {
379                Some(crate::BitrateMode::Cbr(bps)) => Some(bps),
380                Some(crate::BitrateMode::Vbr { max, .. }) => Some(max),
381                _ => None,
382            };
383            if let Some(bps) = effective_bitrate
384                && bps > 800_000_000
385            {
386                log::warn!("video bitrate exceeds maximum bitrate={bps} maximum=800000000");
387                return Err(EncodeError::InvalidBitrate { bitrate: bps });
388            }
389        }
390
391        if let Some(VideoCodecOptions::Av1(ref opts)) = self.codec_options
392            && opts.cpu_used > 8
393        {
394            return Err(EncodeError::InvalidOption {
395                name: "cpu_used".to_string(),
396                reason: "must be 0–8".to_string(),
397            });
398        }
399
400        if let Some(VideoCodecOptions::Av1Svt(ref opts)) = self.codec_options
401            && opts.preset > 13
402        {
403            return Err(EncodeError::InvalidOption {
404                name: "preset".to_string(),
405                reason: "must be 0–13".to_string(),
406            });
407        }
408
409        if let Some(VideoCodecOptions::Vp9(ref opts)) = self.codec_options {
410            if opts.cpu_used < -8 || opts.cpu_used > 8 {
411                return Err(EncodeError::InvalidOption {
412                    name: "cpu_used".to_string(),
413                    reason: "must be -8–8".to_string(),
414                });
415            }
416            if let Some(cq) = opts.cq_level
417                && cq > 63
418            {
419                return Err(EncodeError::InvalidOption {
420                    name: "cq_level".to_string(),
421                    reason: "must be 0–63".to_string(),
422                });
423            }
424        }
425
426        if let Some(VideoCodecOptions::Dnxhd(ref opts)) = self.codec_options
427            && opts.variant.is_dnxhd()
428        {
429            let valid = matches!(
430                (self.video_width, self.video_height),
431                (Some(1920), Some(1080)) | (Some(1280), Some(720))
432            );
433            if !valid {
434                return Err(EncodeError::InvalidOption {
435                    name: "variant".to_string(),
436                    reason: "DNxHD variants require 1920×1080 or 1280×720 resolution".to_string(),
437                });
438            }
439        }
440
441        // WebM container codec enforcement.
442        let is_webm = self
443            .path
444            .extension()
445            .and_then(|e| e.to_str())
446            .is_some_and(|e| e.eq_ignore_ascii_case("webm"))
447            || self
448                .container
449                .as_ref()
450                .is_some_and(|c| *c == OutputContainer::WebM);
451
452        if is_webm {
453            let webm_video_ok = matches!(
454                self.video_codec,
455                VideoCodec::Vp9 | VideoCodec::Av1 | VideoCodec::Av1Svt
456            );
457            if !webm_video_ok {
458                return Err(EncodeError::UnsupportedContainerCodecCombination {
459                    container: "webm".to_string(),
460                    codec: self.video_codec.name().to_string(),
461                    hint: "WebM supports VP9, AV1 (video) and Vorbis, Opus (audio)".to_string(),
462                });
463            }
464
465            let webm_audio_ok = matches!(self.audio_codec, AudioCodec::Opus | AudioCodec::Vorbis);
466            if !webm_audio_ok {
467                return Err(EncodeError::UnsupportedContainerCodecCombination {
468                    container: "webm".to_string(),
469                    codec: self.audio_codec.name().to_string(),
470                    hint: "WebM supports VP9, AV1 (video) and Vorbis, Opus (audio)".to_string(),
471                });
472            }
473        }
474
475        // AVI container codec enforcement.
476        let is_avi = self
477            .path
478            .extension()
479            .and_then(|e| e.to_str())
480            .is_some_and(|e| e.eq_ignore_ascii_case("avi"))
481            || self
482                .container
483                .as_ref()
484                .is_some_and(|c| *c == OutputContainer::Avi);
485
486        if is_avi {
487            let avi_video_ok = matches!(self.video_codec, VideoCodec::H264 | VideoCodec::Mpeg4);
488            if !avi_video_ok {
489                return Err(EncodeError::UnsupportedContainerCodecCombination {
490                    container: "avi".to_string(),
491                    codec: self.video_codec.name().to_string(),
492                    hint: "AVI supports H264 and MPEG-4 (video); MP3, AAC, and PCM 16-bit (audio)"
493                        .to_string(),
494                });
495            }
496
497            let avi_audio_ok = matches!(
498                self.audio_codec,
499                AudioCodec::Mp3 | AudioCodec::Aac | AudioCodec::Pcm | AudioCodec::Pcm16
500            );
501            if !avi_audio_ok {
502                return Err(EncodeError::UnsupportedContainerCodecCombination {
503                    container: "avi".to_string(),
504                    codec: self.audio_codec.name().to_string(),
505                    hint: "AVI supports H264 and MPEG-4 (video); MP3, AAC, and PCM 16-bit (audio)"
506                        .to_string(),
507                });
508            }
509        }
510
511        // MOV container codec enforcement.
512        let is_mov = self
513            .path
514            .extension()
515            .and_then(|e| e.to_str())
516            .is_some_and(|e| e.eq_ignore_ascii_case("mov"))
517            || self
518                .container
519                .as_ref()
520                .is_some_and(|c| *c == OutputContainer::Mov);
521
522        if is_mov {
523            let mov_video_ok = matches!(
524                self.video_codec,
525                VideoCodec::H264 | VideoCodec::H265 | VideoCodec::ProRes
526            );
527            if !mov_video_ok {
528                return Err(EncodeError::UnsupportedContainerCodecCombination {
529                    container: "mov".to_string(),
530                    codec: self.video_codec.name().to_string(),
531                    hint: "MOV supports H264, H265, and ProRes (video); AAC and PCM (audio)"
532                        .to_string(),
533                });
534            }
535
536            let mov_audio_ok = matches!(
537                self.audio_codec,
538                AudioCodec::Aac | AudioCodec::Pcm | AudioCodec::Pcm16 | AudioCodec::Pcm24
539            );
540            if !mov_audio_ok {
541                return Err(EncodeError::UnsupportedContainerCodecCombination {
542                    container: "mov".to_string(),
543                    codec: self.audio_codec.name().to_string(),
544                    hint: "MOV supports H264, H265, and ProRes (video); AAC and PCM (audio)"
545                        .to_string(),
546                });
547            }
548        }
549
550        // fMP4 container codec enforcement.
551        let is_fmp4 = self
552            .container
553            .as_ref()
554            .is_some_and(|c| *c == OutputContainer::FMp4);
555
556        if is_fmp4 {
557            let fmp4_video_ok = !matches!(
558                self.video_codec,
559                VideoCodec::Mpeg2 | VideoCodec::Mpeg4 | VideoCodec::Mjpeg
560            );
561            if !fmp4_video_ok {
562                return Err(EncodeError::UnsupportedContainerCodecCombination {
563                    container: "fMP4".to_string(),
564                    codec: self.video_codec.name().to_string(),
565                    hint: "fMP4 supports H.264, H.265, VP9, AV1".to_string(),
566                });
567            }
568        }
569
570        if has_audio {
571            if let Some(rate) = self.audio_sample_rate
572                && rate == 0
573            {
574                return Err(EncodeError::InvalidConfig {
575                    reason: "Audio sample rate must be non-zero".to_string(),
576                });
577            }
578            if let Some(ch) = self.audio_channels
579                && ch == 0
580            {
581                return Err(EncodeError::InvalidConfig {
582                    reason: "Audio channels must be non-zero".to_string(),
583                });
584            }
585        }
586
587        Ok(())
588    }
589}
590
591/// Encodes video (and optionally audio) frames to a file using `FFmpeg`.
592///
593/// # Construction
594///
595/// Use [`VideoEncoder::create()`] to get a [`VideoEncoderBuilder`], then call
596/// [`VideoEncoderBuilder::build()`]:
597///
598/// ```ignore
599/// use ff_encode::{VideoEncoder, VideoCodec};
600///
601/// let mut encoder = VideoEncoder::create(test_out("output.mp4"))
602///     .video(1920, 1080, 30.0)
603///     .video_codec(VideoCodec::H264)
604///     .build()?;
605/// ```
606pub struct VideoEncoder {
607    inner: Option<VideoEncoderInner>,
608    _config: VideoEncoderConfig,
609    start_time: Instant,
610    progress_callback: Option<Box<dyn crate::EncodeProgressCallback>>,
611}
612
613impl VideoEncoder {
614    /// Creates a builder for the specified output file path.
615    ///
616    /// This method is infallible. Validation occurs when
617    /// [`VideoEncoderBuilder::build()`] is called.
618    pub fn create<P: AsRef<std::path::Path>>(path: P) -> VideoEncoderBuilder {
619        VideoEncoderBuilder::new(path.as_ref().to_path_buf())
620    }
621
622    pub(crate) fn from_builder(builder: VideoEncoderBuilder) -> Result<Self, EncodeError> {
623        let config = VideoEncoderConfig {
624            path: builder.path.clone(),
625            video_width: builder.video_width,
626            video_height: builder.video_height,
627            video_fps: builder.video_fps,
628            video_codec: builder.video_codec,
629            video_bitrate_mode: builder.video_bitrate_mode,
630            preset: preset_to_string(builder.preset),
631            hardware_encoder: builder.hardware_encoder,
632            allow_codec_substitution: builder.allow_codec_substitution,
633            audio_sample_rate: builder.audio_sample_rate,
634            audio_channels: builder.audio_channels,
635            audio_codec: builder.audio_codec,
636            audio_bitrate: builder.audio_bitrate,
637            _progress_callback: builder.progress_callback.is_some(),
638            two_pass: builder.two_pass,
639            metadata: builder.metadata,
640            chapters: builder.chapters,
641            subtitle_passthrough: builder.subtitle_passthrough,
642            codec_options: builder.codec_options,
643            codec_opts: builder.codec_opts,
644            pixel_format: builder.pixel_format,
645            hdr10_metadata: builder.hdr10_metadata,
646            color_space: builder.color_space,
647            color_transfer: builder.color_transfer,
648            color_primaries: builder.color_primaries,
649            attachments: builder.attachments,
650            container: builder.container,
651            faststart: builder.faststart,
652        };
653
654        // Create the inner encoder when at least one of video or audio is
655        // configured.  `video_width.is_some()` alone is not sufficient:
656        // audio-only presets (e.g. podcast_mono) set audio fields but no video
657        // dimensions, so we must also check for audio configuration.
658        let has_audio = config.audio_sample_rate.is_some() && config.audio_channels.is_some();
659        let inner = if config.video_width.is_some() || has_audio {
660            Some(VideoEncoderInner::new(&config, builder.sink)?)
661        } else {
662            None
663        };
664
665        Ok(Self {
666            inner,
667            _config: config,
668            start_time: Instant::now(),
669            progress_callback: builder.progress_callback,
670        })
671    }
672
673    /// Returns the name of the `FFmpeg` encoder actually used (e.g. `"h264_nvenc"`, `"libx264"`).
674    #[must_use]
675    pub fn actual_video_codec(&self) -> &str {
676        self.inner
677            .as_ref()
678            .map_or("", |inner| inner.actual_video_codec.as_str())
679    }
680
681    /// Returns the name of the `FFmpeg` audio encoder actually used.
682    #[must_use]
683    pub fn actual_audio_codec(&self) -> &str {
684        self.inner
685            .as_ref()
686            .map_or("", |inner| inner.actual_audio_codec.as_str())
687    }
688
689    /// Returns the hardware encoder actually in use.
690    #[must_use]
691    pub fn hardware_encoder(&self) -> crate::HardwareEncoder {
692        let codec_name = self.actual_video_codec();
693        if codec_name.contains("nvenc") {
694            crate::HardwareEncoder::Nvenc
695        } else if codec_name.contains("qsv") {
696            crate::HardwareEncoder::Qsv
697        } else if codec_name.contains("amf") {
698            crate::HardwareEncoder::Amf
699        } else if codec_name.contains("videotoolbox") {
700            crate::HardwareEncoder::VideoToolbox
701        } else if codec_name.contains("vaapi") {
702            crate::HardwareEncoder::Vaapi
703        } else {
704            crate::HardwareEncoder::None
705        }
706    }
707
708    /// Returns `true` if a hardware encoder is active.
709    #[must_use]
710    pub fn is_hardware_encoding(&self) -> bool {
711        !matches!(self.hardware_encoder(), crate::HardwareEncoder::None)
712    }
713
714    /// Returns `true` if the selected encoder is LGPL-compatible (safe for commercial use).
715    #[must_use]
716    pub fn is_lgpl_compliant(&self) -> bool {
717        let codec_name = self.actual_video_codec();
718        if codec_name.contains("nvenc")
719            || codec_name.contains("qsv")
720            || codec_name.contains("amf")
721            || codec_name.contains("videotoolbox")
722            || codec_name.contains("vaapi")
723        {
724            return true;
725        }
726        if codec_name.contains("vp9")
727            || codec_name.contains("av1")
728            || codec_name.contains("aom")
729            || codec_name.contains("svt")
730            || codec_name.contains("prores")
731            || codec_name == "mpeg4"
732            || codec_name == "dnxhd"
733        {
734            return true;
735        }
736        if codec_name == "libx264" || codec_name == "libx265" {
737            return false;
738        }
739        true
740    }
741
742    /// Pushes a video frame for encoding.
743    ///
744    /// # Errors
745    ///
746    /// Returns [`EncodeError`] if encoding fails or the encoder is not initialised.
747    /// Returns [`EncodeError::Cancelled`] if the progress callback requested cancellation.
748    pub fn push_video(&mut self, frame: &VideoFrame) -> Result<(), EncodeError> {
749        if let Some(ref callback) = self.progress_callback
750            && callback.should_cancel()
751        {
752            return Err(EncodeError::Cancelled);
753        }
754        let inner = self
755            .inner
756            .as_mut()
757            .ok_or_else(|| EncodeError::InvalidConfig {
758                reason: "Video encoder not initialized".to_string(),
759            })?;
760        inner.push_video_frame(frame)?;
761        let progress = self.create_progress_info();
762        if let Some(ref mut callback) = self.progress_callback {
763            callback.on_progress(&progress);
764        }
765        Ok(())
766    }
767
768    /// Pushes an audio frame for encoding.
769    ///
770    /// # Errors
771    ///
772    /// Returns [`EncodeError`] if encoding fails or the encoder is not initialised.
773    pub fn push_audio(&mut self, frame: &AudioFrame) -> Result<(), EncodeError> {
774        if let Some(ref callback) = self.progress_callback
775            && callback.should_cancel()
776        {
777            return Err(EncodeError::Cancelled);
778        }
779        let inner = self
780            .inner
781            .as_mut()
782            .ok_or_else(|| EncodeError::InvalidConfig {
783                reason: "Audio encoder not initialized".to_string(),
784            })?;
785        inner.push_audio_frame(frame)?;
786        let progress = self.create_progress_info();
787        if let Some(ref mut callback) = self.progress_callback {
788            callback.on_progress(&progress);
789        }
790        Ok(())
791    }
792
793    /// Flushes remaining frames and writes the file trailer.
794    ///
795    /// # Errors
796    ///
797    /// Returns [`EncodeError`] if finalising fails.
798    pub fn finish(mut self) -> Result<(), EncodeError> {
799        if let Some(mut inner) = self.inner.take() {
800            inner.finish()?;
801        }
802        Ok(())
803    }
804
805    fn create_progress_info(&self) -> crate::EncodeProgress {
806        let elapsed = self.start_time.elapsed();
807        let (frames_encoded, bytes_written) = self
808            .inner
809            .as_ref()
810            .map_or((0, 0), |inner| (inner.frame_count, inner.bytes_written));
811        #[allow(clippy::cast_precision_loss)]
812        let current_fps = if !elapsed.is_zero() {
813            frames_encoded as f64 / elapsed.as_secs_f64()
814        } else {
815            0.0
816        };
817        #[allow(clippy::cast_precision_loss)]
818        // Bitrate is a non-negative bits-per-second rate; the f64→u64 fallback
819        // (used only when the integer division would divide by zero) cannot wrap
820        // or lose a sign in practice.
821        #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
822        let current_bitrate = if !elapsed.is_zero() {
823            let elapsed_secs = elapsed.as_secs();
824            match (bytes_written * 8).checked_div(elapsed_secs) {
825                Some(bitrate) => bitrate,
826                None => ((bytes_written * 8) as f64 / elapsed.as_secs_f64()) as u64,
827            }
828        } else {
829            0
830        };
831        crate::EncodeProgress {
832            frames_encoded,
833            total_frames: None,
834            bytes_written,
835            current_bitrate,
836            elapsed,
837            remaining: None,
838            current_fps,
839        }
840    }
841}
842
843impl Drop for VideoEncoder {
844    fn drop(&mut self) {
845        // VideoEncoderInner handles cleanup in its own Drop.
846    }
847}
848
849#[cfg(test)]
850#[allow(clippy::unwrap_used)]
851mod tests {
852    #[test]
853    fn codec_opt_should_collect_pairs_in_order() {
854        // Order is preserved because it decides the outcome for a repeated key,
855        // and because comma-joined values like `x264-params` are position
856        // sensitive.
857        let builder = VideoEncoder::create("out.mp4")
858            .codec_opt("x264-params", "keyint=48")
859            .codec_opt("aq-mode", "2")
860            .codec_opt("x264-params", "keyint=24");
861        assert_eq!(
862            builder.codec_opts,
863            vec![
864                ("x264-params".to_string(), "keyint=48".to_string()),
865                ("aq-mode".to_string(), "2".to_string()),
866                ("x264-params".to_string(), "keyint=24".to_string()),
867            ],
868            "pairs must be kept in call order, duplicates included"
869        );
870    }
871
872    #[test]
873    fn codec_opt_should_appear_in_the_builder_debug() {
874        // `VideoEncoderBuilder` writes its `Debug` by hand, so a new field is
875        // silently missing from debug output unless it is added there too.
876        let builder = VideoEncoder::create("out.mp4").codec_opt("aq-mode", "2");
877        let rendered = format!("{builder:?}");
878        assert!(
879            rendered.contains("codec_opts"),
880            "the field must be listed in the manual Debug impl; got {rendered}"
881        );
882        assert!(
883            rendered.contains("aq-mode"),
884            "the value must be rendered, not just the field name; got {rendered}"
885        );
886    }
887
888    use super::super::encoder_inner::{VideoEncoderConfig, VideoEncoderInner};
889    use super::*;
890    use crate::HardwareEncoder;
891
892    /// Returns a path inside `target/test-output/` so that any files created
893    /// by builder unit tests do not litter the crate root.
894    fn test_out(name: &str) -> String {
895        let dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
896            .join("target")
897            .join("test-output");
898        std::fs::create_dir_all(&dir).ok();
899        dir.join(name).to_string_lossy().into_owned()
900    }
901
902    fn create_mock_encoder(video_codec_name: &str, audio_codec_name: &str) -> VideoEncoder {
903        VideoEncoder {
904            inner: Some(VideoEncoderInner {
905                // A real (unopened) mux context; the mock never muxes and drops it untouched.
906                format_ctx: ff_sys::OutputFormatContext::new(
907                    None,
908                    std::path::Path::new("mock.mp4"),
909                )
910                .expect("mock output context allocation should succeed"),
911                video_codec_ctx: None,
912                audio_codec_ctx: None,
913                video_stream_index: -1,
914                audio_stream_index: -1,
915                sws_ctx: None,
916                swr_ctx: None,
917                audio_fifo: None,
918                frame_count: 0,
919                audio_sample_count: 0,
920                bytes_written: 0,
921                actual_video_codec: video_codec_name.to_string(),
922                actual_audio_codec: audio_codec_name.to_string(),
923                last_src_width: None,
924                last_src_height: None,
925                last_src_format: None,
926                two_pass: false,
927                pass1_codec_ctx: None,
928                buffered_frames: Vec::new(),
929                two_pass_config: None,
930                subtitle_passthrough: None,
931                hdr10_metadata: None,
932                video_frame_period: None,
933            }),
934            _config: VideoEncoderConfig {
935                path: "test.mp4".into(),
936                video_width: Some(1920),
937                video_height: Some(1080),
938                video_fps: Some(30.0),
939                video_codec: crate::VideoCodec::H264,
940                video_bitrate_mode: None,
941                preset: "medium".to_string(),
942                hardware_encoder: HardwareEncoder::Auto,
943                allow_codec_substitution: false,
944                audio_sample_rate: None,
945                audio_channels: None,
946                audio_codec: crate::AudioCodec::Aac,
947                audio_bitrate: None,
948                _progress_callback: false,
949                two_pass: false,
950                metadata: Vec::new(),
951                chapters: Vec::new(),
952                subtitle_passthrough: None,
953                codec_options: None,
954                codec_opts: Vec::new(),
955                pixel_format: None,
956                hdr10_metadata: None,
957                color_space: None,
958                color_transfer: None,
959                color_primaries: None,
960                attachments: Vec::new(),
961                container: None,
962                faststart: false,
963            },
964            start_time: std::time::Instant::now(),
965            progress_callback: None,
966        }
967    }
968
969    #[test]
970    fn create_should_return_builder_without_error() {
971        let _builder: VideoEncoderBuilder = VideoEncoder::create(test_out("output.mp4"));
972    }
973
974    #[test]
975    fn build_without_streams_should_return_error() {
976        let result = VideoEncoder::create(test_out("output.mp4")).build();
977        assert!(result.is_err());
978    }
979
980    #[test]
981    fn build_with_odd_width_should_return_error() {
982        let result = VideoEncoder::create(test_out("output.mp4"))
983            .video(1921, 1080, 30.0)
984            .build();
985        assert!(result.is_err());
986    }
987
988    #[test]
989    fn build_with_odd_height_should_return_error() {
990        let result = VideoEncoder::create(test_out("output.mp4"))
991            .video(1920, 1081, 30.0)
992            .build();
993        assert!(result.is_err());
994    }
995
996    #[test]
997    fn build_with_invalid_fps_should_return_error() {
998        let result = VideoEncoder::create(test_out("output.mp4"))
999            .video(1920, 1080, -1.0)
1000            .build();
1001        assert!(result.is_err());
1002    }
1003
1004    #[test]
1005    fn two_pass_with_audio_should_return_error() {
1006        let result = VideoEncoder::create(test_out("output.mp4"))
1007            .video(640, 480, 30.0)
1008            .audio(48000, 2)
1009            .two_pass()
1010            .build();
1011        assert!(result.is_err());
1012        if let Err(e) = result {
1013            assert!(
1014                matches!(e, crate::EncodeError::InvalidConfig { .. }),
1015                "expected InvalidConfig, got {e:?}"
1016            );
1017        }
1018    }
1019
1020    #[test]
1021    fn two_pass_without_video_should_return_error() {
1022        let result = VideoEncoder::create(test_out("output.mp4"))
1023            .two_pass()
1024            .build();
1025        assert!(result.is_err());
1026    }
1027
1028    #[test]
1029    fn build_with_crf_above_51_should_return_error() {
1030        let result = VideoEncoder::create(test_out("output.mp4"))
1031            .video(1920, 1080, 30.0)
1032            .bitrate_mode(crate::BitrateMode::Crf(100))
1033            .build();
1034        assert!(result.is_err());
1035    }
1036
1037    #[test]
1038    fn bitrate_mode_vbr_with_max_less_than_target_should_return_error() {
1039        let result = VideoEncoder::create(test_out("test_vbr.mp4"))
1040            .video(640, 480, 30.0)
1041            .bitrate_mode(crate::BitrateMode::Vbr {
1042                target: 4_000_000,
1043                max: 2_000_000,
1044            })
1045            .build();
1046        assert!(result.is_err());
1047    }
1048
1049    #[test]
1050    fn is_lgpl_compliant_should_be_true_for_hardware_encoders() {
1051        for codec_name in &[
1052            "h264_nvenc",
1053            "h264_qsv",
1054            "h264_amf",
1055            "h264_videotoolbox",
1056            "hevc_vaapi",
1057        ] {
1058            let encoder = create_mock_encoder(codec_name, "");
1059            assert!(
1060                encoder.is_lgpl_compliant(),
1061                "expected LGPL-compliant for {codec_name}"
1062            );
1063        }
1064    }
1065
1066    #[test]
1067    fn is_lgpl_compliant_should_be_false_for_gpl_encoders() {
1068        for codec_name in &["libx264", "libx265"] {
1069            let encoder = create_mock_encoder(codec_name, "");
1070            assert!(
1071                !encoder.is_lgpl_compliant(),
1072                "expected non-LGPL for {codec_name}"
1073            );
1074        }
1075    }
1076
1077    #[test]
1078    fn hardware_encoder_detection_should_match_codec_name() {
1079        let cases: &[(&str, HardwareEncoder, bool)] = &[
1080            ("h264_nvenc", HardwareEncoder::Nvenc, true),
1081            ("h264_qsv", HardwareEncoder::Qsv, true),
1082            ("h264_amf", HardwareEncoder::Amf, true),
1083            ("h264_videotoolbox", HardwareEncoder::VideoToolbox, true),
1084            ("h264_vaapi", HardwareEncoder::Vaapi, true),
1085            ("libx264", HardwareEncoder::None, false),
1086            ("libvpx-vp9", HardwareEncoder::None, false),
1087        ];
1088        for (codec_name, expected_hw, expected_is_hw) in cases {
1089            let encoder = create_mock_encoder(codec_name, "");
1090            assert_eq!(
1091                encoder.hardware_encoder(),
1092                *expected_hw,
1093                "hw for {codec_name}"
1094            );
1095            assert_eq!(
1096                encoder.is_hardware_encoding(),
1097                *expected_is_hw,
1098                "is_hw for {codec_name}"
1099            );
1100        }
1101    }
1102
1103    #[test]
1104    fn webm_extension_without_explicit_codec_should_default_to_vp9_opus() {
1105        let builder = VideoEncoder::create(test_out("output.webm")).video(640, 480, 30.0);
1106        let normalized = builder.apply_container_defaults();
1107        assert_eq!(normalized.video_codec, VideoCodec::Vp9);
1108        assert_eq!(normalized.audio_codec, AudioCodec::Opus);
1109    }
1110
1111    #[test]
1112    fn webm_extension_with_explicit_vp9_should_preserve_codec() {
1113        let builder = VideoEncoder::create(test_out("output.webm"))
1114            .video(640, 480, 30.0)
1115            .video_codec(VideoCodec::Vp9);
1116        assert!(builder.video_codec_explicit);
1117        let normalized = builder.apply_container_defaults();
1118        assert_eq!(normalized.video_codec, VideoCodec::Vp9);
1119    }
1120
1121    #[test]
1122    fn avi_extension_without_explicit_codec_should_default_to_h264_mp3() {
1123        let builder = VideoEncoder::create(test_out("output.avi")).video(640, 480, 30.0);
1124        let normalized = builder.apply_container_defaults();
1125        assert_eq!(normalized.video_codec, VideoCodec::H264);
1126        assert_eq!(normalized.audio_codec, AudioCodec::Mp3);
1127    }
1128
1129    #[test]
1130    fn mov_extension_without_explicit_codec_should_default_to_h264_aac() {
1131        let builder = VideoEncoder::create(test_out("output.mov")).video(640, 480, 30.0);
1132        let normalized = builder.apply_container_defaults();
1133        assert_eq!(normalized.video_codec, VideoCodec::H264);
1134        assert_eq!(normalized.audio_codec, AudioCodec::Aac);
1135    }
1136
1137    #[test]
1138    fn webm_extension_with_h264_video_codec_should_return_error() {
1139        let result = VideoEncoder::create(test_out("output.webm"))
1140            .video(640, 480, 30.0)
1141            .video_codec(VideoCodec::H264)
1142            .build();
1143        assert!(matches!(
1144            result,
1145            Err(crate::EncodeError::UnsupportedContainerCodecCombination { .. })
1146        ));
1147    }
1148
1149    #[test]
1150    fn webm_extension_with_h265_video_codec_should_return_error() {
1151        let result = VideoEncoder::create(test_out("output.webm"))
1152            .video(640, 480, 30.0)
1153            .video_codec(VideoCodec::H265)
1154            .build();
1155        assert!(matches!(
1156            result,
1157            Err(crate::EncodeError::UnsupportedContainerCodecCombination { .. })
1158        ));
1159    }
1160
1161    #[test]
1162    fn webm_extension_with_incompatible_audio_codec_should_return_error() {
1163        let result = VideoEncoder::create(test_out("output.webm"))
1164            .video(640, 480, 30.0)
1165            .video_codec(VideoCodec::Vp9)
1166            .audio(48000, 2)
1167            .audio_codec(AudioCodec::Aac)
1168            .build();
1169        assert!(matches!(
1170            result,
1171            Err(crate::EncodeError::UnsupportedContainerCodecCombination { .. })
1172        ));
1173    }
1174
1175    #[test]
1176    fn webm_container_enum_with_incompatible_codec_should_return_error() {
1177        let result = VideoEncoder::create(test_out("output.mkv"))
1178            .video(640, 480, 30.0)
1179            .container(OutputContainer::WebM)
1180            .video_codec(VideoCodec::H264)
1181            .build();
1182        assert!(matches!(
1183            result,
1184            Err(crate::EncodeError::UnsupportedContainerCodecCombination { .. })
1185        ));
1186    }
1187
1188    #[test]
1189    fn non_webm_extension_should_not_enforce_webm_codecs() {
1190        // H264 + AAC on .mp4 should not trigger WebM validation
1191        let result = VideoEncoder::create(test_out("output.mp4"))
1192            .video(640, 480, 30.0)
1193            .video_codec(VideoCodec::H264)
1194            .build();
1195        // Should not fail with UnsupportedContainerCodecCombination
1196        assert!(!matches!(
1197            result,
1198            Err(crate::EncodeError::UnsupportedContainerCodecCombination { .. })
1199        ));
1200    }
1201
1202    #[test]
1203    fn avi_with_incompatible_video_codec_should_return_error() {
1204        let result = VideoEncoder::create(test_out("output.avi"))
1205            .video(640, 480, 30.0)
1206            .video_codec(VideoCodec::Vp9)
1207            .build();
1208        assert!(matches!(
1209            result,
1210            Err(crate::EncodeError::UnsupportedContainerCodecCombination { .. })
1211        ));
1212    }
1213
1214    #[test]
1215    fn avi_with_incompatible_audio_codec_should_return_error() {
1216        let result = VideoEncoder::create(test_out("output.avi"))
1217            .video(640, 480, 30.0)
1218            .video_codec(VideoCodec::H264)
1219            .audio(48000, 2)
1220            .audio_codec(AudioCodec::Opus)
1221            .build();
1222        assert!(matches!(
1223            result,
1224            Err(crate::EncodeError::UnsupportedContainerCodecCombination { .. })
1225        ));
1226    }
1227
1228    #[test]
1229    fn mov_with_incompatible_video_codec_should_return_error() {
1230        let result = VideoEncoder::create(test_out("output.mov"))
1231            .video(640, 480, 30.0)
1232            .video_codec(VideoCodec::Vp9)
1233            .build();
1234        assert!(matches!(
1235            result,
1236            Err(crate::EncodeError::UnsupportedContainerCodecCombination { .. })
1237        ));
1238    }
1239
1240    #[test]
1241    fn mov_with_incompatible_audio_codec_should_return_error() {
1242        let result = VideoEncoder::create(test_out("output.mov"))
1243            .video(640, 480, 30.0)
1244            .video_codec(VideoCodec::H264)
1245            .audio(48000, 2)
1246            .audio_codec(AudioCodec::Opus)
1247            .build();
1248        assert!(matches!(
1249            result,
1250            Err(crate::EncodeError::UnsupportedContainerCodecCombination { .. })
1251        ));
1252    }
1253
1254    #[test]
1255    fn avi_container_enum_with_incompatible_codec_should_return_error() {
1256        let result = VideoEncoder::create(test_out("output.mp4"))
1257            .video(640, 480, 30.0)
1258            .container(OutputContainer::Avi)
1259            .video_codec(VideoCodec::Vp9)
1260            .build();
1261        assert!(matches!(
1262            result,
1263            Err(crate::EncodeError::UnsupportedContainerCodecCombination { .. })
1264        ));
1265    }
1266
1267    #[test]
1268    fn mov_container_enum_with_incompatible_codec_should_return_error() {
1269        let result = VideoEncoder::create(test_out("output.mp4"))
1270            .video(640, 480, 30.0)
1271            .container(OutputContainer::Mov)
1272            .video_codec(VideoCodec::Vp9)
1273            .build();
1274        assert!(matches!(
1275            result,
1276            Err(crate::EncodeError::UnsupportedContainerCodecCombination { .. })
1277        ));
1278    }
1279
1280    #[test]
1281    fn avi_with_pcm_audio_should_pass_validation() {
1282        // AudioCodec::Pcm (backward-compat alias for 16-bit PCM) must be accepted in AVI.
1283        let result = VideoEncoder::create(test_out("output.avi"))
1284            .video(640, 480, 30.0)
1285            .video_codec(VideoCodec::H264)
1286            .audio(48000, 2)
1287            .audio_codec(AudioCodec::Pcm)
1288            .build();
1289        assert!(!matches!(
1290            result,
1291            Err(crate::EncodeError::UnsupportedContainerCodecCombination { .. })
1292        ));
1293    }
1294
1295    #[test]
1296    fn mov_with_pcm24_audio_should_pass_validation() {
1297        let result = VideoEncoder::create(test_out("output.mov"))
1298            .video(640, 480, 30.0)
1299            .video_codec(VideoCodec::H264)
1300            .audio(48000, 2)
1301            .audio_codec(AudioCodec::Pcm24)
1302            .build();
1303        assert!(!matches!(
1304            result,
1305            Err(crate::EncodeError::UnsupportedContainerCodecCombination { .. })
1306        ));
1307    }
1308
1309    #[test]
1310    fn non_avi_mov_extension_should_not_enforce_avi_mov_codecs() {
1311        // Vp9 on .webm should not trigger AVI/MOV validation
1312        let result = VideoEncoder::create(test_out("output.webm"))
1313            .video(640, 480, 30.0)
1314            .video_codec(VideoCodec::Vp9)
1315            .build();
1316        assert!(!matches!(
1317            result,
1318            Err(crate::EncodeError::UnsupportedContainerCodecCombination {
1319                ref container, ..
1320            }) if container == "avi" || container == "mov"
1321        ));
1322    }
1323
1324    #[test]
1325    fn fmp4_container_with_h264_should_pass_validation() {
1326        let result = VideoEncoder::create(test_out("output.mp4"))
1327            .video(640, 480, 30.0)
1328            .video_codec(VideoCodec::H264)
1329            .container(OutputContainer::FMp4)
1330            .build();
1331        assert!(!matches!(
1332            result,
1333            Err(crate::EncodeError::UnsupportedContainerCodecCombination { .. })
1334        ));
1335    }
1336
1337    #[test]
1338    fn fmp4_container_with_mpeg4_should_return_error() {
1339        let result = VideoEncoder::create(test_out("output.mp4"))
1340            .video(640, 480, 30.0)
1341            .video_codec(VideoCodec::Mpeg4)
1342            .container(OutputContainer::FMp4)
1343            .build();
1344        assert!(matches!(
1345            result,
1346            Err(crate::EncodeError::UnsupportedContainerCodecCombination {
1347                ref container, ..
1348            }) if container == "fMP4"
1349        ));
1350    }
1351
1352    #[test]
1353    fn fmp4_container_with_mjpeg_should_return_error() {
1354        let result = VideoEncoder::create(test_out("output.mp4"))
1355            .video(640, 480, 30.0)
1356            .video_codec(VideoCodec::Mjpeg)
1357            .container(OutputContainer::FMp4)
1358            .build();
1359        assert!(matches!(
1360            result,
1361            Err(crate::EncodeError::UnsupportedContainerCodecCombination {
1362                ref container, ..
1363            }) if container == "fMP4"
1364        ));
1365    }
1366}