use std::ffi::OsString;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use serde::{Deserialize, Serialize};
use crate::error::{MediaError, STDERR_TAIL_MAX_BYTES, stderr_tail};
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct FfmpegHighlightCommand {
ffmpeg_bin: String,
input_path: PathBuf,
output_path: PathBuf,
start_seconds: u32,
duration_seconds: u32,
}
impl FfmpegHighlightCommand {
#[must_use]
pub fn new(
ffmpeg_bin: impl Into<String>,
input_path: impl Into<PathBuf>,
output_path: impl Into<PathBuf>,
start_seconds: u32,
duration_seconds: u32,
) -> Self {
Self {
ffmpeg_bin: ffmpeg_bin.into(),
input_path: input_path.into(),
output_path: output_path.into(),
start_seconds,
duration_seconds,
}
}
#[must_use]
pub fn args(&self) -> Vec<OsString> {
vec![
OsString::from("-y"),
OsString::from("-ss"),
OsString::from(self.start_seconds.to_string()),
OsString::from("-i"),
self.input_path.as_os_str().to_owned(),
OsString::from("-t"),
OsString::from(self.duration_seconds.to_string()),
OsString::from("-c:v"),
OsString::from("libx264"),
OsString::from("-preset"),
OsString::from("veryfast"),
OsString::from("-c:a"),
OsString::from("aac"),
OsString::from("-movflags"),
OsString::from("+faststart"),
self.output_path.as_os_str().to_owned(),
]
}
pub fn run(&self) -> Result<u64, MediaError> {
if !self.input_path.exists() {
return Err(MediaError::FfmpegSourceMissing {
path: self.input_path.display().to_string(),
});
}
create_output_parent(&self.output_path)?;
let output = run_ffmpeg_capped(&self.ffmpeg_bin, self.args()).map_err(|source| {
MediaError::FfmpegSpawn {
bin: self.ffmpeg_bin.clone(),
source,
}
})?;
check_exit(&output)?;
output_size(&self.output_path)
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct FfmpegPosterCommand {
ffmpeg_bin: String,
input_paths: Vec<PathBuf>,
output_path: PathBuf,
tail_offset_seconds: u32,
}
impl FfmpegPosterCommand {
#[must_use]
pub fn new(
ffmpeg_bin: impl Into<String>,
input_paths: Vec<PathBuf>,
output_path: impl Into<PathBuf>,
tail_offset_seconds: u32,
) -> Self {
Self {
ffmpeg_bin: ffmpeg_bin.into(),
input_paths,
output_path: output_path.into(),
tail_offset_seconds,
}
}
#[must_use]
pub(crate) fn poster_args(&self, concat_list: Option<&Path>) -> Vec<OsString> {
let mut args = vec![
OsString::from("-y"),
OsString::from("-sseof"),
OsString::from(format!("-{}", self.tail_offset_seconds)),
];
if let Some(list) = concat_list {
args.extend([
OsString::from("-f"),
OsString::from("concat"),
OsString::from("-safe"),
OsString::from("0"),
OsString::from("-i"),
list.as_os_str().to_owned(),
]);
} else {
args.extend([
OsString::from("-i"),
self.input_paths[0].as_os_str().to_owned(),
]);
}
args.extend([
OsString::from("-frames:v"),
OsString::from("1"),
OsString::from("-q:v"),
OsString::from("4"),
self.output_path.as_os_str().to_owned(),
]);
args
}
pub fn run(&self) -> Result<u64, MediaError> {
require_sources(&self.input_paths)?;
create_output_parent(&self.output_path)?;
let _concat_guard;
let concat_list_path = if self.input_paths.len() > 1 {
let path = write_concat_list(&self.input_paths, &self.output_path)?;
_concat_guard = ConcatListGuard::new(path.clone());
Some(path)
} else {
None
};
let output = run_ffmpeg_capped(
&self.ffmpeg_bin,
self.poster_args(concat_list_path.as_deref()),
)
.map_err(|source| MediaError::FfmpegSpawn {
bin: self.ffmpeg_bin.clone(),
source,
})?;
check_exit(&output)?;
output_size(&self.output_path)
}
}
pub const PREVIEW_FRAME_INTERVAL_SECONDS: u32 = 10;
pub const PREVIEW_SPRITE_COLUMNS: u32 = 10;
pub const PREVIEW_CELL_WIDTH: u32 = 160;
pub const PREVIEW_CELL_HEIGHT: u32 = 90;
#[must_use]
fn format_vtt_timestamp(total_seconds: u32) -> String {
let hours = total_seconds / 3600;
let minutes = (total_seconds % 3600) / 60;
let seconds = total_seconds % 60;
format!("{hours:02}:{minutes:02}:{seconds:02}.000")
}
#[must_use]
pub fn build_preview_webvtt(
frame_count: u32,
interval_seconds: u32,
cols: u32,
cell_w: u32,
cell_h: u32,
sprite_url: &str,
) -> String {
use std::fmt::Write as _;
let cols = cols.max(1);
let mut vtt = String::from("WEBVTT\n\n");
for i in 0..frame_count {
let start = i * interval_seconds;
let end = (i + 1) * interval_seconds;
let x = (i % cols) * cell_w;
let y = (i / cols) * cell_h;
let _ = write!(
vtt,
"{} --> {}\n{sprite_url}#xywh={x},{y},{cell_w},{cell_h}\n\n",
format_vtt_timestamp(start),
format_vtt_timestamp(end),
);
}
vtt
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct FfmpegPreviewSpriteCommand {
ffmpeg_bin: String,
input_paths: Vec<PathBuf>,
output_path: PathBuf,
cols: u32,
rows: u32,
}
impl FfmpegPreviewSpriteCommand {
#[must_use]
pub fn new(
ffmpeg_bin: impl Into<String>,
input_paths: Vec<PathBuf>,
output_path: impl Into<PathBuf>,
cols: u32,
rows: u32,
) -> Self {
Self {
ffmpeg_bin: ffmpeg_bin.into(),
input_paths,
output_path: output_path.into(),
cols: cols.max(1),
rows: rows.max(1),
}
}
#[must_use]
pub(crate) fn sprite_args(&self, concat_list: Option<&Path>) -> Vec<OsString> {
let mut args = vec![OsString::from("-y")];
if let Some(list) = concat_list {
args.extend([
OsString::from("-f"),
OsString::from("concat"),
OsString::from("-safe"),
OsString::from("0"),
OsString::from("-i"),
list.as_os_str().to_owned(),
]);
} else {
args.extend([
OsString::from("-i"),
self.input_paths[0].as_os_str().to_owned(),
]);
}
let filter = format!(
"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}",
cw = PREVIEW_CELL_WIDTH,
ch = PREVIEW_CELL_HEIGHT,
cols = self.cols,
rows = self.rows,
);
args.extend([
OsString::from("-frames:v"),
OsString::from("1"),
OsString::from("-vf"),
OsString::from(filter),
OsString::from("-q:v"),
OsString::from("5"),
self.output_path.as_os_str().to_owned(),
]);
args
}
pub fn run(&self) -> Result<u64, MediaError> {
require_sources(&self.input_paths)?;
create_output_parent(&self.output_path)?;
let _concat_guard;
let concat_list_path = if self.input_paths.len() > 1 {
let path = write_concat_list(&self.input_paths, &self.output_path)?;
_concat_guard = ConcatListGuard::new(path.clone());
Some(path)
} else {
None
};
let output = run_ffmpeg_capped(
&self.ffmpeg_bin,
self.sprite_args(concat_list_path.as_deref()),
)
.map_err(|source| MediaError::FfmpegSpawn {
bin: self.ffmpeg_bin.clone(),
source,
})?;
check_exit(&output)?;
output_size(&self.output_path)
}
}
fn write_concat_list(input_paths: &[PathBuf], near_path: &Path) -> Result<PathBuf, MediaError> {
use std::io::Write as _;
let parent = near_path
.parent()
.map_or_else(|| PathBuf::from("."), Path::to_path_buf);
std::fs::create_dir_all(&parent).map_err(|source| MediaError::ConcatList {
path: parent.display().to_string(),
source,
})?;
let stem = near_path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("clip");
let seq = CONCAT_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let pid = std::process::id();
let list_path = parent.join(format!(".{stem}.{pid}-{seq}.concat.txt"));
let mut list_file =
std::fs::File::create(&list_path).map_err(|source| MediaError::ConcatList {
path: list_path.display().to_string(),
source,
})?;
for path in input_paths {
let absolute = absolutize_for_concat(path)?;
let mut line = Vec::with_capacity(absolute.as_os_str().len() + 8);
line.extend_from_slice(b"file '");
line.extend_from_slice(&concat_escape(&path_to_bytes(&absolute)));
line.extend_from_slice(b"'\n");
list_file
.write_all(&line)
.map_err(|source| MediaError::ConcatList {
path: list_path.display().to_string(),
source,
})?;
}
Ok(list_path)
}
static CONCAT_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
fn concat_escape(path: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(path.len());
for &byte in path {
if byte == b'\'' {
out.extend_from_slice(b"'\\''");
} else {
out.push(byte);
}
}
out
}
fn path_to_bytes(path: &Path) -> Vec<u8> {
#[cfg(unix)]
{
use std::os::unix::ffi::OsStrExt as _;
path.as_os_str().as_bytes().to_vec()
}
#[cfg(not(unix))]
{
path.display().to_string().into_bytes()
}
}
fn absolutize_for_concat(path: &Path) -> Result<PathBuf, MediaError> {
if path.is_absolute() {
return Ok(path.to_path_buf());
}
if let Ok(canonical) = std::fs::canonicalize(path) {
return Ok(canonical);
}
let cwd = std::env::current_dir().map_err(|source| MediaError::ConcatList {
path: path.display().to_string(),
source,
})?;
Ok(cwd.join(path))
}
struct ConcatListGuard {
path: PathBuf,
}
impl ConcatListGuard {
const fn new(path: PathBuf) -> Self {
Self { path }
}
}
impl Drop for ConcatListGuard {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.path);
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct FfmpegClipTailCommand {
ffmpeg_bin: String,
input_paths: Vec<PathBuf>,
output_path: PathBuf,
duration_seconds: u32,
tail_offset_seconds: u32,
}
impl FfmpegClipTailCommand {
#[must_use]
pub fn new(
ffmpeg_bin: impl Into<String>,
input_paths: Vec<PathBuf>,
output_path: impl Into<PathBuf>,
duration_seconds: u32,
tail_offset_seconds: u32,
) -> Self {
Self {
ffmpeg_bin: ffmpeg_bin.into(),
input_paths,
output_path: output_path.into(),
duration_seconds,
tail_offset_seconds,
}
}
#[must_use]
fn input_args(&self, concat_list: Option<&Path>) -> Vec<OsString> {
concat_list.map_or_else(
|| {
vec![
OsString::from("-i"),
self.input_paths[0].as_os_str().to_owned(),
]
},
|list| {
vec![
OsString::from("-f"),
OsString::from("concat"),
OsString::from("-safe"),
OsString::from("0"),
OsString::from("-i"),
list.as_os_str().to_owned(),
]
},
)
}
#[must_use]
pub(crate) fn encode_args(&self, concat_list: Option<&Path>) -> Vec<OsString> {
let mut args = vec![
OsString::from("-y"),
OsString::from("-sseof"),
OsString::from(format!("-{}", self.tail_offset_seconds)),
];
args.extend(self.input_args(concat_list));
args.extend([
OsString::from("-t"),
OsString::from(self.duration_seconds.to_string()),
OsString::from("-c:v"),
OsString::from("libx264"),
OsString::from("-preset"),
OsString::from("veryfast"),
OsString::from("-c:a"),
OsString::from("aac"),
OsString::from("-movflags"),
OsString::from("+faststart"),
self.output_path.as_os_str().to_owned(),
]);
args
}
pub fn run(&self) -> Result<u64, MediaError> {
require_sources(&self.input_paths)?;
create_output_parent(&self.output_path)?;
let _concat_guard;
let concat_list_path = if self.input_paths.len() > 1 {
let path = write_concat_list(&self.input_paths, &self.output_path)?;
_concat_guard = ConcatListGuard::new(path.clone());
Some(path)
} else {
None
};
let output = run_ffmpeg_capped(
&self.ffmpeg_bin,
self.encode_args(concat_list_path.as_deref()),
)
.map_err(|source| MediaError::FfmpegSpawn {
bin: self.ffmpeg_bin.clone(),
source,
})?;
check_exit(&output)?;
output_size(&self.output_path)
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct FfmpegLiveThumbnailCommand {
ffmpeg_bin: String,
input_url: String,
output_path: PathBuf,
}
impl FfmpegLiveThumbnailCommand {
#[must_use]
pub fn new(
ffmpeg_bin: impl Into<String>,
input_url: impl Into<String>,
output_path: impl Into<PathBuf>,
) -> Self {
Self {
ffmpeg_bin: ffmpeg_bin.into(),
input_url: input_url.into(),
output_path: output_path.into(),
}
}
#[must_use]
pub fn args(&self) -> Vec<OsString> {
vec![
OsString::from("-y"),
OsString::from("-rw_timeout"),
OsString::from("5000000"),
OsString::from("-i"),
OsString::from(self.input_url.clone()),
OsString::from("-frames:v"),
OsString::from("1"),
OsString::from("-q:v"),
OsString::from("4"),
OsString::from("-an"),
self.output_path.as_os_str().to_owned(),
]
}
pub fn run(&self) -> Result<u64, MediaError> {
create_output_parent(&self.output_path)?;
let output = run_ffmpeg_capped(&self.ffmpeg_bin, self.args()).map_err(|source| {
MediaError::FfmpegSpawn {
bin: self.ffmpeg_bin.clone(),
source,
}
})?;
check_exit(&output)?;
output_size(&self.output_path)
}
pub async fn run_with_deadline(
&self,
deadline: std::time::Duration,
) -> Result<u64, MediaError> {
if let Some(parent) = self.output_path.parent() {
tokio::fs::create_dir_all(parent).await.map_err(|source| {
MediaError::FfmpegOutputIo {
path: parent.display().to_string(),
source,
}
})?;
}
let mut child = tokio::process::Command::new(&self.ffmpeg_bin)
.args(self.args())
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true)
.spawn()
.map_err(|source| MediaError::FfmpegSpawn {
bin: self.ffmpeg_bin.clone(),
source,
})?;
let output = match tokio::time::timeout(
deadline,
Box::pin(capture_child_capped(&mut child)),
)
.await
{
Ok(result) => result.map_err(|source| MediaError::FfmpegSpawn {
bin: self.ffmpeg_bin.clone(),
source,
})?,
Err(_elapsed) => return Err(MediaError::FfmpegTimeout),
};
check_exit(&output)?;
let metadata = tokio::fs::metadata(&self.output_path)
.await
.map_err(|source| MediaError::FfmpegOutputIo {
path: self.output_path.display().to_string(),
source,
})?;
Ok(metadata.len())
}
}
pub const ROOM_COMPOSITE_CELL_WIDTH: u32 = 640;
pub const ROOM_COMPOSITE_CELL_HEIGHT: u32 = 360;
const ROOM_COMPOSITE_MAX_INPUTS: usize = 6;
const ROOM_COMPOSITE_MIN_INPUTS: usize = 2;
pub(crate) fn validate_room_composite_input_count(count: usize) -> Result<(), MediaError> {
if count < ROOM_COMPOSITE_MIN_INPUTS {
return Err(MediaError::FfmpegSourceMissing {
path: "<room composite needs at least two participant recordings>".to_owned(),
});
}
if count > ROOM_COMPOSITE_MAX_INPUTS {
return Err(MediaError::FfmpegSourceMissing {
path: "<room composite accepts at most six participant recordings>".to_owned(),
});
}
Ok(())
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct FfmpegRoomCompositeCommand {
ffmpeg_bin: String,
input_paths: Vec<PathBuf>,
output_path: PathBuf,
audio_present: Vec<bool>,
}
impl FfmpegRoomCompositeCommand {
#[must_use]
pub fn new(
ffmpeg_bin: impl Into<String>,
input_paths: Vec<PathBuf>,
output_path: impl Into<PathBuf>,
audio_present: Vec<bool>,
) -> Self {
Self {
ffmpeg_bin: ffmpeg_bin.into(),
input_paths,
output_path: output_path.into(),
audio_present,
}
}
#[must_use]
pub fn args(&self) -> Vec<OsString> {
let n = self.input_paths.len();
let has_audio_out = self.audio_present.iter().any(|&present| present);
let mut args = vec![OsString::from("-y")];
for path in &self.input_paths {
args.push(OsString::from("-i"));
args.push(path.as_os_str().to_owned());
}
args.push(OsString::from("-filter_complex"));
args.push(OsString::from(room_composite_filtergraph(
n,
&self.audio_present,
)));
args.extend([OsString::from("-map"), OsString::from("[vout]")]);
if has_audio_out {
args.extend([OsString::from("-map"), OsString::from("[aout]")]);
}
args.extend([
OsString::from("-c:v"),
OsString::from("libx264"),
OsString::from("-preset"),
OsString::from("veryfast"),
]);
if has_audio_out {
args.extend([OsString::from("-c:a"), OsString::from("aac")]);
}
args.extend([
OsString::from("-movflags"),
OsString::from("+faststart"),
self.output_path.as_os_str().to_owned(),
]);
args
}
pub fn run(&self) -> Result<u64, MediaError> {
require_sources(&self.input_paths)?;
validate_room_composite_input_count(self.input_paths.len())?;
create_output_parent(&self.output_path)?;
let output = run_ffmpeg_capped(&self.ffmpeg_bin, self.args()).map_err(|source| {
MediaError::FfmpegSpawn {
bin: self.ffmpeg_bin.clone(),
source,
}
})?;
check_exit(&output)?;
output_size(&self.output_path)
}
}
const fn room_composite_grid_dims(n: usize) -> (usize, usize) {
if n <= 2 {
(2, 1)
} else if n <= 4 {
(2, 2)
} else {
(3, 2)
}
}
fn room_composite_filtergraph(n: usize, audio_present: &[bool]) -> String {
use std::fmt::Write as _;
let (cols, _rows) = room_composite_grid_dims(n);
let (w, h) = (ROOM_COMPOSITE_CELL_WIDTH, ROOM_COMPOSITE_CELL_HEIGHT);
let mut parts = Vec::new();
let mut video_labels = String::new();
for i in 0..n {
parts.push(format!(
"[{i}:v]scale={w}:{h}:force_original_aspect_ratio=decrease,pad={w}:{h}:(ow-iw)/2:(oh-ih)/2,setsar=1[v{i}]"
));
let _ = write!(video_labels, "[v{i}]");
}
let layout = room_composite_layout(cols, n);
parts.push(format!(
"{video_labels}xstack=inputs={n}:layout={layout}[vout]"
));
let audio_inputs: Vec<usize> = (0..n)
.filter(|&i| audio_present.get(i).copied().unwrap_or(false))
.collect();
if !audio_inputs.is_empty() {
let mut audio_labels = String::new();
for &i in &audio_inputs {
let _ = write!(audio_labels, "[{i}:a]");
}
parts.push(format!(
"{audio_labels}amix=inputs={}:normalize=1[aout]",
audio_inputs.len()
));
}
parts.join(";")
}
fn room_composite_layout(cols: usize, n: usize) -> String {
(0..n)
.map(|i| {
let col = i % cols;
let row = i / cols;
let x = if col == 0 {
"0".to_owned()
} else {
vec!["w0"; col].join("+")
};
let y = if row == 0 {
"0".to_owned()
} else {
vec!["h0"; row].join("+")
};
format!("{x}_{y}")
})
.collect::<Vec<_>>()
.join("|")
}
fn require_sources(input_paths: &[PathBuf]) -> Result<(), MediaError> {
if input_paths.is_empty() {
return Err(MediaError::FfmpegSourceMissing {
path: "<no source recordings>".to_owned(),
});
}
if let Some(missing) = input_paths.iter().find(|path| !path.exists()) {
return Err(MediaError::FfmpegSourceMissing {
path: missing.display().to_string(),
});
}
Ok(())
}
fn create_output_parent(output_path: &Path) -> Result<(), MediaError> {
if let Some(parent) = output_path.parent() {
std::fs::create_dir_all(parent).map_err(|source| MediaError::FfmpegOutputIo {
path: parent.display().to_string(),
source,
})?;
}
Ok(())
}
struct CapturedOutput {
status: std::process::ExitStatus,
stderr: Vec<u8>,
}
struct TailBuffer {
cap: usize,
buf: Vec<u8>,
}
impl TailBuffer {
const fn new(cap: usize) -> Self {
Self {
cap,
buf: Vec::new(),
}
}
fn push(&mut self, data: &[u8]) {
if self.cap == 0 {
return;
}
if data.len() >= self.cap {
self.buf.clear();
self.buf.extend_from_slice(&data[data.len() - self.cap..]);
return;
}
let overflow = (self.buf.len() + data.len()).saturating_sub(self.cap);
if overflow > 0 {
self.buf.drain(..overflow);
}
self.buf.extend_from_slice(data);
}
fn into_vec(self) -> Vec<u8> {
self.buf
}
}
fn drain_tail_sync<R: std::io::Read>(mut reader: R, cap: usize) -> Vec<u8> {
let mut tail = TailBuffer::new(cap);
let mut buf = [0u8; 8192];
loop {
match reader.read(&mut buf) {
Ok(0) | Err(_) => break,
Ok(n) => tail.push(&buf[..n]),
}
}
tail.into_vec()
}
fn drain_discard_sync<R: std::io::Read>(mut reader: R) {
let mut buf = [0u8; 8192];
loop {
match reader.read(&mut buf) {
Ok(0) | Err(_) => break,
Ok(_) => {}
}
}
}
fn run_ffmpeg_capped(bin: &str, args: Vec<OsString>) -> std::io::Result<CapturedOutput> {
let mut child = Command::new(bin)
.args(args)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()?;
let stdout = child.stdout.take();
let stderr = child.stderr.take();
let stderr_handle = std::thread::spawn(move || {
stderr.map_or_else(Vec::new, |reader| {
drain_tail_sync(reader, STDERR_TAIL_MAX_BYTES)
})
});
let stdout_handle = std::thread::spawn(move || {
if let Some(reader) = stdout {
drain_discard_sync(reader);
}
});
let status = child.wait()?;
let stderr = stderr_handle.join().unwrap_or_default();
let _ = stdout_handle.join();
Ok(CapturedOutput { status, stderr })
}
async fn drain_tail_async<R: tokio::io::AsyncRead + Unpin>(mut reader: R, cap: usize) -> Vec<u8> {
use tokio::io::AsyncReadExt as _;
let mut tail = TailBuffer::new(cap);
let mut buf = [0u8; 8192];
loop {
match reader.read(&mut buf).await {
Ok(0) | Err(_) => break,
Ok(n) => tail.push(&buf[..n]),
}
}
tail.into_vec()
}
async fn drain_discard_async<R: tokio::io::AsyncRead + Unpin>(mut reader: R) {
use tokio::io::AsyncReadExt as _;
let mut buf = [0u8; 8192];
loop {
match reader.read(&mut buf).await {
Ok(0) | Err(_) => break,
Ok(_) => {}
}
}
}
async fn capture_child_capped(
child: &mut tokio::process::Child,
) -> std::io::Result<CapturedOutput> {
let stdout = child.stdout.take();
let stderr = child.stderr.take();
let stderr_fut = async {
match stderr {
Some(reader) => drain_tail_async(reader, STDERR_TAIL_MAX_BYTES).await,
None => Vec::new(),
}
};
let stdout_fut = async {
if let Some(reader) = stdout {
drain_discard_async(reader).await;
}
};
let (status, stderr, ()) = tokio::join!(child.wait(), stderr_fut, stdout_fut);
Ok(CapturedOutput {
status: status?,
stderr,
})
}
fn check_exit(output: &CapturedOutput) -> Result<(), MediaError> {
if output.status.success() {
return Ok(());
}
Err(MediaError::FfmpegNonZeroExit {
code: output
.status
.code()
.map_or_else(|| "signal".to_owned(), |c| c.to_string()),
stderr_tail: stderr_tail(&output.stderr),
})
}
fn output_size(output_path: &Path) -> Result<u64, MediaError> {
let metadata = std::fs::metadata(output_path).map_err(|source| MediaError::FfmpegOutputIo {
path: output_path.display().to_string(),
source,
})?;
Ok(metadata.len())
}
#[must_use]
pub fn slugify(input: &str) -> String {
let slug = input
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() {
ch.to_ascii_lowercase()
} else {
'-'
}
})
.collect::<String>()
.split('-')
.filter(|part| !part.is_empty())
.collect::<Vec<_>>()
.join("-");
if slug.is_empty() {
"clip".to_owned()
} else {
slug
}
}
#[must_use]
pub fn newest_recording_file(recording_dir: &Path) -> Option<PathBuf> {
newest_recording_files(recording_dir, 1).into_iter().next()
}
#[must_use]
pub fn newest_recording_files(recording_dir: &Path, max_count: usize) -> Vec<PathBuf> {
newest_recording_files_since(recording_dir, max_count, None)
}
#[must_use]
pub fn newest_recording_files_since(
recording_dir: &Path,
max_count: usize,
min_modified: Option<std::time::SystemTime>,
) -> Vec<PathBuf> {
if max_count == 0 {
return Vec::new();
}
let Ok(entries) = std::fs::read_dir(recording_dir) else {
return Vec::new();
};
let mut candidates: Vec<(std::time::SystemTime, PathBuf)> = entries
.filter_map(Result::ok)
.filter_map(|entry| {
let path = entry.path();
let modified = entry.metadata().ok()?.modified().ok()?;
if let Some(cutoff) = min_modified
&& modified < cutoff
{
return None;
}
Some((modified, path))
})
.collect();
candidates.sort_by_key(|b| std::cmp::Reverse(b.0));
candidates.truncate(max_count);
candidates.sort_by_key(|a| a.0);
candidates.into_iter().map(|(_, path)| path).collect()
}
#[must_use]
pub fn recording_segments_covering_window(
recording_dir: &Path,
broadcast_started_at: std::time::SystemTime,
anchored_at: std::time::SystemTime,
lookback: std::time::Duration,
) -> Vec<PathBuf> {
let Ok(entries) = std::fs::read_dir(recording_dir) else {
return Vec::new();
};
let mut candidates: Vec<(std::time::SystemTime, PathBuf)> = entries
.filter_map(Result::ok)
.filter_map(|entry| {
let path = entry.path();
let modified = entry.metadata().ok()?.modified().ok()?;
if modified < broadcast_started_at {
return None;
}
Some((modified, path))
})
.collect();
candidates.sort_by_key(|a| a.0);
if candidates.is_empty() {
return Vec::new();
}
let anchor_idx = candidates
.iter()
.position(|(mtime, _)| *mtime >= anchored_at)
.unwrap_or(candidates.len() - 1);
let cutoff = anchored_at
.checked_sub(lookback)
.unwrap_or(broadcast_started_at);
let mut indices = vec![anchor_idx];
let mut i = anchor_idx;
while i > 0 {
i -= 1;
if candidates[i].0 > cutoff {
indices.push(i);
} else {
break;
}
}
indices.sort_unstable();
indices
.into_iter()
.map(|idx| candidates[idx].1.clone())
.collect()
}
#[cfg(test)]
mod tests {
use std::fs::File;
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};
use super::{
FfmpegClipTailCommand, FfmpegHighlightCommand, FfmpegLiveThumbnailCommand,
FfmpegPosterCommand, FfmpegPreviewSpriteCommand, FfmpegRoomCompositeCommand,
PREVIEW_CELL_HEIGHT, PREVIEW_CELL_WIDTH, PREVIEW_FRAME_INTERVAL_SECONDS,
PREVIEW_SPRITE_COLUMNS, build_preview_webvtt, newest_recording_files,
newest_recording_files_since, recording_segments_covering_window, slugify,
validate_room_composite_input_count,
};
#[test]
fn highlight_args_have_expected_flags() {
let cmd = FfmpegHighlightCommand::new("ffmpeg", "/rec/in.mp4", "/out/clip.mp4", 12, 30);
let args = lossy(&cmd.args());
assert_eq!(args.first().map(String::as_str), Some("-y"));
let ss = args.iter().position(|a| a == "-ss").unwrap();
assert_eq!(args[ss + 1], "12");
let i = args.iter().position(|a| a == "-i").unwrap();
assert!(ss < i, "-ss must precede -i");
assert_eq!(args[i + 1], "/rec/in.mp4");
let t = args.iter().position(|a| a == "-t").unwrap();
assert_eq!(args[t + 1], "30");
assert_windowed(&args, "-c:v", "libx264");
assert_windowed(&args, "-preset", "veryfast");
assert_windowed(&args, "-c:a", "aac");
assert_windowed(&args, "-movflags", "+faststart");
assert_eq!(args.last().map(String::as_str), Some("/out/clip.mp4"));
}
#[test]
fn clip_tail_single_source_uses_plain_input() {
let cmd = FfmpegClipTailCommand::new(
"ffmpeg",
vec![PathBuf::from("/rec/seg0.mp4")],
"/out/clip.mp4",
20,
25,
);
let args = lossy(&cmd.encode_args(None));
let sseof = args.iter().position(|a| a == "-sseof").unwrap();
assert_eq!(args[sseof + 1], "-25");
assert!(!args.iter().any(|a| a == "concat"));
let i = args.iter().position(|a| a == "-i").unwrap();
assert_eq!(args[i + 1], "/rec/seg0.mp4");
assert_eq!(args.iter().filter(|a| *a == "-i").count(), 1);
let t = args.iter().position(|a| a == "-t").unwrap();
assert_eq!(args[t + 1], "20");
}
#[test]
fn clip_tail_multi_source_uses_concat_list() {
let cmd = FfmpegClipTailCommand::new(
"ffmpeg",
vec![PathBuf::from("/rec/a.mp4"), PathBuf::from("/rec/b.mp4")],
"/out/clip.mp4",
20,
25,
);
let list = PathBuf::from("/out/.clip.concat.txt");
let args = lossy(&cmd.encode_args(Some(&list)));
assert_windowed(&args, "-f", "concat");
assert_windowed(&args, "-safe", "0");
let i = args.iter().position(|a| a == "-i").unwrap();
assert_eq!(args[i + 1], "/out/.clip.concat.txt");
let t = args.iter().position(|a| a == "-t").unwrap();
assert_eq!(args[t + 1], "20");
}
#[test]
fn poster_single_vs_concat() {
let single = FfmpegPosterCommand::new(
"ffmpeg",
vec![PathBuf::from("/rec/seg.mp4")],
"/out/poster.jpg",
4,
);
let sa = lossy(&single.poster_args(None));
let sseof = sa.iter().position(|a| a == "-sseof").unwrap();
assert_eq!(sa[sseof + 1], "-4");
assert!(!sa.iter().any(|a| a == "concat"));
assert_windowed(&sa, "-frames:v", "1");
assert_windowed(&sa, "-q:v", "4");
assert_eq!(sa.last().map(String::as_str), Some("/out/poster.jpg"));
let list = PathBuf::from("/out/.poster.concat.txt");
let multi = FfmpegPosterCommand::new(
"ffmpeg",
vec![PathBuf::from("/rec/a.mp4"), PathBuf::from("/rec/b.mp4")],
"/out/poster.jpg",
4,
);
let ma = lossy(&multi.poster_args(Some(&list)));
assert_windowed(&ma, "-f", "concat");
assert_windowed(&ma, "-safe", "0");
let i = ma.iter().position(|a| a == "-i").unwrap();
assert_eq!(ma[i + 1], "/out/.poster.concat.txt");
}
#[test]
fn sprite_args_have_exact_filter() {
let cmd = FfmpegPreviewSpriteCommand::new(
"ffmpeg",
vec![PathBuf::from("/rec/vod.mp4")],
"/out/sprite.jpg",
10,
3,
);
let args = lossy(&cmd.sprite_args(None));
let vf = args.iter().position(|a| a == "-vf").unwrap();
assert_eq!(
args[vf + 1],
"fps=1/10,scale=160:90:force_original_aspect_ratio=decrease,pad=160:90:(ow-iw)/2:(oh-ih)/2,tile=10x3"
);
assert_windowed(&args, "-frames:v", "1");
assert_windowed(&args, "-q:v", "5");
assert_eq!(args.last().map(String::as_str), Some("/out/sprite.jpg"));
}
#[test]
fn sprite_new_clamps_cols_rows_to_one() {
let cmd = FfmpegPreviewSpriteCommand::new(
"ffmpeg",
vec![PathBuf::from("/rec/vod.mp4")],
"/out/sprite.jpg",
0,
0,
);
let args = lossy(&cmd.sprite_args(None));
let vf = args.iter().position(|a| a == "-vf").unwrap();
assert!(args[vf + 1].ends_with("tile=1x1"));
}
#[test]
fn live_thumbnail_args_bound_reads_and_drop_audio() {
let cmd = FfmpegLiveThumbnailCommand::new(
"ffmpeg",
"http://mediamtx/live/key/index.m3u8",
"/out/thumb.jpg",
);
let args = lossy(&cmd.args());
assert_windowed(&args, "-rw_timeout", "5000000");
assert_windowed(&args, "-frames:v", "1");
assert!(args.iter().any(|a| a == "-an"), "must drop audio");
let i = args.iter().position(|a| a == "-i").unwrap();
assert_eq!(args[i + 1], "http://mediamtx/live/key/index.m3u8");
assert_eq!(args.last().map(String::as_str), Some("/out/thumb.jpg"));
}
#[test]
fn room_composite_two_participants_side_by_side() {
let cmd = FfmpegRoomCompositeCommand::new(
"ffmpeg",
vec![PathBuf::from("/rec/a.mp4"), PathBuf::from("/rec/b.mp4")],
"/out/room.mp4",
vec![true, true],
);
let args = lossy(&cmd.args());
assert_eq!(args.first().map(String::as_str), Some("-y"));
assert_eq!(args.iter().filter(|a| *a == "-i").count(), 2);
let fc = args.iter().position(|a| a == "-filter_complex").unwrap();
assert_eq!(
args[fc + 1],
"[0:v]scale=640:360:force_original_aspect_ratio=decrease,pad=640:360:(ow-iw)/2:(oh-ih)/2,setsar=1[v0];\
[1:v]scale=640:360:force_original_aspect_ratio=decrease,pad=640:360:(ow-iw)/2:(oh-ih)/2,setsar=1[v1];\
[v0][v1]xstack=inputs=2:layout=0_0|w0_0[vout];\
[0:a][1:a]amix=inputs=2:normalize=1[aout]"
);
assert_windowed(&args, "-map", "[vout]");
assert_windowed(&args, "-c:v", "libx264");
assert_windowed(&args, "-c:a", "aac");
assert_windowed(&args, "-movflags", "+faststart");
assert_eq!(args.last().map(String::as_str), Some("/out/room.mp4"));
}
#[test]
fn room_composite_four_participants_two_by_two_grid() {
let cmd = FfmpegRoomCompositeCommand::new(
"ffmpeg",
vec![
PathBuf::from("/rec/a.mp4"),
PathBuf::from("/rec/b.mp4"),
PathBuf::from("/rec/c.mp4"),
PathBuf::from("/rec/d.mp4"),
],
"/out/room.mp4",
vec![true, true, true, true],
);
let args = lossy(&cmd.args());
assert_eq!(args.iter().filter(|a| *a == "-i").count(), 4);
let fc = args.iter().position(|a| a == "-filter_complex").unwrap();
assert_eq!(
args[fc + 1],
"[0:v]scale=640:360:force_original_aspect_ratio=decrease,pad=640:360:(ow-iw)/2:(oh-ih)/2,setsar=1[v0];\
[1:v]scale=640:360:force_original_aspect_ratio=decrease,pad=640:360:(ow-iw)/2:(oh-ih)/2,setsar=1[v1];\
[2:v]scale=640:360:force_original_aspect_ratio=decrease,pad=640:360:(ow-iw)/2:(oh-ih)/2,setsar=1[v2];\
[3:v]scale=640:360:force_original_aspect_ratio=decrease,pad=640:360:(ow-iw)/2:(oh-ih)/2,setsar=1[v3];\
[v0][v1][v2][v3]xstack=inputs=4:layout=0_0|w0_0|0_h0|w0_h0[vout];\
[0:a][1:a][2:a][3:a]amix=inputs=4:normalize=1[aout]"
);
}
#[test]
fn room_composite_mixes_only_inputs_with_audio() {
let cmd = FfmpegRoomCompositeCommand::new(
"ffmpeg",
vec![
PathBuf::from("/rec/a.mp4"),
PathBuf::from("/rec/b.mp4"),
PathBuf::from("/rec/c.mp4"),
],
"/out/room.mp4",
vec![true, false, true],
);
let args = lossy(&cmd.args());
let fc = args.iter().position(|a| a == "-filter_complex").unwrap();
let graph = &args[fc + 1];
assert!(graph.contains("[0:v]scale="));
assert!(graph.contains("[1:v]scale="));
assert!(graph.contains("[2:v]scale="));
assert!(graph.contains("[v0][v1][v2]xstack=inputs=3:"));
assert!(
graph.contains("[0:a][2:a]amix=inputs=2:normalize=1[aout]"),
"graph was: {graph}"
);
assert!(
!graph.contains("[1:a]"),
"video-only input must not be mixed"
);
assert!(
args.windows(2).any(|w| w[0] == "-map" && w[1] == "[aout]"),
"audio must be mapped to output"
);
assert_windowed(&args, "-c:a", "aac");
}
#[test]
fn room_composite_single_audio_uses_amix_inputs_one() {
let cmd = FfmpegRoomCompositeCommand::new(
"ffmpeg",
vec![
PathBuf::from("/rec/a.mp4"),
PathBuf::from("/rec/b.mp4"),
PathBuf::from("/rec/c.mp4"),
],
"/out/room.mp4",
vec![false, true, false],
);
let args = lossy(&cmd.args());
let fc = args.iter().position(|a| a == "-filter_complex").unwrap();
let graph = &args[fc + 1];
assert!(graph.contains("[v0][v1][v2]xstack=inputs=3:"));
assert!(
graph.contains("[1:a]amix=inputs=1:normalize=1[aout]"),
"graph was: {graph}"
);
assert!(!graph.contains("[0:a]"));
assert!(!graph.contains("[2:a]"));
assert!(
args.windows(2).any(|w| w[0] == "-map" && w[1] == "[aout]"),
"audio must be mapped to output"
);
assert_windowed(&args, "-c:a", "aac");
}
#[test]
fn room_composite_all_video_only_emits_silent_grid() {
let cmd = FfmpegRoomCompositeCommand::new(
"ffmpeg",
vec![PathBuf::from("/rec/a.mp4"), PathBuf::from("/rec/b.mp4")],
"/out/room.mp4",
vec![false, false],
);
let args = lossy(&cmd.args());
let fc = args.iter().position(|a| a == "-filter_complex").unwrap();
assert_eq!(
args[fc + 1],
"[0:v]scale=640:360:force_original_aspect_ratio=decrease,pad=640:360:(ow-iw)/2:(oh-ih)/2,setsar=1[v0];\
[1:v]scale=640:360:force_original_aspect_ratio=decrease,pad=640:360:(ow-iw)/2:(oh-ih)/2,setsar=1[v1];\
[v0][v1]xstack=inputs=2:layout=0_0|w0_0[vout]"
);
assert_windowed(&args, "-map", "[vout]");
assert_windowed(&args, "-c:v", "libx264");
assert!(!args.iter().any(|a| a == "[aout]"), "no [aout] map");
assert!(!args.iter().any(|a| a == "-c:a"), "no audio codec");
assert_eq!(args.iter().filter(|a| *a == "-map").count(), 1);
assert_eq!(args.last().map(String::as_str), Some("/out/room.mp4"));
}
#[test]
fn room_composite_grid_dims_match_participant_bands() {
use super::room_composite_grid_dims;
assert_eq!(room_composite_grid_dims(2), (2, 1));
assert_eq!(room_composite_grid_dims(3), (2, 2));
assert_eq!(room_composite_grid_dims(4), (2, 2));
assert_eq!(room_composite_grid_dims(5), (3, 2));
assert_eq!(room_composite_grid_dims(6), (3, 2));
}
#[test]
fn room_composite_run_rejects_single_participant() {
let dir = tempfile::tempdir().unwrap();
let only = dir.path().join("solo.mp4");
File::create(&only).unwrap();
let cmd = FfmpegRoomCompositeCommand::new(
"ffmpeg",
vec![only],
dir.path().join("room.mp4"),
vec![true],
);
assert!(matches!(
cmd.run(),
Err(super::MediaError::FfmpegSourceMissing { .. })
));
}
#[test]
fn room_composite_run_rejects_more_than_six_participants() {
let dir = tempfile::tempdir().unwrap();
let sources: Vec<PathBuf> = (0..7)
.map(|i| {
let path = dir.path().join(format!("p{i}.mp4"));
File::create(&path).unwrap();
path
})
.collect();
let audio = vec![true; sources.len()];
let cmd =
FfmpegRoomCompositeCommand::new("ffmpeg", sources, dir.path().join("room.mp4"), audio);
assert!(matches!(
cmd.run(),
Err(super::MediaError::FfmpegSourceMissing { .. })
));
}
#[test]
fn validate_room_composite_input_count_enforces_two_to_six_band() {
for count in [0, 1] {
assert!(matches!(
validate_room_composite_input_count(count),
Err(super::MediaError::FfmpegSourceMissing { .. })
));
}
for count in 2..=6 {
assert!(validate_room_composite_input_count(count).is_ok());
}
for count in [7, 100] {
assert!(matches!(
validate_room_composite_input_count(count),
Err(super::MediaError::FfmpegSourceMissing { .. })
));
}
}
fn lossy(args: &[std::ffi::OsString]) -> Vec<String> {
args.iter()
.map(|a| a.to_string_lossy().into_owned())
.collect()
}
fn assert_windowed(args: &[String], flag: &str, value: &str) {
let idx = args
.iter()
.position(|a| a == flag)
.unwrap_or_else(|| panic!("flag {flag} not found in {args:?}"));
assert_eq!(
args[idx + 1],
value,
"flag {flag} should be followed by {value}"
);
}
#[test]
fn webvtt_header_and_cue_per_frame() {
let vtt = build_preview_webvtt(
23,
PREVIEW_FRAME_INTERVAL_SECONDS,
PREVIEW_SPRITE_COLUMNS,
PREVIEW_CELL_WIDTH,
PREVIEW_CELL_HEIGHT,
"https://example.test/previews/7.jpg",
);
assert!(
vtt.starts_with("WEBVTT\n\n"),
"must start with WEBVTT header"
);
assert_eq!(vtt.matches(" --> ").count(), 23, "one cue per frame");
assert!(vtt.contains("00:00:00.000 --> 00:00:10.000\n"));
assert!(vtt.contains("https://example.test/previews/7.jpg#xywh=0,0,160,90\n"));
assert!(vtt.contains("00:01:40.000 --> 00:01:50.000\n"));
assert!(vtt.contains("https://example.test/previews/7.jpg#xywh=0,90,160,90\n"));
assert!(vtt.contains("https://example.test/previews/7.jpg#xywh=160,90,160,90\n"));
}
#[test]
fn webvtt_cols_guard_treats_zero_as_one() {
let vtt = build_preview_webvtt(3, 10, 0, 160, 90, "s.jpg");
assert!(vtt.contains("s.jpg#xywh=0,0,160,90\n"));
assert!(vtt.contains("s.jpg#xywh=0,90,160,90\n"));
assert!(vtt.contains("s.jpg#xywh=0,180,160,90\n"));
}
#[test]
fn webvtt_empty_is_header_only() {
assert_eq!(
build_preview_webvtt(0, 10, 10, 160, 90, "sprite.jpg"),
"WEBVTT\n\n"
);
}
#[test]
fn slugify_lowercases_and_dashes() {
assert_eq!(slugify("Hello World"), "hello-world");
assert_eq!(slugify("Foo_Bar.Baz!"), "foo-bar-baz");
assert_eq!(slugify("--Trim--This--"), "trim-this");
assert_eq!(slugify("a b"), "a-b");
}
#[test]
fn slugify_empty_and_all_punct_fall_back_to_clip() {
assert_eq!(slugify(""), "clip");
assert_eq!(slugify("!!!"), "clip");
assert_eq!(slugify("---"), "clip");
}
#[test]
fn slugify_drops_non_ascii() {
assert_eq!(slugify("café"), "caf");
assert_eq!(slugify("naïve rocks"), "na-ve-rocks");
}
#[test]
fn write_concat_list_writes_absolute_entries_and_guard_removes_it() {
let dir = tempfile::tempdir().unwrap();
let a = dir.path().join("a.mp4");
let b = dir.path().join("b.mp4");
File::create(&a).unwrap();
File::create(&b).unwrap();
let near = dir.path().join("clip.mp4");
let list = super::write_concat_list(&[a.clone(), b.clone()], &near).unwrap();
let name = list.file_name().unwrap().to_str().unwrap();
assert!(name.starts_with(".clip."), "name: {name}");
assert!(name.ends_with(".concat.txt"), "name: {name}");
let body = std::fs::read_to_string(&list).unwrap();
let lines: Vec<&str> = body.lines().collect();
assert_eq!(lines.len(), 2);
for (line, src) in lines.iter().zip([&a, &b]) {
assert!(line.starts_with("file '"), "line: {line}");
assert!(line.ends_with('\''));
let inner = &line["file '".len()..line.len() - 1];
assert!(
Path::new(inner).is_absolute(),
"entry not absolute: {inner}"
);
assert!(inner.ends_with(src.file_name().unwrap().to_str().unwrap()));
}
{
let _guard = super::ConcatListGuard::new(list.clone());
assert!(list.exists());
}
assert!(!list.exists(), "guard drop must remove the concat list");
}
#[test]
fn concat_escape_handles_single_quotes_and_leaves_others_literal() {
assert_eq!(
super::concat_escape(b"creator's-stream/seg.mp4"),
b"creator'\\''s-stream/seg.mp4"
);
assert_eq!(super::concat_escape(b"a'b'c"), b"a'\\''b'\\''c");
assert_eq!(super::concat_escape(br"C:\rec\seg.mp4"), br"C:\rec\seg.mp4");
assert_eq!(super::concat_escape(b"/rec/seg.mp4"), b"/rec/seg.mp4");
assert_eq!(
super::concat_escape(&[0xff, b'\'', 0xfe]),
vec![0xff, b'\'', b'\\', b'\'', b'\'', 0xfe]
);
}
#[test]
fn write_concat_list_escapes_apostrophes_in_paths() {
let dir = tempfile::tempdir().unwrap();
let sub = dir.path().join("creator's-stream");
std::fs::create_dir_all(&sub).unwrap();
let a = sub.join("seg.mp4");
File::create(&a).unwrap();
let near = dir.path().join("clip.mp4");
let list = super::write_concat_list(std::slice::from_ref(&a), &near).unwrap();
let _guard = super::ConcatListGuard::new(list.clone());
let body = std::fs::read_to_string(&list).unwrap();
let line = body.lines().next().unwrap();
assert!(line.contains("'\\''"), "line: {line}");
let inner = &line["file '".len()..line.len() - 1];
let unescaped = inner.replace("'\\''", "'");
let expected = super::absolutize_for_concat(&a).unwrap();
assert_eq!(unescaped, expected.display().to_string());
}
#[test]
fn write_concat_list_uses_unique_filenames_per_invocation() {
let dir = tempfile::tempdir().unwrap();
let a = dir.path().join("a.mp4");
let b = dir.path().join("b.mp4");
File::create(&a).unwrap();
File::create(&b).unwrap();
let near_mp4 = dir.path().join("clip.mp4");
let near_jpg = dir.path().join("clip.jpg");
let list1 = super::write_concat_list(&[a.clone(), b.clone()], &near_mp4).unwrap();
let g1 = super::ConcatListGuard::new(list1.clone());
let list2 = super::write_concat_list(&[a, b], &near_jpg).unwrap();
let g2 = super::ConcatListGuard::new(list2.clone());
assert_ne!(
list1, list2,
"same-stem concurrent encodes must not collide"
);
assert!(list1.exists());
assert!(list2.exists());
drop(g1);
assert!(!list1.exists(), "guard removes its own file");
assert!(list2.exists(), "the other list is untouched");
drop(g2);
assert!(!list2.exists());
}
fn touch_at(dir: &Path, name: &str, base: SystemTime, offset_secs: u64) -> PathBuf {
let path = dir.join(name);
let f = File::create(&path).unwrap();
f.set_modified(base + Duration::from_secs(offset_secs))
.unwrap();
path
}
#[test]
fn newest_files_returns_k_newest_in_chronological_order() {
let dir = tempfile::tempdir().unwrap();
let base = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000);
touch_at(dir.path(), "s0.mp4", base, 0);
touch_at(dir.path(), "s1.mp4", base, 10);
touch_at(dir.path(), "s2.mp4", base, 20);
touch_at(dir.path(), "s3.mp4", base, 30);
let got = newest_recording_files(dir.path(), 2);
let names: Vec<String> = got
.iter()
.map(|p| p.file_name().unwrap().to_str().unwrap().to_owned())
.collect();
assert_eq!(names, vec!["s2.mp4", "s3.mp4"]);
}
#[test]
fn newest_files_zero_count_and_missing_dir_are_empty() {
let dir = tempfile::tempdir().unwrap();
assert!(newest_recording_files(dir.path(), 0).is_empty());
assert!(newest_recording_files(Path::new("/no/such/dir/xyz"), 3).is_empty());
}
#[test]
fn newest_files_since_honors_min_modified() {
let dir = tempfile::tempdir().unwrap();
let base = SystemTime::UNIX_EPOCH + Duration::from_secs(2_000_000);
touch_at(dir.path(), "old.mp4", base, 0);
touch_at(dir.path(), "new.mp4", base, 100);
let cutoff = base + Duration::from_secs(50);
let got = newest_recording_files_since(dir.path(), 10, Some(cutoff));
let names: Vec<String> = got
.iter()
.map(|p| p.file_name().unwrap().to_str().unwrap().to_owned())
.collect();
assert_eq!(names, vec!["new.mp4"], "old segment must be filtered out");
}
#[test]
fn covering_window_anchors_on_first_mtime_at_or_after_anchor() {
let dir = tempfile::tempdir().unwrap();
let base = SystemTime::UNIX_EPOCH + Duration::from_mins(50_000);
touch_at(dir.path(), "seg0.mp4", base, 0);
touch_at(dir.path(), "seg1.mp4", base, 10);
touch_at(dir.path(), "seg2.mp4", base, 20);
touch_at(dir.path(), "seg3.mp4", base, 30);
let broadcast_started = base;
let anchored = base + Duration::from_secs(20); let lookback = Duration::from_secs(15);
let got =
recording_segments_covering_window(dir.path(), broadcast_started, anchored, lookback);
let names: Vec<String> = got
.iter()
.map(|p| p.file_name().unwrap().to_str().unwrap().to_owned())
.collect();
assert_eq!(names, vec!["seg1.mp4", "seg2.mp4"]);
}
#[test]
fn covering_window_drops_pre_broadcast_segments() {
let dir = tempfile::tempdir().unwrap();
let base = SystemTime::UNIX_EPOCH + Duration::from_secs(4_000_000);
touch_at(dir.path(), "stale.mp4", base, 0);
touch_at(dir.path(), "live.mp4", base, 100);
let broadcast_started = base + Duration::from_secs(50);
let anchored = base + Duration::from_secs(100);
let got = recording_segments_covering_window(
dir.path(),
broadcast_started,
anchored,
Duration::from_secs(30),
);
let names: Vec<String> = got
.iter()
.map(|p| p.file_name().unwrap().to_str().unwrap().to_owned())
.collect();
assert_eq!(
names,
vec!["live.mp4"],
"pre-broadcast segment must be dropped"
);
}
#[test]
fn covering_window_falls_back_to_newest_when_no_anchor_match() {
let dir = tempfile::tempdir().unwrap();
let base = SystemTime::UNIX_EPOCH + Duration::from_secs(5_000_000);
touch_at(dir.path(), "a.mp4", base, 0);
touch_at(dir.path(), "b.mp4", base, 10);
let broadcast_started = base;
let anchored = base + Duration::from_secs(10_000);
let got = recording_segments_covering_window(
dir.path(),
broadcast_started,
anchored,
Duration::from_secs(5),
);
let names: Vec<String> = got
.iter()
.map(|p| p.file_name().unwrap().to_str().unwrap().to_owned())
.collect();
assert_eq!(names, vec!["b.mp4"]);
}
#[test]
fn covering_window_missing_dir_is_empty() {
let got = recording_segments_covering_window(
Path::new("/no/such/dir/xyz"),
SystemTime::UNIX_EPOCH,
SystemTime::UNIX_EPOCH,
Duration::from_secs(1),
);
assert!(got.is_empty());
}
}