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