1use std::fs::File;
34use std::io::{BufWriter, Read, Write};
35use std::path::{Path, PathBuf};
36use std::process::{Child, ChildStdin, Command, Stdio};
37use std::thread::JoinHandle;
38
39use serde::{Deserialize, Serialize};
40
41#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
49pub enum OutputFormat {
50 #[default]
55 Mp4H264Aac,
56 Mp4H265Aac,
60 WebmVp9Opus,
64 WebmAv1Opus,
68}
69
70impl OutputFormat {
71 #[must_use]
73 pub fn extension(self) -> &'static str {
74 match self {
75 Self::Mp4H264Aac | Self::Mp4H265Aac => "mp4",
76 Self::WebmVp9Opus | Self::WebmAv1Opus => "webm",
77 }
78 }
79
80 #[must_use]
83 pub fn slug(self) -> &'static str {
84 match self {
85 Self::Mp4H264Aac => "mp4-h264",
86 Self::Mp4H265Aac => "mp4-h265",
87 Self::WebmVp9Opus => "webm-vp9",
88 Self::WebmAv1Opus => "webm-av1",
89 }
90 }
91
92 #[must_use]
96 pub fn from_slug(slug: &str) -> Option<Self> {
97 match slug {
98 "mp4-h264" => Some(Self::Mp4H264Aac),
99 "mp4-h265" => Some(Self::Mp4H265Aac),
100 "webm-vp9" => Some(Self::WebmVp9Opus),
101 "webm-av1" => Some(Self::WebmAv1Opus),
102 _ => None,
103 }
104 }
105
106 #[must_use]
120 pub const fn max_encode_edge(self) -> Option<u32> {
121 match self {
122 Self::Mp4H264Aac => Some(4096),
123 Self::Mp4H265Aac => Some(8192),
124 Self::WebmVp9Opus | Self::WebmAv1Opus => None,
125 }
126 }
127}
128
129#[derive(Clone, Debug, PartialEq, Eq)]
133pub struct EncoderConfig {
134 pub output_path: PathBuf,
137 pub width: u32,
139 pub height: u32,
141 pub framerate: u32,
143 pub sample_rate: u32,
145 pub channels: u8,
147 pub format: OutputFormat,
149}
150
151impl EncoderConfig {
152 #[must_use]
155 pub fn for_output(output_path: PathBuf, format: OutputFormat) -> Self {
156 Self {
157 output_path,
158 width: 1920,
159 height: 1080,
160 framerate: 30,
161 sample_rate: 48_000,
162 channels: 2,
163 format,
164 }
165 }
166}
167
168#[must_use]
199pub fn fit_within_encoder_limits(width: u32, height: u32, format: OutputFormat) -> (u32, u32) {
200 let to_even = |v: u32| (v & !1u32).max(2);
201 let Some(max_edge) = format.max_encode_edge() else {
202 return (to_even(width), to_even(height));
203 };
204 let longest = width.max(height);
205 if longest <= max_edge {
206 return (to_even(width), to_even(height));
207 }
208 let scale = |v: u32| -> u32 {
212 let scaled = u64::from(v) * u64::from(max_edge) / u64::from(longest);
213 to_even(u32::try_from(scaled).unwrap_or(max_edge))
214 };
215 (scale(width), scale(height))
216}
217
218pub const RECORDING_MAX_LONG_EDGE: u32 = 1920;
220pub const RECORDING_MAX_SHORT_EDGE: u32 = 1080;
222
223#[must_use]
250pub fn cap_recording_dims(width: u32, height: u32) -> (u32, u32) {
251 let to_even = |v: u32| (v & !1u32).max(2);
252 let long = width.max(height);
253 let short = width.min(height);
254 if long <= RECORDING_MAX_LONG_EDGE && short <= RECORDING_MAX_SHORT_EDGE {
255 return (to_even(width), to_even(height));
256 }
257 let scale = |v: u32| -> u32 {
260 let by_long = u64::from(v) * u64::from(RECORDING_MAX_LONG_EDGE) / u64::from(long);
261 let by_short = u64::from(v) * u64::from(RECORDING_MAX_SHORT_EDGE) / u64::from(short);
262 to_even(u32::try_from(by_long.min(by_short)).unwrap_or(RECORDING_MAX_SHORT_EDGE))
263 };
264 (scale(width), scale(height))
265}
266
267#[derive(Debug, thiserror::Error)]
269pub enum EncodeError {
270 #[error("failed to spawn `gst-launch-1.0`: {source} (PATH={path})")]
272 Spawn {
273 #[source]
275 source: std::io::Error,
276 path: String,
278 },
279 #[error("encoder I/O: {0}")]
281 Io(#[from] std::io::Error),
282 #[error("encode pipeline failed (exit {exit:?}): {stderr}")]
285 PipelineFailed {
286 exit: Option<i32>,
288 stderr: String,
290 },
291 #[error("encoder not yet wired for ({format:?}, {os}): {reason}")]
296 Unsupported {
297 format: OutputFormat,
299 os: &'static str,
301 reason: &'static str,
303 },
304 #[error("invalid encoder config: {0}")]
306 InvalidConfig(String),
307}
308
309pub trait VideoEncoder: Send + Sync {
314 fn push_video_frame(
321 &mut self,
322 bgra: &[u8],
323 pts: std::time::Duration,
324 ) -> Result<(), EncodeError>;
325
326 fn push_audio_chunk(
333 &mut self,
334 samples: &[f32],
335 pts: std::time::Duration,
336 ) -> Result<(), EncodeError>;
337
338 fn finalize(self: Box<Self>) -> Result<PathBuf, EncodeError>;
348}
349
350#[derive(Debug)]
369pub struct LiveGstreamerEncoder {
370 config: EncoderConfig,
371 video_intermediate_path: PathBuf,
374 audio_scratch_path: PathBuf,
377 video_child: Child,
379 video_stdin: Option<ChildStdin>,
381 stderr_drain: Option<JoinHandle<String>>,
385 audio_writer: BufWriter<File>,
386 expected_video_bytes_per_frame: usize,
387 frames_pushed: u64,
388 audio_chunks_pushed: u64,
389}
390
391impl LiveGstreamerEncoder {
392 pub fn new(config: EncoderConfig) -> Result<Self, EncodeError> {
402 if config.width == 0 || config.height == 0 || config.framerate == 0 {
403 return Err(EncodeError::InvalidConfig(format!(
404 "width={}, height={}, framerate={} — none may be zero",
405 config.width, config.height, config.framerate
406 )));
407 }
408 if config.channels == 0 || config.sample_rate == 0 {
409 return Err(EncodeError::InvalidConfig(format!(
410 "channels={}, sample_rate={} — neither may be zero",
411 config.channels, config.sample_rate
412 )));
413 }
414
415 let video_intermediate_path = scratch_path(&config.output_path, ".live-video.scratch");
416 let audio_scratch_path = scratch_path(&config.output_path, ".f32.scratch");
417
418 let video_args = build_live_video_args(&config, &video_intermediate_path)?;
422 let mut child = Command::new("gst-launch-1.0")
423 .args(&video_args)
424 .stdin(Stdio::piped())
425 .stdout(Stdio::null())
426 .stderr(Stdio::piped())
427 .spawn()
428 .map_err(|err| EncodeError::Spawn {
429 source: err,
430 path: std::env::var("PATH").unwrap_or_else(|_| "<unset>".into()),
431 })?;
432
433 let video_stdin = child.stdin.take();
434 let stderr_drain = child.stderr.take().map(|mut stderr| {
435 std::thread::spawn(move || {
436 let mut buf = String::new();
437 let _ = stderr.read_to_string(&mut buf);
438 buf
439 })
440 });
441
442 let audio_file = File::create(&audio_scratch_path)?;
443 let expected_video_bytes_per_frame = (config.width as usize) * (config.height as usize) * 4;
444
445 Ok(Self {
446 config,
447 video_intermediate_path,
448 audio_scratch_path,
449 video_child: child,
450 video_stdin,
451 stderr_drain,
452 audio_writer: BufWriter::new(audio_file),
453 expected_video_bytes_per_frame,
454 frames_pushed: 0,
455 audio_chunks_pushed: 0,
456 })
457 }
458
459 #[must_use]
461 pub fn frames_pushed(&self) -> u64 {
462 self.frames_pushed
463 }
464
465 #[must_use]
467 pub fn audio_chunks_pushed(&self) -> u64 {
468 self.audio_chunks_pushed
469 }
470
471 #[must_use]
473 pub fn config(&self) -> &EncoderConfig {
474 &self.config
475 }
476}
477
478impl VideoEncoder for LiveGstreamerEncoder {
479 fn push_video_frame(
480 &mut self,
481 bgra: &[u8],
482 _pts: std::time::Duration,
483 ) -> Result<(), EncodeError> {
484 if bgra.len() != self.expected_video_bytes_per_frame {
485 return Err(EncodeError::InvalidConfig(format!(
486 "frame byte length mismatch: got {}, expected {}",
487 bgra.len(),
488 self.expected_video_bytes_per_frame
489 )));
490 }
491 let Some(stdin) = self.video_stdin.as_mut() else {
492 return Err(EncodeError::Io(std::io::Error::new(
493 std::io::ErrorKind::BrokenPipe,
494 "live encoder stdin already closed",
495 )));
496 };
497 stdin.write_all(bgra)?;
500 self.frames_pushed = self.frames_pushed.saturating_add(1);
501 Ok(())
502 }
503
504 fn push_audio_chunk(
505 &mut self,
506 samples: &[f32],
507 _pts: std::time::Duration,
508 ) -> Result<(), EncodeError> {
509 for sample in samples {
510 self.audio_writer.write_all(&sample.to_le_bytes())?;
511 }
512 self.audio_chunks_pushed = self.audio_chunks_pushed.saturating_add(1);
513 Ok(())
514 }
515
516 fn finalize(mut self: Box<Self>) -> Result<PathBuf, EncodeError> {
517 drop(self.video_stdin.take());
520 let status = self.video_child.wait()?;
521 let stderr = self
522 .stderr_drain
523 .take()
524 .and_then(|h| h.join().ok())
525 .unwrap_or_default();
526 if !status.success() {
527 let _ = std::fs::remove_file(&self.video_intermediate_path);
528 let _ = std::fs::remove_file(&self.audio_scratch_path);
529 return Err(EncodeError::PipelineFailed {
530 exit: status.code(),
531 stderr,
532 });
533 }
534
535 self.audio_writer.flush()?;
540
541 let has_video = self.frames_pushed > 0;
542 let has_audio = self.audio_chunks_pushed > 0;
543
544 if has_video && !has_audio {
546 if std::fs::rename(&self.video_intermediate_path, &self.config.output_path).is_err() {
552 std::fs::copy(&self.video_intermediate_path, &self.config.output_path)?;
553 let _ = std::fs::remove_file(&self.video_intermediate_path);
554 }
555 let _ = std::fs::remove_file(&self.audio_scratch_path);
556 } else {
557 let args = build_remux_args(
558 &self.config,
559 &self.video_intermediate_path,
560 &self.audio_scratch_path,
561 has_video,
562 has_audio,
563 );
564 tracing::info!(
565 output = %self.config.output_path.display(),
566 format = ?self.config.format,
567 video_frames = self.frames_pushed,
568 audio_chunks = self.audio_chunks_pushed,
569 remux_args = ?args,
570 "LiveGstreamerEncoder::finalize remuxing"
571 );
572 let output = Command::new("gst-launch-1.0")
573 .args(&args)
574 .output()
575 .map_err(|err| EncodeError::Spawn {
576 source: err,
577 path: std::env::var("PATH").unwrap_or_else(|_| "<unset>".into()),
578 })?;
579 if !output.status.success() {
580 return Err(EncodeError::PipelineFailed {
581 exit: output.status.code(),
582 stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
583 });
584 }
585 let _ = std::fs::remove_file(&self.video_intermediate_path);
586 let _ = std::fs::remove_file(&self.audio_scratch_path);
587 }
588
589 Ok(self.config.output_path.clone())
590 }
591}
592
593impl Drop for LiveGstreamerEncoder {
594 fn drop(&mut self) {
595 if self.video_stdin.take().is_some() {
600 let _ = self.video_child.kill();
601 let _ = self.video_child.wait();
602 }
603 }
604}
605
606pub fn build_live_video_args(
621 config: &EncoderConfig,
622 intermediate: &Path,
623) -> Result<Vec<String>, EncodeError> {
624 let (video_encoder_elements, mux_element) =
625 encoder_and_mux_elements(config.format, std::env::consts::OS)?;
626
627 let mut args: Vec<String> = vec![
631 "-q".to_string(),
632 "-e".to_string(),
633 "fdsrc".to_string(),
634 "fd=0".to_string(),
635 "!".to_string(),
636 "rawvideoparse".to_string(),
637 "format=bgra".to_string(),
638 format!("width={}", config.width),
639 format!("height={}", config.height),
640 format!("framerate={}/1", config.framerate),
641 "!".to_string(),
642 "videoconvert".to_string(),
643 "!".to_string(),
644 ];
645 for elem in &video_encoder_elements {
646 for token in elem.split_whitespace() {
651 args.push(token.to_string());
652 }
653 args.push("!".to_string());
654 }
655 args.push(mux_to_parser(config.format).to_string());
656 args.push("!".to_string());
657 args.push(mux_element.to_string());
658 args.push("!".to_string());
659 args.push("filesink".to_string());
660 args.push(format!("location={}", intermediate.display()));
661 Ok(args)
662}
663
664#[must_use]
679pub fn build_remux_args(
680 config: &EncoderConfig,
681 intermediate: &Path,
682 audio_scratch: &Path,
683 has_video: bool,
684 has_audio: bool,
685) -> Vec<String> {
686 let mut args: Vec<String> = vec![
687 "-q".to_string(),
688 "-e".to_string(),
689 mux_element_for(config.format).to_string(),
690 "name=mux".to_string(),
691 "!".to_string(),
692 "filesink".to_string(),
693 format!("location={}", config.output_path.display()),
694 ];
695 if has_video {
696 args.extend([
697 "filesrc".to_string(),
698 format!("location={}", intermediate.display()),
699 "!".to_string(),
700 demux_for(config.format).to_string(),
701 "!".to_string(),
702 mux_to_parser(config.format).to_string(),
703 "!".to_string(),
704 "mux.".to_string(),
705 ]);
706 }
707 if has_audio {
708 args.extend([
709 "filesrc".to_string(),
710 format!("location={}", audio_scratch.display()),
711 "!".to_string(),
712 "rawaudioparse".to_string(),
713 "pcm-format=f32le".to_string(),
714 format!("sample-rate={}", config.sample_rate),
715 format!("num-channels={}", config.channels),
716 "!".to_string(),
717 "audioconvert".to_string(),
718 "!".to_string(),
719 "audioresample".to_string(),
720 "!".to_string(),
721 audio_encoder_element(config.format).to_string(),
722 "!".to_string(),
723 "mux.".to_string(),
724 ]);
725 }
726 args
727}
728
729#[must_use]
748pub fn build_audio_decode_args(source: &Path, sample_rate: u32, channels: u8) -> Vec<String> {
749 vec![
750 "-q".to_string(),
751 "filesrc".to_string(),
752 format!("location={}", source.display()),
753 "!".to_string(),
754 "decodebin".to_string(),
755 "!".to_string(),
756 "audioconvert".to_string(),
757 "!".to_string(),
758 "audioresample".to_string(),
759 "!".to_string(),
760 format!(
761 "audio/x-raw,format=F32LE,rate={sample_rate},channels={channels},layout=interleaved"
762 ),
763 "!".to_string(),
764 "fdsink".to_string(),
765 "fd=1".to_string(),
766 ]
767}
768
769pub fn decode_source_audio_f32(
779 source: &Path,
780 sample_rate: u32,
781 channels: u8,
782) -> Result<Vec<f32>, EncodeError> {
783 if !scratch_has_audio(source) {
784 return Ok(Vec::new());
785 }
786 let args = build_audio_decode_args(source, sample_rate, channels);
787 let output = Command::new("gst-launch-1.0")
788 .args(&args)
789 .output()
790 .map_err(|err| EncodeError::Spawn {
791 source: err,
792 path: std::env::var("PATH").unwrap_or_else(|_| "<unset>".into()),
793 })?;
794 if !output.status.success() {
795 return Err(EncodeError::PipelineFailed {
796 exit: output.status.code(),
797 stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
798 });
799 }
800 let samples = output
803 .stdout
804 .chunks_exact(4)
805 .map(|b| f32::from_le_bytes([b[0], b[1], b[2], b[3]]))
806 .collect();
807 Ok(samples)
808}
809
810pub fn generate_poster(video_path: &Path) -> Result<Option<PathBuf>, EncodeError> {
829 if !video_path.exists() {
830 return Err(EncodeError::InvalidConfig(format!(
831 "video file does not exist: {}",
832 video_path.display()
833 )));
834 }
835 let poster_path = poster_path_for(video_path);
836 let args = poster_pipeline_args(video_path, &poster_path);
837
838 let output = Command::new("gst-launch-1.0")
839 .args(&args)
840 .output()
841 .map_err(|err| EncodeError::Spawn {
842 source: err,
843 path: std::env::var("PATH").unwrap_or_else(|_| "<unset>".into()),
844 })?;
845
846 if output.status.success() {
847 tracing::info!(
848 video = %video_path.display(),
849 poster = %poster_path.display(),
850 "generate_poster: AVIF thumbnail written"
851 );
852 Ok(Some(poster_path))
853 } else {
854 let stderr = String::from_utf8_lossy(&output.stderr);
855 if stderr.contains("avifenc") && stderr.to_lowercase().contains("no such element")
860 || stderr.contains("no element \"avifenc\"")
861 {
862 tracing::warn!(
863 "generate_poster: avifenc GStreamer element not installed — \
864 skipping AVIF poster (install gst-plugins-bad to enable)"
865 );
866 return Ok(None);
867 }
868 Err(EncodeError::PipelineFailed {
869 exit: output.status.code(),
870 stderr: stderr.into_owned(),
871 })
872 }
873}
874
875#[must_use]
878pub fn poster_pipeline_args(video_path: &Path, poster_path: &Path) -> Vec<String> {
879 vec![
880 "-q".to_string(),
881 "filesrc".to_string(),
882 format!("location={}", video_path.display()),
883 "!".to_string(),
884 "decodebin".to_string(),
885 "!".to_string(),
886 "videoconvert".to_string(),
887 "!".to_string(),
888 "videoscale".to_string(),
889 "!".to_string(),
890 "video/x-raw,width=640".to_string(),
891 "!".to_string(),
892 "avifenc".to_string(),
893 "!".to_string(),
894 "filesink".to_string(),
895 format!("location={}", poster_path.display()),
896 ]
897}
898
899#[must_use]
903pub fn poster_path_for(video_path: &Path) -> PathBuf {
904 let mut p = video_path.to_path_buf();
905 p.set_extension("avif");
906 p
907}
908
909pub fn transcode_to_webm(input: &Path, output: &Path) -> Result<(), EncodeError> {
933 if !input.exists() {
934 return Err(EncodeError::InvalidConfig(format!(
935 "transcode input does not exist: {}",
936 input.display()
937 )));
938 }
939 let has_audio = scratch_has_audio(input);
940 let args = build_webm_transcode_args(input, output, has_audio);
941
942 tracing::info!(
943 input = %input.display(),
944 output = %output.display(),
945 has_audio,
946 "transcode_to_webm: spawning gst-launch-1.0"
947 );
948
949 let result = Command::new("gst-launch-1.0")
950 .args(&args)
951 .output()
952 .map_err(|err| EncodeError::Spawn {
953 source: err,
954 path: std::env::var("PATH").unwrap_or_else(|_| "<unset>".into()),
955 })?;
956
957 if result.status.success() {
958 Ok(())
959 } else {
960 Err(EncodeError::PipelineFailed {
961 exit: result.status.code(),
962 stderr: String::from_utf8_lossy(&result.stderr).into_owned(),
963 })
964 }
965}
966
967#[must_use]
976pub fn build_webm_transcode_args(input: &Path, output: &Path, has_audio: bool) -> Vec<String> {
977 let mut args = vec![
980 "-q".to_string(),
981 "-e".to_string(),
982 "filesrc".to_string(),
983 format!("location={}", input.display()),
984 "!".to_string(),
985 "decodebin".to_string(),
986 "name=d".to_string(),
987 "webmmux".to_string(),
990 "name=mux".to_string(),
991 "!".to_string(),
992 "filesink".to_string(),
993 format!("location={}", output.display()),
994 "d.".to_string(),
996 "!".to_string(),
997 "queue".to_string(),
998 "!".to_string(),
999 "videoconvert".to_string(),
1000 "!".to_string(),
1001 "vp9enc".to_string(),
1002 "!".to_string(),
1003 "mux.".to_string(),
1004 ];
1005 if has_audio {
1006 args.extend(
1007 [
1008 "d.",
1009 "!",
1010 "queue",
1011 "!",
1012 "audioconvert",
1013 "!",
1014 "audioresample",
1015 "!",
1016 "opusenc",
1017 "!",
1018 "mux.",
1019 ]
1020 .into_iter()
1021 .map(String::from),
1022 );
1023 }
1024 args
1025}
1026
1027#[must_use]
1033pub fn scratch_has_audio(input: &Path) -> bool {
1034 let Ok(output) = Command::new("gst-discoverer-1.0").arg(input).output() else {
1035 return false;
1036 };
1037 if !output.status.success() {
1038 return false;
1039 }
1040 let lower = String::from_utf8_lossy(&output.stdout).to_lowercase();
1043 lower.contains("audio #") || lower.contains("audio:")
1044}
1045
1046fn encoder_and_mux_elements(
1055 format: OutputFormat,
1056 os: &str,
1057) -> Result<(Vec<&'static str>, &'static str), EncodeError> {
1058 let pair = match (format, os) {
1059 (OutputFormat::Mp4H264Aac, "macos") => {
1069 (vec!["vtenc_h264_hw allow-frame-reordering=false"], "mp4mux")
1070 }
1071 (OutputFormat::Mp4H265Aac, "macos") => {
1072 (vec!["vtenc_h265_hw allow-frame-reordering=false"], "mp4mux")
1073 }
1074 (OutputFormat::WebmVp9Opus, "macos") => (vec!["vp9enc"], "webmmux"),
1075 (OutputFormat::WebmAv1Opus, "macos") => (vec!["svtav1enc"], "webmmux"),
1076 (OutputFormat::Mp4H264Aac, "windows") => (vec!["mfh264enc"], "mp4mux"),
1077 (OutputFormat::Mp4H265Aac, "windows") => (vec!["mfhevcenc"], "mp4mux"),
1078 (OutputFormat::WebmVp9Opus, "windows") => (vec!["mfvp9enc"], "webmmux"),
1079 (OutputFormat::WebmAv1Opus, "windows") => (vec!["qsvav1enc"], "webmmux"),
1080 (OutputFormat::Mp4H264Aac, "linux") => (vec!["vaapih264enc"], "mp4mux"),
1081 (OutputFormat::Mp4H265Aac, "linux") => (vec!["vaapih265enc"], "mp4mux"),
1082 (OutputFormat::WebmVp9Opus, "linux") => (vec!["vaapivp9enc"], "webmmux"),
1083 (OutputFormat::WebmAv1Opus, "linux") => (vec!["vaapiav1enc"], "webmmux"),
1084 (format, other) => {
1085 return Err(EncodeError::Unsupported {
1086 format,
1087 os: leak_os_name(other),
1088 reason: "no encoder wired for this OS/format combo",
1089 });
1090 }
1091 };
1092 Ok(pair)
1093}
1094
1095fn audio_encoder_element(format: OutputFormat) -> &'static str {
1097 match format {
1098 OutputFormat::Mp4H264Aac | OutputFormat::Mp4H265Aac => "avenc_aac",
1099 OutputFormat::WebmVp9Opus | OutputFormat::WebmAv1Opus => "opusenc",
1100 }
1101}
1102
1103fn mux_element_for(format: OutputFormat) -> &'static str {
1105 match format {
1106 OutputFormat::Mp4H264Aac | OutputFormat::Mp4H265Aac => "mp4mux",
1107 OutputFormat::WebmVp9Opus | OutputFormat::WebmAv1Opus => "webmmux",
1108 }
1109}
1110
1111fn demux_for(format: OutputFormat) -> &'static str {
1114 match format {
1115 OutputFormat::Mp4H264Aac | OutputFormat::Mp4H265Aac => "qtdemux",
1116 OutputFormat::WebmVp9Opus | OutputFormat::WebmAv1Opus => "matroskademux",
1117 }
1118}
1119
1120fn mux_to_parser(format: OutputFormat) -> &'static str {
1121 match format {
1122 OutputFormat::Mp4H264Aac => "h264parse",
1123 OutputFormat::Mp4H265Aac => "h265parse",
1124 OutputFormat::WebmVp9Opus => "vp9parse",
1125 OutputFormat::WebmAv1Opus => "av1parse",
1126 }
1127}
1128
1129fn scratch_path(output_path: &Path, suffix: &str) -> PathBuf {
1130 let mut s = output_path.as_os_str().to_owned();
1131 s.push(suffix);
1132 PathBuf::from(s)
1133}
1134
1135fn leak_os_name(os: &str) -> &'static str {
1140 match os {
1141 "macos" => "macos",
1142 "windows" => "windows",
1143 "linux" => "linux",
1144 "freebsd" => "freebsd",
1145 _ => "other",
1146 }
1147}
1148
1149#[cfg(test)]
1150mod tests {
1151 use super::*;
1152
1153 #[test]
1156 fn output_format_default_is_mp4_h264() {
1157 assert_eq!(OutputFormat::default(), OutputFormat::Mp4H264Aac);
1158 }
1159
1160 #[test]
1161 fn output_format_extension_matches_container() {
1162 assert_eq!(OutputFormat::Mp4H264Aac.extension(), "mp4");
1163 assert_eq!(OutputFormat::Mp4H265Aac.extension(), "mp4");
1164 assert_eq!(OutputFormat::WebmVp9Opus.extension(), "webm");
1165 assert_eq!(OutputFormat::WebmAv1Opus.extension(), "webm");
1166 }
1167
1168 #[test]
1169 fn output_format_slug_round_trips() {
1170 for f in [
1171 OutputFormat::Mp4H264Aac,
1172 OutputFormat::Mp4H265Aac,
1173 OutputFormat::WebmVp9Opus,
1174 OutputFormat::WebmAv1Opus,
1175 ] {
1176 assert_eq!(OutputFormat::from_slug(f.slug()), Some(f));
1177 }
1178 }
1179
1180 #[test]
1181 fn output_format_from_slug_rejects_unknown() {
1182 assert!(OutputFormat::from_slug("mp3").is_none());
1183 assert!(OutputFormat::from_slug("").is_none());
1184 assert!(OutputFormat::from_slug("mp4").is_none());
1185 }
1186
1187 #[test]
1188 fn output_format_serde_round_trip() {
1189 for f in [
1190 OutputFormat::Mp4H264Aac,
1191 OutputFormat::Mp4H265Aac,
1192 OutputFormat::WebmVp9Opus,
1193 OutputFormat::WebmAv1Opus,
1194 ] {
1195 let json = serde_json::to_string(&f).unwrap();
1196 let back: OutputFormat = serde_json::from_str(&json).unwrap();
1197 assert_eq!(back, f);
1198 }
1199 }
1200
1201 #[test]
1204 fn config_for_output_uses_1920_1080_30fps_48k_stereo() {
1205 let cfg = EncoderConfig::for_output(PathBuf::from("/tmp/x.mp4"), OutputFormat::Mp4H264Aac);
1206 assert_eq!(cfg.width, 1920);
1207 assert_eq!(cfg.height, 1080);
1208 assert_eq!(cfg.framerate, 30);
1209 assert_eq!(cfg.sample_rate, 48_000);
1210 assert_eq!(cfg.channels, 2);
1211 }
1212
1213 fn test_config(format: OutputFormat) -> EncoderConfig {
1216 EncoderConfig::for_output(PathBuf::from("/tmp/test.x"), format)
1217 }
1218
1219 #[test]
1222 fn scratch_path_appends_suffix_to_full_filename() {
1223 let p = scratch_path(Path::new("/tmp/out.mp4"), ".bgra.scratch");
1224 assert_eq!(p, PathBuf::from("/tmp/out.mp4.bgra.scratch"));
1225 }
1226
1227 #[test]
1228 fn scratch_path_handles_no_extension() {
1229 let p = scratch_path(Path::new("/tmp/outfile"), ".scratch");
1230 assert_eq!(p, PathBuf::from("/tmp/outfile.scratch"));
1231 }
1232
1233 #[test]
1236 fn poster_path_replaces_extension() {
1237 assert_eq!(
1238 poster_path_for(Path::new("/tmp/Screen-2026-05-17-180000.mp4")),
1239 PathBuf::from("/tmp/Screen-2026-05-17-180000.avif")
1240 );
1241 assert_eq!(
1242 poster_path_for(Path::new("/tmp/Screen-2026-05-17-180000.webm")),
1243 PathBuf::from("/tmp/Screen-2026-05-17-180000.avif")
1244 );
1245 }
1246
1247 #[test]
1248 fn poster_pipeline_args_contains_required_elements() {
1249 let args = poster_pipeline_args(Path::new("/tmp/test.mp4"), Path::new("/tmp/test.avif"));
1250 assert!(args.iter().any(|a| a == "filesrc"));
1252 assert!(args.iter().any(|a| a == "location=/tmp/test.mp4"));
1253 assert!(args.iter().any(|a| a == "decodebin"));
1255 assert!(args.iter().any(|a| a == "videoconvert"));
1256 assert!(args.iter().any(|a| a == "videoscale"));
1257 assert!(args.iter().any(|a| a == "video/x-raw,width=640"));
1259 assert!(args.iter().any(|a| a == "avifenc"));
1261 assert!(args.iter().any(|a| a == "location=/tmp/test.avif"));
1262 }
1263
1264 #[test]
1265 fn generate_poster_rejects_missing_video_file() {
1266 let result = generate_poster(Path::new("/tmp/definitely-not-a-real-video.mp4"));
1267 assert!(matches!(result, Err(EncodeError::InvalidConfig(_))));
1268 }
1269
1270 #[test]
1273 fn webm_transcode_args_video_only_omits_audio_leg() {
1274 let args =
1275 build_webm_transcode_args(Path::new("/tmp/in.mp4"), Path::new("/tmp/out.webm"), false);
1276 assert!(args.iter().any(|a| a == "filesrc"));
1278 assert!(args.iter().any(|a| a == "location=/tmp/in.mp4"));
1279 assert!(args.iter().any(|a| a == "decodebin"));
1280 assert!(args.iter().any(|a| a == "vp9enc"));
1281 assert!(args.iter().any(|a| a == "webmmux"));
1282 assert!(args.iter().any(|a| a == "location=/tmp/out.webm"));
1283 assert!(!args.iter().any(|a| a == "opusenc"));
1285 assert!(!args.iter().any(|a| a == "audioconvert"));
1286 assert_eq!(args.iter().filter(|a| a.as_str() == "d.").count(), 1);
1288 }
1289
1290 #[test]
1291 fn webm_transcode_args_with_audio_includes_opus_leg() {
1292 let args =
1293 build_webm_transcode_args(Path::new("/tmp/in.mp4"), Path::new("/tmp/out.webm"), true);
1294 assert!(args.iter().any(|a| a == "vp9enc"));
1295 assert!(args.iter().any(|a| a == "opusenc"));
1296 assert!(args.iter().any(|a| a == "audioconvert"));
1297 assert!(args.iter().any(|a| a == "audioresample"));
1298 assert_eq!(args.iter().filter(|a| a.as_str() == "d.").count(), 2);
1300 }
1301
1302 #[test]
1303 fn transcode_to_webm_rejects_missing_input() {
1304 let result = transcode_to_webm(
1305 Path::new("/tmp/definitely-not-a-real-scratch.mp4"),
1306 Path::new("/tmp/out.webm"),
1307 );
1308 assert!(matches!(result, Err(EncodeError::InvalidConfig(_))));
1309 }
1310
1311 #[test]
1314 fn live_video_args_stream_from_stdin_with_caps() {
1315 if std::env::consts::OS != "macos"
1316 && std::env::consts::OS != "windows"
1317 && std::env::consts::OS != "linux"
1318 {
1319 return;
1320 }
1321 let cfg = test_config(OutputFormat::Mp4H264Aac);
1322 let args =
1323 build_live_video_args(&cfg, Path::new("/tmp/inter.scratch")).expect("supported OS");
1324 assert!(args.iter().any(|a| a == "fdsrc"));
1326 assert!(args.iter().any(|a| a == "fd=0"));
1327 assert!(args.iter().any(|a| a == "format=bgra"));
1329 assert!(args.iter().any(|a| a.starts_with("width=1920")));
1330 assert!(args.iter().any(|a| a.starts_with("height=1080")));
1331 assert!(args.iter().any(|a| a.starts_with("framerate=30/1")));
1332 assert!(args.iter().any(|a| a == "mp4mux"));
1334 assert!(args.iter().any(|a| a == "h264parse"));
1335 assert!(args.iter().any(|a| a == "location=/tmp/inter.scratch"));
1336 assert!(!args.iter().any(|a| a == "audioconvert"));
1338 }
1339
1340 #[test]
1341 fn live_h264_disables_frame_reordering_so_the_scratch_remuxes() {
1342 if std::env::consts::OS != "macos" {
1348 return;
1349 }
1350 let cfg = test_config(OutputFormat::Mp4H264Aac);
1351 let args = build_live_video_args(&cfg, Path::new("/tmp/v.scratch")).expect("macos");
1352 assert!(
1353 args.iter().any(|a| a == "vtenc_h264_hw"),
1354 "encoder element is its own token"
1355 );
1356 assert!(
1357 args.iter().any(|a| a == "allow-frame-reordering=false"),
1358 "B-frame reordering off so the scratch demuxes with PTS for the finalize remux"
1359 );
1360 }
1361
1362 #[test]
1363 fn remux_args_copy_video_and_encode_audio() {
1364 let cfg = test_config(OutputFormat::Mp4H264Aac);
1365 let args = build_remux_args(
1366 &cfg,
1367 Path::new("/tmp/inter.scratch"),
1368 Path::new("/tmp/a.f32.scratch"),
1369 true,
1370 true,
1371 );
1372 assert!(args.iter().any(|a| a == "qtdemux"));
1374 assert!(args.iter().any(|a| a == "h264parse"));
1375 assert!(args.iter().any(|a| a == "location=/tmp/inter.scratch"));
1376 assert!(!args.iter().any(|a| a == "vtenc_h264_hw"));
1378 assert!(args.iter().any(|a| a == "rawaudioparse"));
1380 assert!(args.iter().any(|a| a == "pcm-format=f32le"));
1381 assert!(args.iter().any(|a| a == "avenc_aac"));
1382 assert!(args.iter().any(|a| a == "mp4mux"));
1384 assert!(args.iter().any(|a| a == "location=/tmp/test.x"));
1385 }
1386
1387 #[test]
1388 fn audio_decode_args_pipe_raw_f32le_to_stdout() {
1389 let args = build_audio_decode_args(Path::new("/tmp/source.mp4"), 48_000, 2);
1393 assert_eq!(args.first().map(String::as_str), Some("-q"));
1394 assert!(args.iter().any(|a| a == "location=/tmp/source.mp4"));
1395 assert!(args.iter().any(|a| a == "decodebin"));
1396 assert!(args.iter().any(|a| a == "audioconvert"));
1397 assert!(args.iter().any(|a| a == "audioresample"));
1398 assert!(args.iter().any(|a| a.contains("format=F32LE")
1401 && a.contains("rate=48000")
1402 && a.contains("channels=2")
1403 && a.contains("layout=interleaved")));
1404 assert!(args.iter().any(|a| a == "fdsink"));
1407 assert!(args.iter().any(|a| a == "fd=1"));
1408 assert!(!args.iter().any(|a| a == "avenc_aac"));
1410 assert!(!args.iter().any(|a| a == "mp4mux"));
1411 }
1412
1413 #[test]
1414 fn remux_args_video_only_omits_audio_leg() {
1415 let cfg = test_config(OutputFormat::Mp4H264Aac);
1416 let args = build_remux_args(
1417 &cfg,
1418 Path::new("/tmp/inter.scratch"),
1419 Path::new("/tmp/a.f32.scratch"),
1420 true,
1421 false,
1422 );
1423 assert!(args.iter().any(|a| a == "qtdemux"));
1424 assert!(!args.iter().any(|a| a == "avenc_aac"));
1425 assert!(!args.iter().any(|a| a == "rawaudioparse"));
1426 }
1427
1428 #[test]
1433 fn remux_args_never_use_legacy_pcm_f32le_token() {
1434 let cfg = test_config(OutputFormat::Mp4H264Aac);
1435 let args = build_remux_args(
1436 &cfg,
1437 Path::new("/tmp/inter.scratch"),
1438 Path::new("/tmp/a.f32.scratch"),
1439 true,
1440 true,
1441 );
1442 assert!(!args.iter().any(|a| a == "format=pcm-f32le"));
1443 assert!(args.iter().any(|a| a == "pcm-format=f32le"));
1444 }
1445
1446 #[test]
1447 fn encoder_and_mux_elements_rejects_unknown_os() {
1448 let result = encoder_and_mux_elements(OutputFormat::Mp4H264Aac, "plan9");
1449 assert!(matches!(result, Err(EncodeError::Unsupported { .. })));
1450 }
1451
1452 #[test]
1453 fn encoder_and_mux_elements_maps_each_format() {
1454 let os = std::env::consts::OS;
1458 if os != "macos" && os != "windows" && os != "linux" {
1459 return;
1460 }
1461 for (fmt, want_mux, want_audio) in [
1462 (OutputFormat::Mp4H264Aac, "mp4mux", "avenc_aac"),
1463 (OutputFormat::Mp4H265Aac, "mp4mux", "avenc_aac"),
1464 (OutputFormat::WebmVp9Opus, "webmmux", "opusenc"),
1465 (OutputFormat::WebmAv1Opus, "webmmux", "opusenc"),
1466 ] {
1467 let (encoders, mux) = encoder_and_mux_elements(fmt, os).expect("supported OS");
1468 assert!(!encoders.is_empty(), "{fmt:?}: needs an encoder element");
1469 assert_eq!(mux, want_mux, "{fmt:?}: muxer");
1470 assert_eq!(
1471 audio_encoder_element(fmt),
1472 want_audio,
1473 "{fmt:?}: audio encoder"
1474 );
1475 }
1476 }
1477
1478 #[test]
1479 fn live_encoder_new_rejects_zero_dimensions() {
1480 let mut cfg = test_config(OutputFormat::Mp4H264Aac);
1483 cfg.width = 0;
1484 let result = LiveGstreamerEncoder::new(cfg);
1485 assert!(matches!(result, Err(EncodeError::InvalidConfig(_))));
1486 }
1487
1488 #[test]
1491 fn max_encode_edge_is_codec_specific() {
1492 assert_eq!(OutputFormat::Mp4H264Aac.max_encode_edge(), Some(4096));
1493 assert_eq!(OutputFormat::Mp4H265Aac.max_encode_edge(), Some(8192));
1494 assert_eq!(OutputFormat::WebmVp9Opus.max_encode_edge(), None);
1495 assert_eq!(OutputFormat::WebmAv1Opus.max_encode_edge(), None);
1496 }
1497
1498 #[test]
1499 fn fit_within_limits_passes_through_when_within_h264_cap() {
1500 for (w, h) in [
1502 (1920, 1080),
1503 (3840, 2160),
1504 (4096, 2160),
1505 (4096, 2304),
1506 (4096, 4096),
1507 ] {
1508 assert_eq!(
1509 fit_within_encoder_limits(w, h, OutputFormat::Mp4H264Aac),
1510 (w, h),
1511 "{w}x{h} is within the H.264 cap and must pass through"
1512 );
1513 }
1514 }
1515
1516 #[test]
1517 fn fit_within_limits_downscales_real_over_4k_displays_to_4096x2304() {
1518 for (w, h) in [(5120, 2880), (6016, 3384), (7680, 4320)] {
1521 let (cw, ch) = fit_within_encoder_limits(w, h, OutputFormat::Mp4H264Aac);
1522 assert!(cw <= 4096 && ch <= 4096, "{w}x{h} -> {cw}x{ch} exceeds cap");
1523 assert_eq!(cw % 2, 0, "width must be even");
1524 assert_eq!(ch % 2, 0, "height must be even");
1525 assert_eq!((cw, ch), (4096, 2304), "{w}x{h} should clamp to 4096x2304");
1526 }
1527 }
1528
1529 #[test]
1530 fn fit_within_limits_clamps_longest_edge_and_keeps_ratio() {
1531 for (w, h) in [(5120, 2160), (2880, 5120), (5120, 1440)] {
1536 let (cw, ch) = fit_within_encoder_limits(w, h, OutputFormat::Mp4H264Aac);
1537 assert!(cw <= 4096 && ch <= 4096, "{w}x{h} -> {cw}x{ch} exceeds cap");
1538 assert_eq!(cw.max(ch), 4096, "longest edge scales to the 4096 cap");
1539 assert_eq!(cw % 2, 0);
1540 assert_eq!(ch % 2, 0);
1541 let drift = (i64::from(w) * i64::from(ch) - i64::from(h) * i64::from(cw)).abs();
1544 assert!(
1545 drift <= 2 * i64::from(w.max(h)),
1546 "{w}x{h} -> {cw}x{ch} skews the aspect ratio"
1547 );
1548 }
1549 }
1550
1551 #[test]
1552 fn fit_within_limits_h265_keeps_5k_and_caps_at_8192() {
1553 assert_eq!(
1555 fit_within_encoder_limits(5120, 2880, OutputFormat::Mp4H265Aac),
1556 (5120, 2880)
1557 );
1558 assert_eq!(
1559 fit_within_encoder_limits(8192, 4320, OutputFormat::Mp4H265Aac),
1560 (8192, 4320)
1561 );
1562 let (cw, ch) = fit_within_encoder_limits(10240, 4320, OutputFormat::Mp4H265Aac);
1563 assert!(cw <= 8192 && ch <= 8192);
1564 assert_eq!(cw, 8192, "longest edge clamps to the HEVC 8192 cap");
1565 }
1566
1567 #[test]
1568 fn fit_within_limits_never_clamps_software_webm() {
1569 assert_eq!(
1571 fit_within_encoder_limits(5120, 2880, OutputFormat::WebmVp9Opus),
1572 (5120, 2880)
1573 );
1574 assert_eq!(
1575 fit_within_encoder_limits(7680, 4320, OutputFormat::WebmAv1Opus),
1576 (7680, 4320)
1577 );
1578 }
1579
1580 #[test]
1581 fn fit_within_limits_evens_odd_input() {
1582 let (cw, ch) = fit_within_encoder_limits(4097, 2161, OutputFormat::Mp4H264Aac);
1585 assert!(cw <= 4096 && ch <= 4096);
1586 assert_eq!(cw % 2, 0);
1587 assert_eq!(ch % 2, 0);
1588 }
1589}