1use 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
18pub 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 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 #[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 #[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 #[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 #[must_use]
163 pub fn preset(mut self, preset: Preset) -> Self {
164 self.preset = preset;
165 self
166 }
167
168 #[must_use]
170 pub fn hardware_encoder(mut self, hw: HardwareEncoder) -> Self {
171 self.hardware_encoder = hw;
172 self
173 }
174
175 #[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 #[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 #[must_use]
195 pub fn audio_bitrate(mut self, bitrate: u64) -> Self {
196 self.audio_bitrate = Some(bitrate);
197 self
198 }
199
200 #[must_use]
204 pub fn container(mut self, container: OutputContainer) -> Self {
205 self.container = Some(container);
206 self
207 }
208
209 #[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 #[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 #[must_use]
234 pub fn two_pass(mut self) -> Self {
235 self.two_pass = true;
236 self
237 }
238
239 #[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 #[must_use]
259 pub fn chapter(mut self, chapter: ff_format::chapter::ChapterInfo) -> Self {
260 self.chapters.push(chapter);
261 self
262 }
263
264 #[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 #[must_use]
296 pub fn codec_options(mut self, opts: VideoCodecOptions) -> Self {
297 self.codec_options = Some(opts);
298 self
299 }
300
301 #[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 #[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 #[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 #[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 #[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 #[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 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 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 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 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 let requires_even_dims = !matches!(self.video_codec, VideoCodec::Png);
520
521 if has_video {
522 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 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 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 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 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 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
799pub 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 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 #[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 #[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 #[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 #[must_use]
910 pub fn is_hardware_encoding(&self) -> bool {
911 !matches!(self.hardware_encoder(), crate::HardwareEncoder::None)
912 }
913
914 #[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 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 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 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 }
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 let result = VideoEncoder::create("output.mp4")
1397 .video(640, 480, 30.0)
1398 .video_codec(VideoCodec::H264)
1399 .build();
1400 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 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 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}