1use std::ffi::OsString;
17use std::path::{Path, PathBuf};
18use std::process::{Command, Stdio};
19
20use serde::{Deserialize, Serialize};
21
22use crate::error::{MediaError, STDERR_TAIL_MAX_BYTES, stderr_tail};
23
24#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
30pub struct FfmpegHighlightCommand {
31 ffmpeg_bin: String,
32 input_path: PathBuf,
33 output_path: PathBuf,
34 start_seconds: u32,
35 duration_seconds: u32,
36}
37
38impl FfmpegHighlightCommand {
39 #[must_use]
41 pub fn new(
42 ffmpeg_bin: impl Into<String>,
43 input_path: impl Into<PathBuf>,
44 output_path: impl Into<PathBuf>,
45 start_seconds: u32,
46 duration_seconds: u32,
47 ) -> Self {
48 Self {
49 ffmpeg_bin: ffmpeg_bin.into(),
50 input_path: input_path.into(),
51 output_path: output_path.into(),
52 start_seconds,
53 duration_seconds,
54 }
55 }
56
57 #[must_use]
63 pub fn args(&self) -> Vec<OsString> {
64 vec![
65 OsString::from("-y"),
66 OsString::from("-ss"),
67 OsString::from(self.start_seconds.to_string()),
68 OsString::from("-i"),
69 self.input_path.as_os_str().to_owned(),
70 OsString::from("-t"),
71 OsString::from(self.duration_seconds.to_string()),
72 OsString::from("-c:v"),
73 OsString::from("libx264"),
74 OsString::from("-preset"),
75 OsString::from("veryfast"),
76 OsString::from("-c:a"),
77 OsString::from("aac"),
78 OsString::from("-movflags"),
79 OsString::from("+faststart"),
80 self.output_path.as_os_str().to_owned(),
81 ]
82 }
83
84 pub fn run(&self) -> Result<u64, MediaError> {
92 if !self.input_path.exists() {
93 return Err(MediaError::FfmpegSourceMissing {
94 path: self.input_path.display().to_string(),
95 });
96 }
97 create_output_parent(&self.output_path)?;
98
99 let output = run_ffmpeg_capped(&self.ffmpeg_bin, self.args()).map_err(|source| {
100 MediaError::FfmpegSpawn {
101 bin: self.ffmpeg_bin.clone(),
102 source,
103 }
104 })?;
105
106 check_exit(&output)?;
107 output_size(&self.output_path)
108 }
109}
110
111#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
121pub struct FfmpegPosterCommand {
122 ffmpeg_bin: String,
123 input_paths: Vec<PathBuf>,
124 output_path: PathBuf,
125 tail_offset_seconds: u32,
126}
127
128impl FfmpegPosterCommand {
129 #[must_use]
131 pub fn new(
132 ffmpeg_bin: impl Into<String>,
133 input_paths: Vec<PathBuf>,
134 output_path: impl Into<PathBuf>,
135 tail_offset_seconds: u32,
136 ) -> Self {
137 Self {
138 ffmpeg_bin: ffmpeg_bin.into(),
139 input_paths,
140 output_path: output_path.into(),
141 tail_offset_seconds,
142 }
143 }
144
145 #[must_use]
148 pub(crate) fn poster_args(&self, concat_list: Option<&Path>) -> Vec<OsString> {
149 let mut args = vec![
150 OsString::from("-y"),
151 OsString::from("-sseof"),
152 OsString::from(format!("-{}", self.tail_offset_seconds)),
153 ];
154 if let Some(list) = concat_list {
155 args.extend([
156 OsString::from("-f"),
157 OsString::from("concat"),
158 OsString::from("-safe"),
159 OsString::from("0"),
160 OsString::from("-i"),
161 list.as_os_str().to_owned(),
162 ]);
163 } else {
164 args.extend([
165 OsString::from("-i"),
166 self.input_paths[0].as_os_str().to_owned(),
167 ]);
168 }
169 args.extend([
170 OsString::from("-frames:v"),
171 OsString::from("1"),
172 OsString::from("-q:v"),
173 OsString::from("4"),
174 self.output_path.as_os_str().to_owned(),
175 ]);
176 args
177 }
178
179 pub fn run(&self) -> Result<u64, MediaError> {
187 require_sources(&self.input_paths)?;
188 create_output_parent(&self.output_path)?;
189
190 let _concat_guard;
191 let concat_list_path = if self.input_paths.len() > 1 {
192 let path = write_concat_list(&self.input_paths, &self.output_path)?;
193 _concat_guard = ConcatListGuard::new(path.clone());
194 Some(path)
195 } else {
196 None
197 };
198
199 let output = run_ffmpeg_capped(
200 &self.ffmpeg_bin,
201 self.poster_args(concat_list_path.as_deref()),
202 )
203 .map_err(|source| MediaError::FfmpegSpawn {
204 bin: self.ffmpeg_bin.clone(),
205 source,
206 })?;
207
208 check_exit(&output)?;
209 output_size(&self.output_path)
210 }
211}
212
213pub const PREVIEW_FRAME_INTERVAL_SECONDS: u32 = 10;
219
220pub const PREVIEW_SPRITE_COLUMNS: u32 = 10;
223
224pub const PREVIEW_CELL_WIDTH: u32 = 160;
226
227pub const PREVIEW_CELL_HEIGHT: u32 = 90;
229
230#[must_use]
232fn format_vtt_timestamp(total_seconds: u32) -> String {
233 let hours = total_seconds / 3600;
234 let minutes = (total_seconds % 3600) / 60;
235 let seconds = total_seconds % 60;
236 format!("{hours:02}:{minutes:02}:{seconds:02}.000")
237}
238
239#[must_use]
247pub fn build_preview_webvtt(
248 frame_count: u32,
249 interval_seconds: u32,
250 cols: u32,
251 cell_w: u32,
252 cell_h: u32,
253 sprite_url: &str,
254) -> String {
255 use std::fmt::Write as _;
256
257 let cols = cols.max(1);
258 let mut vtt = String::from("WEBVTT\n\n");
259 for i in 0..frame_count {
260 let start = i * interval_seconds;
261 let end = (i + 1) * interval_seconds;
262 let x = (i % cols) * cell_w;
263 let y = (i / cols) * cell_h;
264 let _ = write!(
265 vtt,
266 "{} --> {}\n{sprite_url}#xywh={x},{y},{cell_w},{cell_h}\n\n",
267 format_vtt_timestamp(start),
268 format_vtt_timestamp(end),
269 );
270 }
271 vtt
272}
273
274#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
284pub struct FfmpegPreviewSpriteCommand {
285 ffmpeg_bin: String,
286 input_paths: Vec<PathBuf>,
287 output_path: PathBuf,
288 cols: u32,
289 rows: u32,
290}
291
292impl FfmpegPreviewSpriteCommand {
293 #[must_use]
295 pub fn new(
296 ffmpeg_bin: impl Into<String>,
297 input_paths: Vec<PathBuf>,
298 output_path: impl Into<PathBuf>,
299 cols: u32,
300 rows: u32,
301 ) -> Self {
302 Self {
303 ffmpeg_bin: ffmpeg_bin.into(),
304 input_paths,
305 output_path: output_path.into(),
306 cols: cols.max(1),
307 rows: rows.max(1),
308 }
309 }
310
311 #[must_use]
314 pub(crate) fn sprite_args(&self, concat_list: Option<&Path>) -> Vec<OsString> {
315 let mut args = vec![OsString::from("-y")];
316 if let Some(list) = concat_list {
317 args.extend([
318 OsString::from("-f"),
319 OsString::from("concat"),
320 OsString::from("-safe"),
321 OsString::from("0"),
322 OsString::from("-i"),
323 list.as_os_str().to_owned(),
324 ]);
325 } else {
326 args.extend([
327 OsString::from("-i"),
328 self.input_paths[0].as_os_str().to_owned(),
329 ]);
330 }
331 let filter = format!(
332 "fps=1/{PREVIEW_FRAME_INTERVAL_SECONDS},scale={cw}:{ch}:force_original_aspect_ratio=decrease,pad={cw}:{ch}:(ow-iw)/2:(oh-ih)/2,tile={cols}x{rows}",
333 cw = PREVIEW_CELL_WIDTH,
334 ch = PREVIEW_CELL_HEIGHT,
335 cols = self.cols,
336 rows = self.rows,
337 );
338 args.extend([
339 OsString::from("-frames:v"),
340 OsString::from("1"),
341 OsString::from("-vf"),
342 OsString::from(filter),
343 OsString::from("-q:v"),
344 OsString::from("5"),
345 self.output_path.as_os_str().to_owned(),
346 ]);
347 args
348 }
349
350 pub fn run(&self) -> Result<u64, MediaError> {
358 require_sources(&self.input_paths)?;
359 create_output_parent(&self.output_path)?;
360
361 let _concat_guard;
362 let concat_list_path = if self.input_paths.len() > 1 {
363 let path = write_concat_list(&self.input_paths, &self.output_path)?;
364 _concat_guard = ConcatListGuard::new(path.clone());
365 Some(path)
366 } else {
367 None
368 };
369
370 let output = run_ffmpeg_capped(
371 &self.ffmpeg_bin,
372 self.sprite_args(concat_list_path.as_deref()),
373 )
374 .map_err(|source| MediaError::FfmpegSpawn {
375 bin: self.ffmpeg_bin.clone(),
376 source,
377 })?;
378
379 check_exit(&output)?;
380 output_size(&self.output_path)
381 }
382}
383
384fn write_concat_list(input_paths: &[PathBuf], near_path: &Path) -> Result<PathBuf, MediaError> {
391 use std::io::Write as _;
392 let parent = near_path
393 .parent()
394 .map_or_else(|| PathBuf::from("."), Path::to_path_buf);
395 std::fs::create_dir_all(&parent).map_err(|source| MediaError::ConcatList {
396 path: parent.display().to_string(),
397 source,
398 })?;
399 let stem = near_path
400 .file_stem()
401 .and_then(|s| s.to_str())
402 .unwrap_or("clip");
403 let seq = CONCAT_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
411 let pid = std::process::id();
412 let list_path = parent.join(format!(".{stem}.{pid}-{seq}.concat.txt"));
413 let mut list_file =
414 std::fs::File::create(&list_path).map_err(|source| MediaError::ConcatList {
415 path: list_path.display().to_string(),
416 source,
417 })?;
418 for path in input_paths {
419 let absolute = absolutize_for_concat(path)?;
420 let mut line = Vec::with_capacity(absolute.as_os_str().len() + 8);
430 line.extend_from_slice(b"file '");
431 line.extend_from_slice(&concat_escape(&path_to_bytes(&absolute)));
432 line.extend_from_slice(b"'\n");
433 list_file
434 .write_all(&line)
435 .map_err(|source| MediaError::ConcatList {
436 path: list_path.display().to_string(),
437 source,
438 })?;
439 }
440 Ok(list_path)
441}
442
443static CONCAT_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
447
448fn concat_escape(path: &[u8]) -> Vec<u8> {
457 let mut out = Vec::with_capacity(path.len());
458 for &byte in path {
459 if byte == b'\'' {
460 out.extend_from_slice(b"'\\''");
461 } else {
462 out.push(byte);
463 }
464 }
465 out
466}
467
468fn path_to_bytes(path: &Path) -> Vec<u8> {
473 #[cfg(unix)]
474 {
475 use std::os::unix::ffi::OsStrExt as _;
476 path.as_os_str().as_bytes().to_vec()
477 }
478 #[cfg(not(unix))]
479 {
480 path.display().to_string().into_bytes()
481 }
482}
483
484fn absolutize_for_concat(path: &Path) -> Result<PathBuf, MediaError> {
485 if path.is_absolute() {
486 return Ok(path.to_path_buf());
487 }
488 if let Ok(canonical) = std::fs::canonicalize(path) {
489 return Ok(canonical);
490 }
491 let cwd = std::env::current_dir().map_err(|source| MediaError::ConcatList {
495 path: path.display().to_string(),
496 source,
497 })?;
498 Ok(cwd.join(path))
499}
500
501struct ConcatListGuard {
503 path: PathBuf,
504}
505
506impl ConcatListGuard {
507 const fn new(path: PathBuf) -> Self {
508 Self { path }
509 }
510}
511
512impl Drop for ConcatListGuard {
513 fn drop(&mut self) {
514 let _ = std::fs::remove_file(&self.path);
515 }
516}
517
518#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
530pub struct FfmpegClipTailCommand {
531 ffmpeg_bin: String,
532 input_paths: Vec<PathBuf>,
533 output_path: PathBuf,
534 duration_seconds: u32,
535 tail_offset_seconds: u32,
536}
537
538impl FfmpegClipTailCommand {
539 #[must_use]
541 pub fn new(
542 ffmpeg_bin: impl Into<String>,
543 input_paths: Vec<PathBuf>,
544 output_path: impl Into<PathBuf>,
545 duration_seconds: u32,
546 tail_offset_seconds: u32,
547 ) -> Self {
548 Self {
549 ffmpeg_bin: ffmpeg_bin.into(),
550 input_paths,
551 output_path: output_path.into(),
552 duration_seconds,
553 tail_offset_seconds,
554 }
555 }
556
557 #[must_use]
558 fn input_args(&self, concat_list: Option<&Path>) -> Vec<OsString> {
559 concat_list.map_or_else(
560 || {
561 vec![
562 OsString::from("-i"),
563 self.input_paths[0].as_os_str().to_owned(),
564 ]
565 },
566 |list| {
567 vec![
568 OsString::from("-f"),
569 OsString::from("concat"),
570 OsString::from("-safe"),
571 OsString::from("0"),
572 OsString::from("-i"),
573 list.as_os_str().to_owned(),
574 ]
575 },
576 )
577 }
578
579 #[must_use]
582 pub(crate) fn encode_args(&self, concat_list: Option<&Path>) -> Vec<OsString> {
583 let mut args = vec![
584 OsString::from("-y"),
585 OsString::from("-sseof"),
586 OsString::from(format!("-{}", self.tail_offset_seconds)),
587 ];
588 args.extend(self.input_args(concat_list));
589 args.extend([
590 OsString::from("-t"),
591 OsString::from(self.duration_seconds.to_string()),
592 OsString::from("-c:v"),
593 OsString::from("libx264"),
594 OsString::from("-preset"),
595 OsString::from("veryfast"),
596 OsString::from("-c:a"),
597 OsString::from("aac"),
598 OsString::from("-movflags"),
599 OsString::from("+faststart"),
600 self.output_path.as_os_str().to_owned(),
601 ]);
602 args
603 }
604
605 pub fn run(&self) -> Result<u64, MediaError> {
613 require_sources(&self.input_paths)?;
614 create_output_parent(&self.output_path)?;
615
616 let _concat_guard;
617 let concat_list_path = if self.input_paths.len() > 1 {
618 let path = write_concat_list(&self.input_paths, &self.output_path)?;
619 _concat_guard = ConcatListGuard::new(path.clone());
620 Some(path)
621 } else {
622 None
623 };
624
625 let output = run_ffmpeg_capped(
626 &self.ffmpeg_bin,
627 self.encode_args(concat_list_path.as_deref()),
628 )
629 .map_err(|source| MediaError::FfmpegSpawn {
630 bin: self.ffmpeg_bin.clone(),
631 source,
632 })?;
633
634 check_exit(&output)?;
635 output_size(&self.output_path)
636 }
637}
638
639#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
647pub struct FfmpegLiveThumbnailCommand {
648 ffmpeg_bin: String,
649 input_url: String,
650 output_path: PathBuf,
651}
652
653impl FfmpegLiveThumbnailCommand {
654 #[must_use]
656 pub fn new(
657 ffmpeg_bin: impl Into<String>,
658 input_url: impl Into<String>,
659 output_path: impl Into<PathBuf>,
660 ) -> Self {
661 Self {
662 ffmpeg_bin: ffmpeg_bin.into(),
663 input_url: input_url.into(),
664 output_path: output_path.into(),
665 }
666 }
667
668 #[must_use]
673 pub fn args(&self) -> Vec<OsString> {
674 vec![
675 OsString::from("-y"),
676 OsString::from("-rw_timeout"),
680 OsString::from("5000000"),
681 OsString::from("-i"),
682 OsString::from(self.input_url.clone()),
683 OsString::from("-frames:v"),
684 OsString::from("1"),
685 OsString::from("-q:v"),
686 OsString::from("4"),
687 OsString::from("-an"),
688 self.output_path.as_os_str().to_owned(),
689 ]
690 }
691
692 pub fn run(&self) -> Result<u64, MediaError> {
699 create_output_parent(&self.output_path)?;
700
701 let output = run_ffmpeg_capped(&self.ffmpeg_bin, self.args()).map_err(|source| {
702 MediaError::FfmpegSpawn {
703 bin: self.ffmpeg_bin.clone(),
704 source,
705 }
706 })?;
707
708 check_exit(&output)?;
709 output_size(&self.output_path)
710 }
711
712 pub async fn run_with_deadline(
722 &self,
723 deadline: std::time::Duration,
724 ) -> Result<u64, MediaError> {
725 if let Some(parent) = self.output_path.parent() {
726 tokio::fs::create_dir_all(parent).await.map_err(|source| {
727 MediaError::FfmpegOutputIo {
728 path: parent.display().to_string(),
729 source,
730 }
731 })?;
732 }
733
734 let mut child = tokio::process::Command::new(&self.ffmpeg_bin)
735 .args(self.args())
736 .stdin(Stdio::null())
737 .stdout(Stdio::piped())
738 .stderr(Stdio::piped())
739 .kill_on_drop(true)
740 .spawn()
741 .map_err(|source| MediaError::FfmpegSpawn {
742 bin: self.ffmpeg_bin.clone(),
743 source,
744 })?;
745
746 let output = match tokio::time::timeout(
753 deadline,
754 Box::pin(capture_child_capped(&mut child)),
755 )
756 .await
757 {
758 Ok(result) => result.map_err(|source| MediaError::FfmpegSpawn {
759 bin: self.ffmpeg_bin.clone(),
760 source,
761 })?,
762 Err(_elapsed) => return Err(MediaError::FfmpegTimeout),
763 };
764
765 check_exit(&output)?;
766
767 let metadata = tokio::fs::metadata(&self.output_path)
768 .await
769 .map_err(|source| MediaError::FfmpegOutputIo {
770 path: self.output_path.display().to_string(),
771 source,
772 })?;
773 Ok(metadata.len())
774 }
775}
776
777pub const ROOM_COMPOSITE_CELL_WIDTH: u32 = 640;
779
780pub const ROOM_COMPOSITE_CELL_HEIGHT: u32 = 360;
782
783const ROOM_COMPOSITE_MAX_INPUTS: usize = 6;
790
791const ROOM_COMPOSITE_MIN_INPUTS: usize = 2;
793
794pub(crate) fn validate_room_composite_input_count(count: usize) -> Result<(), MediaError> {
803 if count < ROOM_COMPOSITE_MIN_INPUTS {
804 return Err(MediaError::FfmpegSourceMissing {
805 path: "<room composite needs at least two participant recordings>".to_owned(),
806 });
807 }
808 if count > ROOM_COMPOSITE_MAX_INPUTS {
809 return Err(MediaError::FfmpegSourceMissing {
810 path: "<room composite accepts at most six participant recordings>".to_owned(),
811 });
812 }
813 Ok(())
814}
815
816#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
842pub struct FfmpegRoomCompositeCommand {
843 ffmpeg_bin: String,
844 input_paths: Vec<PathBuf>,
845 output_path: PathBuf,
846 audio_present: Vec<bool>,
849}
850
851impl FfmpegRoomCompositeCommand {
852 #[must_use]
856 pub fn new(
857 ffmpeg_bin: impl Into<String>,
858 input_paths: Vec<PathBuf>,
859 output_path: impl Into<PathBuf>,
860 audio_present: Vec<bool>,
861 ) -> Self {
862 Self {
863 ffmpeg_bin: ffmpeg_bin.into(),
864 input_paths,
865 output_path: output_path.into(),
866 audio_present,
867 }
868 }
869
870 #[must_use]
876 pub fn args(&self) -> Vec<OsString> {
877 let n = self.input_paths.len();
878 let has_audio_out = self.audio_present.iter().any(|&present| present);
882 let mut args = vec![OsString::from("-y")];
883 for path in &self.input_paths {
884 args.push(OsString::from("-i"));
885 args.push(path.as_os_str().to_owned());
886 }
887 args.push(OsString::from("-filter_complex"));
888 args.push(OsString::from(room_composite_filtergraph(
889 n,
890 &self.audio_present,
891 )));
892 args.extend([OsString::from("-map"), OsString::from("[vout]")]);
893 if has_audio_out {
894 args.extend([OsString::from("-map"), OsString::from("[aout]")]);
895 }
896 args.extend([
897 OsString::from("-c:v"),
898 OsString::from("libx264"),
899 OsString::from("-preset"),
900 OsString::from("veryfast"),
901 ]);
902 if has_audio_out {
903 args.extend([OsString::from("-c:a"), OsString::from("aac")]);
904 }
905 args.extend([
906 OsString::from("-movflags"),
907 OsString::from("+faststart"),
908 self.output_path.as_os_str().to_owned(),
909 ]);
910 args
911 }
912
913 pub fn run(&self) -> Result<u64, MediaError> {
922 require_sources(&self.input_paths)?;
923 validate_room_composite_input_count(self.input_paths.len())?;
924 create_output_parent(&self.output_path)?;
925
926 let output = run_ffmpeg_capped(&self.ffmpeg_bin, self.args()).map_err(|source| {
927 MediaError::FfmpegSpawn {
928 bin: self.ffmpeg_bin.clone(),
929 source,
930 }
931 })?;
932
933 check_exit(&output)?;
934 output_size(&self.output_path)
935 }
936}
937
938const fn room_composite_grid_dims(n: usize) -> (usize, usize) {
941 if n <= 2 {
942 (2, 1)
943 } else if n <= 4 {
944 (2, 2)
945 } else {
946 (3, 2)
947 }
948}
949
950fn room_composite_filtergraph(n: usize, audio_present: &[bool]) -> String {
964 use std::fmt::Write as _;
965
966 let (cols, _rows) = room_composite_grid_dims(n);
967 let (w, h) = (ROOM_COMPOSITE_CELL_WIDTH, ROOM_COMPOSITE_CELL_HEIGHT);
968 let mut parts = Vec::new();
969 let mut video_labels = String::new();
970 for i in 0..n {
971 parts.push(format!(
972 "[{i}:v]scale={w}:{h}:force_original_aspect_ratio=decrease,pad={w}:{h}:(ow-iw)/2:(oh-ih)/2,setsar=1[v{i}]"
973 ));
974 let _ = write!(video_labels, "[v{i}]");
975 }
976 let layout = room_composite_layout(cols, n);
977 parts.push(format!(
978 "{video_labels}xstack=inputs={n}:layout={layout}[vout]"
979 ));
980
981 let audio_inputs: Vec<usize> = (0..n)
982 .filter(|&i| audio_present.get(i).copied().unwrap_or(false))
983 .collect();
984 if !audio_inputs.is_empty() {
985 let mut audio_labels = String::new();
986 for &i in &audio_inputs {
987 let _ = write!(audio_labels, "[{i}:a]");
988 }
989 parts.push(format!(
990 "{audio_labels}amix=inputs={}:normalize=1[aout]",
991 audio_inputs.len()
992 ));
993 }
994 parts.join(";")
995}
996
997fn room_composite_layout(cols: usize, n: usize) -> String {
1001 (0..n)
1002 .map(|i| {
1003 let col = i % cols;
1004 let row = i / cols;
1005 let x = if col == 0 {
1006 "0".to_owned()
1007 } else {
1008 vec!["w0"; col].join("+")
1009 };
1010 let y = if row == 0 {
1011 "0".to_owned()
1012 } else {
1013 vec!["h0"; row].join("+")
1014 };
1015 format!("{x}_{y}")
1016 })
1017 .collect::<Vec<_>>()
1018 .join("|")
1019}
1020
1021fn require_sources(input_paths: &[PathBuf]) -> Result<(), MediaError> {
1025 if input_paths.is_empty() {
1026 return Err(MediaError::FfmpegSourceMissing {
1027 path: "<no source recordings>".to_owned(),
1028 });
1029 }
1030 if let Some(missing) = input_paths.iter().find(|path| !path.exists()) {
1031 return Err(MediaError::FfmpegSourceMissing {
1032 path: missing.display().to_string(),
1033 });
1034 }
1035 Ok(())
1036}
1037
1038fn create_output_parent(output_path: &Path) -> Result<(), MediaError> {
1040 if let Some(parent) = output_path.parent() {
1041 std::fs::create_dir_all(parent).map_err(|source| MediaError::FfmpegOutputIo {
1042 path: parent.display().to_string(),
1043 source,
1044 })?;
1045 }
1046 Ok(())
1047}
1048
1049struct CapturedOutput {
1056 status: std::process::ExitStatus,
1057 stderr: Vec<u8>,
1058}
1059
1060struct TailBuffer {
1063 cap: usize,
1064 buf: Vec<u8>,
1065}
1066
1067impl TailBuffer {
1068 const fn new(cap: usize) -> Self {
1069 Self {
1070 cap,
1071 buf: Vec::new(),
1072 }
1073 }
1074
1075 fn push(&mut self, data: &[u8]) {
1077 if self.cap == 0 {
1078 return;
1079 }
1080 if data.len() >= self.cap {
1081 self.buf.clear();
1083 self.buf.extend_from_slice(&data[data.len() - self.cap..]);
1084 return;
1085 }
1086 let overflow = (self.buf.len() + data.len()).saturating_sub(self.cap);
1087 if overflow > 0 {
1088 self.buf.drain(..overflow);
1089 }
1090 self.buf.extend_from_slice(data);
1091 }
1092
1093 fn into_vec(self) -> Vec<u8> {
1094 self.buf
1095 }
1096}
1097
1098fn drain_tail_sync<R: std::io::Read>(mut reader: R, cap: usize) -> Vec<u8> {
1100 let mut tail = TailBuffer::new(cap);
1101 let mut buf = [0u8; 8192];
1102 loop {
1103 match reader.read(&mut buf) {
1104 Ok(0) | Err(_) => break,
1105 Ok(n) => tail.push(&buf[..n]),
1106 }
1107 }
1108 tail.into_vec()
1109}
1110
1111fn drain_discard_sync<R: std::io::Read>(mut reader: R) {
1114 let mut buf = [0u8; 8192];
1115 loop {
1116 match reader.read(&mut buf) {
1117 Ok(0) | Err(_) => break,
1118 Ok(_) => {}
1119 }
1120 }
1121}
1122
1123fn run_ffmpeg_capped(bin: &str, args: Vec<OsString>) -> std::io::Result<CapturedOutput> {
1130 let mut child = Command::new(bin)
1131 .args(args)
1132 .stdin(Stdio::null())
1133 .stdout(Stdio::piped())
1134 .stderr(Stdio::piped())
1135 .spawn()?;
1136
1137 let stdout = child.stdout.take();
1138 let stderr = child.stderr.take();
1139 let stderr_handle = std::thread::spawn(move || {
1140 stderr.map_or_else(Vec::new, |reader| {
1141 drain_tail_sync(reader, STDERR_TAIL_MAX_BYTES)
1142 })
1143 });
1144 let stdout_handle = std::thread::spawn(move || {
1145 if let Some(reader) = stdout {
1146 drain_discard_sync(reader);
1147 }
1148 });
1149
1150 let status = child.wait()?;
1151 let stderr = stderr_handle.join().unwrap_or_default();
1152 let _ = stdout_handle.join();
1153 Ok(CapturedOutput { status, stderr })
1154}
1155
1156async fn drain_tail_async<R: tokio::io::AsyncRead + Unpin>(mut reader: R, cap: usize) -> Vec<u8> {
1158 use tokio::io::AsyncReadExt as _;
1159 let mut tail = TailBuffer::new(cap);
1160 let mut buf = [0u8; 8192];
1161 loop {
1162 match reader.read(&mut buf).await {
1163 Ok(0) | Err(_) => break,
1164 Ok(n) => tail.push(&buf[..n]),
1165 }
1166 }
1167 tail.into_vec()
1168}
1169
1170async fn drain_discard_async<R: tokio::io::AsyncRead + Unpin>(mut reader: R) {
1172 use tokio::io::AsyncReadExt as _;
1173 let mut buf = [0u8; 8192];
1174 loop {
1175 match reader.read(&mut buf).await {
1176 Ok(0) | Err(_) => break,
1177 Ok(_) => {}
1178 }
1179 }
1180}
1181
1182async fn capture_child_capped(
1192 child: &mut tokio::process::Child,
1193) -> std::io::Result<CapturedOutput> {
1194 let stdout = child.stdout.take();
1195 let stderr = child.stderr.take();
1196 let stderr_fut = async {
1197 match stderr {
1198 Some(reader) => drain_tail_async(reader, STDERR_TAIL_MAX_BYTES).await,
1199 None => Vec::new(),
1200 }
1201 };
1202 let stdout_fut = async {
1203 if let Some(reader) = stdout {
1204 drain_discard_async(reader).await;
1205 }
1206 };
1207 let (status, stderr, ()) = tokio::join!(child.wait(), stderr_fut, stdout_fut);
1208 Ok(CapturedOutput {
1209 status: status?,
1210 stderr,
1211 })
1212}
1213
1214fn check_exit(output: &CapturedOutput) -> Result<(), MediaError> {
1216 if output.status.success() {
1217 return Ok(());
1218 }
1219 Err(MediaError::FfmpegNonZeroExit {
1220 code: output
1221 .status
1222 .code()
1223 .map_or_else(|| "signal".to_owned(), |c| c.to_string()),
1224 stderr_tail: stderr_tail(&output.stderr),
1225 })
1226}
1227
1228fn output_size(output_path: &Path) -> Result<u64, MediaError> {
1230 let metadata = std::fs::metadata(output_path).map_err(|source| MediaError::FfmpegOutputIo {
1231 path: output_path.display().to_string(),
1232 source,
1233 })?;
1234 Ok(metadata.len())
1235}
1236
1237#[must_use]
1243pub fn slugify(input: &str) -> String {
1244 let slug = input
1245 .chars()
1246 .map(|ch| {
1247 if ch.is_ascii_alphanumeric() {
1248 ch.to_ascii_lowercase()
1249 } else {
1250 '-'
1251 }
1252 })
1253 .collect::<String>()
1254 .split('-')
1255 .filter(|part| !part.is_empty())
1256 .collect::<Vec<_>>()
1257 .join("-");
1258
1259 if slug.is_empty() {
1260 "clip".to_owned()
1261 } else {
1262 slug
1263 }
1264}
1265
1266#[must_use]
1268pub fn newest_recording_file(recording_dir: &Path) -> Option<PathBuf> {
1269 newest_recording_files(recording_dir, 1).into_iter().next()
1270}
1271
1272#[must_use]
1275pub fn newest_recording_files(recording_dir: &Path, max_count: usize) -> Vec<PathBuf> {
1276 newest_recording_files_since(recording_dir, max_count, None)
1277}
1278
1279#[must_use]
1287pub fn newest_recording_files_since(
1288 recording_dir: &Path,
1289 max_count: usize,
1290 min_modified: Option<std::time::SystemTime>,
1291) -> Vec<PathBuf> {
1292 if max_count == 0 {
1293 return Vec::new();
1294 }
1295 let Ok(entries) = std::fs::read_dir(recording_dir) else {
1296 return Vec::new();
1297 };
1298 let mut candidates: Vec<(std::time::SystemTime, PathBuf)> = entries
1299 .filter_map(Result::ok)
1300 .filter_map(|entry| {
1301 let path = entry.path();
1302 let modified = entry.metadata().ok()?.modified().ok()?;
1303 if let Some(cutoff) = min_modified
1304 && modified < cutoff
1305 {
1306 return None;
1307 }
1308 Some((modified, path))
1309 })
1310 .collect();
1311 candidates.sort_by_key(|b| std::cmp::Reverse(b.0));
1314 candidates.truncate(max_count);
1315 candidates.sort_by_key(|a| a.0);
1316 candidates.into_iter().map(|(_, path)| path).collect()
1317}
1318
1319#[must_use]
1337pub fn recording_segments_covering_window(
1338 recording_dir: &Path,
1339 broadcast_started_at: std::time::SystemTime,
1340 anchored_at: std::time::SystemTime,
1341 lookback: std::time::Duration,
1342) -> Vec<PathBuf> {
1343 let Ok(entries) = std::fs::read_dir(recording_dir) else {
1344 return Vec::new();
1345 };
1346 let mut candidates: Vec<(std::time::SystemTime, PathBuf)> = entries
1347 .filter_map(Result::ok)
1348 .filter_map(|entry| {
1349 let path = entry.path();
1350 let modified = entry.metadata().ok()?.modified().ok()?;
1351 if modified < broadcast_started_at {
1352 return None;
1353 }
1354 Some((modified, path))
1355 })
1356 .collect();
1357 candidates.sort_by_key(|a| a.0);
1358 if candidates.is_empty() {
1359 return Vec::new();
1360 }
1361
1362 let anchor_idx = candidates
1363 .iter()
1364 .position(|(mtime, _)| *mtime >= anchored_at)
1365 .unwrap_or(candidates.len() - 1);
1366 let cutoff = anchored_at
1367 .checked_sub(lookback)
1368 .unwrap_or(broadcast_started_at);
1369
1370 let mut indices = vec![anchor_idx];
1371 let mut i = anchor_idx;
1372 while i > 0 {
1373 i -= 1;
1374 if candidates[i].0 > cutoff {
1375 indices.push(i);
1376 } else {
1377 break;
1378 }
1379 }
1380 indices.sort_unstable();
1381 indices
1382 .into_iter()
1383 .map(|idx| candidates[idx].1.clone())
1384 .collect()
1385}
1386
1387#[cfg(test)]
1388mod tests {
1389 use std::fs::File;
1390 use std::path::{Path, PathBuf};
1391 use std::time::{Duration, SystemTime};
1392
1393 use super::{
1394 FfmpegClipTailCommand, FfmpegHighlightCommand, FfmpegLiveThumbnailCommand,
1395 FfmpegPosterCommand, FfmpegPreviewSpriteCommand, FfmpegRoomCompositeCommand,
1396 PREVIEW_CELL_HEIGHT, PREVIEW_CELL_WIDTH, PREVIEW_FRAME_INTERVAL_SECONDS,
1397 PREVIEW_SPRITE_COLUMNS, build_preview_webvtt, newest_recording_files,
1398 newest_recording_files_since, recording_segments_covering_window, slugify,
1399 validate_room_composite_input_count,
1400 };
1401
1402 #[test]
1405 fn highlight_args_have_expected_flags() {
1406 let cmd = FfmpegHighlightCommand::new("ffmpeg", "/rec/in.mp4", "/out/clip.mp4", 12, 30);
1407 let args = lossy(&cmd.args());
1408 assert_eq!(args.first().map(String::as_str), Some("-y"));
1409 let ss = args.iter().position(|a| a == "-ss").unwrap();
1411 assert_eq!(args[ss + 1], "12");
1412 let i = args.iter().position(|a| a == "-i").unwrap();
1413 assert!(ss < i, "-ss must precede -i");
1414 assert_eq!(args[i + 1], "/rec/in.mp4");
1415 let t = args.iter().position(|a| a == "-t").unwrap();
1416 assert_eq!(args[t + 1], "30");
1417 assert_windowed(&args, "-c:v", "libx264");
1418 assert_windowed(&args, "-preset", "veryfast");
1419 assert_windowed(&args, "-c:a", "aac");
1420 assert_windowed(&args, "-movflags", "+faststart");
1421 assert_eq!(args.last().map(String::as_str), Some("/out/clip.mp4"));
1422 }
1423
1424 #[test]
1425 fn clip_tail_single_source_uses_plain_input() {
1426 let cmd = FfmpegClipTailCommand::new(
1427 "ffmpeg",
1428 vec![PathBuf::from("/rec/seg0.mp4")],
1429 "/out/clip.mp4",
1430 20,
1431 25,
1432 );
1433 let args = lossy(&cmd.encode_args(None));
1434 let sseof = args.iter().position(|a| a == "-sseof").unwrap();
1436 assert_eq!(args[sseof + 1], "-25");
1437 assert!(!args.iter().any(|a| a == "concat"));
1439 let i = args.iter().position(|a| a == "-i").unwrap();
1440 assert_eq!(args[i + 1], "/rec/seg0.mp4");
1441 assert_eq!(args.iter().filter(|a| *a == "-i").count(), 1);
1442 let t = args.iter().position(|a| a == "-t").unwrap();
1443 assert_eq!(args[t + 1], "20");
1444 }
1445
1446 #[test]
1447 fn clip_tail_multi_source_uses_concat_list() {
1448 let cmd = FfmpegClipTailCommand::new(
1449 "ffmpeg",
1450 vec![PathBuf::from("/rec/a.mp4"), PathBuf::from("/rec/b.mp4")],
1451 "/out/clip.mp4",
1452 20,
1453 25,
1454 );
1455 let list = PathBuf::from("/out/.clip.concat.txt");
1456 let args = lossy(&cmd.encode_args(Some(&list)));
1457 assert_windowed(&args, "-f", "concat");
1458 assert_windowed(&args, "-safe", "0");
1459 let i = args.iter().position(|a| a == "-i").unwrap();
1460 assert_eq!(args[i + 1], "/out/.clip.concat.txt");
1461 let t = args.iter().position(|a| a == "-t").unwrap();
1462 assert_eq!(args[t + 1], "20");
1463 }
1464
1465 #[test]
1466 fn poster_single_vs_concat() {
1467 let single = FfmpegPosterCommand::new(
1468 "ffmpeg",
1469 vec![PathBuf::from("/rec/seg.mp4")],
1470 "/out/poster.jpg",
1471 4,
1472 );
1473 let sa = lossy(&single.poster_args(None));
1474 let sseof = sa.iter().position(|a| a == "-sseof").unwrap();
1475 assert_eq!(sa[sseof + 1], "-4");
1476 assert!(!sa.iter().any(|a| a == "concat"));
1477 assert_windowed(&sa, "-frames:v", "1");
1478 assert_windowed(&sa, "-q:v", "4");
1479 assert_eq!(sa.last().map(String::as_str), Some("/out/poster.jpg"));
1480
1481 let list = PathBuf::from("/out/.poster.concat.txt");
1482 let multi = FfmpegPosterCommand::new(
1483 "ffmpeg",
1484 vec![PathBuf::from("/rec/a.mp4"), PathBuf::from("/rec/b.mp4")],
1485 "/out/poster.jpg",
1486 4,
1487 );
1488 let ma = lossy(&multi.poster_args(Some(&list)));
1489 assert_windowed(&ma, "-f", "concat");
1490 assert_windowed(&ma, "-safe", "0");
1491 let i = ma.iter().position(|a| a == "-i").unwrap();
1492 assert_eq!(ma[i + 1], "/out/.poster.concat.txt");
1493 }
1494
1495 #[test]
1496 fn sprite_args_have_exact_filter() {
1497 let cmd = FfmpegPreviewSpriteCommand::new(
1498 "ffmpeg",
1499 vec![PathBuf::from("/rec/vod.mp4")],
1500 "/out/sprite.jpg",
1501 10,
1502 3,
1503 );
1504 let args = lossy(&cmd.sprite_args(None));
1505 let vf = args.iter().position(|a| a == "-vf").unwrap();
1506 assert_eq!(
1507 args[vf + 1],
1508 "fps=1/10,scale=160:90:force_original_aspect_ratio=decrease,pad=160:90:(ow-iw)/2:(oh-ih)/2,tile=10x3"
1509 );
1510 assert_windowed(&args, "-frames:v", "1");
1511 assert_windowed(&args, "-q:v", "5");
1512 assert_eq!(args.last().map(String::as_str), Some("/out/sprite.jpg"));
1513 }
1514
1515 #[test]
1516 fn sprite_new_clamps_cols_rows_to_one() {
1517 let cmd = FfmpegPreviewSpriteCommand::new(
1518 "ffmpeg",
1519 vec![PathBuf::from("/rec/vod.mp4")],
1520 "/out/sprite.jpg",
1521 0,
1522 0,
1523 );
1524 let args = lossy(&cmd.sprite_args(None));
1525 let vf = args.iter().position(|a| a == "-vf").unwrap();
1526 assert!(args[vf + 1].ends_with("tile=1x1"));
1527 }
1528
1529 #[test]
1530 fn live_thumbnail_args_bound_reads_and_drop_audio() {
1531 let cmd = FfmpegLiveThumbnailCommand::new(
1532 "ffmpeg",
1533 "http://mediamtx/live/key/index.m3u8",
1534 "/out/thumb.jpg",
1535 );
1536 let args = lossy(&cmd.args());
1537 assert_windowed(&args, "-rw_timeout", "5000000");
1538 assert_windowed(&args, "-frames:v", "1");
1539 assert!(args.iter().any(|a| a == "-an"), "must drop audio");
1540 let i = args.iter().position(|a| a == "-i").unwrap();
1541 assert_eq!(args[i + 1], "http://mediamtx/live/key/index.m3u8");
1542 assert_eq!(args.last().map(String::as_str), Some("/out/thumb.jpg"));
1543 }
1544
1545 #[test]
1546 fn room_composite_two_participants_side_by_side() {
1547 let cmd = FfmpegRoomCompositeCommand::new(
1548 "ffmpeg",
1549 vec![PathBuf::from("/rec/a.mp4"), PathBuf::from("/rec/b.mp4")],
1550 "/out/room.mp4",
1551 vec![true, true],
1552 );
1553 let args = lossy(&cmd.args());
1554 assert_eq!(args.first().map(String::as_str), Some("-y"));
1555 assert_eq!(args.iter().filter(|a| *a == "-i").count(), 2);
1557 let fc = args.iter().position(|a| a == "-filter_complex").unwrap();
1559 assert_eq!(
1560 args[fc + 1],
1561 "[0:v]scale=640:360:force_original_aspect_ratio=decrease,pad=640:360:(ow-iw)/2:(oh-ih)/2,setsar=1[v0];\
1562 [1:v]scale=640:360:force_original_aspect_ratio=decrease,pad=640:360:(ow-iw)/2:(oh-ih)/2,setsar=1[v1];\
1563 [v0][v1]xstack=inputs=2:layout=0_0|w0_0[vout];\
1564 [0:a][1:a]amix=inputs=2:normalize=1[aout]"
1565 );
1566 assert_windowed(&args, "-map", "[vout]");
1567 assert_windowed(&args, "-c:v", "libx264");
1568 assert_windowed(&args, "-c:a", "aac");
1569 assert_windowed(&args, "-movflags", "+faststart");
1570 assert_eq!(args.last().map(String::as_str), Some("/out/room.mp4"));
1571 }
1572
1573 #[test]
1574 fn room_composite_four_participants_two_by_two_grid() {
1575 let cmd = FfmpegRoomCompositeCommand::new(
1576 "ffmpeg",
1577 vec![
1578 PathBuf::from("/rec/a.mp4"),
1579 PathBuf::from("/rec/b.mp4"),
1580 PathBuf::from("/rec/c.mp4"),
1581 PathBuf::from("/rec/d.mp4"),
1582 ],
1583 "/out/room.mp4",
1584 vec![true, true, true, true],
1585 );
1586 let args = lossy(&cmd.args());
1587 assert_eq!(args.iter().filter(|a| *a == "-i").count(), 4);
1588 let fc = args.iter().position(|a| a == "-filter_complex").unwrap();
1589 assert_eq!(
1590 args[fc + 1],
1591 "[0:v]scale=640:360:force_original_aspect_ratio=decrease,pad=640:360:(ow-iw)/2:(oh-ih)/2,setsar=1[v0];\
1592 [1:v]scale=640:360:force_original_aspect_ratio=decrease,pad=640:360:(ow-iw)/2:(oh-ih)/2,setsar=1[v1];\
1593 [2:v]scale=640:360:force_original_aspect_ratio=decrease,pad=640:360:(ow-iw)/2:(oh-ih)/2,setsar=1[v2];\
1594 [3:v]scale=640:360:force_original_aspect_ratio=decrease,pad=640:360:(ow-iw)/2:(oh-ih)/2,setsar=1[v3];\
1595 [v0][v1][v2][v3]xstack=inputs=4:layout=0_0|w0_0|0_h0|w0_h0[vout];\
1596 [0:a][1:a][2:a][3:a]amix=inputs=4:normalize=1[aout]"
1597 );
1598 }
1599
1600 #[test]
1601 fn room_composite_mixes_only_inputs_with_audio() {
1602 let cmd = FfmpegRoomCompositeCommand::new(
1606 "ffmpeg",
1607 vec![
1608 PathBuf::from("/rec/a.mp4"),
1609 PathBuf::from("/rec/b.mp4"),
1610 PathBuf::from("/rec/c.mp4"),
1611 ],
1612 "/out/room.mp4",
1613 vec![true, false, true],
1614 );
1615 let args = lossy(&cmd.args());
1616 let fc = args.iter().position(|a| a == "-filter_complex").unwrap();
1617 let graph = &args[fc + 1];
1618 assert!(graph.contains("[0:v]scale="));
1620 assert!(graph.contains("[1:v]scale="));
1621 assert!(graph.contains("[2:v]scale="));
1622 assert!(graph.contains("[v0][v1][v2]xstack=inputs=3:"));
1623 assert!(
1625 graph.contains("[0:a][2:a]amix=inputs=2:normalize=1[aout]"),
1626 "graph was: {graph}"
1627 );
1628 assert!(
1629 !graph.contains("[1:a]"),
1630 "video-only input must not be mixed"
1631 );
1632 assert!(
1634 args.windows(2).any(|w| w[0] == "-map" && w[1] == "[aout]"),
1635 "audio must be mapped to output"
1636 );
1637 assert_windowed(&args, "-c:a", "aac");
1638 }
1639
1640 #[test]
1641 fn room_composite_single_audio_uses_amix_inputs_one() {
1642 let cmd = FfmpegRoomCompositeCommand::new(
1645 "ffmpeg",
1646 vec![
1647 PathBuf::from("/rec/a.mp4"),
1648 PathBuf::from("/rec/b.mp4"),
1649 PathBuf::from("/rec/c.mp4"),
1650 ],
1651 "/out/room.mp4",
1652 vec![false, true, false],
1653 );
1654 let args = lossy(&cmd.args());
1655 let fc = args.iter().position(|a| a == "-filter_complex").unwrap();
1656 let graph = &args[fc + 1];
1657 assert!(graph.contains("[v0][v1][v2]xstack=inputs=3:"));
1659 assert!(
1660 graph.contains("[1:a]amix=inputs=1:normalize=1[aout]"),
1661 "graph was: {graph}"
1662 );
1663 assert!(!graph.contains("[0:a]"));
1664 assert!(!graph.contains("[2:a]"));
1665 assert!(
1666 args.windows(2).any(|w| w[0] == "-map" && w[1] == "[aout]"),
1667 "audio must be mapped to output"
1668 );
1669 assert_windowed(&args, "-c:a", "aac");
1670 }
1671
1672 #[test]
1673 fn room_composite_all_video_only_emits_silent_grid() {
1674 let cmd = FfmpegRoomCompositeCommand::new(
1677 "ffmpeg",
1678 vec![PathBuf::from("/rec/a.mp4"), PathBuf::from("/rec/b.mp4")],
1679 "/out/room.mp4",
1680 vec![false, false],
1681 );
1682 let args = lossy(&cmd.args());
1683 let fc = args.iter().position(|a| a == "-filter_complex").unwrap();
1684 assert_eq!(
1685 args[fc + 1],
1686 "[0:v]scale=640:360:force_original_aspect_ratio=decrease,pad=640:360:(ow-iw)/2:(oh-ih)/2,setsar=1[v0];\
1687 [1:v]scale=640:360:force_original_aspect_ratio=decrease,pad=640:360:(ow-iw)/2:(oh-ih)/2,setsar=1[v1];\
1688 [v0][v1]xstack=inputs=2:layout=0_0|w0_0[vout]"
1689 );
1690 assert_windowed(&args, "-map", "[vout]");
1692 assert_windowed(&args, "-c:v", "libx264");
1693 assert!(!args.iter().any(|a| a == "[aout]"), "no [aout] map");
1695 assert!(!args.iter().any(|a| a == "-c:a"), "no audio codec");
1696 assert_eq!(args.iter().filter(|a| *a == "-map").count(), 1);
1698 assert_eq!(args.last().map(String::as_str), Some("/out/room.mp4"));
1699 }
1700
1701 #[test]
1702 fn room_composite_grid_dims_match_participant_bands() {
1703 use super::room_composite_grid_dims;
1704 assert_eq!(room_composite_grid_dims(2), (2, 1));
1705 assert_eq!(room_composite_grid_dims(3), (2, 2));
1706 assert_eq!(room_composite_grid_dims(4), (2, 2));
1707 assert_eq!(room_composite_grid_dims(5), (3, 2));
1708 assert_eq!(room_composite_grid_dims(6), (3, 2));
1709 }
1710
1711 #[test]
1712 fn room_composite_run_rejects_single_participant() {
1713 let dir = tempfile::tempdir().unwrap();
1714 let only = dir.path().join("solo.mp4");
1715 File::create(&only).unwrap();
1716 let cmd = FfmpegRoomCompositeCommand::new(
1717 "ffmpeg",
1718 vec![only],
1719 dir.path().join("room.mp4"),
1720 vec![true],
1721 );
1722 assert!(matches!(
1725 cmd.run(),
1726 Err(super::MediaError::FfmpegSourceMissing { .. })
1727 ));
1728 }
1729
1730 #[test]
1731 fn room_composite_run_rejects_more_than_six_participants() {
1732 let dir = tempfile::tempdir().unwrap();
1733 let sources: Vec<PathBuf> = (0..7)
1736 .map(|i| {
1737 let path = dir.path().join(format!("p{i}.mp4"));
1738 File::create(&path).unwrap();
1739 path
1740 })
1741 .collect();
1742 let audio = vec![true; sources.len()];
1743 let cmd =
1744 FfmpegRoomCompositeCommand::new("ffmpeg", sources, dir.path().join("room.mp4"), audio);
1745 assert!(matches!(
1746 cmd.run(),
1747 Err(super::MediaError::FfmpegSourceMissing { .. })
1748 ));
1749 }
1750
1751 #[test]
1752 fn validate_room_composite_input_count_enforces_two_to_six_band() {
1753 for count in [0, 1] {
1755 assert!(matches!(
1756 validate_room_composite_input_count(count),
1757 Err(super::MediaError::FfmpegSourceMissing { .. })
1758 ));
1759 }
1760 for count in 2..=6 {
1762 assert!(validate_room_composite_input_count(count).is_ok());
1763 }
1764 for count in [7, 100] {
1766 assert!(matches!(
1767 validate_room_composite_input_count(count),
1768 Err(super::MediaError::FfmpegSourceMissing { .. })
1769 ));
1770 }
1771 }
1772
1773 fn lossy(args: &[std::ffi::OsString]) -> Vec<String> {
1776 args.iter()
1777 .map(|a| a.to_string_lossy().into_owned())
1778 .collect()
1779 }
1780
1781 fn assert_windowed(args: &[String], flag: &str, value: &str) {
1783 let idx = args
1784 .iter()
1785 .position(|a| a == flag)
1786 .unwrap_or_else(|| panic!("flag {flag} not found in {args:?}"));
1787 assert_eq!(
1788 args[idx + 1],
1789 value,
1790 "flag {flag} should be followed by {value}"
1791 );
1792 }
1793
1794 #[test]
1797 fn webvtt_header_and_cue_per_frame() {
1798 let vtt = build_preview_webvtt(
1799 23,
1800 PREVIEW_FRAME_INTERVAL_SECONDS,
1801 PREVIEW_SPRITE_COLUMNS,
1802 PREVIEW_CELL_WIDTH,
1803 PREVIEW_CELL_HEIGHT,
1804 "https://example.test/previews/7.jpg",
1805 );
1806 assert!(
1807 vtt.starts_with("WEBVTT\n\n"),
1808 "must start with WEBVTT header"
1809 );
1810 assert_eq!(vtt.matches(" --> ").count(), 23, "one cue per frame");
1811 assert!(vtt.contains("00:00:00.000 --> 00:00:10.000\n"));
1813 assert!(vtt.contains("https://example.test/previews/7.jpg#xywh=0,0,160,90\n"));
1814 assert!(vtt.contains("00:01:40.000 --> 00:01:50.000\n"));
1816 assert!(vtt.contains("https://example.test/previews/7.jpg#xywh=0,90,160,90\n"));
1817 assert!(vtt.contains("https://example.test/previews/7.jpg#xywh=160,90,160,90\n"));
1819 }
1820
1821 #[test]
1822 fn webvtt_cols_guard_treats_zero_as_one() {
1823 let vtt = build_preview_webvtt(3, 10, 0, 160, 90, "s.jpg");
1825 assert!(vtt.contains("s.jpg#xywh=0,0,160,90\n"));
1826 assert!(vtt.contains("s.jpg#xywh=0,90,160,90\n"));
1827 assert!(vtt.contains("s.jpg#xywh=0,180,160,90\n"));
1828 }
1829
1830 #[test]
1831 fn webvtt_empty_is_header_only() {
1832 assert_eq!(
1833 build_preview_webvtt(0, 10, 10, 160, 90, "sprite.jpg"),
1834 "WEBVTT\n\n"
1835 );
1836 }
1837
1838 #[test]
1841 fn slugify_lowercases_and_dashes() {
1842 assert_eq!(slugify("Hello World"), "hello-world");
1843 assert_eq!(slugify("Foo_Bar.Baz!"), "foo-bar-baz");
1844 assert_eq!(slugify("--Trim--This--"), "trim-this");
1845 assert_eq!(slugify("a b"), "a-b");
1846 }
1847
1848 #[test]
1849 fn slugify_empty_and_all_punct_fall_back_to_clip() {
1850 assert_eq!(slugify(""), "clip");
1851 assert_eq!(slugify("!!!"), "clip");
1852 assert_eq!(slugify("---"), "clip");
1853 }
1854
1855 #[test]
1856 fn slugify_drops_non_ascii() {
1857 assert_eq!(slugify("café"), "caf");
1859 assert_eq!(slugify("naïve rocks"), "na-ve-rocks");
1860 }
1861
1862 #[test]
1865 fn write_concat_list_writes_absolute_entries_and_guard_removes_it() {
1866 let dir = tempfile::tempdir().unwrap();
1867 let a = dir.path().join("a.mp4");
1868 let b = dir.path().join("b.mp4");
1869 File::create(&a).unwrap();
1870 File::create(&b).unwrap();
1871 let near = dir.path().join("clip.mp4");
1872
1873 let list = super::write_concat_list(&[a.clone(), b.clone()], &near).unwrap();
1874 let name = list.file_name().unwrap().to_str().unwrap();
1875 assert!(name.starts_with(".clip."), "name: {name}");
1877 assert!(name.ends_with(".concat.txt"), "name: {name}");
1878
1879 let body = std::fs::read_to_string(&list).unwrap();
1880 let lines: Vec<&str> = body.lines().collect();
1881 assert_eq!(lines.len(), 2);
1882 for (line, src) in lines.iter().zip([&a, &b]) {
1884 assert!(line.starts_with("file '"), "line: {line}");
1885 assert!(line.ends_with('\''));
1886 let inner = &line["file '".len()..line.len() - 1];
1887 assert!(
1888 Path::new(inner).is_absolute(),
1889 "entry not absolute: {inner}"
1890 );
1891 assert!(inner.ends_with(src.file_name().unwrap().to_str().unwrap()));
1892 }
1893
1894 {
1895 let _guard = super::ConcatListGuard::new(list.clone());
1896 assert!(list.exists());
1897 }
1898 assert!(!list.exists(), "guard drop must remove the concat list");
1899 }
1900
1901 #[test]
1902 fn concat_escape_handles_single_quotes_and_leaves_others_literal() {
1903 assert_eq!(
1905 super::concat_escape(b"creator's-stream/seg.mp4"),
1906 b"creator'\\''s-stream/seg.mp4"
1907 );
1908 assert_eq!(super::concat_escape(b"a'b'c"), b"a'\\''b'\\''c");
1910 assert_eq!(super::concat_escape(br"C:\rec\seg.mp4"), br"C:\rec\seg.mp4");
1912 assert_eq!(super::concat_escape(b"/rec/seg.mp4"), b"/rec/seg.mp4");
1914 assert_eq!(
1916 super::concat_escape(&[0xff, b'\'', 0xfe]),
1917 vec![0xff, b'\'', b'\\', b'\'', b'\'', 0xfe]
1918 );
1919 }
1920
1921 #[test]
1922 fn write_concat_list_escapes_apostrophes_in_paths() {
1923 let dir = tempfile::tempdir().unwrap();
1924 let sub = dir.path().join("creator's-stream");
1925 std::fs::create_dir_all(&sub).unwrap();
1926 let a = sub.join("seg.mp4");
1927 File::create(&a).unwrap();
1928 let near = dir.path().join("clip.mp4");
1929
1930 let list = super::write_concat_list(std::slice::from_ref(&a), &near).unwrap();
1931 let _guard = super::ConcatListGuard::new(list.clone());
1932 let body = std::fs::read_to_string(&list).unwrap();
1933 let line = body.lines().next().unwrap();
1934
1935 assert!(line.contains("'\\''"), "line: {line}");
1937 let inner = &line["file '".len()..line.len() - 1];
1940 let unescaped = inner.replace("'\\''", "'");
1941 let expected = super::absolutize_for_concat(&a).unwrap();
1942 assert_eq!(unescaped, expected.display().to_string());
1943 }
1944
1945 #[test]
1946 fn write_concat_list_uses_unique_filenames_per_invocation() {
1947 let dir = tempfile::tempdir().unwrap();
1948 let a = dir.path().join("a.mp4");
1949 let b = dir.path().join("b.mp4");
1950 File::create(&a).unwrap();
1951 File::create(&b).unwrap();
1952 let near_mp4 = dir.path().join("clip.mp4");
1954 let near_jpg = dir.path().join("clip.jpg");
1955
1956 let list1 = super::write_concat_list(&[a.clone(), b.clone()], &near_mp4).unwrap();
1957 let g1 = super::ConcatListGuard::new(list1.clone());
1958 let list2 = super::write_concat_list(&[a, b], &near_jpg).unwrap();
1959 let g2 = super::ConcatListGuard::new(list2.clone());
1960
1961 assert_ne!(
1962 list1, list2,
1963 "same-stem concurrent encodes must not collide"
1964 );
1965 assert!(list1.exists());
1966 assert!(list2.exists());
1967
1968 drop(g1);
1969 assert!(!list1.exists(), "guard removes its own file");
1970 assert!(list2.exists(), "the other list is untouched");
1971 drop(g2);
1972 assert!(!list2.exists());
1973 }
1974
1975 fn touch_at(dir: &Path, name: &str, base: SystemTime, offset_secs: u64) -> PathBuf {
1979 let path = dir.join(name);
1980 let f = File::create(&path).unwrap();
1981 f.set_modified(base + Duration::from_secs(offset_secs))
1982 .unwrap();
1983 path
1984 }
1985
1986 #[test]
1987 fn newest_files_returns_k_newest_in_chronological_order() {
1988 let dir = tempfile::tempdir().unwrap();
1989 let base = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000);
1990 touch_at(dir.path(), "s0.mp4", base, 0);
1991 touch_at(dir.path(), "s1.mp4", base, 10);
1992 touch_at(dir.path(), "s2.mp4", base, 20);
1993 touch_at(dir.path(), "s3.mp4", base, 30);
1994
1995 let got = newest_recording_files(dir.path(), 2);
1996 let names: Vec<String> = got
1997 .iter()
1998 .map(|p| p.file_name().unwrap().to_str().unwrap().to_owned())
1999 .collect();
2000 assert_eq!(names, vec!["s2.mp4", "s3.mp4"]);
2002 }
2003
2004 #[test]
2005 fn newest_files_zero_count_and_missing_dir_are_empty() {
2006 let dir = tempfile::tempdir().unwrap();
2007 assert!(newest_recording_files(dir.path(), 0).is_empty());
2008 assert!(newest_recording_files(Path::new("/no/such/dir/xyz"), 3).is_empty());
2009 }
2010
2011 #[test]
2012 fn newest_files_since_honors_min_modified() {
2013 let dir = tempfile::tempdir().unwrap();
2014 let base = SystemTime::UNIX_EPOCH + Duration::from_secs(2_000_000);
2015 touch_at(dir.path(), "old.mp4", base, 0);
2016 touch_at(dir.path(), "new.mp4", base, 100);
2017
2018 let cutoff = base + Duration::from_secs(50);
2019 let got = newest_recording_files_since(dir.path(), 10, Some(cutoff));
2020 let names: Vec<String> = got
2021 .iter()
2022 .map(|p| p.file_name().unwrap().to_str().unwrap().to_owned())
2023 .collect();
2024 assert_eq!(names, vec!["new.mp4"], "old segment must be filtered out");
2025 }
2026
2027 #[test]
2028 fn covering_window_anchors_on_first_mtime_at_or_after_anchor() {
2029 let dir = tempfile::tempdir().unwrap();
2030 let base = SystemTime::UNIX_EPOCH + Duration::from_mins(50_000);
2031 touch_at(dir.path(), "seg0.mp4", base, 0);
2033 touch_at(dir.path(), "seg1.mp4", base, 10);
2034 touch_at(dir.path(), "seg2.mp4", base, 20);
2035 touch_at(dir.path(), "seg3.mp4", base, 30);
2036
2037 let broadcast_started = base;
2038 let anchored = base + Duration::from_secs(20); let lookback = Duration::from_secs(15); let got =
2042 recording_segments_covering_window(dir.path(), broadcast_started, anchored, lookback);
2043 let names: Vec<String> = got
2044 .iter()
2045 .map(|p| p.file_name().unwrap().to_str().unwrap().to_owned())
2046 .collect();
2047 assert_eq!(names, vec!["seg1.mp4", "seg2.mp4"]);
2049 }
2050
2051 #[test]
2052 fn covering_window_drops_pre_broadcast_segments() {
2053 let dir = tempfile::tempdir().unwrap();
2054 let base = SystemTime::UNIX_EPOCH + Duration::from_secs(4_000_000);
2055 touch_at(dir.path(), "stale.mp4", base, 0);
2057 touch_at(dir.path(), "live.mp4", base, 100);
2058
2059 let broadcast_started = base + Duration::from_secs(50);
2060 let anchored = base + Duration::from_secs(100);
2061 let got = recording_segments_covering_window(
2062 dir.path(),
2063 broadcast_started,
2064 anchored,
2065 Duration::from_secs(30),
2066 );
2067 let names: Vec<String> = got
2068 .iter()
2069 .map(|p| p.file_name().unwrap().to_str().unwrap().to_owned())
2070 .collect();
2071 assert_eq!(
2072 names,
2073 vec!["live.mp4"],
2074 "pre-broadcast segment must be dropped"
2075 );
2076 }
2077
2078 #[test]
2079 fn covering_window_falls_back_to_newest_when_no_anchor_match() {
2080 let dir = tempfile::tempdir().unwrap();
2081 let base = SystemTime::UNIX_EPOCH + Duration::from_secs(5_000_000);
2082 touch_at(dir.path(), "a.mp4", base, 0);
2083 touch_at(dir.path(), "b.mp4", base, 10);
2084
2085 let broadcast_started = base;
2086 let anchored = base + Duration::from_secs(10_000);
2088 let got = recording_segments_covering_window(
2089 dir.path(),
2090 broadcast_started,
2091 anchored,
2092 Duration::from_secs(5),
2093 );
2094 let names: Vec<String> = got
2095 .iter()
2096 .map(|p| p.file_name().unwrap().to_str().unwrap().to_owned())
2097 .collect();
2098 assert_eq!(names, vec!["b.mp4"]);
2100 }
2101
2102 #[test]
2103 fn covering_window_missing_dir_is_empty() {
2104 let got = recording_segments_covering_window(
2105 Path::new("/no/such/dir/xyz"),
2106 SystemTime::UNIX_EPOCH,
2107 SystemTime::UNIX_EPOCH,
2108 Duration::from_secs(1),
2109 );
2110 assert!(got.is_empty());
2111 }
2112}