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