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, Container, EncodeError, EncodeProgressCallback, HardwareEncoder, Preset, VideoCodec,
15};
16
17pub struct VideoEncoderBuilder {
34 pub(crate) path: PathBuf,
35 pub(crate) container: Option<Container>,
36 pub(crate) video_width: Option<u32>,
37 pub(crate) video_height: Option<u32>,
38 pub(crate) video_fps: Option<f64>,
39 pub(crate) video_codec: VideoCodec,
40 pub(crate) video_bitrate_mode: Option<crate::BitrateMode>,
41 pub(crate) preset: Preset,
42 pub(crate) hardware_encoder: HardwareEncoder,
43 pub(crate) audio_sample_rate: Option<u32>,
44 pub(crate) audio_channels: Option<u32>,
45 pub(crate) audio_codec: AudioCodec,
46 pub(crate) audio_bitrate: Option<u64>,
47 pub(crate) progress_callback: Option<Box<dyn EncodeProgressCallback>>,
48 pub(crate) two_pass: bool,
49 pub(crate) metadata: Vec<(String, String)>,
50 pub(crate) chapters: Vec<ff_format::chapter::ChapterInfo>,
51 pub(crate) subtitle_passthrough: Option<(String, usize)>,
52 pub(crate) codec_options: Option<VideoCodecOptions>,
53 pub(crate) video_codec_explicit: bool,
54 pub(crate) audio_codec_explicit: bool,
55 pub(crate) pixel_format: Option<ff_format::PixelFormat>,
56 pub(crate) hdr10_metadata: Option<ff_format::Hdr10Metadata>,
57 pub(crate) color_space: Option<ff_format::ColorSpace>,
58 pub(crate) color_transfer: Option<ff_format::ColorTransfer>,
59 pub(crate) color_primaries: Option<ff_format::ColorPrimaries>,
60 pub(crate) attachments: Vec<(Vec<u8>, String, String)>,
62}
63
64impl std::fmt::Debug for VideoEncoderBuilder {
65 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
66 f.debug_struct("VideoEncoderBuilder")
67 .field("path", &self.path)
68 .field("container", &self.container)
69 .field("video_width", &self.video_width)
70 .field("video_height", &self.video_height)
71 .field("video_fps", &self.video_fps)
72 .field("video_codec", &self.video_codec)
73 .field("video_bitrate_mode", &self.video_bitrate_mode)
74 .field("preset", &self.preset)
75 .field("hardware_encoder", &self.hardware_encoder)
76 .field("audio_sample_rate", &self.audio_sample_rate)
77 .field("audio_channels", &self.audio_channels)
78 .field("audio_codec", &self.audio_codec)
79 .field("audio_bitrate", &self.audio_bitrate)
80 .field(
81 "progress_callback",
82 &self.progress_callback.as_ref().map(|_| "<callback>"),
83 )
84 .field("two_pass", &self.two_pass)
85 .field("metadata", &self.metadata)
86 .field("chapters", &self.chapters)
87 .field("subtitle_passthrough", &self.subtitle_passthrough)
88 .field("codec_options", &self.codec_options)
89 .field("video_codec_explicit", &self.video_codec_explicit)
90 .field("audio_codec_explicit", &self.audio_codec_explicit)
91 .field("pixel_format", &self.pixel_format)
92 .field("hdr10_metadata", &self.hdr10_metadata)
93 .field("color_space", &self.color_space)
94 .field("color_transfer", &self.color_transfer)
95 .field("color_primaries", &self.color_primaries)
96 .field("attachments_count", &self.attachments.len())
97 .finish()
98 }
99}
100
101impl VideoEncoderBuilder {
102 pub(crate) fn new(path: PathBuf) -> Self {
103 Self {
104 path,
105 container: None,
106 video_width: None,
107 video_height: None,
108 video_fps: None,
109 video_codec: VideoCodec::default(),
110 video_bitrate_mode: None,
111 preset: Preset::default(),
112 hardware_encoder: HardwareEncoder::default(),
113 audio_sample_rate: None,
114 audio_channels: None,
115 audio_codec: AudioCodec::default(),
116 audio_bitrate: None,
117 progress_callback: None,
118 two_pass: false,
119 metadata: Vec::new(),
120 chapters: Vec::new(),
121 subtitle_passthrough: None,
122 codec_options: None,
123 video_codec_explicit: false,
124 audio_codec_explicit: false,
125 pixel_format: None,
126 hdr10_metadata: None,
127 color_space: None,
128 color_transfer: None,
129 color_primaries: None,
130 attachments: Vec::new(),
131 }
132 }
133
134 #[must_use]
138 pub fn video(mut self, width: u32, height: u32, fps: f64) -> Self {
139 self.video_width = Some(width);
140 self.video_height = Some(height);
141 self.video_fps = Some(fps);
142 self
143 }
144
145 #[must_use]
147 pub fn video_codec(mut self, codec: VideoCodec) -> Self {
148 self.video_codec = codec;
149 self.video_codec_explicit = true;
150 self
151 }
152
153 #[must_use]
155 pub fn bitrate_mode(mut self, mode: crate::BitrateMode) -> Self {
156 self.video_bitrate_mode = Some(mode);
157 self
158 }
159
160 #[must_use]
162 pub fn preset(mut self, preset: Preset) -> Self {
163 self.preset = preset;
164 self
165 }
166
167 #[must_use]
169 pub fn hardware_encoder(mut self, hw: HardwareEncoder) -> Self {
170 self.hardware_encoder = hw;
171 self
172 }
173
174 #[must_use]
178 pub fn audio(mut self, sample_rate: u32, channels: u32) -> Self {
179 self.audio_sample_rate = Some(sample_rate);
180 self.audio_channels = Some(channels);
181 self
182 }
183
184 #[must_use]
186 pub fn audio_codec(mut self, codec: AudioCodec) -> Self {
187 self.audio_codec = codec;
188 self.audio_codec_explicit = true;
189 self
190 }
191
192 #[must_use]
194 pub fn audio_bitrate(mut self, bitrate: u64) -> Self {
195 self.audio_bitrate = Some(bitrate);
196 self
197 }
198
199 #[must_use]
203 pub fn container(mut self, container: Container) -> Self {
204 self.container = Some(container);
205 self
206 }
207
208 #[must_use]
212 pub fn on_progress<F>(mut self, callback: F) -> Self
213 where
214 F: FnMut(&crate::EncodeProgress) + Send + 'static,
215 {
216 self.progress_callback = Some(Box::new(callback));
217 self
218 }
219
220 #[must_use]
222 pub fn progress_callback<C: EncodeProgressCallback + 'static>(mut self, callback: C) -> Self {
223 self.progress_callback = Some(Box::new(callback));
224 self
225 }
226
227 #[must_use]
233 pub fn two_pass(mut self) -> Self {
234 self.two_pass = true;
235 self
236 }
237
238 #[must_use]
246 pub fn metadata(mut self, key: &str, value: &str) -> Self {
247 self.metadata.push((key.to_string(), value.to_string()));
248 self
249 }
250
251 #[must_use]
258 pub fn chapter(mut self, chapter: ff_format::chapter::ChapterInfo) -> Self {
259 self.chapters.push(chapter);
260 self
261 }
262
263 #[must_use]
278 pub fn subtitle_passthrough(mut self, source_path: &str, stream_index: usize) -> Self {
279 self.subtitle_passthrough = Some((source_path.to_string(), stream_index));
280 self
281 }
282
283 #[must_use]
295 pub fn codec_options(mut self, opts: VideoCodecOptions) -> Self {
296 self.codec_options = Some(opts);
297 self
298 }
299
300 #[must_use]
307 pub fn pixel_format(mut self, fmt: ff_format::PixelFormat) -> Self {
308 self.pixel_format = Some(fmt);
309 self
310 }
311
312 #[must_use]
327 pub fn hdr10_metadata(mut self, meta: ff_format::Hdr10Metadata) -> Self {
328 self.hdr10_metadata = Some(meta);
329 self
330 }
331
332 #[must_use]
340 pub fn color_space(mut self, cs: ff_format::ColorSpace) -> Self {
341 self.color_space = Some(cs);
342 self
343 }
344
345 #[must_use]
351 pub fn color_transfer(mut self, trc: ff_format::ColorTransfer) -> Self {
352 self.color_transfer = Some(trc);
353 self
354 }
355
356 #[must_use]
361 pub fn color_primaries(mut self, cp: ff_format::ColorPrimaries) -> Self {
362 self.color_primaries = Some(cp);
363 self
364 }
365
366 #[must_use]
382 pub fn add_attachment(mut self, data: Vec<u8>, mime_type: &str, filename: &str) -> Self {
383 self.attachments
384 .push((data, mime_type.to_string(), filename.to_string()));
385 self
386 }
387
388 pub fn build(self) -> Result<VideoEncoder, EncodeError> {
397 let this = self.apply_container_defaults();
398 this.validate()?;
399 VideoEncoder::from_builder(this)
400 }
401
402 fn apply_container_defaults(mut self) -> Self {
407 let is_webm = self
408 .path
409 .extension()
410 .and_then(|e| e.to_str())
411 .is_some_and(|e| e.eq_ignore_ascii_case("webm"))
412 || self
413 .container
414 .as_ref()
415 .is_some_and(|c| *c == Container::WebM);
416
417 if is_webm {
418 if !self.video_codec_explicit {
419 self.video_codec = VideoCodec::Vp9;
420 }
421 if !self.audio_codec_explicit {
422 self.audio_codec = AudioCodec::Opus;
423 }
424 }
425
426 let is_avi = self
427 .path
428 .extension()
429 .and_then(|e| e.to_str())
430 .is_some_and(|e| e.eq_ignore_ascii_case("avi"))
431 || self
432 .container
433 .as_ref()
434 .is_some_and(|c| *c == Container::Avi);
435
436 if is_avi {
437 if !self.video_codec_explicit {
438 self.video_codec = VideoCodec::H264;
439 }
440 if !self.audio_codec_explicit {
441 self.audio_codec = AudioCodec::Mp3;
442 }
443 }
444
445 let is_mov = self
446 .path
447 .extension()
448 .and_then(|e| e.to_str())
449 .is_some_and(|e| e.eq_ignore_ascii_case("mov"))
450 || self
451 .container
452 .as_ref()
453 .is_some_and(|c| *c == Container::Mov);
454
455 if is_mov {
456 if !self.video_codec_explicit {
457 self.video_codec = VideoCodec::H264;
458 }
459 if !self.audio_codec_explicit {
460 self.audio_codec = AudioCodec::Aac;
461 }
462 }
463
464 let is_image_sequence = self.path.to_str().is_some_and(|s| s.contains('%'));
467 if is_image_sequence && !self.video_codec_explicit {
468 let ext = self
469 .path
470 .to_str()
471 .and_then(|s| s.rfind('.').map(|i| &s[i + 1..]))
472 .unwrap_or("");
473 if ext.eq_ignore_ascii_case("png") {
474 self.video_codec = VideoCodec::Png;
475 } else if ext.eq_ignore_ascii_case("jpg") || ext.eq_ignore_ascii_case("jpeg") {
476 self.video_codec = VideoCodec::Mjpeg;
477 }
478 }
479
480 self
481 }
482
483 fn validate(&self) -> Result<(), EncodeError> {
484 let has_video =
485 self.video_width.is_some() && self.video_height.is_some() && self.video_fps.is_some();
486 let has_audio = self.audio_sample_rate.is_some() && self.audio_channels.is_some();
487
488 if !has_video && !has_audio {
489 return Err(EncodeError::InvalidConfig {
490 reason: "At least one video or audio stream must be configured".to_string(),
491 });
492 }
493
494 if self.two_pass {
495 if !has_video {
496 return Err(EncodeError::InvalidConfig {
497 reason: "Two-pass encoding requires a video stream".to_string(),
498 });
499 }
500 if has_audio {
501 return Err(EncodeError::InvalidConfig {
502 reason:
503 "Two-pass encoding is video-only and is incompatible with audio streams"
504 .to_string(),
505 });
506 }
507 }
508
509 let is_image_sequence = self.path.to_str().is_some_and(|s| s.contains('%'));
511 if is_image_sequence && has_audio {
512 return Err(EncodeError::InvalidConfig {
513 reason: "Image sequence output does not support audio streams".to_string(),
514 });
515 }
516
517 let requires_even_dims = !matches!(self.video_codec, VideoCodec::Png);
519
520 if has_video {
521 if let Some(width) = self.video_width
522 && (width == 0 || (requires_even_dims && width % 2 != 0))
523 {
524 return Err(EncodeError::InvalidConfig {
525 reason: format!("Video width must be non-zero and even, got {width}"),
526 });
527 }
528 if let Some(height) = self.video_height
529 && (height == 0 || (requires_even_dims && height % 2 != 0))
530 {
531 return Err(EncodeError::InvalidConfig {
532 reason: format!("Video height must be non-zero and even, got {height}"),
533 });
534 }
535 if let Some(fps) = self.video_fps
536 && fps <= 0.0
537 {
538 return Err(EncodeError::InvalidConfig {
539 reason: format!("Video FPS must be positive, got {fps}"),
540 });
541 }
542 if let Some(crate::BitrateMode::Crf(q)) = self.video_bitrate_mode
543 && q > crate::CRF_MAX
544 {
545 return Err(EncodeError::InvalidConfig {
546 reason: format!(
547 "BitrateMode::Crf value must be 0-{}, got {q}",
548 crate::CRF_MAX
549 ),
550 });
551 }
552 if let Some(crate::BitrateMode::Vbr { target, max }) = self.video_bitrate_mode
553 && max < target
554 {
555 return Err(EncodeError::InvalidConfig {
556 reason: format!("BitrateMode::Vbr max ({max}) must be >= target ({target})"),
557 });
558 }
559 }
560
561 if let Some(VideoCodecOptions::Av1(ref opts)) = self.codec_options
562 && opts.cpu_used > 8
563 {
564 return Err(EncodeError::InvalidOption {
565 name: "cpu_used".to_string(),
566 reason: "must be 0–8".to_string(),
567 });
568 }
569
570 if let Some(VideoCodecOptions::Av1Svt(ref opts)) = self.codec_options
571 && opts.preset > 13
572 {
573 return Err(EncodeError::InvalidOption {
574 name: "preset".to_string(),
575 reason: "must be 0–13".to_string(),
576 });
577 }
578
579 if let Some(VideoCodecOptions::Vp9(ref opts)) = self.codec_options {
580 if opts.cpu_used < -8 || opts.cpu_used > 8 {
581 return Err(EncodeError::InvalidOption {
582 name: "cpu_used".to_string(),
583 reason: "must be -8–8".to_string(),
584 });
585 }
586 if let Some(cq) = opts.cq_level
587 && cq > 63
588 {
589 return Err(EncodeError::InvalidOption {
590 name: "cq_level".to_string(),
591 reason: "must be 0–63".to_string(),
592 });
593 }
594 }
595
596 if let Some(VideoCodecOptions::Dnxhd(ref opts)) = self.codec_options
597 && opts.variant.is_dnxhd()
598 {
599 let valid = matches!(
600 (self.video_width, self.video_height),
601 (Some(1920), Some(1080)) | (Some(1280), Some(720))
602 );
603 if !valid {
604 return Err(EncodeError::InvalidOption {
605 name: "variant".to_string(),
606 reason: "DNxHD variants require 1920×1080 or 1280×720 resolution".to_string(),
607 });
608 }
609 }
610
611 let is_webm = self
613 .path
614 .extension()
615 .and_then(|e| e.to_str())
616 .is_some_and(|e| e.eq_ignore_ascii_case("webm"))
617 || self
618 .container
619 .as_ref()
620 .is_some_and(|c| *c == Container::WebM);
621
622 if is_webm {
623 let webm_video_ok = matches!(
624 self.video_codec,
625 VideoCodec::Vp9 | VideoCodec::Av1 | VideoCodec::Av1Svt
626 );
627 if !webm_video_ok {
628 return Err(EncodeError::UnsupportedContainerCodecCombination {
629 container: "webm".to_string(),
630 codec: self.video_codec.name().to_string(),
631 hint: "WebM supports VP9, AV1 (video) and Vorbis, Opus (audio)".to_string(),
632 });
633 }
634
635 let webm_audio_ok = matches!(self.audio_codec, AudioCodec::Opus | AudioCodec::Vorbis);
636 if !webm_audio_ok {
637 return Err(EncodeError::UnsupportedContainerCodecCombination {
638 container: "webm".to_string(),
639 codec: self.audio_codec.name().to_string(),
640 hint: "WebM supports VP9, AV1 (video) and Vorbis, Opus (audio)".to_string(),
641 });
642 }
643 }
644
645 let is_avi = self
647 .path
648 .extension()
649 .and_then(|e| e.to_str())
650 .is_some_and(|e| e.eq_ignore_ascii_case("avi"))
651 || self
652 .container
653 .as_ref()
654 .is_some_and(|c| *c == Container::Avi);
655
656 if is_avi {
657 let avi_video_ok = matches!(self.video_codec, VideoCodec::H264 | VideoCodec::Mpeg4);
658 if !avi_video_ok {
659 return Err(EncodeError::UnsupportedContainerCodecCombination {
660 container: "avi".to_string(),
661 codec: self.video_codec.name().to_string(),
662 hint: "AVI supports H264 and MPEG-4 (video); MP3, AAC, and PCM 16-bit (audio)"
663 .to_string(),
664 });
665 }
666
667 let avi_audio_ok = matches!(
668 self.audio_codec,
669 AudioCodec::Mp3 | AudioCodec::Aac | AudioCodec::Pcm | AudioCodec::Pcm16
670 );
671 if !avi_audio_ok {
672 return Err(EncodeError::UnsupportedContainerCodecCombination {
673 container: "avi".to_string(),
674 codec: self.audio_codec.name().to_string(),
675 hint: "AVI supports H264 and MPEG-4 (video); MP3, AAC, and PCM 16-bit (audio)"
676 .to_string(),
677 });
678 }
679 }
680
681 let is_mov = self
683 .path
684 .extension()
685 .and_then(|e| e.to_str())
686 .is_some_and(|e| e.eq_ignore_ascii_case("mov"))
687 || self
688 .container
689 .as_ref()
690 .is_some_and(|c| *c == Container::Mov);
691
692 if is_mov {
693 let mov_video_ok = matches!(
694 self.video_codec,
695 VideoCodec::H264 | VideoCodec::H265 | VideoCodec::ProRes
696 );
697 if !mov_video_ok {
698 return Err(EncodeError::UnsupportedContainerCodecCombination {
699 container: "mov".to_string(),
700 codec: self.video_codec.name().to_string(),
701 hint: "MOV supports H264, H265, and ProRes (video); AAC and PCM (audio)"
702 .to_string(),
703 });
704 }
705
706 let mov_audio_ok = matches!(
707 self.audio_codec,
708 AudioCodec::Aac | AudioCodec::Pcm | AudioCodec::Pcm16 | AudioCodec::Pcm24
709 );
710 if !mov_audio_ok {
711 return Err(EncodeError::UnsupportedContainerCodecCombination {
712 container: "mov".to_string(),
713 codec: self.audio_codec.name().to_string(),
714 hint: "MOV supports H264, H265, and ProRes (video); AAC and PCM (audio)"
715 .to_string(),
716 });
717 }
718 }
719
720 let is_fmp4 = self
722 .container
723 .as_ref()
724 .is_some_and(|c| *c == Container::FMp4);
725
726 if is_fmp4 {
727 let fmp4_video_ok = !matches!(
728 self.video_codec,
729 VideoCodec::Mpeg2 | VideoCodec::Mpeg4 | VideoCodec::Mjpeg
730 );
731 if !fmp4_video_ok {
732 return Err(EncodeError::UnsupportedContainerCodecCombination {
733 container: "fMP4".to_string(),
734 codec: self.video_codec.name().to_string(),
735 hint: "fMP4 supports H.264, H.265, VP9, AV1".to_string(),
736 });
737 }
738 }
739
740 if has_audio {
741 if let Some(rate) = self.audio_sample_rate
742 && rate == 0
743 {
744 return Err(EncodeError::InvalidConfig {
745 reason: "Audio sample rate must be non-zero".to_string(),
746 });
747 }
748 if let Some(ch) = self.audio_channels
749 && ch == 0
750 {
751 return Err(EncodeError::InvalidConfig {
752 reason: "Audio channels must be non-zero".to_string(),
753 });
754 }
755 }
756
757 Ok(())
758 }
759}
760
761pub struct VideoEncoder {
777 inner: Option<VideoEncoderInner>,
778 _config: VideoEncoderConfig,
779 start_time: Instant,
780 progress_callback: Option<Box<dyn crate::EncodeProgressCallback>>,
781}
782
783impl VideoEncoder {
784 pub fn create<P: AsRef<std::path::Path>>(path: P) -> VideoEncoderBuilder {
789 VideoEncoderBuilder::new(path.as_ref().to_path_buf())
790 }
791
792 pub(crate) fn from_builder(builder: VideoEncoderBuilder) -> Result<Self, EncodeError> {
793 let config = VideoEncoderConfig {
794 path: builder.path.clone(),
795 video_width: builder.video_width,
796 video_height: builder.video_height,
797 video_fps: builder.video_fps,
798 video_codec: builder.video_codec,
799 video_bitrate_mode: builder.video_bitrate_mode,
800 preset: preset_to_string(&builder.preset),
801 hardware_encoder: builder.hardware_encoder,
802 audio_sample_rate: builder.audio_sample_rate,
803 audio_channels: builder.audio_channels,
804 audio_codec: builder.audio_codec,
805 audio_bitrate: builder.audio_bitrate,
806 _progress_callback: builder.progress_callback.is_some(),
807 two_pass: builder.two_pass,
808 metadata: builder.metadata,
809 chapters: builder.chapters,
810 subtitle_passthrough: builder.subtitle_passthrough,
811 codec_options: builder.codec_options,
812 pixel_format: builder.pixel_format,
813 hdr10_metadata: builder.hdr10_metadata,
814 color_space: builder.color_space,
815 color_transfer: builder.color_transfer,
816 color_primaries: builder.color_primaries,
817 attachments: builder.attachments,
818 container: builder.container,
819 };
820
821 let inner = if config.video_width.is_some() {
822 Some(VideoEncoderInner::new(&config)?)
823 } else {
824 None
825 };
826
827 Ok(Self {
828 inner,
829 _config: config,
830 start_time: Instant::now(),
831 progress_callback: builder.progress_callback,
832 })
833 }
834
835 #[must_use]
837 pub fn actual_video_codec(&self) -> &str {
838 self.inner
839 .as_ref()
840 .map_or("", |inner| inner.actual_video_codec.as_str())
841 }
842
843 #[must_use]
845 pub fn actual_audio_codec(&self) -> &str {
846 self.inner
847 .as_ref()
848 .map_or("", |inner| inner.actual_audio_codec.as_str())
849 }
850
851 #[must_use]
853 pub fn hardware_encoder(&self) -> crate::HardwareEncoder {
854 let codec_name = self.actual_video_codec();
855 if codec_name.contains("nvenc") {
856 crate::HardwareEncoder::Nvenc
857 } else if codec_name.contains("qsv") {
858 crate::HardwareEncoder::Qsv
859 } else if codec_name.contains("amf") {
860 crate::HardwareEncoder::Amf
861 } else if codec_name.contains("videotoolbox") {
862 crate::HardwareEncoder::VideoToolbox
863 } else if codec_name.contains("vaapi") {
864 crate::HardwareEncoder::Vaapi
865 } else {
866 crate::HardwareEncoder::None
867 }
868 }
869
870 #[must_use]
872 pub fn is_hardware_encoding(&self) -> bool {
873 !matches!(self.hardware_encoder(), crate::HardwareEncoder::None)
874 }
875
876 #[must_use]
878 pub fn is_lgpl_compliant(&self) -> bool {
879 let codec_name = self.actual_video_codec();
880 if codec_name.contains("nvenc")
881 || codec_name.contains("qsv")
882 || codec_name.contains("amf")
883 || codec_name.contains("videotoolbox")
884 || codec_name.contains("vaapi")
885 {
886 return true;
887 }
888 if codec_name.contains("vp9")
889 || codec_name.contains("av1")
890 || codec_name.contains("aom")
891 || codec_name.contains("svt")
892 || codec_name.contains("prores")
893 || codec_name == "mpeg4"
894 || codec_name == "dnxhd"
895 {
896 return true;
897 }
898 if codec_name == "libx264" || codec_name == "libx265" {
899 return false;
900 }
901 true
902 }
903
904 pub fn push_video(&mut self, frame: &VideoFrame) -> Result<(), EncodeError> {
911 if let Some(ref callback) = self.progress_callback
912 && callback.should_cancel()
913 {
914 return Err(EncodeError::Cancelled);
915 }
916 let inner = self
917 .inner
918 .as_mut()
919 .ok_or_else(|| EncodeError::InvalidConfig {
920 reason: "Video encoder not initialized".to_string(),
921 })?;
922 unsafe { inner.push_video_frame(frame)? };
924 let progress = self.create_progress_info();
925 if let Some(ref mut callback) = self.progress_callback {
926 callback.on_progress(&progress);
927 }
928 Ok(())
929 }
930
931 pub fn push_audio(&mut self, frame: &AudioFrame) -> Result<(), EncodeError> {
937 if let Some(ref callback) = self.progress_callback
938 && callback.should_cancel()
939 {
940 return Err(EncodeError::Cancelled);
941 }
942 let inner = self
943 .inner
944 .as_mut()
945 .ok_or_else(|| EncodeError::InvalidConfig {
946 reason: "Audio encoder not initialized".to_string(),
947 })?;
948 unsafe { inner.push_audio_frame(frame)? };
950 let progress = self.create_progress_info();
951 if let Some(ref mut callback) = self.progress_callback {
952 callback.on_progress(&progress);
953 }
954 Ok(())
955 }
956
957 pub fn finish(mut self) -> Result<(), EncodeError> {
963 if let Some(mut inner) = self.inner.take() {
964 unsafe { inner.finish()? };
966 }
967 Ok(())
968 }
969
970 fn create_progress_info(&self) -> crate::EncodeProgress {
971 let elapsed = self.start_time.elapsed();
972 let (frames_encoded, bytes_written) = self
973 .inner
974 .as_ref()
975 .map_or((0, 0), |inner| (inner.frame_count, inner.bytes_written));
976 #[allow(clippy::cast_precision_loss)]
977 let current_fps = if !elapsed.is_zero() {
978 frames_encoded as f64 / elapsed.as_secs_f64()
979 } else {
980 0.0
981 };
982 #[allow(clippy::cast_precision_loss)]
983 let current_bitrate = if !elapsed.is_zero() {
984 let elapsed_secs = elapsed.as_secs();
985 if elapsed_secs > 0 {
986 (bytes_written * 8) / elapsed_secs
987 } else {
988 ((bytes_written * 8) as f64 / elapsed.as_secs_f64()) as u64
989 }
990 } else {
991 0
992 };
993 crate::EncodeProgress {
994 frames_encoded,
995 total_frames: None,
996 bytes_written,
997 current_bitrate,
998 elapsed,
999 remaining: None,
1000 current_fps,
1001 }
1002 }
1003}
1004
1005impl Drop for VideoEncoder {
1006 fn drop(&mut self) {
1007 }
1009}
1010
1011#[cfg(test)]
1012#[allow(clippy::unwrap_used)]
1013mod tests {
1014 use super::super::encoder_inner::{VideoEncoderConfig, VideoEncoderInner};
1015 use super::*;
1016 use crate::HardwareEncoder;
1017
1018 fn create_mock_encoder(video_codec_name: &str, audio_codec_name: &str) -> VideoEncoder {
1019 VideoEncoder {
1020 inner: Some(VideoEncoderInner {
1021 format_ctx: std::ptr::null_mut(),
1022 video_codec_ctx: None,
1023 audio_codec_ctx: None,
1024 video_stream_index: -1,
1025 audio_stream_index: -1,
1026 sws_ctx: None,
1027 swr_ctx: None,
1028 frame_count: 0,
1029 audio_sample_count: 0,
1030 bytes_written: 0,
1031 actual_video_codec: video_codec_name.to_string(),
1032 actual_audio_codec: audio_codec_name.to_string(),
1033 last_src_width: None,
1034 last_src_height: None,
1035 last_src_format: None,
1036 two_pass: false,
1037 pass1_codec_ctx: None,
1038 buffered_frames: Vec::new(),
1039 two_pass_config: None,
1040 stats_in_cstr: None,
1041 subtitle_passthrough: None,
1042 hdr10_metadata: None,
1043 }),
1044 _config: VideoEncoderConfig {
1045 path: "test.mp4".into(),
1046 video_width: Some(1920),
1047 video_height: Some(1080),
1048 video_fps: Some(30.0),
1049 video_codec: crate::VideoCodec::H264,
1050 video_bitrate_mode: None,
1051 preset: "medium".to_string(),
1052 hardware_encoder: HardwareEncoder::Auto,
1053 audio_sample_rate: None,
1054 audio_channels: None,
1055 audio_codec: crate::AudioCodec::Aac,
1056 audio_bitrate: None,
1057 _progress_callback: false,
1058 two_pass: false,
1059 metadata: Vec::new(),
1060 chapters: Vec::new(),
1061 subtitle_passthrough: None,
1062 codec_options: None,
1063 pixel_format: None,
1064 hdr10_metadata: None,
1065 color_space: None,
1066 color_transfer: None,
1067 color_primaries: None,
1068 attachments: Vec::new(),
1069 container: None,
1070 },
1071 start_time: std::time::Instant::now(),
1072 progress_callback: None,
1073 }
1074 }
1075
1076 #[test]
1077 fn create_should_return_builder_without_error() {
1078 let _builder: VideoEncoderBuilder = VideoEncoder::create("output.mp4");
1079 }
1080
1081 #[test]
1082 fn builder_video_settings_should_be_stored() {
1083 let builder = VideoEncoder::create("output.mp4")
1084 .video(1920, 1080, 30.0)
1085 .video_codec(VideoCodec::H264)
1086 .bitrate_mode(crate::BitrateMode::Cbr(8_000_000));
1087 assert_eq!(builder.video_width, Some(1920));
1088 assert_eq!(builder.video_height, Some(1080));
1089 assert_eq!(builder.video_fps, Some(30.0));
1090 assert_eq!(builder.video_codec, VideoCodec::H264);
1091 assert_eq!(
1092 builder.video_bitrate_mode,
1093 Some(crate::BitrateMode::Cbr(8_000_000))
1094 );
1095 }
1096
1097 #[test]
1098 fn builder_audio_settings_should_be_stored() {
1099 let builder = VideoEncoder::create("output.mp4")
1100 .audio(48000, 2)
1101 .audio_codec(AudioCodec::Aac)
1102 .audio_bitrate(192_000);
1103 assert_eq!(builder.audio_sample_rate, Some(48000));
1104 assert_eq!(builder.audio_channels, Some(2));
1105 assert_eq!(builder.audio_codec, AudioCodec::Aac);
1106 assert_eq!(builder.audio_bitrate, Some(192_000));
1107 }
1108
1109 #[test]
1110 fn builder_preset_should_be_stored() {
1111 let builder = VideoEncoder::create("output.mp4")
1112 .video(1920, 1080, 30.0)
1113 .preset(Preset::Fast);
1114 assert_eq!(builder.preset, Preset::Fast);
1115 }
1116
1117 #[test]
1118 fn builder_hardware_encoder_should_be_stored() {
1119 let builder = VideoEncoder::create("output.mp4")
1120 .video(1920, 1080, 30.0)
1121 .hardware_encoder(HardwareEncoder::Nvenc);
1122 assert_eq!(builder.hardware_encoder, HardwareEncoder::Nvenc);
1123 }
1124
1125 #[test]
1126 fn builder_container_should_be_stored() {
1127 let builder = VideoEncoder::create("output.mp4")
1128 .video(1920, 1080, 30.0)
1129 .container(Container::Mp4);
1130 assert_eq!(builder.container, Some(Container::Mp4));
1131 }
1132
1133 #[test]
1134 fn build_without_streams_should_return_error() {
1135 let result = VideoEncoder::create("output.mp4").build();
1136 assert!(result.is_err());
1137 }
1138
1139 #[test]
1140 fn build_with_odd_width_should_return_error() {
1141 let result = VideoEncoder::create("output.mp4")
1142 .video(1921, 1080, 30.0)
1143 .build();
1144 assert!(result.is_err());
1145 }
1146
1147 #[test]
1148 fn build_with_odd_height_should_return_error() {
1149 let result = VideoEncoder::create("output.mp4")
1150 .video(1920, 1081, 30.0)
1151 .build();
1152 assert!(result.is_err());
1153 }
1154
1155 #[test]
1156 fn build_with_invalid_fps_should_return_error() {
1157 let result = VideoEncoder::create("output.mp4")
1158 .video(1920, 1080, -1.0)
1159 .build();
1160 assert!(result.is_err());
1161 }
1162
1163 #[test]
1164 fn two_pass_flag_should_be_stored_in_builder() {
1165 let builder = VideoEncoder::create("output.mp4")
1166 .video(640, 480, 30.0)
1167 .two_pass();
1168 assert!(builder.two_pass);
1169 }
1170
1171 #[test]
1172 fn two_pass_with_audio_should_return_error() {
1173 let result = VideoEncoder::create("output.mp4")
1174 .video(640, 480, 30.0)
1175 .audio(48000, 2)
1176 .two_pass()
1177 .build();
1178 assert!(result.is_err());
1179 if let Err(e) = result {
1180 assert!(
1181 matches!(e, crate::EncodeError::InvalidConfig { .. }),
1182 "expected InvalidConfig, got {e:?}"
1183 );
1184 }
1185 }
1186
1187 #[test]
1188 fn two_pass_without_video_should_return_error() {
1189 let result = VideoEncoder::create("output.mp4").two_pass().build();
1190 assert!(result.is_err());
1191 }
1192
1193 #[test]
1194 fn build_with_crf_above_51_should_return_error() {
1195 let result = VideoEncoder::create("output.mp4")
1196 .video(1920, 1080, 30.0)
1197 .bitrate_mode(crate::BitrateMode::Crf(100))
1198 .build();
1199 assert!(result.is_err());
1200 }
1201
1202 #[test]
1203 fn bitrate_mode_vbr_with_max_less_than_target_should_return_error() {
1204 let output_path = "test_vbr.mp4";
1205 let result = VideoEncoder::create(output_path)
1206 .video(640, 480, 30.0)
1207 .bitrate_mode(crate::BitrateMode::Vbr {
1208 target: 4_000_000,
1209 max: 2_000_000,
1210 })
1211 .build();
1212 assert!(result.is_err());
1213 }
1214
1215 #[test]
1216 fn is_lgpl_compliant_should_be_true_for_hardware_encoders() {
1217 for codec_name in &[
1218 "h264_nvenc",
1219 "h264_qsv",
1220 "h264_amf",
1221 "h264_videotoolbox",
1222 "hevc_vaapi",
1223 ] {
1224 let encoder = create_mock_encoder(codec_name, "");
1225 assert!(
1226 encoder.is_lgpl_compliant(),
1227 "expected LGPL-compliant for {codec_name}"
1228 );
1229 }
1230 }
1231
1232 #[test]
1233 fn is_lgpl_compliant_should_be_false_for_gpl_encoders() {
1234 for codec_name in &["libx264", "libx265"] {
1235 let encoder = create_mock_encoder(codec_name, "");
1236 assert!(
1237 !encoder.is_lgpl_compliant(),
1238 "expected non-LGPL for {codec_name}"
1239 );
1240 }
1241 }
1242
1243 #[test]
1244 fn hardware_encoder_detection_should_match_codec_name() {
1245 let cases: &[(&str, HardwareEncoder, bool)] = &[
1246 ("h264_nvenc", HardwareEncoder::Nvenc, true),
1247 ("h264_qsv", HardwareEncoder::Qsv, true),
1248 ("h264_amf", HardwareEncoder::Amf, true),
1249 ("h264_videotoolbox", HardwareEncoder::VideoToolbox, true),
1250 ("h264_vaapi", HardwareEncoder::Vaapi, true),
1251 ("libx264", HardwareEncoder::None, false),
1252 ("libvpx-vp9", HardwareEncoder::None, false),
1253 ];
1254 for (codec_name, expected_hw, expected_is_hw) in cases {
1255 let encoder = create_mock_encoder(codec_name, "");
1256 assert_eq!(
1257 encoder.hardware_encoder(),
1258 *expected_hw,
1259 "hw for {codec_name}"
1260 );
1261 assert_eq!(
1262 encoder.is_hardware_encoding(),
1263 *expected_is_hw,
1264 "is_hw for {codec_name}"
1265 );
1266 }
1267 }
1268
1269 #[test]
1270 fn add_attachment_should_accumulate_entries() {
1271 let builder = VideoEncoder::create("output.mkv")
1272 .video(320, 240, 30.0)
1273 .add_attachment(vec![1, 2, 3], "application/x-truetype-font", "font.ttf")
1274 .add_attachment(vec![4, 5, 6], "image/jpeg", "cover.jpg");
1275 assert_eq!(builder.attachments.len(), 2);
1276 assert_eq!(builder.attachments[0].0, vec![1u8, 2, 3]);
1277 assert_eq!(builder.attachments[0].1, "application/x-truetype-font");
1278 assert_eq!(builder.attachments[0].2, "font.ttf");
1279 assert_eq!(builder.attachments[1].1, "image/jpeg");
1280 assert_eq!(builder.attachments[1].2, "cover.jpg");
1281 }
1282
1283 #[test]
1284 fn add_attachment_with_no_attachments_should_start_empty() {
1285 let builder = VideoEncoder::create("output.mkv").video(320, 240, 30.0);
1286 assert!(builder.attachments.is_empty());
1287 }
1288
1289 #[test]
1290 fn webm_extension_with_h264_video_codec_should_return_error() {
1291 let result = VideoEncoder::create("output.webm")
1292 .video(640, 480, 30.0)
1293 .video_codec(VideoCodec::H264)
1294 .build();
1295 assert!(matches!(
1296 result,
1297 Err(crate::EncodeError::UnsupportedContainerCodecCombination { .. })
1298 ));
1299 }
1300
1301 #[test]
1302 fn webm_extension_with_h265_video_codec_should_return_error() {
1303 let result = VideoEncoder::create("output.webm")
1304 .video(640, 480, 30.0)
1305 .video_codec(VideoCodec::H265)
1306 .build();
1307 assert!(matches!(
1308 result,
1309 Err(crate::EncodeError::UnsupportedContainerCodecCombination { .. })
1310 ));
1311 }
1312
1313 #[test]
1314 fn webm_extension_with_incompatible_audio_codec_should_return_error() {
1315 let result = VideoEncoder::create("output.webm")
1316 .video(640, 480, 30.0)
1317 .video_codec(VideoCodec::Vp9)
1318 .audio(48000, 2)
1319 .audio_codec(AudioCodec::Aac)
1320 .build();
1321 assert!(matches!(
1322 result,
1323 Err(crate::EncodeError::UnsupportedContainerCodecCombination { .. })
1324 ));
1325 }
1326
1327 #[test]
1328 fn webm_extension_without_explicit_codec_should_default_to_vp9_opus() {
1329 let builder = VideoEncoder::create("output.webm").video(640, 480, 30.0);
1330 let normalized = builder.apply_container_defaults();
1331 assert_eq!(normalized.video_codec, VideoCodec::Vp9);
1332 assert_eq!(normalized.audio_codec, AudioCodec::Opus);
1333 }
1334
1335 #[test]
1336 fn webm_extension_with_explicit_vp9_should_preserve_codec() {
1337 let builder = VideoEncoder::create("output.webm")
1338 .video(640, 480, 30.0)
1339 .video_codec(VideoCodec::Vp9);
1340 assert!(builder.video_codec_explicit);
1341 let normalized = builder.apply_container_defaults();
1342 assert_eq!(normalized.video_codec, VideoCodec::Vp9);
1343 }
1344
1345 #[test]
1346 fn webm_container_enum_with_incompatible_codec_should_return_error() {
1347 let result = VideoEncoder::create("output.mkv")
1348 .video(640, 480, 30.0)
1349 .container(Container::WebM)
1350 .video_codec(VideoCodec::H264)
1351 .build();
1352 assert!(matches!(
1353 result,
1354 Err(crate::EncodeError::UnsupportedContainerCodecCombination { .. })
1355 ));
1356 }
1357
1358 #[test]
1359 fn non_webm_extension_should_not_enforce_webm_codecs() {
1360 let result = VideoEncoder::create("output.mp4")
1362 .video(640, 480, 30.0)
1363 .video_codec(VideoCodec::H264)
1364 .build();
1365 assert!(!matches!(
1367 result,
1368 Err(crate::EncodeError::UnsupportedContainerCodecCombination { .. })
1369 ));
1370 }
1371
1372 #[test]
1373 fn avi_extension_without_explicit_codec_should_default_to_h264_mp3() {
1374 let builder = VideoEncoder::create("output.avi").video(640, 480, 30.0);
1375 let normalized = builder.apply_container_defaults();
1376 assert_eq!(normalized.video_codec, VideoCodec::H264);
1377 assert_eq!(normalized.audio_codec, AudioCodec::Mp3);
1378 }
1379
1380 #[test]
1381 fn mov_extension_without_explicit_codec_should_default_to_h264_aac() {
1382 let builder = VideoEncoder::create("output.mov").video(640, 480, 30.0);
1383 let normalized = builder.apply_container_defaults();
1384 assert_eq!(normalized.video_codec, VideoCodec::H264);
1385 assert_eq!(normalized.audio_codec, AudioCodec::Aac);
1386 }
1387
1388 #[test]
1389 fn avi_with_incompatible_video_codec_should_return_error() {
1390 let result = VideoEncoder::create("output.avi")
1391 .video(640, 480, 30.0)
1392 .video_codec(VideoCodec::Vp9)
1393 .build();
1394 assert!(matches!(
1395 result,
1396 Err(crate::EncodeError::UnsupportedContainerCodecCombination { .. })
1397 ));
1398 }
1399
1400 #[test]
1401 fn avi_with_incompatible_audio_codec_should_return_error() {
1402 let result = VideoEncoder::create("output.avi")
1403 .video(640, 480, 30.0)
1404 .video_codec(VideoCodec::H264)
1405 .audio(48000, 2)
1406 .audio_codec(AudioCodec::Opus)
1407 .build();
1408 assert!(matches!(
1409 result,
1410 Err(crate::EncodeError::UnsupportedContainerCodecCombination { .. })
1411 ));
1412 }
1413
1414 #[test]
1415 fn mov_with_incompatible_video_codec_should_return_error() {
1416 let result = VideoEncoder::create("output.mov")
1417 .video(640, 480, 30.0)
1418 .video_codec(VideoCodec::Vp9)
1419 .build();
1420 assert!(matches!(
1421 result,
1422 Err(crate::EncodeError::UnsupportedContainerCodecCombination { .. })
1423 ));
1424 }
1425
1426 #[test]
1427 fn mov_with_incompatible_audio_codec_should_return_error() {
1428 let result = VideoEncoder::create("output.mov")
1429 .video(640, 480, 30.0)
1430 .video_codec(VideoCodec::H264)
1431 .audio(48000, 2)
1432 .audio_codec(AudioCodec::Opus)
1433 .build();
1434 assert!(matches!(
1435 result,
1436 Err(crate::EncodeError::UnsupportedContainerCodecCombination { .. })
1437 ));
1438 }
1439
1440 #[test]
1441 fn avi_container_enum_with_incompatible_codec_should_return_error() {
1442 let result = VideoEncoder::create("output.mp4")
1443 .video(640, 480, 30.0)
1444 .container(Container::Avi)
1445 .video_codec(VideoCodec::Vp9)
1446 .build();
1447 assert!(matches!(
1448 result,
1449 Err(crate::EncodeError::UnsupportedContainerCodecCombination { .. })
1450 ));
1451 }
1452
1453 #[test]
1454 fn mov_container_enum_with_incompatible_codec_should_return_error() {
1455 let result = VideoEncoder::create("output.mp4")
1456 .video(640, 480, 30.0)
1457 .container(Container::Mov)
1458 .video_codec(VideoCodec::Vp9)
1459 .build();
1460 assert!(matches!(
1461 result,
1462 Err(crate::EncodeError::UnsupportedContainerCodecCombination { .. })
1463 ));
1464 }
1465
1466 #[test]
1467 fn avi_with_pcm_audio_should_pass_validation() {
1468 let result = VideoEncoder::create("output.avi")
1470 .video(640, 480, 30.0)
1471 .video_codec(VideoCodec::H264)
1472 .audio(48000, 2)
1473 .audio_codec(AudioCodec::Pcm)
1474 .build();
1475 assert!(!matches!(
1476 result,
1477 Err(crate::EncodeError::UnsupportedContainerCodecCombination { .. })
1478 ));
1479 }
1480
1481 #[test]
1482 fn mov_with_pcm24_audio_should_pass_validation() {
1483 let result = VideoEncoder::create("output.mov")
1484 .video(640, 480, 30.0)
1485 .video_codec(VideoCodec::H264)
1486 .audio(48000, 2)
1487 .audio_codec(AudioCodec::Pcm24)
1488 .build();
1489 assert!(!matches!(
1490 result,
1491 Err(crate::EncodeError::UnsupportedContainerCodecCombination { .. })
1492 ));
1493 }
1494
1495 #[test]
1496 fn non_avi_mov_extension_should_not_enforce_avi_mov_codecs() {
1497 let result = VideoEncoder::create("output.webm")
1499 .video(640, 480, 30.0)
1500 .video_codec(VideoCodec::Vp9)
1501 .build();
1502 assert!(!matches!(
1503 result,
1504 Err(crate::EncodeError::UnsupportedContainerCodecCombination {
1505 ref container, ..
1506 }) if container == "avi" || container == "mov"
1507 ));
1508 }
1509
1510 #[test]
1511 fn fmp4_container_with_h264_should_pass_validation() {
1512 let result = VideoEncoder::create("output.mp4")
1513 .video(640, 480, 30.0)
1514 .video_codec(VideoCodec::H264)
1515 .container(Container::FMp4)
1516 .build();
1517 assert!(!matches!(
1518 result,
1519 Err(crate::EncodeError::UnsupportedContainerCodecCombination { .. })
1520 ));
1521 }
1522
1523 #[test]
1524 fn fmp4_container_with_mpeg4_should_return_error() {
1525 let result = VideoEncoder::create("output.mp4")
1526 .video(640, 480, 30.0)
1527 .video_codec(VideoCodec::Mpeg4)
1528 .container(Container::FMp4)
1529 .build();
1530 assert!(matches!(
1531 result,
1532 Err(crate::EncodeError::UnsupportedContainerCodecCombination {
1533 ref container, ..
1534 }) if container == "fMP4"
1535 ));
1536 }
1537
1538 #[test]
1539 fn fmp4_container_with_mjpeg_should_return_error() {
1540 let result = VideoEncoder::create("output.mp4")
1541 .video(640, 480, 30.0)
1542 .video_codec(VideoCodec::Mjpeg)
1543 .container(Container::FMp4)
1544 .build();
1545 assert!(matches!(
1546 result,
1547 Err(crate::EncodeError::UnsupportedContainerCodecCombination {
1548 ref container, ..
1549 }) if container == "fMP4"
1550 ));
1551 }
1552}