Skip to main content

ff_encode/video/builder/
mod.rs

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