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