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::encoder_inner::{VideoEncoderConfig, VideoEncoderInner, preset_to_string};
12use crate::{
13    AudioCodec, Container, EncodeError, HardwareEncoder, Preset, ProgressCallback, VideoCodec,
14};
15
16/// Builder for constructing a [`VideoEncoder`].
17///
18/// Created by calling [`VideoEncoder::create()`]. Call [`build()`](Self::build)
19/// to open the output file and prepare for encoding.
20///
21/// # Examples
22///
23/// ```ignore
24/// use ff_encode::{VideoEncoder, VideoCodec, Preset};
25///
26/// let mut encoder = VideoEncoder::create("output.mp4")
27///     .video(1920, 1080, 30.0)
28///     .video_codec(VideoCodec::H264)
29///     .preset(Preset::Medium)
30///     .build()?;
31/// ```
32pub struct VideoEncoderBuilder {
33    pub(crate) path: PathBuf,
34    pub(crate) container: Option<Container>,
35    pub(crate) video_width: Option<u32>,
36    pub(crate) video_height: Option<u32>,
37    pub(crate) video_fps: Option<f64>,
38    pub(crate) video_codec: VideoCodec,
39    pub(crate) video_bitrate_mode: Option<crate::BitrateMode>,
40    pub(crate) preset: Preset,
41    pub(crate) hardware_encoder: HardwareEncoder,
42    pub(crate) audio_sample_rate: Option<u32>,
43    pub(crate) audio_channels: Option<u32>,
44    pub(crate) audio_codec: AudioCodec,
45    pub(crate) audio_bitrate: Option<u64>,
46    pub(crate) progress_callback: Option<Box<dyn ProgressCallback>>,
47    pub(crate) two_pass: bool,
48    pub(crate) metadata: Vec<(String, String)>,
49    pub(crate) chapters: Vec<ff_format::chapter::ChapterInfo>,
50    pub(crate) subtitle_passthrough: Option<(String, usize)>,
51}
52
53impl std::fmt::Debug for VideoEncoderBuilder {
54    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
55        f.debug_struct("VideoEncoderBuilder")
56            .field("path", &self.path)
57            .field("container", &self.container)
58            .field("video_width", &self.video_width)
59            .field("video_height", &self.video_height)
60            .field("video_fps", &self.video_fps)
61            .field("video_codec", &self.video_codec)
62            .field("video_bitrate_mode", &self.video_bitrate_mode)
63            .field("preset", &self.preset)
64            .field("hardware_encoder", &self.hardware_encoder)
65            .field("audio_sample_rate", &self.audio_sample_rate)
66            .field("audio_channels", &self.audio_channels)
67            .field("audio_codec", &self.audio_codec)
68            .field("audio_bitrate", &self.audio_bitrate)
69            .field(
70                "progress_callback",
71                &self.progress_callback.as_ref().map(|_| "<callback>"),
72            )
73            .field("two_pass", &self.two_pass)
74            .field("metadata", &self.metadata)
75            .field("chapters", &self.chapters)
76            .field("subtitle_passthrough", &self.subtitle_passthrough)
77            .finish()
78    }
79}
80
81impl VideoEncoderBuilder {
82    pub(crate) fn new(path: PathBuf) -> Self {
83        Self {
84            path,
85            container: None,
86            video_width: None,
87            video_height: None,
88            video_fps: None,
89            video_codec: VideoCodec::default(),
90            video_bitrate_mode: None,
91            preset: Preset::default(),
92            hardware_encoder: HardwareEncoder::default(),
93            audio_sample_rate: None,
94            audio_channels: None,
95            audio_codec: AudioCodec::default(),
96            audio_bitrate: None,
97            progress_callback: None,
98            two_pass: false,
99            metadata: Vec::new(),
100            chapters: Vec::new(),
101            subtitle_passthrough: None,
102        }
103    }
104
105    // === Video settings ===
106
107    /// Configure video stream settings.
108    #[must_use]
109    pub fn video(mut self, width: u32, height: u32, fps: f64) -> Self {
110        self.video_width = Some(width);
111        self.video_height = Some(height);
112        self.video_fps = Some(fps);
113        self
114    }
115
116    /// Set video codec.
117    #[must_use]
118    pub fn video_codec(mut self, codec: VideoCodec) -> Self {
119        self.video_codec = codec;
120        self
121    }
122
123    /// Set the bitrate control mode for video encoding.
124    #[must_use]
125    pub fn bitrate_mode(mut self, mode: crate::BitrateMode) -> Self {
126        self.video_bitrate_mode = Some(mode);
127        self
128    }
129
130    /// Set encoding preset (speed vs quality tradeoff).
131    #[must_use]
132    pub fn preset(mut self, preset: Preset) -> Self {
133        self.preset = preset;
134        self
135    }
136
137    /// Set hardware encoder.
138    #[must_use]
139    pub fn hardware_encoder(mut self, hw: HardwareEncoder) -> Self {
140        self.hardware_encoder = hw;
141        self
142    }
143
144    // === Audio settings ===
145
146    /// Configure audio stream settings.
147    #[must_use]
148    pub fn audio(mut self, sample_rate: u32, channels: u32) -> Self {
149        self.audio_sample_rate = Some(sample_rate);
150        self.audio_channels = Some(channels);
151        self
152    }
153
154    /// Set audio codec.
155    #[must_use]
156    pub fn audio_codec(mut self, codec: AudioCodec) -> Self {
157        self.audio_codec = codec;
158        self
159    }
160
161    /// Set audio bitrate in bits per second.
162    #[must_use]
163    pub fn audio_bitrate(mut self, bitrate: u64) -> Self {
164        self.audio_bitrate = Some(bitrate);
165        self
166    }
167
168    // === Container settings ===
169
170    /// Set container format explicitly (usually auto-detected from file extension).
171    #[must_use]
172    pub fn container(mut self, container: Container) -> Self {
173        self.container = Some(container);
174        self
175    }
176
177    // === Callbacks ===
178
179    /// Set a closure as the progress callback.
180    #[must_use]
181    pub fn on_progress<F>(mut self, callback: F) -> Self
182    where
183        F: FnMut(&crate::Progress) + Send + 'static,
184    {
185        self.progress_callback = Some(Box::new(callback));
186        self
187    }
188
189    /// Set a [`ProgressCallback`] trait object (supports cancellation).
190    #[must_use]
191    pub fn progress_callback<C: ProgressCallback + 'static>(mut self, callback: C) -> Self {
192        self.progress_callback = Some(Box::new(callback));
193        self
194    }
195
196    // === Two-pass ===
197
198    /// Enable two-pass encoding for more accurate bitrate distribution.
199    ///
200    /// Two-pass encoding is video-only and is incompatible with audio streams.
201    #[must_use]
202    pub fn two_pass(mut self) -> Self {
203        self.two_pass = true;
204        self
205    }
206
207    // === Metadata ===
208
209    /// Embed a metadata tag in the output container.
210    ///
211    /// Calls `av_dict_set` on `AVFormatContext->metadata` before the header
212    /// is written. Multiple calls accumulate entries; duplicate keys use the
213    /// last value.
214    #[must_use]
215    pub fn metadata(mut self, key: &str, value: &str) -> Self {
216        self.metadata.push((key.to_string(), value.to_string()));
217        self
218    }
219
220    // === Chapters ===
221
222    /// Add a chapter to the output container.
223    ///
224    /// Allocates an `AVChapter` entry on `AVFormatContext` before the header
225    /// is written. Multiple calls accumulate chapters in the order added.
226    #[must_use]
227    pub fn chapter(mut self, chapter: ff_format::chapter::ChapterInfo) -> Self {
228        self.chapters.push(chapter);
229        self
230    }
231
232    // === Subtitle passthrough ===
233
234    /// Copy a subtitle stream from an existing file into the output container.
235    ///
236    /// Opens `source_path`, locates the stream at `stream_index`, and registers it
237    /// as a passthrough stream in the output.  Packets are copied verbatim using
238    /// `av_interleaved_write_frame` without re-encoding.
239    ///
240    /// `stream_index` is the zero-based index of the subtitle stream inside
241    /// `source_path`.  For files with a single subtitle track this is typically `0`
242    /// (or whichever index `ffprobe` reports).
243    ///
244    /// If the source cannot be opened or the stream index is invalid, a warning is
245    /// logged and encoding continues without subtitles.
246    #[must_use]
247    pub fn subtitle_passthrough(mut self, source_path: &str, stream_index: usize) -> Self {
248        self.subtitle_passthrough = Some((source_path.to_string(), stream_index));
249        self
250    }
251
252    // === Build ===
253
254    /// Validate builder state and open the output file.
255    ///
256    /// # Errors
257    ///
258    /// Returns [`EncodeError`] if configuration is invalid, the output path
259    /// cannot be created, or no suitable encoder is found.
260    pub fn build(self) -> Result<VideoEncoder, EncodeError> {
261        self.validate()?;
262        VideoEncoder::from_builder(self)
263    }
264
265    fn validate(&self) -> Result<(), EncodeError> {
266        let has_video =
267            self.video_width.is_some() && self.video_height.is_some() && self.video_fps.is_some();
268        let has_audio = self.audio_sample_rate.is_some() && self.audio_channels.is_some();
269
270        if !has_video && !has_audio {
271            return Err(EncodeError::InvalidConfig {
272                reason: "At least one video or audio stream must be configured".to_string(),
273            });
274        }
275
276        if self.two_pass {
277            if !has_video {
278                return Err(EncodeError::InvalidConfig {
279                    reason: "Two-pass encoding requires a video stream".to_string(),
280                });
281            }
282            if has_audio {
283                return Err(EncodeError::InvalidConfig {
284                    reason:
285                        "Two-pass encoding is video-only and is incompatible with audio streams"
286                            .to_string(),
287                });
288            }
289        }
290
291        if has_video {
292            if let Some(width) = self.video_width
293                && (width == 0 || width % 2 != 0)
294            {
295                return Err(EncodeError::InvalidConfig {
296                    reason: format!("Video width must be non-zero and even, got {width}"),
297                });
298            }
299            if let Some(height) = self.video_height
300                && (height == 0 || height % 2 != 0)
301            {
302                return Err(EncodeError::InvalidConfig {
303                    reason: format!("Video height must be non-zero and even, got {height}"),
304                });
305            }
306            if let Some(fps) = self.video_fps
307                && fps <= 0.0
308            {
309                return Err(EncodeError::InvalidConfig {
310                    reason: format!("Video FPS must be positive, got {fps}"),
311                });
312            }
313            if let Some(crate::BitrateMode::Crf(q)) = self.video_bitrate_mode
314                && q > crate::CRF_MAX
315            {
316                return Err(EncodeError::InvalidConfig {
317                    reason: format!(
318                        "BitrateMode::Crf value must be 0-{}, got {q}",
319                        crate::CRF_MAX
320                    ),
321                });
322            }
323            if let Some(crate::BitrateMode::Vbr { target, max }) = self.video_bitrate_mode
324                && max < target
325            {
326                return Err(EncodeError::InvalidConfig {
327                    reason: format!("BitrateMode::Vbr max ({max}) must be >= target ({target})"),
328                });
329            }
330        }
331
332        if has_audio {
333            if let Some(rate) = self.audio_sample_rate
334                && rate == 0
335            {
336                return Err(EncodeError::InvalidConfig {
337                    reason: "Audio sample rate must be non-zero".to_string(),
338                });
339            }
340            if let Some(ch) = self.audio_channels
341                && ch == 0
342            {
343                return Err(EncodeError::InvalidConfig {
344                    reason: "Audio channels must be non-zero".to_string(),
345                });
346            }
347        }
348
349        Ok(())
350    }
351}
352
353/// Encodes video (and optionally audio) frames to a file using FFmpeg.
354///
355/// # Construction
356///
357/// Use [`VideoEncoder::create()`] to get a [`VideoEncoderBuilder`], then call
358/// [`VideoEncoderBuilder::build()`]:
359///
360/// ```ignore
361/// use ff_encode::{VideoEncoder, VideoCodec};
362///
363/// let mut encoder = VideoEncoder::create("output.mp4")
364///     .video(1920, 1080, 30.0)
365///     .video_codec(VideoCodec::H264)
366///     .build()?;
367/// ```
368pub struct VideoEncoder {
369    inner: Option<VideoEncoderInner>,
370    _config: VideoEncoderConfig,
371    start_time: Instant,
372    progress_callback: Option<Box<dyn crate::ProgressCallback>>,
373}
374
375impl VideoEncoder {
376    /// Creates a builder for the specified output file path.
377    ///
378    /// This method is infallible. Validation occurs when
379    /// [`VideoEncoderBuilder::build()`] is called.
380    pub fn create<P: AsRef<std::path::Path>>(path: P) -> VideoEncoderBuilder {
381        VideoEncoderBuilder::new(path.as_ref().to_path_buf())
382    }
383
384    pub(crate) fn from_builder(builder: VideoEncoderBuilder) -> Result<Self, EncodeError> {
385        let config = VideoEncoderConfig {
386            path: builder.path.clone(),
387            video_width: builder.video_width,
388            video_height: builder.video_height,
389            video_fps: builder.video_fps,
390            video_codec: builder.video_codec,
391            video_bitrate_mode: builder.video_bitrate_mode,
392            preset: preset_to_string(&builder.preset),
393            hardware_encoder: builder.hardware_encoder,
394            audio_sample_rate: builder.audio_sample_rate,
395            audio_channels: builder.audio_channels,
396            audio_codec: builder.audio_codec,
397            audio_bitrate: builder.audio_bitrate,
398            _progress_callback: builder.progress_callback.is_some(),
399            two_pass: builder.two_pass,
400            metadata: builder.metadata,
401            chapters: builder.chapters,
402            subtitle_passthrough: builder.subtitle_passthrough,
403        };
404
405        let inner = if config.video_width.is_some() {
406            Some(VideoEncoderInner::new(&config)?)
407        } else {
408            None
409        };
410
411        Ok(Self {
412            inner,
413            _config: config,
414            start_time: Instant::now(),
415            progress_callback: builder.progress_callback,
416        })
417    }
418
419    /// Returns the name of the FFmpeg encoder actually used (e.g. `"h264_nvenc"`, `"libx264"`).
420    #[must_use]
421    pub fn actual_video_codec(&self) -> &str {
422        self.inner
423            .as_ref()
424            .map_or("", |inner| inner.actual_video_codec.as_str())
425    }
426
427    /// Returns the name of the FFmpeg audio encoder actually used.
428    #[must_use]
429    pub fn actual_audio_codec(&self) -> &str {
430        self.inner
431            .as_ref()
432            .map_or("", |inner| inner.actual_audio_codec.as_str())
433    }
434
435    /// Returns the hardware encoder actually in use.
436    #[must_use]
437    pub fn hardware_encoder(&self) -> crate::HardwareEncoder {
438        let codec_name = self.actual_video_codec();
439        if codec_name.contains("nvenc") {
440            crate::HardwareEncoder::Nvenc
441        } else if codec_name.contains("qsv") {
442            crate::HardwareEncoder::Qsv
443        } else if codec_name.contains("amf") {
444            crate::HardwareEncoder::Amf
445        } else if codec_name.contains("videotoolbox") {
446            crate::HardwareEncoder::VideoToolbox
447        } else if codec_name.contains("vaapi") {
448            crate::HardwareEncoder::Vaapi
449        } else {
450            crate::HardwareEncoder::None
451        }
452    }
453
454    /// Returns `true` if a hardware encoder is active.
455    #[must_use]
456    pub fn is_hardware_encoding(&self) -> bool {
457        !matches!(self.hardware_encoder(), crate::HardwareEncoder::None)
458    }
459
460    /// Returns `true` if the selected encoder is LGPL-compatible (safe for commercial use).
461    #[must_use]
462    pub fn is_lgpl_compliant(&self) -> bool {
463        let codec_name = self.actual_video_codec();
464        if codec_name.contains("nvenc")
465            || codec_name.contains("qsv")
466            || codec_name.contains("amf")
467            || codec_name.contains("videotoolbox")
468            || codec_name.contains("vaapi")
469        {
470            return true;
471        }
472        if codec_name.contains("vp9")
473            || codec_name.contains("av1")
474            || codec_name.contains("aom")
475            || codec_name.contains("svt")
476            || codec_name.contains("prores")
477            || codec_name == "mpeg4"
478            || codec_name == "dnxhd"
479        {
480            return true;
481        }
482        if codec_name == "libx264" || codec_name == "libx265" {
483            return false;
484        }
485        true
486    }
487
488    /// Pushes a video frame for encoding.
489    ///
490    /// # Errors
491    ///
492    /// Returns [`EncodeError`] if encoding fails or the encoder is not initialised.
493    /// Returns [`EncodeError::Cancelled`] if the progress callback requested cancellation.
494    pub fn push_video(&mut self, frame: &VideoFrame) -> Result<(), EncodeError> {
495        if let Some(ref callback) = self.progress_callback
496            && callback.should_cancel()
497        {
498            return Err(EncodeError::Cancelled);
499        }
500        let inner = self
501            .inner
502            .as_mut()
503            .ok_or_else(|| EncodeError::InvalidConfig {
504                reason: "Video encoder not initialized".to_string(),
505            })?;
506        // SAFETY: inner is properly initialised and we have exclusive access.
507        unsafe { inner.push_video_frame(frame)? };
508        let progress = self.create_progress_info();
509        if let Some(ref mut callback) = self.progress_callback {
510            callback.on_progress(&progress);
511        }
512        Ok(())
513    }
514
515    /// Pushes an audio frame for encoding.
516    ///
517    /// # Errors
518    ///
519    /// Returns [`EncodeError`] if encoding fails or the encoder is not initialised.
520    pub fn push_audio(&mut self, frame: &AudioFrame) -> Result<(), EncodeError> {
521        if let Some(ref callback) = self.progress_callback
522            && callback.should_cancel()
523        {
524            return Err(EncodeError::Cancelled);
525        }
526        let inner = self
527            .inner
528            .as_mut()
529            .ok_or_else(|| EncodeError::InvalidConfig {
530                reason: "Audio encoder not initialized".to_string(),
531            })?;
532        // SAFETY: inner is properly initialised and we have exclusive access.
533        unsafe { inner.push_audio_frame(frame)? };
534        let progress = self.create_progress_info();
535        if let Some(ref mut callback) = self.progress_callback {
536            callback.on_progress(&progress);
537        }
538        Ok(())
539    }
540
541    /// Flushes remaining frames and writes the file trailer.
542    ///
543    /// # Errors
544    ///
545    /// Returns [`EncodeError`] if finalising fails.
546    pub fn finish(mut self) -> Result<(), EncodeError> {
547        if let Some(mut inner) = self.inner.take() {
548            // SAFETY: inner is properly initialised and we have exclusive access.
549            unsafe { inner.finish()? };
550        }
551        Ok(())
552    }
553
554    fn create_progress_info(&self) -> crate::Progress {
555        let elapsed = self.start_time.elapsed();
556        let (frames_encoded, bytes_written) = self
557            .inner
558            .as_ref()
559            .map_or((0, 0), |inner| (inner.frame_count, inner.bytes_written));
560        #[allow(clippy::cast_precision_loss)]
561        let current_fps = if !elapsed.is_zero() {
562            frames_encoded as f64 / elapsed.as_secs_f64()
563        } else {
564            0.0
565        };
566        #[allow(clippy::cast_precision_loss)]
567        let current_bitrate = if !elapsed.is_zero() {
568            let elapsed_secs = elapsed.as_secs();
569            if elapsed_secs > 0 {
570                (bytes_written * 8) / elapsed_secs
571            } else {
572                ((bytes_written * 8) as f64 / elapsed.as_secs_f64()) as u64
573            }
574        } else {
575            0
576        };
577        crate::Progress {
578            frames_encoded,
579            total_frames: None,
580            bytes_written,
581            current_bitrate,
582            elapsed,
583            remaining: None,
584            current_fps,
585        }
586    }
587}
588
589impl Drop for VideoEncoder {
590    fn drop(&mut self) {
591        // VideoEncoderInner handles cleanup in its own Drop.
592    }
593}
594
595#[cfg(test)]
596#[allow(clippy::unwrap_used)]
597mod tests {
598    use super::super::encoder_inner::{VideoEncoderConfig, VideoEncoderInner};
599    use super::*;
600    use crate::HardwareEncoder;
601
602    fn create_mock_encoder(video_codec_name: &str, audio_codec_name: &str) -> VideoEncoder {
603        VideoEncoder {
604            inner: Some(VideoEncoderInner {
605                format_ctx: std::ptr::null_mut(),
606                video_codec_ctx: None,
607                audio_codec_ctx: None,
608                video_stream_index: -1,
609                audio_stream_index: -1,
610                sws_ctx: None,
611                swr_ctx: None,
612                frame_count: 0,
613                audio_sample_count: 0,
614                bytes_written: 0,
615                actual_video_codec: video_codec_name.to_string(),
616                actual_audio_codec: audio_codec_name.to_string(),
617                last_src_width: None,
618                last_src_height: None,
619                last_src_format: None,
620                two_pass: false,
621                pass1_codec_ctx: None,
622                buffered_frames: Vec::new(),
623                two_pass_config: None,
624                stats_in_cstr: None,
625                subtitle_passthrough: None,
626            }),
627            _config: VideoEncoderConfig {
628                path: "test.mp4".into(),
629                video_width: Some(1920),
630                video_height: Some(1080),
631                video_fps: Some(30.0),
632                video_codec: crate::VideoCodec::H264,
633                video_bitrate_mode: None,
634                preset: "medium".to_string(),
635                hardware_encoder: HardwareEncoder::Auto,
636                audio_sample_rate: None,
637                audio_channels: None,
638                audio_codec: crate::AudioCodec::Aac,
639                audio_bitrate: None,
640                _progress_callback: false,
641                two_pass: false,
642                metadata: Vec::new(),
643                chapters: Vec::new(),
644                subtitle_passthrough: None,
645            },
646            start_time: std::time::Instant::now(),
647            progress_callback: None,
648        }
649    }
650
651    #[test]
652    fn create_should_return_builder_without_error() {
653        let _builder: VideoEncoderBuilder = VideoEncoder::create("output.mp4");
654    }
655
656    #[test]
657    fn builder_video_settings_should_be_stored() {
658        let builder = VideoEncoder::create("output.mp4")
659            .video(1920, 1080, 30.0)
660            .video_codec(VideoCodec::H264)
661            .bitrate_mode(crate::BitrateMode::Cbr(8_000_000));
662        assert_eq!(builder.video_width, Some(1920));
663        assert_eq!(builder.video_height, Some(1080));
664        assert_eq!(builder.video_fps, Some(30.0));
665        assert_eq!(builder.video_codec, VideoCodec::H264);
666        assert_eq!(
667            builder.video_bitrate_mode,
668            Some(crate::BitrateMode::Cbr(8_000_000))
669        );
670    }
671
672    #[test]
673    fn builder_audio_settings_should_be_stored() {
674        let builder = VideoEncoder::create("output.mp4")
675            .audio(48000, 2)
676            .audio_codec(AudioCodec::Aac)
677            .audio_bitrate(192_000);
678        assert_eq!(builder.audio_sample_rate, Some(48000));
679        assert_eq!(builder.audio_channels, Some(2));
680        assert_eq!(builder.audio_codec, AudioCodec::Aac);
681        assert_eq!(builder.audio_bitrate, Some(192_000));
682    }
683
684    #[test]
685    fn builder_preset_should_be_stored() {
686        let builder = VideoEncoder::create("output.mp4")
687            .video(1920, 1080, 30.0)
688            .preset(Preset::Fast);
689        assert_eq!(builder.preset, Preset::Fast);
690    }
691
692    #[test]
693    fn builder_hardware_encoder_should_be_stored() {
694        let builder = VideoEncoder::create("output.mp4")
695            .video(1920, 1080, 30.0)
696            .hardware_encoder(HardwareEncoder::Nvenc);
697        assert_eq!(builder.hardware_encoder, HardwareEncoder::Nvenc);
698    }
699
700    #[test]
701    fn builder_container_should_be_stored() {
702        let builder = VideoEncoder::create("output.mp4")
703            .video(1920, 1080, 30.0)
704            .container(Container::Mp4);
705        assert_eq!(builder.container, Some(Container::Mp4));
706    }
707
708    #[test]
709    fn build_without_streams_should_return_error() {
710        let result = VideoEncoder::create("output.mp4").build();
711        assert!(result.is_err());
712    }
713
714    #[test]
715    fn build_with_odd_width_should_return_error() {
716        let result = VideoEncoder::create("output.mp4")
717            .video(1921, 1080, 30.0)
718            .build();
719        assert!(result.is_err());
720    }
721
722    #[test]
723    fn build_with_odd_height_should_return_error() {
724        let result = VideoEncoder::create("output.mp4")
725            .video(1920, 1081, 30.0)
726            .build();
727        assert!(result.is_err());
728    }
729
730    #[test]
731    fn build_with_invalid_fps_should_return_error() {
732        let result = VideoEncoder::create("output.mp4")
733            .video(1920, 1080, -1.0)
734            .build();
735        assert!(result.is_err());
736    }
737
738    #[test]
739    fn two_pass_flag_should_be_stored_in_builder() {
740        let builder = VideoEncoder::create("output.mp4")
741            .video(640, 480, 30.0)
742            .two_pass();
743        assert!(builder.two_pass);
744    }
745
746    #[test]
747    fn two_pass_with_audio_should_return_error() {
748        let result = VideoEncoder::create("output.mp4")
749            .video(640, 480, 30.0)
750            .audio(48000, 2)
751            .two_pass()
752            .build();
753        assert!(result.is_err());
754        if let Err(e) = result {
755            assert!(
756                matches!(e, crate::EncodeError::InvalidConfig { .. }),
757                "expected InvalidConfig, got {e:?}"
758            );
759        }
760    }
761
762    #[test]
763    fn two_pass_without_video_should_return_error() {
764        let result = VideoEncoder::create("output.mp4").two_pass().build();
765        assert!(result.is_err());
766    }
767
768    #[test]
769    fn build_with_crf_above_51_should_return_error() {
770        let result = VideoEncoder::create("output.mp4")
771            .video(1920, 1080, 30.0)
772            .bitrate_mode(crate::BitrateMode::Crf(100))
773            .build();
774        assert!(result.is_err());
775    }
776
777    #[test]
778    fn bitrate_mode_vbr_with_max_less_than_target_should_return_error() {
779        let output_path = "test_vbr.mp4";
780        let result = VideoEncoder::create(output_path)
781            .video(640, 480, 30.0)
782            .bitrate_mode(crate::BitrateMode::Vbr {
783                target: 4_000_000,
784                max: 2_000_000,
785            })
786            .build();
787        assert!(result.is_err());
788    }
789
790    #[test]
791    fn is_lgpl_compliant_should_be_true_for_hardware_encoders() {
792        for codec_name in &[
793            "h264_nvenc",
794            "h264_qsv",
795            "h264_amf",
796            "h264_videotoolbox",
797            "hevc_vaapi",
798        ] {
799            let encoder = create_mock_encoder(codec_name, "");
800            assert!(
801                encoder.is_lgpl_compliant(),
802                "expected LGPL-compliant for {codec_name}"
803            );
804        }
805    }
806
807    #[test]
808    fn is_lgpl_compliant_should_be_false_for_gpl_encoders() {
809        for codec_name in &["libx264", "libx265"] {
810            let encoder = create_mock_encoder(codec_name, "");
811            assert!(
812                !encoder.is_lgpl_compliant(),
813                "expected non-LGPL for {codec_name}"
814            );
815        }
816    }
817
818    #[test]
819    fn hardware_encoder_detection_should_match_codec_name() {
820        let cases: &[(&str, HardwareEncoder, bool)] = &[
821            ("h264_nvenc", HardwareEncoder::Nvenc, true),
822            ("h264_qsv", HardwareEncoder::Qsv, true),
823            ("h264_amf", HardwareEncoder::Amf, true),
824            ("h264_videotoolbox", HardwareEncoder::VideoToolbox, true),
825            ("h264_vaapi", HardwareEncoder::Vaapi, true),
826            ("libx264", HardwareEncoder::None, false),
827            ("libvpx-vp9", HardwareEncoder::None, false),
828        ];
829        for (codec_name, expected_hw, expected_is_hw) in cases {
830            let encoder = create_mock_encoder(codec_name, "");
831            assert_eq!(
832                encoder.hardware_encoder(),
833                *expected_hw,
834                "hw for {codec_name}"
835            );
836            assert_eq!(
837                encoder.is_hardware_encoding(),
838                *expected_is_hw,
839                "is_hw for {codec_name}"
840            );
841        }
842    }
843}