1use std::path::PathBuf;
7use std::time::Instant;
8
9use ff_format::{AudioFrame, VideoFrame};
10
11use super::encoder_inner::{VideoEncoderConfig, VideoEncoderInner, preset_to_string};
12use crate::{
13 AudioCodec, Container, EncodeError, HardwareEncoder, Preset, ProgressCallback, VideoCodec,
14};
15
16pub struct VideoEncoderBuilder {
33 pub(crate) path: PathBuf,
34 pub(crate) container: Option<Container>,
35 pub(crate) video_width: Option<u32>,
36 pub(crate) video_height: Option<u32>,
37 pub(crate) video_fps: Option<f64>,
38 pub(crate) video_codec: VideoCodec,
39 pub(crate) video_bitrate_mode: Option<crate::BitrateMode>,
40 pub(crate) preset: Preset,
41 pub(crate) hardware_encoder: HardwareEncoder,
42 pub(crate) audio_sample_rate: Option<u32>,
43 pub(crate) audio_channels: Option<u32>,
44 pub(crate) audio_codec: AudioCodec,
45 pub(crate) audio_bitrate: Option<u64>,
46 pub(crate) progress_callback: Option<Box<dyn ProgressCallback>>,
47 pub(crate) two_pass: bool,
48 pub(crate) metadata: Vec<(String, String)>,
49 pub(crate) chapters: Vec<ff_format::chapter::ChapterInfo>,
50 pub(crate) subtitle_passthrough: Option<(String, usize)>,
51}
52
53impl std::fmt::Debug for VideoEncoderBuilder {
54 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
55 f.debug_struct("VideoEncoderBuilder")
56 .field("path", &self.path)
57 .field("container", &self.container)
58 .field("video_width", &self.video_width)
59 .field("video_height", &self.video_height)
60 .field("video_fps", &self.video_fps)
61 .field("video_codec", &self.video_codec)
62 .field("video_bitrate_mode", &self.video_bitrate_mode)
63 .field("preset", &self.preset)
64 .field("hardware_encoder", &self.hardware_encoder)
65 .field("audio_sample_rate", &self.audio_sample_rate)
66 .field("audio_channels", &self.audio_channels)
67 .field("audio_codec", &self.audio_codec)
68 .field("audio_bitrate", &self.audio_bitrate)
69 .field(
70 "progress_callback",
71 &self.progress_callback.as_ref().map(|_| "<callback>"),
72 )
73 .field("two_pass", &self.two_pass)
74 .field("metadata", &self.metadata)
75 .field("chapters", &self.chapters)
76 .field("subtitle_passthrough", &self.subtitle_passthrough)
77 .finish()
78 }
79}
80
81impl VideoEncoderBuilder {
82 pub(crate) fn new(path: PathBuf) -> Self {
83 Self {
84 path,
85 container: None,
86 video_width: None,
87 video_height: None,
88 video_fps: None,
89 video_codec: VideoCodec::default(),
90 video_bitrate_mode: None,
91 preset: Preset::default(),
92 hardware_encoder: HardwareEncoder::default(),
93 audio_sample_rate: None,
94 audio_channels: None,
95 audio_codec: AudioCodec::default(),
96 audio_bitrate: None,
97 progress_callback: None,
98 two_pass: false,
99 metadata: Vec::new(),
100 chapters: Vec::new(),
101 subtitle_passthrough: None,
102 }
103 }
104
105 #[must_use]
109 pub fn video(mut self, width: u32, height: u32, fps: f64) -> Self {
110 self.video_width = Some(width);
111 self.video_height = Some(height);
112 self.video_fps = Some(fps);
113 self
114 }
115
116 #[must_use]
118 pub fn video_codec(mut self, codec: VideoCodec) -> Self {
119 self.video_codec = codec;
120 self
121 }
122
123 #[must_use]
125 pub fn bitrate_mode(mut self, mode: crate::BitrateMode) -> Self {
126 self.video_bitrate_mode = Some(mode);
127 self
128 }
129
130 #[must_use]
132 pub fn preset(mut self, preset: Preset) -> Self {
133 self.preset = preset;
134 self
135 }
136
137 #[must_use]
139 pub fn hardware_encoder(mut self, hw: HardwareEncoder) -> Self {
140 self.hardware_encoder = hw;
141 self
142 }
143
144 #[must_use]
148 pub fn audio(mut self, sample_rate: u32, channels: u32) -> Self {
149 self.audio_sample_rate = Some(sample_rate);
150 self.audio_channels = Some(channels);
151 self
152 }
153
154 #[must_use]
156 pub fn audio_codec(mut self, codec: AudioCodec) -> Self {
157 self.audio_codec = codec;
158 self
159 }
160
161 #[must_use]
163 pub fn audio_bitrate(mut self, bitrate: u64) -> Self {
164 self.audio_bitrate = Some(bitrate);
165 self
166 }
167
168 #[must_use]
172 pub fn container(mut self, container: Container) -> Self {
173 self.container = Some(container);
174 self
175 }
176
177 #[must_use]
181 pub fn on_progress<F>(mut self, callback: F) -> Self
182 where
183 F: FnMut(&crate::Progress) + Send + 'static,
184 {
185 self.progress_callback = Some(Box::new(callback));
186 self
187 }
188
189 #[must_use]
191 pub fn progress_callback<C: ProgressCallback + 'static>(mut self, callback: C) -> Self {
192 self.progress_callback = Some(Box::new(callback));
193 self
194 }
195
196 #[must_use]
202 pub fn two_pass(mut self) -> Self {
203 self.two_pass = true;
204 self
205 }
206
207 #[must_use]
215 pub fn metadata(mut self, key: &str, value: &str) -> Self {
216 self.metadata.push((key.to_string(), value.to_string()));
217 self
218 }
219
220 #[must_use]
227 pub fn chapter(mut self, chapter: ff_format::chapter::ChapterInfo) -> Self {
228 self.chapters.push(chapter);
229 self
230 }
231
232 #[must_use]
247 pub fn subtitle_passthrough(mut self, source_path: &str, stream_index: usize) -> Self {
248 self.subtitle_passthrough = Some((source_path.to_string(), stream_index));
249 self
250 }
251
252 pub fn build(self) -> Result<VideoEncoder, EncodeError> {
261 self.validate()?;
262 VideoEncoder::from_builder(self)
263 }
264
265 fn validate(&self) -> Result<(), EncodeError> {
266 let has_video =
267 self.video_width.is_some() && self.video_height.is_some() && self.video_fps.is_some();
268 let has_audio = self.audio_sample_rate.is_some() && self.audio_channels.is_some();
269
270 if !has_video && !has_audio {
271 return Err(EncodeError::InvalidConfig {
272 reason: "At least one video or audio stream must be configured".to_string(),
273 });
274 }
275
276 if self.two_pass {
277 if !has_video {
278 return Err(EncodeError::InvalidConfig {
279 reason: "Two-pass encoding requires a video stream".to_string(),
280 });
281 }
282 if has_audio {
283 return Err(EncodeError::InvalidConfig {
284 reason:
285 "Two-pass encoding is video-only and is incompatible with audio streams"
286 .to_string(),
287 });
288 }
289 }
290
291 if has_video {
292 if let Some(width) = self.video_width
293 && (width == 0 || width % 2 != 0)
294 {
295 return Err(EncodeError::InvalidConfig {
296 reason: format!("Video width must be non-zero and even, got {width}"),
297 });
298 }
299 if let Some(height) = self.video_height
300 && (height == 0 || height % 2 != 0)
301 {
302 return Err(EncodeError::InvalidConfig {
303 reason: format!("Video height must be non-zero and even, got {height}"),
304 });
305 }
306 if let Some(fps) = self.video_fps
307 && fps <= 0.0
308 {
309 return Err(EncodeError::InvalidConfig {
310 reason: format!("Video FPS must be positive, got {fps}"),
311 });
312 }
313 if let Some(crate::BitrateMode::Crf(q)) = self.video_bitrate_mode
314 && q > crate::CRF_MAX
315 {
316 return Err(EncodeError::InvalidConfig {
317 reason: format!(
318 "BitrateMode::Crf value must be 0-{}, got {q}",
319 crate::CRF_MAX
320 ),
321 });
322 }
323 if let Some(crate::BitrateMode::Vbr { target, max }) = self.video_bitrate_mode
324 && max < target
325 {
326 return Err(EncodeError::InvalidConfig {
327 reason: format!("BitrateMode::Vbr max ({max}) must be >= target ({target})"),
328 });
329 }
330 }
331
332 if has_audio {
333 if let Some(rate) = self.audio_sample_rate
334 && rate == 0
335 {
336 return Err(EncodeError::InvalidConfig {
337 reason: "Audio sample rate must be non-zero".to_string(),
338 });
339 }
340 if let Some(ch) = self.audio_channels
341 && ch == 0
342 {
343 return Err(EncodeError::InvalidConfig {
344 reason: "Audio channels must be non-zero".to_string(),
345 });
346 }
347 }
348
349 Ok(())
350 }
351}
352
353pub struct VideoEncoder {
369 inner: Option<VideoEncoderInner>,
370 _config: VideoEncoderConfig,
371 start_time: Instant,
372 progress_callback: Option<Box<dyn crate::ProgressCallback>>,
373}
374
375impl VideoEncoder {
376 pub fn create<P: AsRef<std::path::Path>>(path: P) -> VideoEncoderBuilder {
381 VideoEncoderBuilder::new(path.as_ref().to_path_buf())
382 }
383
384 pub(crate) fn from_builder(builder: VideoEncoderBuilder) -> Result<Self, EncodeError> {
385 let config = VideoEncoderConfig {
386 path: builder.path.clone(),
387 video_width: builder.video_width,
388 video_height: builder.video_height,
389 video_fps: builder.video_fps,
390 video_codec: builder.video_codec,
391 video_bitrate_mode: builder.video_bitrate_mode,
392 preset: preset_to_string(&builder.preset),
393 hardware_encoder: builder.hardware_encoder,
394 audio_sample_rate: builder.audio_sample_rate,
395 audio_channels: builder.audio_channels,
396 audio_codec: builder.audio_codec,
397 audio_bitrate: builder.audio_bitrate,
398 _progress_callback: builder.progress_callback.is_some(),
399 two_pass: builder.two_pass,
400 metadata: builder.metadata,
401 chapters: builder.chapters,
402 subtitle_passthrough: builder.subtitle_passthrough,
403 };
404
405 let inner = if config.video_width.is_some() {
406 Some(VideoEncoderInner::new(&config)?)
407 } else {
408 None
409 };
410
411 Ok(Self {
412 inner,
413 _config: config,
414 start_time: Instant::now(),
415 progress_callback: builder.progress_callback,
416 })
417 }
418
419 #[must_use]
421 pub fn actual_video_codec(&self) -> &str {
422 self.inner
423 .as_ref()
424 .map_or("", |inner| inner.actual_video_codec.as_str())
425 }
426
427 #[must_use]
429 pub fn actual_audio_codec(&self) -> &str {
430 self.inner
431 .as_ref()
432 .map_or("", |inner| inner.actual_audio_codec.as_str())
433 }
434
435 #[must_use]
437 pub fn hardware_encoder(&self) -> crate::HardwareEncoder {
438 let codec_name = self.actual_video_codec();
439 if codec_name.contains("nvenc") {
440 crate::HardwareEncoder::Nvenc
441 } else if codec_name.contains("qsv") {
442 crate::HardwareEncoder::Qsv
443 } else if codec_name.contains("amf") {
444 crate::HardwareEncoder::Amf
445 } else if codec_name.contains("videotoolbox") {
446 crate::HardwareEncoder::VideoToolbox
447 } else if codec_name.contains("vaapi") {
448 crate::HardwareEncoder::Vaapi
449 } else {
450 crate::HardwareEncoder::None
451 }
452 }
453
454 #[must_use]
456 pub fn is_hardware_encoding(&self) -> bool {
457 !matches!(self.hardware_encoder(), crate::HardwareEncoder::None)
458 }
459
460 #[must_use]
462 pub fn is_lgpl_compliant(&self) -> bool {
463 let codec_name = self.actual_video_codec();
464 if codec_name.contains("nvenc")
465 || codec_name.contains("qsv")
466 || codec_name.contains("amf")
467 || codec_name.contains("videotoolbox")
468 || codec_name.contains("vaapi")
469 {
470 return true;
471 }
472 if codec_name.contains("vp9")
473 || codec_name.contains("av1")
474 || codec_name.contains("aom")
475 || codec_name.contains("svt")
476 || codec_name.contains("prores")
477 || codec_name == "mpeg4"
478 || codec_name == "dnxhd"
479 {
480 return true;
481 }
482 if codec_name == "libx264" || codec_name == "libx265" {
483 return false;
484 }
485 true
486 }
487
488 pub fn push_video(&mut self, frame: &VideoFrame) -> Result<(), EncodeError> {
495 if let Some(ref callback) = self.progress_callback
496 && callback.should_cancel()
497 {
498 return Err(EncodeError::Cancelled);
499 }
500 let inner = self
501 .inner
502 .as_mut()
503 .ok_or_else(|| EncodeError::InvalidConfig {
504 reason: "Video encoder not initialized".to_string(),
505 })?;
506 unsafe { inner.push_video_frame(frame)? };
508 let progress = self.create_progress_info();
509 if let Some(ref mut callback) = self.progress_callback {
510 callback.on_progress(&progress);
511 }
512 Ok(())
513 }
514
515 pub fn push_audio(&mut self, frame: &AudioFrame) -> Result<(), EncodeError> {
521 if let Some(ref callback) = self.progress_callback
522 && callback.should_cancel()
523 {
524 return Err(EncodeError::Cancelled);
525 }
526 let inner = self
527 .inner
528 .as_mut()
529 .ok_or_else(|| EncodeError::InvalidConfig {
530 reason: "Audio encoder not initialized".to_string(),
531 })?;
532 unsafe { inner.push_audio_frame(frame)? };
534 let progress = self.create_progress_info();
535 if let Some(ref mut callback) = self.progress_callback {
536 callback.on_progress(&progress);
537 }
538 Ok(())
539 }
540
541 pub fn finish(mut self) -> Result<(), EncodeError> {
547 if let Some(mut inner) = self.inner.take() {
548 unsafe { inner.finish()? };
550 }
551 Ok(())
552 }
553
554 fn create_progress_info(&self) -> crate::Progress {
555 let elapsed = self.start_time.elapsed();
556 let (frames_encoded, bytes_written) = self
557 .inner
558 .as_ref()
559 .map_or((0, 0), |inner| (inner.frame_count, inner.bytes_written));
560 #[allow(clippy::cast_precision_loss)]
561 let current_fps = if !elapsed.is_zero() {
562 frames_encoded as f64 / elapsed.as_secs_f64()
563 } else {
564 0.0
565 };
566 #[allow(clippy::cast_precision_loss)]
567 let current_bitrate = if !elapsed.is_zero() {
568 let elapsed_secs = elapsed.as_secs();
569 if elapsed_secs > 0 {
570 (bytes_written * 8) / elapsed_secs
571 } else {
572 ((bytes_written * 8) as f64 / elapsed.as_secs_f64()) as u64
573 }
574 } else {
575 0
576 };
577 crate::Progress {
578 frames_encoded,
579 total_frames: None,
580 bytes_written,
581 current_bitrate,
582 elapsed,
583 remaining: None,
584 current_fps,
585 }
586 }
587}
588
589impl Drop for VideoEncoder {
590 fn drop(&mut self) {
591 }
593}
594
595#[cfg(test)]
596#[allow(clippy::unwrap_used)]
597mod tests {
598 use super::super::encoder_inner::{VideoEncoderConfig, VideoEncoderInner};
599 use super::*;
600 use crate::HardwareEncoder;
601
602 fn create_mock_encoder(video_codec_name: &str, audio_codec_name: &str) -> VideoEncoder {
603 VideoEncoder {
604 inner: Some(VideoEncoderInner {
605 format_ctx: std::ptr::null_mut(),
606 video_codec_ctx: None,
607 audio_codec_ctx: None,
608 video_stream_index: -1,
609 audio_stream_index: -1,
610 sws_ctx: None,
611 swr_ctx: None,
612 frame_count: 0,
613 audio_sample_count: 0,
614 bytes_written: 0,
615 actual_video_codec: video_codec_name.to_string(),
616 actual_audio_codec: audio_codec_name.to_string(),
617 last_src_width: None,
618 last_src_height: None,
619 last_src_format: None,
620 two_pass: false,
621 pass1_codec_ctx: None,
622 buffered_frames: Vec::new(),
623 two_pass_config: None,
624 stats_in_cstr: None,
625 subtitle_passthrough: None,
626 }),
627 _config: VideoEncoderConfig {
628 path: "test.mp4".into(),
629 video_width: Some(1920),
630 video_height: Some(1080),
631 video_fps: Some(30.0),
632 video_codec: crate::VideoCodec::H264,
633 video_bitrate_mode: None,
634 preset: "medium".to_string(),
635 hardware_encoder: HardwareEncoder::Auto,
636 audio_sample_rate: None,
637 audio_channels: None,
638 audio_codec: crate::AudioCodec::Aac,
639 audio_bitrate: None,
640 _progress_callback: false,
641 two_pass: false,
642 metadata: Vec::new(),
643 chapters: Vec::new(),
644 subtitle_passthrough: None,
645 },
646 start_time: std::time::Instant::now(),
647 progress_callback: None,
648 }
649 }
650
651 #[test]
652 fn create_should_return_builder_without_error() {
653 let _builder: VideoEncoderBuilder = VideoEncoder::create("output.mp4");
654 }
655
656 #[test]
657 fn builder_video_settings_should_be_stored() {
658 let builder = VideoEncoder::create("output.mp4")
659 .video(1920, 1080, 30.0)
660 .video_codec(VideoCodec::H264)
661 .bitrate_mode(crate::BitrateMode::Cbr(8_000_000));
662 assert_eq!(builder.video_width, Some(1920));
663 assert_eq!(builder.video_height, Some(1080));
664 assert_eq!(builder.video_fps, Some(30.0));
665 assert_eq!(builder.video_codec, VideoCodec::H264);
666 assert_eq!(
667 builder.video_bitrate_mode,
668 Some(crate::BitrateMode::Cbr(8_000_000))
669 );
670 }
671
672 #[test]
673 fn builder_audio_settings_should_be_stored() {
674 let builder = VideoEncoder::create("output.mp4")
675 .audio(48000, 2)
676 .audio_codec(AudioCodec::Aac)
677 .audio_bitrate(192_000);
678 assert_eq!(builder.audio_sample_rate, Some(48000));
679 assert_eq!(builder.audio_channels, Some(2));
680 assert_eq!(builder.audio_codec, AudioCodec::Aac);
681 assert_eq!(builder.audio_bitrate, Some(192_000));
682 }
683
684 #[test]
685 fn builder_preset_should_be_stored() {
686 let builder = VideoEncoder::create("output.mp4")
687 .video(1920, 1080, 30.0)
688 .preset(Preset::Fast);
689 assert_eq!(builder.preset, Preset::Fast);
690 }
691
692 #[test]
693 fn builder_hardware_encoder_should_be_stored() {
694 let builder = VideoEncoder::create("output.mp4")
695 .video(1920, 1080, 30.0)
696 .hardware_encoder(HardwareEncoder::Nvenc);
697 assert_eq!(builder.hardware_encoder, HardwareEncoder::Nvenc);
698 }
699
700 #[test]
701 fn builder_container_should_be_stored() {
702 let builder = VideoEncoder::create("output.mp4")
703 .video(1920, 1080, 30.0)
704 .container(Container::Mp4);
705 assert_eq!(builder.container, Some(Container::Mp4));
706 }
707
708 #[test]
709 fn build_without_streams_should_return_error() {
710 let result = VideoEncoder::create("output.mp4").build();
711 assert!(result.is_err());
712 }
713
714 #[test]
715 fn build_with_odd_width_should_return_error() {
716 let result = VideoEncoder::create("output.mp4")
717 .video(1921, 1080, 30.0)
718 .build();
719 assert!(result.is_err());
720 }
721
722 #[test]
723 fn build_with_odd_height_should_return_error() {
724 let result = VideoEncoder::create("output.mp4")
725 .video(1920, 1081, 30.0)
726 .build();
727 assert!(result.is_err());
728 }
729
730 #[test]
731 fn build_with_invalid_fps_should_return_error() {
732 let result = VideoEncoder::create("output.mp4")
733 .video(1920, 1080, -1.0)
734 .build();
735 assert!(result.is_err());
736 }
737
738 #[test]
739 fn two_pass_flag_should_be_stored_in_builder() {
740 let builder = VideoEncoder::create("output.mp4")
741 .video(640, 480, 30.0)
742 .two_pass();
743 assert!(builder.two_pass);
744 }
745
746 #[test]
747 fn two_pass_with_audio_should_return_error() {
748 let result = VideoEncoder::create("output.mp4")
749 .video(640, 480, 30.0)
750 .audio(48000, 2)
751 .two_pass()
752 .build();
753 assert!(result.is_err());
754 if let Err(e) = result {
755 assert!(
756 matches!(e, crate::EncodeError::InvalidConfig { .. }),
757 "expected InvalidConfig, got {e:?}"
758 );
759 }
760 }
761
762 #[test]
763 fn two_pass_without_video_should_return_error() {
764 let result = VideoEncoder::create("output.mp4").two_pass().build();
765 assert!(result.is_err());
766 }
767
768 #[test]
769 fn build_with_crf_above_51_should_return_error() {
770 let result = VideoEncoder::create("output.mp4")
771 .video(1920, 1080, 30.0)
772 .bitrate_mode(crate::BitrateMode::Crf(100))
773 .build();
774 assert!(result.is_err());
775 }
776
777 #[test]
778 fn bitrate_mode_vbr_with_max_less_than_target_should_return_error() {
779 let output_path = "test_vbr.mp4";
780 let result = VideoEncoder::create(output_path)
781 .video(640, 480, 30.0)
782 .bitrate_mode(crate::BitrateMode::Vbr {
783 target: 4_000_000,
784 max: 2_000_000,
785 })
786 .build();
787 assert!(result.is_err());
788 }
789
790 #[test]
791 fn is_lgpl_compliant_should_be_true_for_hardware_encoders() {
792 for codec_name in &[
793 "h264_nvenc",
794 "h264_qsv",
795 "h264_amf",
796 "h264_videotoolbox",
797 "hevc_vaapi",
798 ] {
799 let encoder = create_mock_encoder(codec_name, "");
800 assert!(
801 encoder.is_lgpl_compliant(),
802 "expected LGPL-compliant for {codec_name}"
803 );
804 }
805 }
806
807 #[test]
808 fn is_lgpl_compliant_should_be_false_for_gpl_encoders() {
809 for codec_name in &["libx264", "libx265"] {
810 let encoder = create_mock_encoder(codec_name, "");
811 assert!(
812 !encoder.is_lgpl_compliant(),
813 "expected non-LGPL for {codec_name}"
814 );
815 }
816 }
817
818 #[test]
819 fn hardware_encoder_detection_should_match_codec_name() {
820 let cases: &[(&str, HardwareEncoder, bool)] = &[
821 ("h264_nvenc", HardwareEncoder::Nvenc, true),
822 ("h264_qsv", HardwareEncoder::Qsv, true),
823 ("h264_amf", HardwareEncoder::Amf, true),
824 ("h264_videotoolbox", HardwareEncoder::VideoToolbox, true),
825 ("h264_vaapi", HardwareEncoder::Vaapi, true),
826 ("libx264", HardwareEncoder::None, false),
827 ("libvpx-vp9", HardwareEncoder::None, false),
828 ];
829 for (codec_name, expected_hw, expected_is_hw) in cases {
830 let encoder = create_mock_encoder(codec_name, "");
831 assert_eq!(
832 encoder.hardware_encoder(),
833 *expected_hw,
834 "hw for {codec_name}"
835 );
836 assert_eq!(
837 encoder.is_hardware_encoding(),
838 *expected_is_hw,
839 "is_hw for {codec_name}"
840 );
841 }
842 }
843}