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