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 frame_count: 0,
828 audio_sample_count: 0,
829 bytes_written: 0,
830 actual_video_codec: video_codec_name.to_string(),
831 actual_audio_codec: audio_codec_name.to_string(),
832 last_src_width: None,
833 last_src_height: None,
834 last_src_format: None,
835 two_pass: false,
836 pass1_codec_ctx: None,
837 buffered_frames: Vec::new(),
838 two_pass_config: None,
839 stats_in_cstr: None,
840 subtitle_passthrough: None,
841 hdr10_metadata: None,
842 }),
843 _config: VideoEncoderConfig {
844 path: "test.mp4".into(),
845 video_width: Some(1920),
846 video_height: Some(1080),
847 video_fps: Some(30.0),
848 video_codec: crate::VideoCodec::H264,
849 video_bitrate_mode: None,
850 preset: "medium".to_string(),
851 hardware_encoder: HardwareEncoder::Auto,
852 audio_sample_rate: None,
853 audio_channels: None,
854 audio_codec: crate::AudioCodec::Aac,
855 audio_bitrate: None,
856 _progress_callback: false,
857 two_pass: false,
858 metadata: Vec::new(),
859 chapters: Vec::new(),
860 subtitle_passthrough: None,
861 codec_options: None,
862 pixel_format: None,
863 hdr10_metadata: None,
864 color_space: None,
865 color_transfer: None,
866 color_primaries: None,
867 attachments: Vec::new(),
868 container: None,
869 },
870 start_time: std::time::Instant::now(),
871 progress_callback: None,
872 }
873 }
874
875 #[test]
876 fn create_should_return_builder_without_error() {
877 let _builder: VideoEncoderBuilder = VideoEncoder::create(test_out("output.mp4"));
878 }
879
880 #[test]
881 fn build_without_streams_should_return_error() {
882 let result = VideoEncoder::create(test_out("output.mp4")).build();
883 assert!(result.is_err());
884 }
885
886 #[test]
887 fn build_with_odd_width_should_return_error() {
888 let result = VideoEncoder::create(test_out("output.mp4"))
889 .video(1921, 1080, 30.0)
890 .build();
891 assert!(result.is_err());
892 }
893
894 #[test]
895 fn build_with_odd_height_should_return_error() {
896 let result = VideoEncoder::create(test_out("output.mp4"))
897 .video(1920, 1081, 30.0)
898 .build();
899 assert!(result.is_err());
900 }
901
902 #[test]
903 fn build_with_invalid_fps_should_return_error() {
904 let result = VideoEncoder::create(test_out("output.mp4"))
905 .video(1920, 1080, -1.0)
906 .build();
907 assert!(result.is_err());
908 }
909
910 #[test]
911 fn two_pass_with_audio_should_return_error() {
912 let result = VideoEncoder::create(test_out("output.mp4"))
913 .video(640, 480, 30.0)
914 .audio(48000, 2)
915 .two_pass()
916 .build();
917 assert!(result.is_err());
918 if let Err(e) = result {
919 assert!(
920 matches!(e, crate::EncodeError::InvalidConfig { .. }),
921 "expected InvalidConfig, got {e:?}"
922 );
923 }
924 }
925
926 #[test]
927 fn two_pass_without_video_should_return_error() {
928 let result = VideoEncoder::create(test_out("output.mp4"))
929 .two_pass()
930 .build();
931 assert!(result.is_err());
932 }
933
934 #[test]
935 fn build_with_crf_above_51_should_return_error() {
936 let result = VideoEncoder::create(test_out("output.mp4"))
937 .video(1920, 1080, 30.0)
938 .bitrate_mode(crate::BitrateMode::Crf(100))
939 .build();
940 assert!(result.is_err());
941 }
942
943 #[test]
944 fn bitrate_mode_vbr_with_max_less_than_target_should_return_error() {
945 let result = VideoEncoder::create(test_out("test_vbr.mp4"))
946 .video(640, 480, 30.0)
947 .bitrate_mode(crate::BitrateMode::Vbr {
948 target: 4_000_000,
949 max: 2_000_000,
950 })
951 .build();
952 assert!(result.is_err());
953 }
954
955 #[test]
956 fn is_lgpl_compliant_should_be_true_for_hardware_encoders() {
957 for codec_name in &[
958 "h264_nvenc",
959 "h264_qsv",
960 "h264_amf",
961 "h264_videotoolbox",
962 "hevc_vaapi",
963 ] {
964 let encoder = create_mock_encoder(codec_name, "");
965 assert!(
966 encoder.is_lgpl_compliant(),
967 "expected LGPL-compliant for {codec_name}"
968 );
969 }
970 }
971
972 #[test]
973 fn is_lgpl_compliant_should_be_false_for_gpl_encoders() {
974 for codec_name in &["libx264", "libx265"] {
975 let encoder = create_mock_encoder(codec_name, "");
976 assert!(
977 !encoder.is_lgpl_compliant(),
978 "expected non-LGPL for {codec_name}"
979 );
980 }
981 }
982
983 #[test]
984 fn hardware_encoder_detection_should_match_codec_name() {
985 let cases: &[(&str, HardwareEncoder, bool)] = &[
986 ("h264_nvenc", HardwareEncoder::Nvenc, true),
987 ("h264_qsv", HardwareEncoder::Qsv, true),
988 ("h264_amf", HardwareEncoder::Amf, true),
989 ("h264_videotoolbox", HardwareEncoder::VideoToolbox, true),
990 ("h264_vaapi", HardwareEncoder::Vaapi, true),
991 ("libx264", HardwareEncoder::None, false),
992 ("libvpx-vp9", HardwareEncoder::None, false),
993 ];
994 for (codec_name, expected_hw, expected_is_hw) in cases {
995 let encoder = create_mock_encoder(codec_name, "");
996 assert_eq!(
997 encoder.hardware_encoder(),
998 *expected_hw,
999 "hw for {codec_name}"
1000 );
1001 assert_eq!(
1002 encoder.is_hardware_encoding(),
1003 *expected_is_hw,
1004 "is_hw for {codec_name}"
1005 );
1006 }
1007 }
1008
1009 #[test]
1010 fn webm_extension_without_explicit_codec_should_default_to_vp9_opus() {
1011 let builder = VideoEncoder::create(test_out("output.webm")).video(640, 480, 30.0);
1012 let normalized = builder.apply_container_defaults();
1013 assert_eq!(normalized.video_codec, VideoCodec::Vp9);
1014 assert_eq!(normalized.audio_codec, AudioCodec::Opus);
1015 }
1016
1017 #[test]
1018 fn webm_extension_with_explicit_vp9_should_preserve_codec() {
1019 let builder = VideoEncoder::create(test_out("output.webm"))
1020 .video(640, 480, 30.0)
1021 .video_codec(VideoCodec::Vp9);
1022 assert!(builder.video_codec_explicit);
1023 let normalized = builder.apply_container_defaults();
1024 assert_eq!(normalized.video_codec, VideoCodec::Vp9);
1025 }
1026
1027 #[test]
1028 fn avi_extension_without_explicit_codec_should_default_to_h264_mp3() {
1029 let builder = VideoEncoder::create(test_out("output.avi")).video(640, 480, 30.0);
1030 let normalized = builder.apply_container_defaults();
1031 assert_eq!(normalized.video_codec, VideoCodec::H264);
1032 assert_eq!(normalized.audio_codec, AudioCodec::Mp3);
1033 }
1034
1035 #[test]
1036 fn mov_extension_without_explicit_codec_should_default_to_h264_aac() {
1037 let builder = VideoEncoder::create(test_out("output.mov")).video(640, 480, 30.0);
1038 let normalized = builder.apply_container_defaults();
1039 assert_eq!(normalized.video_codec, VideoCodec::H264);
1040 assert_eq!(normalized.audio_codec, AudioCodec::Aac);
1041 }
1042
1043 #[test]
1044 fn webm_extension_with_h264_video_codec_should_return_error() {
1045 let result = VideoEncoder::create(test_out("output.webm"))
1046 .video(640, 480, 30.0)
1047 .video_codec(VideoCodec::H264)
1048 .build();
1049 assert!(matches!(
1050 result,
1051 Err(crate::EncodeError::UnsupportedContainerCodecCombination { .. })
1052 ));
1053 }
1054
1055 #[test]
1056 fn webm_extension_with_h265_video_codec_should_return_error() {
1057 let result = VideoEncoder::create(test_out("output.webm"))
1058 .video(640, 480, 30.0)
1059 .video_codec(VideoCodec::H265)
1060 .build();
1061 assert!(matches!(
1062 result,
1063 Err(crate::EncodeError::UnsupportedContainerCodecCombination { .. })
1064 ));
1065 }
1066
1067 #[test]
1068 fn webm_extension_with_incompatible_audio_codec_should_return_error() {
1069 let result = VideoEncoder::create(test_out("output.webm"))
1070 .video(640, 480, 30.0)
1071 .video_codec(VideoCodec::Vp9)
1072 .audio(48000, 2)
1073 .audio_codec(AudioCodec::Aac)
1074 .build();
1075 assert!(matches!(
1076 result,
1077 Err(crate::EncodeError::UnsupportedContainerCodecCombination { .. })
1078 ));
1079 }
1080
1081 #[test]
1082 fn webm_container_enum_with_incompatible_codec_should_return_error() {
1083 let result = VideoEncoder::create(test_out("output.mkv"))
1084 .video(640, 480, 30.0)
1085 .container(OutputContainer::WebM)
1086 .video_codec(VideoCodec::H264)
1087 .build();
1088 assert!(matches!(
1089 result,
1090 Err(crate::EncodeError::UnsupportedContainerCodecCombination { .. })
1091 ));
1092 }
1093
1094 #[test]
1095 fn non_webm_extension_should_not_enforce_webm_codecs() {
1096 let result = VideoEncoder::create(test_out("output.mp4"))
1098 .video(640, 480, 30.0)
1099 .video_codec(VideoCodec::H264)
1100 .build();
1101 assert!(!matches!(
1103 result,
1104 Err(crate::EncodeError::UnsupportedContainerCodecCombination { .. })
1105 ));
1106 }
1107
1108 #[test]
1109 fn avi_with_incompatible_video_codec_should_return_error() {
1110 let result = VideoEncoder::create(test_out("output.avi"))
1111 .video(640, 480, 30.0)
1112 .video_codec(VideoCodec::Vp9)
1113 .build();
1114 assert!(matches!(
1115 result,
1116 Err(crate::EncodeError::UnsupportedContainerCodecCombination { .. })
1117 ));
1118 }
1119
1120 #[test]
1121 fn avi_with_incompatible_audio_codec_should_return_error() {
1122 let result = VideoEncoder::create(test_out("output.avi"))
1123 .video(640, 480, 30.0)
1124 .video_codec(VideoCodec::H264)
1125 .audio(48000, 2)
1126 .audio_codec(AudioCodec::Opus)
1127 .build();
1128 assert!(matches!(
1129 result,
1130 Err(crate::EncodeError::UnsupportedContainerCodecCombination { .. })
1131 ));
1132 }
1133
1134 #[test]
1135 fn mov_with_incompatible_video_codec_should_return_error() {
1136 let result = VideoEncoder::create(test_out("output.mov"))
1137 .video(640, 480, 30.0)
1138 .video_codec(VideoCodec::Vp9)
1139 .build();
1140 assert!(matches!(
1141 result,
1142 Err(crate::EncodeError::UnsupportedContainerCodecCombination { .. })
1143 ));
1144 }
1145
1146 #[test]
1147 fn mov_with_incompatible_audio_codec_should_return_error() {
1148 let result = VideoEncoder::create(test_out("output.mov"))
1149 .video(640, 480, 30.0)
1150 .video_codec(VideoCodec::H264)
1151 .audio(48000, 2)
1152 .audio_codec(AudioCodec::Opus)
1153 .build();
1154 assert!(matches!(
1155 result,
1156 Err(crate::EncodeError::UnsupportedContainerCodecCombination { .. })
1157 ));
1158 }
1159
1160 #[test]
1161 fn avi_container_enum_with_incompatible_codec_should_return_error() {
1162 let result = VideoEncoder::create(test_out("output.mp4"))
1163 .video(640, 480, 30.0)
1164 .container(OutputContainer::Avi)
1165 .video_codec(VideoCodec::Vp9)
1166 .build();
1167 assert!(matches!(
1168 result,
1169 Err(crate::EncodeError::UnsupportedContainerCodecCombination { .. })
1170 ));
1171 }
1172
1173 #[test]
1174 fn mov_container_enum_with_incompatible_codec_should_return_error() {
1175 let result = VideoEncoder::create(test_out("output.mp4"))
1176 .video(640, 480, 30.0)
1177 .container(OutputContainer::Mov)
1178 .video_codec(VideoCodec::Vp9)
1179 .build();
1180 assert!(matches!(
1181 result,
1182 Err(crate::EncodeError::UnsupportedContainerCodecCombination { .. })
1183 ));
1184 }
1185
1186 #[test]
1187 fn avi_with_pcm_audio_should_pass_validation() {
1188 let result = VideoEncoder::create(test_out("output.avi"))
1190 .video(640, 480, 30.0)
1191 .video_codec(VideoCodec::H264)
1192 .audio(48000, 2)
1193 .audio_codec(AudioCodec::Pcm)
1194 .build();
1195 assert!(!matches!(
1196 result,
1197 Err(crate::EncodeError::UnsupportedContainerCodecCombination { .. })
1198 ));
1199 }
1200
1201 #[test]
1202 fn mov_with_pcm24_audio_should_pass_validation() {
1203 let result = VideoEncoder::create(test_out("output.mov"))
1204 .video(640, 480, 30.0)
1205 .video_codec(VideoCodec::H264)
1206 .audio(48000, 2)
1207 .audio_codec(AudioCodec::Pcm24)
1208 .build();
1209 assert!(!matches!(
1210 result,
1211 Err(crate::EncodeError::UnsupportedContainerCodecCombination { .. })
1212 ));
1213 }
1214
1215 #[test]
1216 fn non_avi_mov_extension_should_not_enforce_avi_mov_codecs() {
1217 let result = VideoEncoder::create(test_out("output.webm"))
1219 .video(640, 480, 30.0)
1220 .video_codec(VideoCodec::Vp9)
1221 .build();
1222 assert!(!matches!(
1223 result,
1224 Err(crate::EncodeError::UnsupportedContainerCodecCombination {
1225 ref container, ..
1226 }) if container == "avi" || container == "mov"
1227 ));
1228 }
1229
1230 #[test]
1231 fn fmp4_container_with_h264_should_pass_validation() {
1232 let result = VideoEncoder::create(test_out("output.mp4"))
1233 .video(640, 480, 30.0)
1234 .video_codec(VideoCodec::H264)
1235 .container(OutputContainer::FMp4)
1236 .build();
1237 assert!(!matches!(
1238 result,
1239 Err(crate::EncodeError::UnsupportedContainerCodecCombination { .. })
1240 ));
1241 }
1242
1243 #[test]
1244 fn fmp4_container_with_mpeg4_should_return_error() {
1245 let result = VideoEncoder::create(test_out("output.mp4"))
1246 .video(640, 480, 30.0)
1247 .video_codec(VideoCodec::Mpeg4)
1248 .container(OutputContainer::FMp4)
1249 .build();
1250 assert!(matches!(
1251 result,
1252 Err(crate::EncodeError::UnsupportedContainerCodecCombination {
1253 ref container, ..
1254 }) if container == "fMP4"
1255 ));
1256 }
1257
1258 #[test]
1259 fn fmp4_container_with_mjpeg_should_return_error() {
1260 let result = VideoEncoder::create(test_out("output.mp4"))
1261 .video(640, 480, 30.0)
1262 .video_codec(VideoCodec::Mjpeg)
1263 .container(OutputContainer::FMp4)
1264 .build();
1265 assert!(matches!(
1266 result,
1267 Err(crate::EncodeError::UnsupportedContainerCodecCombination {
1268 ref container, ..
1269 }) if container == "fMP4"
1270 ));
1271 }
1272}