Skip to main content

ff_encode/video/
builder.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, Container, EncodeError, EncodeProgressCallback, HardwareEncoder, Preset, VideoCodec,
15};
16
17/// Builder for constructing a [`VideoEncoder`].
18///
19/// Created by calling [`VideoEncoder::create()`]. Call [`build()`](Self::build)
20/// to open the output file and prepare for encoding.
21///
22/// # Examples
23///
24/// ```ignore
25/// use ff_encode::{VideoEncoder, VideoCodec, Preset};
26///
27/// let mut encoder = VideoEncoder::create("output.mp4")
28///     .video(1920, 1080, 30.0)
29///     .video_codec(VideoCodec::H264)
30///     .preset(Preset::Medium)
31///     .build()?;
32/// ```
33pub struct VideoEncoderBuilder {
34    pub(crate) path: PathBuf,
35    pub(crate) container: Option<Container>,
36    pub(crate) video_width: Option<u32>,
37    pub(crate) video_height: Option<u32>,
38    pub(crate) video_fps: Option<f64>,
39    pub(crate) video_codec: VideoCodec,
40    pub(crate) video_bitrate_mode: Option<crate::BitrateMode>,
41    pub(crate) preset: Preset,
42    pub(crate) hardware_encoder: HardwareEncoder,
43    pub(crate) audio_sample_rate: Option<u32>,
44    pub(crate) audio_channels: Option<u32>,
45    pub(crate) audio_codec: AudioCodec,
46    pub(crate) audio_bitrate: Option<u64>,
47    pub(crate) progress_callback: Option<Box<dyn EncodeProgressCallback>>,
48    pub(crate) two_pass: bool,
49    pub(crate) metadata: Vec<(String, String)>,
50    pub(crate) chapters: Vec<ff_format::chapter::ChapterInfo>,
51    pub(crate) subtitle_passthrough: Option<(String, usize)>,
52    pub(crate) codec_options: Option<VideoCodecOptions>,
53    pub(crate) video_codec_explicit: bool,
54    pub(crate) audio_codec_explicit: bool,
55    pub(crate) pixel_format: Option<ff_format::PixelFormat>,
56    pub(crate) hdr10_metadata: Option<ff_format::Hdr10Metadata>,
57    pub(crate) color_space: Option<ff_format::ColorSpace>,
58    pub(crate) color_transfer: Option<ff_format::ColorTransfer>,
59    pub(crate) color_primaries: Option<ff_format::ColorPrimaries>,
60    /// Binary attachments: (raw data, MIME type, filename).
61    pub(crate) attachments: Vec<(Vec<u8>, String, String)>,
62}
63
64impl std::fmt::Debug for VideoEncoderBuilder {
65    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
66        f.debug_struct("VideoEncoderBuilder")
67            .field("path", &self.path)
68            .field("container", &self.container)
69            .field("video_width", &self.video_width)
70            .field("video_height", &self.video_height)
71            .field("video_fps", &self.video_fps)
72            .field("video_codec", &self.video_codec)
73            .field("video_bitrate_mode", &self.video_bitrate_mode)
74            .field("preset", &self.preset)
75            .field("hardware_encoder", &self.hardware_encoder)
76            .field("audio_sample_rate", &self.audio_sample_rate)
77            .field("audio_channels", &self.audio_channels)
78            .field("audio_codec", &self.audio_codec)
79            .field("audio_bitrate", &self.audio_bitrate)
80            .field(
81                "progress_callback",
82                &self.progress_callback.as_ref().map(|_| "<callback>"),
83            )
84            .field("two_pass", &self.two_pass)
85            .field("metadata", &self.metadata)
86            .field("chapters", &self.chapters)
87            .field("subtitle_passthrough", &self.subtitle_passthrough)
88            .field("codec_options", &self.codec_options)
89            .field("video_codec_explicit", &self.video_codec_explicit)
90            .field("audio_codec_explicit", &self.audio_codec_explicit)
91            .field("pixel_format", &self.pixel_format)
92            .field("hdr10_metadata", &self.hdr10_metadata)
93            .field("color_space", &self.color_space)
94            .field("color_transfer", &self.color_transfer)
95            .field("color_primaries", &self.color_primaries)
96            .field("attachments_count", &self.attachments.len())
97            .finish()
98    }
99}
100
101impl VideoEncoderBuilder {
102    pub(crate) fn new(path: PathBuf) -> Self {
103        Self {
104            path,
105            container: None,
106            video_width: None,
107            video_height: None,
108            video_fps: None,
109            video_codec: VideoCodec::default(),
110            video_bitrate_mode: None,
111            preset: Preset::default(),
112            hardware_encoder: HardwareEncoder::default(),
113            audio_sample_rate: None,
114            audio_channels: None,
115            audio_codec: AudioCodec::default(),
116            audio_bitrate: None,
117            progress_callback: None,
118            two_pass: false,
119            metadata: Vec::new(),
120            chapters: Vec::new(),
121            subtitle_passthrough: None,
122            codec_options: None,
123            video_codec_explicit: false,
124            audio_codec_explicit: false,
125            pixel_format: None,
126            hdr10_metadata: None,
127            color_space: None,
128            color_transfer: None,
129            color_primaries: None,
130            attachments: Vec::new(),
131        }
132    }
133
134    // === Video settings ===
135
136    /// Configure video stream settings.
137    #[must_use]
138    pub fn video(mut self, width: u32, height: u32, fps: f64) -> Self {
139        self.video_width = Some(width);
140        self.video_height = Some(height);
141        self.video_fps = Some(fps);
142        self
143    }
144
145    /// Set video codec.
146    #[must_use]
147    pub fn video_codec(mut self, codec: VideoCodec) -> Self {
148        self.video_codec = codec;
149        self.video_codec_explicit = true;
150        self
151    }
152
153    /// Set the bitrate control mode for video encoding.
154    #[must_use]
155    pub fn bitrate_mode(mut self, mode: crate::BitrateMode) -> Self {
156        self.video_bitrate_mode = Some(mode);
157        self
158    }
159
160    /// Set encoding preset (speed vs quality tradeoff).
161    #[must_use]
162    pub fn preset(mut self, preset: Preset) -> Self {
163        self.preset = preset;
164        self
165    }
166
167    /// Set hardware encoder.
168    #[must_use]
169    pub fn hardware_encoder(mut self, hw: HardwareEncoder) -> Self {
170        self.hardware_encoder = hw;
171        self
172    }
173
174    // === Audio settings ===
175
176    /// Configure audio stream settings.
177    #[must_use]
178    pub fn audio(mut self, sample_rate: u32, channels: u32) -> Self {
179        self.audio_sample_rate = Some(sample_rate);
180        self.audio_channels = Some(channels);
181        self
182    }
183
184    /// Set audio codec.
185    #[must_use]
186    pub fn audio_codec(mut self, codec: AudioCodec) -> Self {
187        self.audio_codec = codec;
188        self.audio_codec_explicit = true;
189        self
190    }
191
192    /// Set audio bitrate in bits per second.
193    #[must_use]
194    pub fn audio_bitrate(mut self, bitrate: u64) -> Self {
195        self.audio_bitrate = Some(bitrate);
196        self
197    }
198
199    // === Container settings ===
200
201    /// Set container format explicitly (usually auto-detected from file extension).
202    #[must_use]
203    pub fn container(mut self, container: Container) -> Self {
204        self.container = Some(container);
205        self
206    }
207
208    // === Callbacks ===
209
210    /// Set a closure as the progress callback.
211    #[must_use]
212    pub fn on_progress<F>(mut self, callback: F) -> Self
213    where
214        F: FnMut(&crate::EncodeProgress) + Send + 'static,
215    {
216        self.progress_callback = Some(Box::new(callback));
217        self
218    }
219
220    /// Set a [`EncodeProgressCallback`] trait object (supports cancellation).
221    #[must_use]
222    pub fn progress_callback<C: EncodeProgressCallback + 'static>(mut self, callback: C) -> Self {
223        self.progress_callback = Some(Box::new(callback));
224        self
225    }
226
227    // === Two-pass ===
228
229    /// Enable two-pass encoding for more accurate bitrate distribution.
230    ///
231    /// Two-pass encoding is video-only and is incompatible with audio streams.
232    #[must_use]
233    pub fn two_pass(mut self) -> Self {
234        self.two_pass = true;
235        self
236    }
237
238    // === Metadata ===
239
240    /// Embed a metadata tag in the output container.
241    ///
242    /// Calls `av_dict_set` on `AVFormatContext->metadata` before the header
243    /// is written. Multiple calls accumulate entries; duplicate keys use the
244    /// last value.
245    #[must_use]
246    pub fn metadata(mut self, key: &str, value: &str) -> Self {
247        self.metadata.push((key.to_string(), value.to_string()));
248        self
249    }
250
251    // === Chapters ===
252
253    /// Add a chapter to the output container.
254    ///
255    /// Allocates an `AVChapter` entry on `AVFormatContext` before the header
256    /// is written. Multiple calls accumulate chapters in the order added.
257    #[must_use]
258    pub fn chapter(mut self, chapter: ff_format::chapter::ChapterInfo) -> Self {
259        self.chapters.push(chapter);
260        self
261    }
262
263    // === Subtitle passthrough ===
264
265    /// Copy a subtitle stream from an existing file into the output container.
266    ///
267    /// Opens `source_path`, locates the stream at `stream_index`, and registers it
268    /// as a passthrough stream in the output.  Packets are copied verbatim using
269    /// `av_interleaved_write_frame` without re-encoding.
270    ///
271    /// `stream_index` is the zero-based index of the subtitle stream inside
272    /// `source_path`.  For files with a single subtitle track this is typically `0`
273    /// (or whichever index `ffprobe` reports).
274    ///
275    /// If the source cannot be opened or the stream index is invalid, a warning is
276    /// logged and encoding continues without subtitles.
277    #[must_use]
278    pub fn subtitle_passthrough(mut self, source_path: &str, stream_index: usize) -> Self {
279        self.subtitle_passthrough = Some((source_path.to_string(), stream_index));
280        self
281    }
282
283    // === Per-codec options ===
284
285    /// Set per-codec encoding options.
286    ///
287    /// Applied via `av_opt_set` before `avcodec_open2` during [`build()`](Self::build).
288    /// This is additive — omitting it leaves codec defaults unchanged.
289    /// Any option that the chosen encoder does not support is logged as a
290    /// warning and skipped; it never causes `build()` to return an error.
291    ///
292    /// The [`VideoCodecOptions`] variant should match the codec selected via
293    /// [`video_codec()`](Self::video_codec).  A mismatch is silently ignored.
294    #[must_use]
295    pub fn codec_options(mut self, opts: VideoCodecOptions) -> Self {
296        self.codec_options = Some(opts);
297        self
298    }
299
300    // === Pixel format ===
301
302    /// Override the pixel format for video encoding.
303    ///
304    /// When omitted the encoder uses `yuv420p` by default, except that
305    /// H.265 `Main10` automatically selects `yuv420p10le`.
306    #[must_use]
307    pub fn pixel_format(mut self, fmt: ff_format::PixelFormat) -> Self {
308        self.pixel_format = Some(fmt);
309        self
310    }
311
312    // === HDR metadata ===
313
314    /// Embed HDR10 static metadata in the output.
315    ///
316    /// Sets `color_primaries = BT.2020`, `color_trc = SMPTE ST 2084 (PQ)`,
317    /// and `colorspace = BT.2020 NCL` on the codec context, then attaches
318    /// `AV_PKT_DATA_CONTENT_LIGHT_LEVEL` and
319    /// `AV_PKT_DATA_MASTERING_DISPLAY_METADATA` packet side data to every
320    /// keyframe.
321    ///
322    /// Pair with [`codec_options`](Self::codec_options) using
323    /// `H265Options { profile: H265Profile::Main10, .. }`
324    /// and [`pixel_format(PixelFormat::Yuv420p10le)`](Self::pixel_format) for a
325    /// complete HDR10 pipeline.
326    #[must_use]
327    pub fn hdr10_metadata(mut self, meta: ff_format::Hdr10Metadata) -> Self {
328        self.hdr10_metadata = Some(meta);
329        self
330    }
331
332    // === Color tagging ===
333
334    /// Override the color space (matrix coefficients) written to the codec context.
335    ///
336    /// When omitted the encoder uses the FFmpeg default. HDR10 metadata, if set
337    /// via [`hdr10_metadata()`](Self::hdr10_metadata), automatically selects
338    /// BT.2020 NCL — this setter takes priority over that automatic choice.
339    #[must_use]
340    pub fn color_space(mut self, cs: ff_format::ColorSpace) -> Self {
341        self.color_space = Some(cs);
342        self
343    }
344
345    /// Override the color transfer characteristic (gamma curve) written to the codec context.
346    ///
347    /// When omitted the encoder uses the FFmpeg default. HDR10 metadata
348    /// automatically selects PQ (SMPTE ST 2084) — this setter takes priority.
349    /// Use [`ColorTransfer::Hlg`](ff_format::ColorTransfer::Hlg) for HLG broadcast HDR.
350    #[must_use]
351    pub fn color_transfer(mut self, trc: ff_format::ColorTransfer) -> Self {
352        self.color_transfer = Some(trc);
353        self
354    }
355
356    /// Override the color primaries written to the codec context.
357    ///
358    /// When omitted the encoder uses the FFmpeg default. HDR10 metadata
359    /// automatically selects BT.2020 — this setter takes priority.
360    #[must_use]
361    pub fn color_primaries(mut self, cp: ff_format::ColorPrimaries) -> Self {
362        self.color_primaries = Some(cp);
363        self
364    }
365
366    // === Attachments ===
367
368    /// Embed a binary attachment in the output container.
369    ///
370    /// Attachments are supported in MKV/WebM containers and are used for
371    /// fonts (required by ASS/SSA subtitle rendering), cover art, or other
372    /// binary files that consumers of the file may need.
373    ///
374    /// - `data` — raw bytes of the attachment
375    /// - `mime_type` — MIME type string (e.g. `"application/x-truetype-font"`,
376    ///   `"image/jpeg"`)
377    /// - `filename` — the name reported inside the container (e.g. `"Arial.ttf"`)
378    ///
379    /// Multiple calls accumulate entries; each attachment becomes its own stream
380    /// with `AVMEDIA_TYPE_ATTACHMENT` codec parameters.
381    #[must_use]
382    pub fn add_attachment(mut self, data: Vec<u8>, mime_type: &str, filename: &str) -> Self {
383        self.attachments
384            .push((data, mime_type.to_string(), filename.to_string()));
385        self
386    }
387
388    // === Build ===
389
390    /// Validate builder state and open the output file.
391    ///
392    /// # Errors
393    ///
394    /// Returns [`EncodeError`] if configuration is invalid, the output path
395    /// cannot be created, or no suitable encoder is found.
396    pub fn build(self) -> Result<VideoEncoder, EncodeError> {
397        let this = self.apply_container_defaults();
398        this.validate()?;
399        VideoEncoder::from_builder(this)
400    }
401
402    /// Apply container-specific codec defaults before validation.
403    ///
404    /// For WebM paths/containers, default to VP9 + Opus when the caller has
405    /// not explicitly chosen a codec.
406    fn apply_container_defaults(mut self) -> Self {
407        let is_webm = self
408            .path
409            .extension()
410            .and_then(|e| e.to_str())
411            .is_some_and(|e| e.eq_ignore_ascii_case("webm"))
412            || self
413                .container
414                .as_ref()
415                .is_some_and(|c| *c == Container::WebM);
416
417        if is_webm {
418            if !self.video_codec_explicit {
419                self.video_codec = VideoCodec::Vp9;
420            }
421            if !self.audio_codec_explicit {
422                self.audio_codec = AudioCodec::Opus;
423            }
424        }
425
426        let is_avi = self
427            .path
428            .extension()
429            .and_then(|e| e.to_str())
430            .is_some_and(|e| e.eq_ignore_ascii_case("avi"))
431            || self
432                .container
433                .as_ref()
434                .is_some_and(|c| *c == Container::Avi);
435
436        if is_avi {
437            if !self.video_codec_explicit {
438                self.video_codec = VideoCodec::H264;
439            }
440            if !self.audio_codec_explicit {
441                self.audio_codec = AudioCodec::Mp3;
442            }
443        }
444
445        let is_mov = self
446            .path
447            .extension()
448            .and_then(|e| e.to_str())
449            .is_some_and(|e| e.eq_ignore_ascii_case("mov"))
450            || self
451                .container
452                .as_ref()
453                .is_some_and(|c| *c == Container::Mov);
454
455        if is_mov {
456            if !self.video_codec_explicit {
457                self.video_codec = VideoCodec::H264;
458            }
459            if !self.audio_codec_explicit {
460                self.audio_codec = AudioCodec::Aac;
461            }
462        }
463
464        // Image-sequence paths contain '%' (e.g. "frames/frame%04d.png").
465        // Auto-select codec from the extension that follows the pattern.
466        let is_image_sequence = self.path.to_str().is_some_and(|s| s.contains('%'));
467        if is_image_sequence && !self.video_codec_explicit {
468            let ext = self
469                .path
470                .to_str()
471                .and_then(|s| s.rfind('.').map(|i| &s[i + 1..]))
472                .unwrap_or("");
473            if ext.eq_ignore_ascii_case("png") {
474                self.video_codec = VideoCodec::Png;
475            } else if ext.eq_ignore_ascii_case("jpg") || ext.eq_ignore_ascii_case("jpeg") {
476                self.video_codec = VideoCodec::Mjpeg;
477            }
478        }
479
480        self
481    }
482
483    fn validate(&self) -> Result<(), EncodeError> {
484        let has_video =
485            self.video_width.is_some() && self.video_height.is_some() && self.video_fps.is_some();
486        let has_audio = self.audio_sample_rate.is_some() && self.audio_channels.is_some();
487
488        if !has_video && !has_audio {
489            return Err(EncodeError::InvalidConfig {
490                reason: "At least one video or audio stream must be configured".to_string(),
491            });
492        }
493
494        if self.two_pass {
495            if !has_video {
496                return Err(EncodeError::InvalidConfig {
497                    reason: "Two-pass encoding requires a video stream".to_string(),
498                });
499            }
500            if has_audio {
501                return Err(EncodeError::InvalidConfig {
502                    reason:
503                        "Two-pass encoding is video-only and is incompatible with audio streams"
504                            .to_string(),
505                });
506            }
507        }
508
509        // Image-sequence paths (containing '%') do not support audio streams.
510        let is_image_sequence = self.path.to_str().is_some_and(|s| s.contains('%'));
511        if is_image_sequence && has_audio {
512            return Err(EncodeError::InvalidConfig {
513                reason: "Image sequence output does not support audio streams".to_string(),
514            });
515        }
516
517        // PNG supports odd dimensions; all other codecs require even width/height.
518        let requires_even_dims = !matches!(self.video_codec, VideoCodec::Png);
519
520        if has_video {
521            if let Some(width) = self.video_width
522                && (width == 0 || (requires_even_dims && width % 2 != 0))
523            {
524                return Err(EncodeError::InvalidConfig {
525                    reason: format!("Video width must be non-zero and even, got {width}"),
526                });
527            }
528            if let Some(height) = self.video_height
529                && (height == 0 || (requires_even_dims && height % 2 != 0))
530            {
531                return Err(EncodeError::InvalidConfig {
532                    reason: format!("Video height must be non-zero and even, got {height}"),
533                });
534            }
535            if let Some(fps) = self.video_fps
536                && fps <= 0.0
537            {
538                return Err(EncodeError::InvalidConfig {
539                    reason: format!("Video FPS must be positive, got {fps}"),
540                });
541            }
542            if let Some(crate::BitrateMode::Crf(q)) = self.video_bitrate_mode
543                && q > crate::CRF_MAX
544            {
545                return Err(EncodeError::InvalidConfig {
546                    reason: format!(
547                        "BitrateMode::Crf value must be 0-{}, got {q}",
548                        crate::CRF_MAX
549                    ),
550                });
551            }
552            if let Some(crate::BitrateMode::Vbr { target, max }) = self.video_bitrate_mode
553                && max < target
554            {
555                return Err(EncodeError::InvalidConfig {
556                    reason: format!("BitrateMode::Vbr max ({max}) must be >= target ({target})"),
557                });
558            }
559        }
560
561        if let Some(VideoCodecOptions::Av1(ref opts)) = self.codec_options
562            && opts.cpu_used > 8
563        {
564            return Err(EncodeError::InvalidOption {
565                name: "cpu_used".to_string(),
566                reason: "must be 0–8".to_string(),
567            });
568        }
569
570        if let Some(VideoCodecOptions::Av1Svt(ref opts)) = self.codec_options
571            && opts.preset > 13
572        {
573            return Err(EncodeError::InvalidOption {
574                name: "preset".to_string(),
575                reason: "must be 0–13".to_string(),
576            });
577        }
578
579        if let Some(VideoCodecOptions::Vp9(ref opts)) = self.codec_options {
580            if opts.cpu_used < -8 || opts.cpu_used > 8 {
581                return Err(EncodeError::InvalidOption {
582                    name: "cpu_used".to_string(),
583                    reason: "must be -8–8".to_string(),
584                });
585            }
586            if let Some(cq) = opts.cq_level
587                && cq > 63
588            {
589                return Err(EncodeError::InvalidOption {
590                    name: "cq_level".to_string(),
591                    reason: "must be 0–63".to_string(),
592                });
593            }
594        }
595
596        if let Some(VideoCodecOptions::Dnxhd(ref opts)) = self.codec_options
597            && opts.variant.is_dnxhd()
598        {
599            let valid = matches!(
600                (self.video_width, self.video_height),
601                (Some(1920), Some(1080)) | (Some(1280), Some(720))
602            );
603            if !valid {
604                return Err(EncodeError::InvalidOption {
605                    name: "variant".to_string(),
606                    reason: "DNxHD variants require 1920×1080 or 1280×720 resolution".to_string(),
607                });
608            }
609        }
610
611        // WebM container codec enforcement.
612        let is_webm = self
613            .path
614            .extension()
615            .and_then(|e| e.to_str())
616            .is_some_and(|e| e.eq_ignore_ascii_case("webm"))
617            || self
618                .container
619                .as_ref()
620                .is_some_and(|c| *c == Container::WebM);
621
622        if is_webm {
623            let webm_video_ok = matches!(
624                self.video_codec,
625                VideoCodec::Vp9 | VideoCodec::Av1 | VideoCodec::Av1Svt
626            );
627            if !webm_video_ok {
628                return Err(EncodeError::UnsupportedContainerCodecCombination {
629                    container: "webm".to_string(),
630                    codec: self.video_codec.name().to_string(),
631                    hint: "WebM supports VP9, AV1 (video) and Vorbis, Opus (audio)".to_string(),
632                });
633            }
634
635            let webm_audio_ok = matches!(self.audio_codec, AudioCodec::Opus | AudioCodec::Vorbis);
636            if !webm_audio_ok {
637                return Err(EncodeError::UnsupportedContainerCodecCombination {
638                    container: "webm".to_string(),
639                    codec: self.audio_codec.name().to_string(),
640                    hint: "WebM supports VP9, AV1 (video) and Vorbis, Opus (audio)".to_string(),
641                });
642            }
643        }
644
645        // AVI container codec enforcement.
646        let is_avi = self
647            .path
648            .extension()
649            .and_then(|e| e.to_str())
650            .is_some_and(|e| e.eq_ignore_ascii_case("avi"))
651            || self
652                .container
653                .as_ref()
654                .is_some_and(|c| *c == Container::Avi);
655
656        if is_avi {
657            let avi_video_ok = matches!(self.video_codec, VideoCodec::H264 | VideoCodec::Mpeg4);
658            if !avi_video_ok {
659                return Err(EncodeError::UnsupportedContainerCodecCombination {
660                    container: "avi".to_string(),
661                    codec: self.video_codec.name().to_string(),
662                    hint: "AVI supports H264 and MPEG-4 (video); MP3, AAC, and PCM 16-bit (audio)"
663                        .to_string(),
664                });
665            }
666
667            let avi_audio_ok = matches!(
668                self.audio_codec,
669                AudioCodec::Mp3 | AudioCodec::Aac | AudioCodec::Pcm | AudioCodec::Pcm16
670            );
671            if !avi_audio_ok {
672                return Err(EncodeError::UnsupportedContainerCodecCombination {
673                    container: "avi".to_string(),
674                    codec: self.audio_codec.name().to_string(),
675                    hint: "AVI supports H264 and MPEG-4 (video); MP3, AAC, and PCM 16-bit (audio)"
676                        .to_string(),
677                });
678            }
679        }
680
681        // MOV container codec enforcement.
682        let is_mov = self
683            .path
684            .extension()
685            .and_then(|e| e.to_str())
686            .is_some_and(|e| e.eq_ignore_ascii_case("mov"))
687            || self
688                .container
689                .as_ref()
690                .is_some_and(|c| *c == Container::Mov);
691
692        if is_mov {
693            let mov_video_ok = matches!(
694                self.video_codec,
695                VideoCodec::H264 | VideoCodec::H265 | VideoCodec::ProRes
696            );
697            if !mov_video_ok {
698                return Err(EncodeError::UnsupportedContainerCodecCombination {
699                    container: "mov".to_string(),
700                    codec: self.video_codec.name().to_string(),
701                    hint: "MOV supports H264, H265, and ProRes (video); AAC and PCM (audio)"
702                        .to_string(),
703                });
704            }
705
706            let mov_audio_ok = matches!(
707                self.audio_codec,
708                AudioCodec::Aac | AudioCodec::Pcm | AudioCodec::Pcm16 | AudioCodec::Pcm24
709            );
710            if !mov_audio_ok {
711                return Err(EncodeError::UnsupportedContainerCodecCombination {
712                    container: "mov".to_string(),
713                    codec: self.audio_codec.name().to_string(),
714                    hint: "MOV supports H264, H265, and ProRes (video); AAC and PCM (audio)"
715                        .to_string(),
716                });
717            }
718        }
719
720        if has_audio {
721            if let Some(rate) = self.audio_sample_rate
722                && rate == 0
723            {
724                return Err(EncodeError::InvalidConfig {
725                    reason: "Audio sample rate must be non-zero".to_string(),
726                });
727            }
728            if let Some(ch) = self.audio_channels
729                && ch == 0
730            {
731                return Err(EncodeError::InvalidConfig {
732                    reason: "Audio channels must be non-zero".to_string(),
733                });
734            }
735        }
736
737        Ok(())
738    }
739}
740
741/// Encodes video (and optionally audio) frames to a file using FFmpeg.
742///
743/// # Construction
744///
745/// Use [`VideoEncoder::create()`] to get a [`VideoEncoderBuilder`], then call
746/// [`VideoEncoderBuilder::build()`]:
747///
748/// ```ignore
749/// use ff_encode::{VideoEncoder, VideoCodec};
750///
751/// let mut encoder = VideoEncoder::create("output.mp4")
752///     .video(1920, 1080, 30.0)
753///     .video_codec(VideoCodec::H264)
754///     .build()?;
755/// ```
756pub struct VideoEncoder {
757    inner: Option<VideoEncoderInner>,
758    _config: VideoEncoderConfig,
759    start_time: Instant,
760    progress_callback: Option<Box<dyn crate::EncodeProgressCallback>>,
761}
762
763impl VideoEncoder {
764    /// Creates a builder for the specified output file path.
765    ///
766    /// This method is infallible. Validation occurs when
767    /// [`VideoEncoderBuilder::build()`] is called.
768    pub fn create<P: AsRef<std::path::Path>>(path: P) -> VideoEncoderBuilder {
769        VideoEncoderBuilder::new(path.as_ref().to_path_buf())
770    }
771
772    pub(crate) fn from_builder(builder: VideoEncoderBuilder) -> Result<Self, EncodeError> {
773        let config = VideoEncoderConfig {
774            path: builder.path.clone(),
775            video_width: builder.video_width,
776            video_height: builder.video_height,
777            video_fps: builder.video_fps,
778            video_codec: builder.video_codec,
779            video_bitrate_mode: builder.video_bitrate_mode,
780            preset: preset_to_string(&builder.preset),
781            hardware_encoder: builder.hardware_encoder,
782            audio_sample_rate: builder.audio_sample_rate,
783            audio_channels: builder.audio_channels,
784            audio_codec: builder.audio_codec,
785            audio_bitrate: builder.audio_bitrate,
786            _progress_callback: builder.progress_callback.is_some(),
787            two_pass: builder.two_pass,
788            metadata: builder.metadata,
789            chapters: builder.chapters,
790            subtitle_passthrough: builder.subtitle_passthrough,
791            codec_options: builder.codec_options,
792            pixel_format: builder.pixel_format,
793            hdr10_metadata: builder.hdr10_metadata,
794            color_space: builder.color_space,
795            color_transfer: builder.color_transfer,
796            color_primaries: builder.color_primaries,
797            attachments: builder.attachments,
798        };
799
800        let inner = if config.video_width.is_some() {
801            Some(VideoEncoderInner::new(&config)?)
802        } else {
803            None
804        };
805
806        Ok(Self {
807            inner,
808            _config: config,
809            start_time: Instant::now(),
810            progress_callback: builder.progress_callback,
811        })
812    }
813
814    /// Returns the name of the FFmpeg encoder actually used (e.g. `"h264_nvenc"`, `"libx264"`).
815    #[must_use]
816    pub fn actual_video_codec(&self) -> &str {
817        self.inner
818            .as_ref()
819            .map_or("", |inner| inner.actual_video_codec.as_str())
820    }
821
822    /// Returns the name of the FFmpeg audio encoder actually used.
823    #[must_use]
824    pub fn actual_audio_codec(&self) -> &str {
825        self.inner
826            .as_ref()
827            .map_or("", |inner| inner.actual_audio_codec.as_str())
828    }
829
830    /// Returns the hardware encoder actually in use.
831    #[must_use]
832    pub fn hardware_encoder(&self) -> crate::HardwareEncoder {
833        let codec_name = self.actual_video_codec();
834        if codec_name.contains("nvenc") {
835            crate::HardwareEncoder::Nvenc
836        } else if codec_name.contains("qsv") {
837            crate::HardwareEncoder::Qsv
838        } else if codec_name.contains("amf") {
839            crate::HardwareEncoder::Amf
840        } else if codec_name.contains("videotoolbox") {
841            crate::HardwareEncoder::VideoToolbox
842        } else if codec_name.contains("vaapi") {
843            crate::HardwareEncoder::Vaapi
844        } else {
845            crate::HardwareEncoder::None
846        }
847    }
848
849    /// Returns `true` if a hardware encoder is active.
850    #[must_use]
851    pub fn is_hardware_encoding(&self) -> bool {
852        !matches!(self.hardware_encoder(), crate::HardwareEncoder::None)
853    }
854
855    /// Returns `true` if the selected encoder is LGPL-compatible (safe for commercial use).
856    #[must_use]
857    pub fn is_lgpl_compliant(&self) -> bool {
858        let codec_name = self.actual_video_codec();
859        if codec_name.contains("nvenc")
860            || codec_name.contains("qsv")
861            || codec_name.contains("amf")
862            || codec_name.contains("videotoolbox")
863            || codec_name.contains("vaapi")
864        {
865            return true;
866        }
867        if codec_name.contains("vp9")
868            || codec_name.contains("av1")
869            || codec_name.contains("aom")
870            || codec_name.contains("svt")
871            || codec_name.contains("prores")
872            || codec_name == "mpeg4"
873            || codec_name == "dnxhd"
874        {
875            return true;
876        }
877        if codec_name == "libx264" || codec_name == "libx265" {
878            return false;
879        }
880        true
881    }
882
883    /// Pushes a video frame for encoding.
884    ///
885    /// # Errors
886    ///
887    /// Returns [`EncodeError`] if encoding fails or the encoder is not initialised.
888    /// Returns [`EncodeError::Cancelled`] if the progress callback requested cancellation.
889    pub fn push_video(&mut self, frame: &VideoFrame) -> Result<(), EncodeError> {
890        if let Some(ref callback) = self.progress_callback
891            && callback.should_cancel()
892        {
893            return Err(EncodeError::Cancelled);
894        }
895        let inner = self
896            .inner
897            .as_mut()
898            .ok_or_else(|| EncodeError::InvalidConfig {
899                reason: "Video encoder not initialized".to_string(),
900            })?;
901        // SAFETY: inner is properly initialised and we have exclusive access.
902        unsafe { inner.push_video_frame(frame)? };
903        let progress = self.create_progress_info();
904        if let Some(ref mut callback) = self.progress_callback {
905            callback.on_progress(&progress);
906        }
907        Ok(())
908    }
909
910    /// Pushes an audio frame for encoding.
911    ///
912    /// # Errors
913    ///
914    /// Returns [`EncodeError`] if encoding fails or the encoder is not initialised.
915    pub fn push_audio(&mut self, frame: &AudioFrame) -> Result<(), EncodeError> {
916        if let Some(ref callback) = self.progress_callback
917            && callback.should_cancel()
918        {
919            return Err(EncodeError::Cancelled);
920        }
921        let inner = self
922            .inner
923            .as_mut()
924            .ok_or_else(|| EncodeError::InvalidConfig {
925                reason: "Audio encoder not initialized".to_string(),
926            })?;
927        // SAFETY: inner is properly initialised and we have exclusive access.
928        unsafe { inner.push_audio_frame(frame)? };
929        let progress = self.create_progress_info();
930        if let Some(ref mut callback) = self.progress_callback {
931            callback.on_progress(&progress);
932        }
933        Ok(())
934    }
935
936    /// Flushes remaining frames and writes the file trailer.
937    ///
938    /// # Errors
939    ///
940    /// Returns [`EncodeError`] if finalising fails.
941    pub fn finish(mut self) -> Result<(), EncodeError> {
942        if let Some(mut inner) = self.inner.take() {
943            // SAFETY: inner is properly initialised and we have exclusive access.
944            unsafe { inner.finish()? };
945        }
946        Ok(())
947    }
948
949    fn create_progress_info(&self) -> crate::EncodeProgress {
950        let elapsed = self.start_time.elapsed();
951        let (frames_encoded, bytes_written) = self
952            .inner
953            .as_ref()
954            .map_or((0, 0), |inner| (inner.frame_count, inner.bytes_written));
955        #[allow(clippy::cast_precision_loss)]
956        let current_fps = if !elapsed.is_zero() {
957            frames_encoded as f64 / elapsed.as_secs_f64()
958        } else {
959            0.0
960        };
961        #[allow(clippy::cast_precision_loss)]
962        let current_bitrate = if !elapsed.is_zero() {
963            let elapsed_secs = elapsed.as_secs();
964            if elapsed_secs > 0 {
965                (bytes_written * 8) / elapsed_secs
966            } else {
967                ((bytes_written * 8) as f64 / elapsed.as_secs_f64()) as u64
968            }
969        } else {
970            0
971        };
972        crate::EncodeProgress {
973            frames_encoded,
974            total_frames: None,
975            bytes_written,
976            current_bitrate,
977            elapsed,
978            remaining: None,
979            current_fps,
980        }
981    }
982}
983
984impl Drop for VideoEncoder {
985    fn drop(&mut self) {
986        // VideoEncoderInner handles cleanup in its own Drop.
987    }
988}
989
990#[cfg(test)]
991#[allow(clippy::unwrap_used)]
992mod tests {
993    use super::super::encoder_inner::{VideoEncoderConfig, VideoEncoderInner};
994    use super::*;
995    use crate::HardwareEncoder;
996
997    fn create_mock_encoder(video_codec_name: &str, audio_codec_name: &str) -> VideoEncoder {
998        VideoEncoder {
999            inner: Some(VideoEncoderInner {
1000                format_ctx: std::ptr::null_mut(),
1001                video_codec_ctx: None,
1002                audio_codec_ctx: None,
1003                video_stream_index: -1,
1004                audio_stream_index: -1,
1005                sws_ctx: None,
1006                swr_ctx: None,
1007                frame_count: 0,
1008                audio_sample_count: 0,
1009                bytes_written: 0,
1010                actual_video_codec: video_codec_name.to_string(),
1011                actual_audio_codec: audio_codec_name.to_string(),
1012                last_src_width: None,
1013                last_src_height: None,
1014                last_src_format: None,
1015                two_pass: false,
1016                pass1_codec_ctx: None,
1017                buffered_frames: Vec::new(),
1018                two_pass_config: None,
1019                stats_in_cstr: None,
1020                subtitle_passthrough: None,
1021                hdr10_metadata: None,
1022            }),
1023            _config: VideoEncoderConfig {
1024                path: "test.mp4".into(),
1025                video_width: Some(1920),
1026                video_height: Some(1080),
1027                video_fps: Some(30.0),
1028                video_codec: crate::VideoCodec::H264,
1029                video_bitrate_mode: None,
1030                preset: "medium".to_string(),
1031                hardware_encoder: HardwareEncoder::Auto,
1032                audio_sample_rate: None,
1033                audio_channels: None,
1034                audio_codec: crate::AudioCodec::Aac,
1035                audio_bitrate: None,
1036                _progress_callback: false,
1037                two_pass: false,
1038                metadata: Vec::new(),
1039                chapters: Vec::new(),
1040                subtitle_passthrough: None,
1041                codec_options: None,
1042                pixel_format: None,
1043                hdr10_metadata: None,
1044                color_space: None,
1045                color_transfer: None,
1046                color_primaries: None,
1047                attachments: Vec::new(),
1048            },
1049            start_time: std::time::Instant::now(),
1050            progress_callback: None,
1051        }
1052    }
1053
1054    #[test]
1055    fn create_should_return_builder_without_error() {
1056        let _builder: VideoEncoderBuilder = VideoEncoder::create("output.mp4");
1057    }
1058
1059    #[test]
1060    fn builder_video_settings_should_be_stored() {
1061        let builder = VideoEncoder::create("output.mp4")
1062            .video(1920, 1080, 30.0)
1063            .video_codec(VideoCodec::H264)
1064            .bitrate_mode(crate::BitrateMode::Cbr(8_000_000));
1065        assert_eq!(builder.video_width, Some(1920));
1066        assert_eq!(builder.video_height, Some(1080));
1067        assert_eq!(builder.video_fps, Some(30.0));
1068        assert_eq!(builder.video_codec, VideoCodec::H264);
1069        assert_eq!(
1070            builder.video_bitrate_mode,
1071            Some(crate::BitrateMode::Cbr(8_000_000))
1072        );
1073    }
1074
1075    #[test]
1076    fn builder_audio_settings_should_be_stored() {
1077        let builder = VideoEncoder::create("output.mp4")
1078            .audio(48000, 2)
1079            .audio_codec(AudioCodec::Aac)
1080            .audio_bitrate(192_000);
1081        assert_eq!(builder.audio_sample_rate, Some(48000));
1082        assert_eq!(builder.audio_channels, Some(2));
1083        assert_eq!(builder.audio_codec, AudioCodec::Aac);
1084        assert_eq!(builder.audio_bitrate, Some(192_000));
1085    }
1086
1087    #[test]
1088    fn builder_preset_should_be_stored() {
1089        let builder = VideoEncoder::create("output.mp4")
1090            .video(1920, 1080, 30.0)
1091            .preset(Preset::Fast);
1092        assert_eq!(builder.preset, Preset::Fast);
1093    }
1094
1095    #[test]
1096    fn builder_hardware_encoder_should_be_stored() {
1097        let builder = VideoEncoder::create("output.mp4")
1098            .video(1920, 1080, 30.0)
1099            .hardware_encoder(HardwareEncoder::Nvenc);
1100        assert_eq!(builder.hardware_encoder, HardwareEncoder::Nvenc);
1101    }
1102
1103    #[test]
1104    fn builder_container_should_be_stored() {
1105        let builder = VideoEncoder::create("output.mp4")
1106            .video(1920, 1080, 30.0)
1107            .container(Container::Mp4);
1108        assert_eq!(builder.container, Some(Container::Mp4));
1109    }
1110
1111    #[test]
1112    fn build_without_streams_should_return_error() {
1113        let result = VideoEncoder::create("output.mp4").build();
1114        assert!(result.is_err());
1115    }
1116
1117    #[test]
1118    fn build_with_odd_width_should_return_error() {
1119        let result = VideoEncoder::create("output.mp4")
1120            .video(1921, 1080, 30.0)
1121            .build();
1122        assert!(result.is_err());
1123    }
1124
1125    #[test]
1126    fn build_with_odd_height_should_return_error() {
1127        let result = VideoEncoder::create("output.mp4")
1128            .video(1920, 1081, 30.0)
1129            .build();
1130        assert!(result.is_err());
1131    }
1132
1133    #[test]
1134    fn build_with_invalid_fps_should_return_error() {
1135        let result = VideoEncoder::create("output.mp4")
1136            .video(1920, 1080, -1.0)
1137            .build();
1138        assert!(result.is_err());
1139    }
1140
1141    #[test]
1142    fn two_pass_flag_should_be_stored_in_builder() {
1143        let builder = VideoEncoder::create("output.mp4")
1144            .video(640, 480, 30.0)
1145            .two_pass();
1146        assert!(builder.two_pass);
1147    }
1148
1149    #[test]
1150    fn two_pass_with_audio_should_return_error() {
1151        let result = VideoEncoder::create("output.mp4")
1152            .video(640, 480, 30.0)
1153            .audio(48000, 2)
1154            .two_pass()
1155            .build();
1156        assert!(result.is_err());
1157        if let Err(e) = result {
1158            assert!(
1159                matches!(e, crate::EncodeError::InvalidConfig { .. }),
1160                "expected InvalidConfig, got {e:?}"
1161            );
1162        }
1163    }
1164
1165    #[test]
1166    fn two_pass_without_video_should_return_error() {
1167        let result = VideoEncoder::create("output.mp4").two_pass().build();
1168        assert!(result.is_err());
1169    }
1170
1171    #[test]
1172    fn build_with_crf_above_51_should_return_error() {
1173        let result = VideoEncoder::create("output.mp4")
1174            .video(1920, 1080, 30.0)
1175            .bitrate_mode(crate::BitrateMode::Crf(100))
1176            .build();
1177        assert!(result.is_err());
1178    }
1179
1180    #[test]
1181    fn bitrate_mode_vbr_with_max_less_than_target_should_return_error() {
1182        let output_path = "test_vbr.mp4";
1183        let result = VideoEncoder::create(output_path)
1184            .video(640, 480, 30.0)
1185            .bitrate_mode(crate::BitrateMode::Vbr {
1186                target: 4_000_000,
1187                max: 2_000_000,
1188            })
1189            .build();
1190        assert!(result.is_err());
1191    }
1192
1193    #[test]
1194    fn is_lgpl_compliant_should_be_true_for_hardware_encoders() {
1195        for codec_name in &[
1196            "h264_nvenc",
1197            "h264_qsv",
1198            "h264_amf",
1199            "h264_videotoolbox",
1200            "hevc_vaapi",
1201        ] {
1202            let encoder = create_mock_encoder(codec_name, "");
1203            assert!(
1204                encoder.is_lgpl_compliant(),
1205                "expected LGPL-compliant for {codec_name}"
1206            );
1207        }
1208    }
1209
1210    #[test]
1211    fn is_lgpl_compliant_should_be_false_for_gpl_encoders() {
1212        for codec_name in &["libx264", "libx265"] {
1213            let encoder = create_mock_encoder(codec_name, "");
1214            assert!(
1215                !encoder.is_lgpl_compliant(),
1216                "expected non-LGPL for {codec_name}"
1217            );
1218        }
1219    }
1220
1221    #[test]
1222    fn hardware_encoder_detection_should_match_codec_name() {
1223        let cases: &[(&str, HardwareEncoder, bool)] = &[
1224            ("h264_nvenc", HardwareEncoder::Nvenc, true),
1225            ("h264_qsv", HardwareEncoder::Qsv, true),
1226            ("h264_amf", HardwareEncoder::Amf, true),
1227            ("h264_videotoolbox", HardwareEncoder::VideoToolbox, true),
1228            ("h264_vaapi", HardwareEncoder::Vaapi, true),
1229            ("libx264", HardwareEncoder::None, false),
1230            ("libvpx-vp9", HardwareEncoder::None, false),
1231        ];
1232        for (codec_name, expected_hw, expected_is_hw) in cases {
1233            let encoder = create_mock_encoder(codec_name, "");
1234            assert_eq!(
1235                encoder.hardware_encoder(),
1236                *expected_hw,
1237                "hw for {codec_name}"
1238            );
1239            assert_eq!(
1240                encoder.is_hardware_encoding(),
1241                *expected_is_hw,
1242                "is_hw for {codec_name}"
1243            );
1244        }
1245    }
1246
1247    #[test]
1248    fn add_attachment_should_accumulate_entries() {
1249        let builder = VideoEncoder::create("output.mkv")
1250            .video(320, 240, 30.0)
1251            .add_attachment(vec![1, 2, 3], "application/x-truetype-font", "font.ttf")
1252            .add_attachment(vec![4, 5, 6], "image/jpeg", "cover.jpg");
1253        assert_eq!(builder.attachments.len(), 2);
1254        assert_eq!(builder.attachments[0].0, vec![1u8, 2, 3]);
1255        assert_eq!(builder.attachments[0].1, "application/x-truetype-font");
1256        assert_eq!(builder.attachments[0].2, "font.ttf");
1257        assert_eq!(builder.attachments[1].1, "image/jpeg");
1258        assert_eq!(builder.attachments[1].2, "cover.jpg");
1259    }
1260
1261    #[test]
1262    fn add_attachment_with_no_attachments_should_start_empty() {
1263        let builder = VideoEncoder::create("output.mkv").video(320, 240, 30.0);
1264        assert!(builder.attachments.is_empty());
1265    }
1266
1267    #[test]
1268    fn webm_extension_with_h264_video_codec_should_return_error() {
1269        let result = VideoEncoder::create("output.webm")
1270            .video(640, 480, 30.0)
1271            .video_codec(VideoCodec::H264)
1272            .build();
1273        assert!(matches!(
1274            result,
1275            Err(crate::EncodeError::UnsupportedContainerCodecCombination { .. })
1276        ));
1277    }
1278
1279    #[test]
1280    fn webm_extension_with_h265_video_codec_should_return_error() {
1281        let result = VideoEncoder::create("output.webm")
1282            .video(640, 480, 30.0)
1283            .video_codec(VideoCodec::H265)
1284            .build();
1285        assert!(matches!(
1286            result,
1287            Err(crate::EncodeError::UnsupportedContainerCodecCombination { .. })
1288        ));
1289    }
1290
1291    #[test]
1292    fn webm_extension_with_incompatible_audio_codec_should_return_error() {
1293        let result = VideoEncoder::create("output.webm")
1294            .video(640, 480, 30.0)
1295            .video_codec(VideoCodec::Vp9)
1296            .audio(48000, 2)
1297            .audio_codec(AudioCodec::Aac)
1298            .build();
1299        assert!(matches!(
1300            result,
1301            Err(crate::EncodeError::UnsupportedContainerCodecCombination { .. })
1302        ));
1303    }
1304
1305    #[test]
1306    fn webm_extension_without_explicit_codec_should_default_to_vp9_opus() {
1307        let builder = VideoEncoder::create("output.webm").video(640, 480, 30.0);
1308        let normalized = builder.apply_container_defaults();
1309        assert_eq!(normalized.video_codec, VideoCodec::Vp9);
1310        assert_eq!(normalized.audio_codec, AudioCodec::Opus);
1311    }
1312
1313    #[test]
1314    fn webm_extension_with_explicit_vp9_should_preserve_codec() {
1315        let builder = VideoEncoder::create("output.webm")
1316            .video(640, 480, 30.0)
1317            .video_codec(VideoCodec::Vp9);
1318        assert!(builder.video_codec_explicit);
1319        let normalized = builder.apply_container_defaults();
1320        assert_eq!(normalized.video_codec, VideoCodec::Vp9);
1321    }
1322
1323    #[test]
1324    fn webm_container_enum_with_incompatible_codec_should_return_error() {
1325        let result = VideoEncoder::create("output.mkv")
1326            .video(640, 480, 30.0)
1327            .container(Container::WebM)
1328            .video_codec(VideoCodec::H264)
1329            .build();
1330        assert!(matches!(
1331            result,
1332            Err(crate::EncodeError::UnsupportedContainerCodecCombination { .. })
1333        ));
1334    }
1335
1336    #[test]
1337    fn non_webm_extension_should_not_enforce_webm_codecs() {
1338        // H264 + AAC on .mp4 should not trigger WebM validation
1339        let result = VideoEncoder::create("output.mp4")
1340            .video(640, 480, 30.0)
1341            .video_codec(VideoCodec::H264)
1342            .build();
1343        // Should not fail with UnsupportedContainerCodecCombination
1344        assert!(!matches!(
1345            result,
1346            Err(crate::EncodeError::UnsupportedContainerCodecCombination { .. })
1347        ));
1348    }
1349
1350    #[test]
1351    fn avi_extension_without_explicit_codec_should_default_to_h264_mp3() {
1352        let builder = VideoEncoder::create("output.avi").video(640, 480, 30.0);
1353        let normalized = builder.apply_container_defaults();
1354        assert_eq!(normalized.video_codec, VideoCodec::H264);
1355        assert_eq!(normalized.audio_codec, AudioCodec::Mp3);
1356    }
1357
1358    #[test]
1359    fn mov_extension_without_explicit_codec_should_default_to_h264_aac() {
1360        let builder = VideoEncoder::create("output.mov").video(640, 480, 30.0);
1361        let normalized = builder.apply_container_defaults();
1362        assert_eq!(normalized.video_codec, VideoCodec::H264);
1363        assert_eq!(normalized.audio_codec, AudioCodec::Aac);
1364    }
1365
1366    #[test]
1367    fn avi_with_incompatible_video_codec_should_return_error() {
1368        let result = VideoEncoder::create("output.avi")
1369            .video(640, 480, 30.0)
1370            .video_codec(VideoCodec::Vp9)
1371            .build();
1372        assert!(matches!(
1373            result,
1374            Err(crate::EncodeError::UnsupportedContainerCodecCombination { .. })
1375        ));
1376    }
1377
1378    #[test]
1379    fn avi_with_incompatible_audio_codec_should_return_error() {
1380        let result = VideoEncoder::create("output.avi")
1381            .video(640, 480, 30.0)
1382            .video_codec(VideoCodec::H264)
1383            .audio(48000, 2)
1384            .audio_codec(AudioCodec::Opus)
1385            .build();
1386        assert!(matches!(
1387            result,
1388            Err(crate::EncodeError::UnsupportedContainerCodecCombination { .. })
1389        ));
1390    }
1391
1392    #[test]
1393    fn mov_with_incompatible_video_codec_should_return_error() {
1394        let result = VideoEncoder::create("output.mov")
1395            .video(640, 480, 30.0)
1396            .video_codec(VideoCodec::Vp9)
1397            .build();
1398        assert!(matches!(
1399            result,
1400            Err(crate::EncodeError::UnsupportedContainerCodecCombination { .. })
1401        ));
1402    }
1403
1404    #[test]
1405    fn mov_with_incompatible_audio_codec_should_return_error() {
1406        let result = VideoEncoder::create("output.mov")
1407            .video(640, 480, 30.0)
1408            .video_codec(VideoCodec::H264)
1409            .audio(48000, 2)
1410            .audio_codec(AudioCodec::Opus)
1411            .build();
1412        assert!(matches!(
1413            result,
1414            Err(crate::EncodeError::UnsupportedContainerCodecCombination { .. })
1415        ));
1416    }
1417
1418    #[test]
1419    fn avi_container_enum_with_incompatible_codec_should_return_error() {
1420        let result = VideoEncoder::create("output.mp4")
1421            .video(640, 480, 30.0)
1422            .container(Container::Avi)
1423            .video_codec(VideoCodec::Vp9)
1424            .build();
1425        assert!(matches!(
1426            result,
1427            Err(crate::EncodeError::UnsupportedContainerCodecCombination { .. })
1428        ));
1429    }
1430
1431    #[test]
1432    fn mov_container_enum_with_incompatible_codec_should_return_error() {
1433        let result = VideoEncoder::create("output.mp4")
1434            .video(640, 480, 30.0)
1435            .container(Container::Mov)
1436            .video_codec(VideoCodec::Vp9)
1437            .build();
1438        assert!(matches!(
1439            result,
1440            Err(crate::EncodeError::UnsupportedContainerCodecCombination { .. })
1441        ));
1442    }
1443
1444    #[test]
1445    fn avi_with_pcm_audio_should_pass_validation() {
1446        // AudioCodec::Pcm (backward-compat alias for 16-bit PCM) must be accepted in AVI.
1447        let result = VideoEncoder::create("output.avi")
1448            .video(640, 480, 30.0)
1449            .video_codec(VideoCodec::H264)
1450            .audio(48000, 2)
1451            .audio_codec(AudioCodec::Pcm)
1452            .build();
1453        assert!(!matches!(
1454            result,
1455            Err(crate::EncodeError::UnsupportedContainerCodecCombination { .. })
1456        ));
1457    }
1458
1459    #[test]
1460    fn mov_with_pcm24_audio_should_pass_validation() {
1461        let result = VideoEncoder::create("output.mov")
1462            .video(640, 480, 30.0)
1463            .video_codec(VideoCodec::H264)
1464            .audio(48000, 2)
1465            .audio_codec(AudioCodec::Pcm24)
1466            .build();
1467        assert!(!matches!(
1468            result,
1469            Err(crate::EncodeError::UnsupportedContainerCodecCombination { .. })
1470        ));
1471    }
1472
1473    #[test]
1474    fn non_avi_mov_extension_should_not_enforce_avi_mov_codecs() {
1475        // Vp9 on .webm should not trigger AVI/MOV validation
1476        let result = VideoEncoder::create("output.webm")
1477            .video(640, 480, 30.0)
1478            .video_codec(VideoCodec::Vp9)
1479            .build();
1480        assert!(!matches!(
1481            result,
1482            Err(crate::EncodeError::UnsupportedContainerCodecCombination {
1483                ref container, ..
1484            }) if container == "avi" || container == "mov"
1485        ));
1486    }
1487}