Skip to main content

autumn_media_plugin/
encode.rs

1//! Dependency-free `FFmpeg` encode primitives: command builders, concat-list
2//! plumbing, seek-preview `WebVTT`, and recording-segment discovery.
3//!
4//! Each command struct is a pure description of an `FFmpeg` invocation — its
5//! [`args`](FfmpegHighlightCommand::args)/`*_args` builders produce the exact
6//! argument vector, and its `run` method shells out and returns the number of
7//! bytes written. Argument construction never touches the filesystem, so the
8//! arg vectors are unit-testable without an `FFmpeg` binary on `PATH`; only the
9//! `run`/`run_with_deadline` methods spawn a process.
10//!
11//! Terminology: a *recording segment directory* is wherever the ingest layer
12//! writes its rolling video segments (one file per segment). The clip/highlight
13//! encoders read those segments; the poster/sprite encoders sample frames from
14//! them; the live-thumbnail encoder reads a live playlist URL instead.
15
16use 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/// Tail-of-file clip/highlight encoder that transcodes a fixed window of a
25/// recording to an MP4.
26///
27/// Seeks `start_seconds` into the source, then writes `duration_seconds` of
28/// H.264/AAC with `+faststart` so the result streams progressively.
29#[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    /// Build a highlight-encode command.
40    #[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    /// The exact `FFmpeg` argument vector this command runs.
58    ///
59    /// Paths are threaded through as `OsString` (their raw `OsStr` bytes) rather
60    /// than a lossy `String`, so a non-UTF-8 recording/output path reaches
61    /// `Command` intact.
62    #[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    /// Run `FFmpeg` and return the encoded file size in bytes.
85    ///
86    /// # Errors
87    ///
88    /// Returns an error when the input recording does not exist, the output
89    /// directory cannot be created, `FFmpeg` exits unsuccessfully, or the
90    /// encoded output cannot be inspected.
91    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/// One-frame poster extraction used to give clip/highlight permalinks a visual
112/// unfurl.
113///
114/// Seeks `tail_offset_seconds` back from end-of-file so the poster always lands
115/// inside the captured window regardless of how long the source recording
116/// segment is. When multiple segments are provided they are concatenated first
117/// so the poster does not degrade to a slice of the freshly-opened segment
118/// after a segment rollover. Best-effort by convention: callers log and ignore
119/// failures so a flaky poster never demotes an otherwise-good clip.
120#[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    /// Build a poster-extraction command.
130    #[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    /// The `FFmpeg` argument vector for this command. `concat_list` is `Some`
146    /// when multiple source segments are demuxed through a concat list.
147    #[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    /// Render the poster JPEG and return its size in bytes.
180    ///
181    /// # Errors
182    ///
183    /// Returns an error when there are no source recordings, a source is
184    /// missing, the output directory or concat list cannot be written,
185    /// `FFmpeg` fails, or the output cannot be inspected after a successful run.
186    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
213/// Seconds of runtime represented by each seek-preview frame.
214///
215/// One frame per 10 seconds satisfies a "≥ 1 frame / 10 s" preview density.
216/// This value drives the `FFmpeg` `fps=1/N` sampling filter, the frame-count
217/// computation, and the `WebVTT` cue timings — keep them aligned.
218pub const PREVIEW_FRAME_INTERVAL_SECONDS: u32 = 10;
219
220/// Number of thumbnail columns in the seek-preview sprite mosaic.
221/// Rows are derived as `ceil(frame_count / PREVIEW_SPRITE_COLUMNS)`.
222pub const PREVIEW_SPRITE_COLUMNS: u32 = 10;
223
224/// Width, in pixels, of a single seek-preview cell (16:9, letterboxed).
225pub const PREVIEW_CELL_WIDTH: u32 = 160;
226
227/// Height, in pixels, of a single seek-preview cell (16:9, letterboxed).
228pub const PREVIEW_CELL_HEIGHT: u32 = 90;
229
230/// Format a whole-second offset as a `WebVTT` `HH:MM:SS.mmm` timestamp.
231#[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/// Build a `WebVTT` thumbnail track for a seek-preview sprite sheet.
240///
241/// Emits a `WEBVTT` header followed by one cue per frame. Cue `i` spans
242/// `[i * interval_seconds, (i + 1) * interval_seconds)` and its payload is
243/// `<sprite_url>#xywh=<x>,<y>,<w>,<h>` where `x = (i % cols) * cell_w` and
244/// `y = (i / cols) * cell_h` — the `#xywh` media-fragment the de-facto standard
245/// VOD players (Video.js, Plyr, JW Player) consume with no per-seek request.
246#[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/// Seek-preview sprite-mosaic encoder.
275///
276/// Samples one frame per [`PREVIEW_FRAME_INTERVAL_SECONDS`] of the recording,
277/// letterboxes each into a [`PREVIEW_CELL_WIDTH`]×[`PREVIEW_CELL_HEIGHT`] cell,
278/// and packs them into a single `cols`×`rows` JPEG mosaic via `FFmpeg`'s `tile`
279/// filter. Multi-segment recordings are concatenated first (mirroring
280/// [`FfmpegPosterCommand`]) so a segment rollover mid-recording does not
281/// truncate the mosaic. Best-effort by convention: callers log and ignore
282/// failures so a flaky sprite never breaks VOD playback.
283#[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    /// Build a sprite-mosaic command. `cols`/`rows` are clamped to at least 1.
294    #[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    /// The `FFmpeg` argument vector for this command. `concat_list` is `Some`
312    /// when multiple source segments are demuxed through a concat list.
313    #[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    /// Render the sprite-sheet JPEG mosaic and return its size in bytes.
351    ///
352    /// # Errors
353    ///
354    /// Returns an error when there are no source recordings, a source is
355    /// missing, the output directory or concat list cannot be written,
356    /// `FFmpeg` fails, or the output cannot be inspected after a successful run.
357    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
384/// Write a temporary `FFmpeg` concat-list file next to `near_path` and return it.
385///
386/// Entries are canonicalized to absolute paths because `FFmpeg`'s concat
387/// demuxer resolves list entries relative to the **list file's** directory,
388/// which is generally not the recording-segment directory, so relative entries
389/// would have `FFmpeg` looking in the wrong place.
390fn 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    // Make the concat-list filename unique per invocation so two concurrent
404    // multi-segment encodes that share an output stem (e.g. `clip.mp4` and its
405    // `clip.jpg` poster, both `file_stem() == "clip"`) never collide on one
406    // `.clip.concat.txt` — otherwise one would truncate/read the file while the
407    // other's `ConcatListGuard` deletes it. Process id + a monotonic counter is
408    // dependency-free (no `tempfile` runtime dep) and keeps the file hidden and
409    // in the same parent dir, so relative concat resolution is unchanged.
410    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        // FFmpeg's concat demuxer parses each entry as a single-quoted string:
421        // a literal single quote is written as `'\''` (close-quote,
422        // backslash-escaped quote, reopen-quote). Backslashes are literal
423        // inside the quotes for the demuxer, so Windows paths need no escaping.
424        //
425        // The path is written as its raw `OsStr` bytes (not a lossy `String`),
426        // so a non-UTF-8 recording path reaches FFmpeg's demuxer intact. The
427        // surrounding `file '…'\n` framing and the single-quote escape are
428        // plain ASCII, so byte-splicing them around the raw path is well-formed.
429        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
443/// Process-global monotonic counter making concat-list filenames unique per
444/// invocation (paired with the process id) so concurrent same-stem encodes
445/// never share a list file.
446static CONCAT_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
447
448/// Escape a path (as raw bytes) for a single-quoted `FFmpeg` concat-demuxer
449/// `file` entry.
450///
451/// The demuxer treats everything inside the single quotes literally except the
452/// closing quote, so only `'` (a single ASCII byte, `0x27`) needs escaping — as
453/// `'\''` (close-quote, backslash-escaped quote, reopen-quote). Every other byte
454/// (including non-UTF-8 path bytes and backslashes, so Windows paths pass through
455/// unchanged) is copied verbatim.
456fn 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
468/// The raw bytes of a path, so a non-UTF-8 path reaches the concat list intact.
469///
470/// On Unix the `OsStr` bytes are used directly; on other platforms (where
471/// `OsStr` has no documented byte view) it falls back to the lossy display form.
472fn 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    // Fall back to cwd-relative resolution when canonicalize fails (rare on
492    // Linux for existing files, but keeps the error message attributable to
493    // FFmpeg rather than us).
494    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
501/// RAII guard that removes a concat-list file when it drops.
502struct 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/// Tail-relative clip encoder.
519///
520/// Uses `FFmpeg`'s `-sseof -<tail_offset_seconds>` to seek backward from the
521/// end of the (possibly-concatenated) source, then `-t <duration_seconds>` to
522/// write a fixed-length clip. Callers pad `tail_offset_seconds` to compensate
523/// for queue-to-encode drift so the captured window always ends at the viewer's
524/// click time, not at `FFmpeg` execution time. When more than one segment is
525/// provided the encoder writes a concat-list file and feeds it through `-f
526/// concat -safe 0 -i <list>`, so a clip that spans a segment rollover still
527/// captures the full window instead of getting truncated to the freshly-opened
528/// segment.
529#[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    /// Build a tail-relative clip-encode command.
540    #[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    /// The `FFmpeg` argument vector for this command. `concat_list` is `Some`
580    /// when multiple source segments are demuxed through a concat list.
581    #[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    /// Run `FFmpeg` and return the encoded file size in bytes.
606    ///
607    /// # Errors
608    ///
609    /// Returns an error when no input recording exists, the output directory
610    /// cannot be created, the concat list file cannot be written, `FFmpeg`
611    /// exits unsuccessfully, or the encoded output cannot be inspected.
612    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/// One-frame capture from a live HLS playlist, used to populate the directory
640/// thumbnail for an active channel.
641///
642/// Network reads are bounded so a stalled HLS source can never block the
643/// per-channel capture loop. `-rw_timeout` is microseconds in `FFmpeg`; it is
644/// set to 5s, which is well below a typical capture cadence so a hung source
645/// fails the tick and lets the next one run unimpeded.
646#[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    /// Build a live-thumbnail capture command.
655    #[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    /// The exact `FFmpeg` argument vector this command runs.
669    ///
670    /// The output path is threaded through as its raw `OsStr` (not a lossy
671    /// `String`) so a non-UTF-8 path reaches `Command` intact.
672    #[must_use]
673    pub fn args(&self) -> Vec<OsString> {
674        vec![
675            OsString::from("-y"),
676            // Bound HLS network reads so a stalled source can't wedge the
677            // per-channel capture loop. 5s is well under a typical capture
678            // cadence; a hung source fails this tick and the next one tries.
679            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    /// Run `FFmpeg` and return the captured file size in bytes.
693    ///
694    /// # Errors
695    ///
696    /// Returns an error when the output directory cannot be created, `FFmpeg`
697    /// exits unsuccessfully, or the output cannot be inspected.
698    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    /// Run `FFmpeg` under an async deadline, killing the child process on
713    /// timeout so a hung source can never leak a detached process across
714    /// capture ticks.
715    ///
716    /// # Errors
717    ///
718    /// Returns an error when the output directory cannot be created, the child
719    /// cannot be spawned, the deadline elapses (after killing the child),
720    /// `FFmpeg` exits unsuccessfully, or the output cannot be inspected.
721    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        // Stream the child's pipes with incremental, capped reads (see
747        // `capture_child_capped`): stderr is retained only as a bounded tail and
748        // stdout is drained-and-discarded, so neither a chatty nor a hostile
749        // encoder can grow an unbounded in-memory buffer. The capture future is
750        // boxed so the awaited deadline future stays small. On timeout the early
751        // return drops `child`, whose `kill_on_drop` reaps the process.
752        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
777/// Width, in pixels, of one participant cell in a room-composite grid (16:9).
778pub const ROOM_COMPOSITE_CELL_WIDTH: u32 = 640;
779
780/// Height, in pixels, of one participant cell in a room-composite grid (16:9).
781pub const ROOM_COMPOSITE_CELL_HEIGHT: u32 = 360;
782
783/// Maximum participant recordings a room composite accepts.
784///
785/// Tied to the mesh-room seat cap
786/// ([`crate::config::DEFAULT_ROOM_MAX_PARTICIPANTS`], 6): a mesh call never
787/// exceeds 6 participants, so a composite never tiles more. Kept as a local
788/// const so this dependency-free encode module needs no `config` import.
789const ROOM_COMPOSITE_MAX_INPUTS: usize = 6;
790
791/// Minimum participant recordings a room composite accepts (a grid needs ≥ 2).
792const ROOM_COMPOSITE_MIN_INPUTS: usize = 2;
793
794/// Validate a room-composite input count against the `2..=6` mesh-room band,
795/// returning the same [`MediaError::FfmpegSourceMissing`] `run()` produces.
796///
797/// Shared by [`FfmpegRoomCompositeCommand::run`] and the composite job caller
798/// (`run_room_composite`) so the caller can reject a malformed (`> 6` or `< 2`)
799/// job **before** spawning any `ffprobe` — a bad queued job otherwise fans out
800/// one probe child process per supplied path before `run()` rejects it anyway.
801/// Single source of truth for both the bound and the error message.
802pub(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/// Grid-composite encoder for a finished mesh-room recording.
817///
818/// Tiles each participant's recording into a grid — `2x1` for two participants,
819/// `2x2` for three–four, `3x2` for five–six — via `FFmpeg`'s `xstack` filter
820/// (each cell letterboxed to
821/// [`ROOM_COMPOSITE_CELL_WIDTH`]×[`ROOM_COMPOSITE_CELL_HEIGHT`]) and mixes every
822/// participant's audio into one track with `amix`, producing a single
823/// composited MP4. Best-effort by convention, like the other recording
824/// encoders. The composite needs `2..=6` participant recordings (the mesh-room
825/// cap); one input per participant, so — unlike the clip/poster encoders — it
826/// never concatenates.
827///
828/// # Per-input audio detection
829///
830/// Not every participant recording carries an audio stream (a viewer who
831/// published video-only, a muted camera-only guest, etc.), so the composite is
832/// **audio-aware**: it is told, per input, whether an audio stream is present
833/// via the `audio_present` flags (`len() == input_paths.len()`). The
834/// `-filter_complex` graph mixes (`amix`) **only** the inputs that actually have
835/// audio, and when **no** input has audio it emits a silent video grid (no
836/// `amix`, no `[aout]`, and `args()` omits `-map [aout]`/`-c:a aac`) — so a
837/// video-only participant no longer makes `FFmpeg` fail on a missing `[i:a]`
838/// stream. Probing is done by the caller (see `run_room_composite`); `args()`
839/// stays a **pure, synchronous, no-I/O** function of these fields, reading
840/// `audio_present` rather than performing any probe itself.
841#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
842pub struct FfmpegRoomCompositeCommand {
843    ffmpeg_bin: String,
844    input_paths: Vec<PathBuf>,
845    output_path: PathBuf,
846    /// One flag per `input_path` (same length/order): `true` when that
847    /// recording carries an audio stream that should be mixed into the output.
848    audio_present: Vec<bool>,
849}
850
851impl FfmpegRoomCompositeCommand {
852    /// Build a room-composite command. One `input_path` per participant, and one
853    /// `audio_present` flag per input (same order) recording whether that input
854    /// carries an audio stream to mix.
855    #[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    /// The exact `FFmpeg` argument vector this command runs.
871    ///
872    /// Each participant input path and the output path are threaded through as
873    /// their raw `OsStr` (not a lossy `String`) so non-UTF-8 paths reach
874    /// `Command` intact.
875    #[must_use]
876    pub fn args(&self) -> Vec<OsString> {
877        let n = self.input_paths.len();
878        // Whether the composited output carries an audio track at all: true iff
879        // at least one input has an audio stream. When false, the filtergraph
880        // produces no `[aout]`, so we must not `-map` it or set an audio codec.
881        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    /// Render the composited MP4 and return its size in bytes.
914    ///
915    /// # Errors
916    ///
917    /// Returns an error when there are fewer than two or more than six source
918    /// recordings (the `2..=6` mesh-room band), a source is missing, the output
919    /// directory cannot be created, `FFmpeg` fails, or the output cannot be
920    /// inspected after a successful run.
921    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
938/// Grid dimensions `(cols, rows)` for an `n`-participant composite: `2x1` for
939/// two, `2x2` for three–four, `3x2` for five–six.
940const 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
950/// Build the `-filter_complex` graph: one letterboxed `scale`/`pad` per input
951/// and an `xstack` grid of them (always every `[i:v]`), plus an `amix` of only
952/// the inputs whose `audio_present` flag is set.
953///
954/// The video half is unconditional. The audio half is driven by `audio_present`
955/// (one flag per input, same order):
956/// - **≥ 2 inputs with audio** → `[i:a]…amix=inputs=K:normalize=1[aout]` over
957///   just those `K` inputs.
958/// - **exactly 1 input with audio** → `[k:a]amix=inputs=1:normalize=1[aout]`.
959///   `amix=inputs=1` is valid `FFmpeg` and keeps the output labeled `[aout]`, so
960///   `args()` maps audio identically regardless of how many inputs had audio.
961/// - **0 inputs with audio** → no `amix` and no `[aout]` at all (silent grid);
962///   `args()` correspondingly omits `-map [aout]`/`-c:a aac`.
963fn 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
997/// The `xstack` `layout` string. Every cell is the same size, so each input at
998/// `(row, col)` is placed at `X_Y` where the column offset is a sum of the
999/// first cell's width (`w0`) and the row offset a sum of its height (`h0`).
1000fn 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
1021// ── Shared run() helpers ────────────────────────────────────────────────────
1022
1023/// Reject an empty or missing set of source recordings before spawning.
1024fn 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
1038/// Create the parent directory of `output_path` if it has one.
1039fn 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
1049/// The bounded outcome of a spawned `FFmpeg` process.
1050///
1051/// Unlike [`std::process::Output`], `stderr` here is not the child's full stderr
1052/// but only the last [`STDERR_TAIL_MAX_BYTES`] of it (what [`stderr_tail`] would
1053/// ultimately surface), and stdout is discarded entirely — so a chatty or
1054/// hostile encoder can never grow an unbounded in-memory buffer.
1055struct CapturedOutput {
1056    status: std::process::ExitStatus,
1057    stderr: Vec<u8>,
1058}
1059
1060/// A fixed-capacity tail buffer: retains only the last `cap` bytes pushed, so
1061/// draining an unbounded stream keeps memory bounded at `cap`.
1062struct 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    /// Append `data`, retaining only the last `cap` bytes overall.
1076    fn push(&mut self, data: &[u8]) {
1077        if self.cap == 0 {
1078            return;
1079        }
1080        if data.len() >= self.cap {
1081            // The new chunk alone overflows the cap: keep only its tail.
1082            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
1098/// Drain a blocking reader into a bounded tail buffer (the last `cap` bytes).
1099fn 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
1111/// Drain a blocking reader to EOF, discarding its bytes (keeps a full stdout
1112/// pipe from wedging the child while using no memory).
1113fn 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
1123/// Spawn `bin` with `args`, streaming its pipes with capped reads.
1124///
1125/// stdout is drained-and-discarded and stderr is retained only as a bounded
1126/// tail (both drained concurrently with `wait()` so a full pipe can never wedge
1127/// the child), so the transient in-memory buffer is bounded regardless of how
1128/// much the child writes. Stdin is `/dev/null`, matching `Command::output()`.
1129fn 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
1156/// Async twin of [`drain_tail_sync`].
1157async 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
1170/// Async twin of [`drain_discard_sync`].
1171async 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
1182/// Await a spawned child while draining its pipes with capped reads.
1183///
1184/// stderr is retained only as a bounded tail (matching [`stderr_tail`]) and
1185/// stdout is drained-and-discarded; both are drained concurrently with `wait()`
1186/// so a full pipe can never wedge the child. Kept a free function (rather than
1187/// an inline block in the async command method) so the `tokio::join!` expansion
1188/// — and the pin `unsafe` it emits — does not live inside a `Deserialize`-
1189/// deriving command's impl. The caller owns the child, so `kill_on_drop` still
1190/// reaps it when a surrounding deadline drops this future.
1191async 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
1214/// Map a non-zero `FFmpeg` exit into [`MediaError::FfmpegNonZeroExit`].
1215fn 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
1228/// Inspect the encoded output and return its size in bytes.
1229fn 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/// Turn arbitrary text into a lowercase, dash-separated slug.
1238///
1239/// ASCII alphanumerics are lowercased; every other character becomes a dash,
1240/// runs of dashes collapse, and leading/trailing dashes are trimmed. An empty
1241/// or all-punctuation input yields the literal fallback `"clip"`.
1242#[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/// Return the single newest recording file in `recording_dir`, if any.
1267#[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/// Return up to `max_count` newest recording files in chronological order
1273/// (oldest first), suitable for feeding to `FFmpeg`'s concat demuxer.
1274#[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/// Variant of [`newest_recording_files`] that ignores entries modified before
1280/// `min_modified`.
1281///
1282/// Used as a backstop when callers genuinely want "the newest N segments". The
1283/// clip pipeline prefers [`recording_segments_covering_window`] so a long
1284/// encode backlog cannot pick later segments than the ones that actually
1285/// contain the captured moment.
1286#[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    // Sort newest-first, then take the requested window, then re-sort
1312    // chronologically so the concat list plays in order.
1313    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/// Pick the recording segments that contain the window
1320/// `[anchored_at - lookback, anchored_at]`, chronologically.
1321///
1322/// The encode step uses this to anchor the source list on the captured moment
1323/// instead of the latest writes — if the encode backlog grew long enough that
1324/// more than two segments rolled since the click, "newest two segments" would
1325/// no longer include the captured moment.
1326///
1327/// Algorithm: find the first segment whose mtime is at or after `anchored_at`
1328/// (that's the segment that was being written when the viewer clicked — its
1329/// mtime advances past the click) and walk backward until the included
1330/// segments' mtimes drop below `anchored_at - lookback`. Segments older than
1331/// `broadcast_started_at` are dropped so a stream key reused for a later
1332/// broadcast does not drag the prior broadcast's tail in. When no segment
1333/// satisfies the anchor (e.g. a click that just barely outran the first segment
1334/// write), falls back to the newest remaining segment so a retrying caller
1335/// still has something to work with.
1336#[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    // ── Arg-vector contracts (no FFmpeg binary required) ────────────────────
1403
1404    #[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        // -ss <start> comes before -i (an input-seek).
1410        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        // -sseof -<tail>
1435        let sseof = args.iter().position(|a| a == "-sseof").unwrap();
1436        assert_eq!(args[sseof + 1], "-25");
1437        // single -i, no concat demuxer
1438        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        // One -i per participant.
1556        assert_eq!(args.iter().filter(|a| *a == "-i").count(), 2);
1557        // Exact filter graph: two letterboxed cells, a 2x1 xstack, an amix.
1558        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        // Three inputs, middle one is video-only: amix must reference [0:a] and
1603        // [2:a] only, with inputs=2 (not 3), while the video half still xstacks
1604        // all three [i:v].
1605        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        // Video half references every input.
1619        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        // Audio half mixes only inputs 0 and 2.
1624        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        // Output still carries audio.
1633        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        // Only the second input has audio: amix=inputs=1 over [1:a] keeps the
1643        // labeled [aout] so args() maps audio uniformly.
1644        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        // Video half still references all three inputs.
1658        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        // Every input is video-only: no amix, no [aout], and args() must omit
1675        // -map [aout] and -c:a aac entirely — a valid silent video grid.
1676        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        // Video is still mapped and encoded.
1691        assert_windowed(&args, "-map", "[vout]");
1692        assert_windowed(&args, "-c:v", "libx264");
1693        // No audio anywhere.
1694        assert!(!args.iter().any(|a| a == "[aout]"), "no [aout] map");
1695        assert!(!args.iter().any(|a| a == "-c:a"), "no audio codec");
1696        // Exactly one -map (the video), not two.
1697        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        // A one-participant composite is meaningless; run() must reject it
1723        // before spawning FFmpeg.
1724        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        // Seven present sources: `require_sources` passes, then the `2..=6`
1734        // upper bound rejects before spawning FFmpeg (mesh rooms cap at six).
1735        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        // Below the band → "at least two" error.
1754        for count in [0, 1] {
1755            assert!(matches!(
1756                validate_room_composite_input_count(count),
1757                Err(super::MediaError::FfmpegSourceMissing { .. })
1758            ));
1759        }
1760        // In-band → Ok.
1761        for count in 2..=6 {
1762            assert!(validate_room_composite_input_count(count).is_ok());
1763        }
1764        // Above the band → "at most six" error.
1765        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    /// Render an `OsString` arg vector as lossy `String`s for `&str` assertions.
1774    /// The builders' paths are ASCII in these tests, so the lossy view is exact.
1775    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    /// Assert `flag` appears immediately followed by `value`.
1782    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    // ── build_preview_webvtt ────────────────────────────────────────────────
1795
1796    #[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        // Frame 0: [0,10) at origin.
1812        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        // Frame 10 wraps to the second row (cols=10): x=0, y=90; time 100..110s.
1815        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        // Frame 11: x=160 (second column), y=90.
1818        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        // cols=0 → clamped to 1, so every frame is its own row (x stays 0).
1824        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    // ── slugify ─────────────────────────────────────────────────────────────
1839
1840    #[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        // Unicode letters aren't ASCII-alphanumeric → become dashes → dropped.
1858        assert_eq!(slugify("café"), "caf");
1859        assert_eq!(slugify("naïve rocks"), "na-ve-rocks");
1860    }
1861
1862    // ── Concat list + guard ─────────────────────────────────────────────────
1863
1864    #[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        // Filename is unique per invocation: `.{stem}.{pid}-{seq}.concat.txt`.
1876        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        // Each entry is `file '<absolute>'`, and the absolutized path exists.
1883        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        // A single quote becomes the demuxer's close/escape/reopen sequence.
1904        assert_eq!(
1905            super::concat_escape(b"creator's-stream/seg.mp4"),
1906            b"creator'\\''s-stream/seg.mp4"
1907        );
1908        // Multiple quotes each get the treatment.
1909        assert_eq!(super::concat_escape(b"a'b'c"), b"a'\\''b'\\''c");
1910        // Backslashes are literal for the demuxer (Windows paths untouched).
1911        assert_eq!(super::concat_escape(br"C:\rec\seg.mp4"), br"C:\rec\seg.mp4");
1912        // A normal path is unchanged.
1913        assert_eq!(super::concat_escape(b"/rec/seg.mp4"), b"/rec/seg.mp4");
1914        // Non-UTF-8 bytes (0xff) pass through verbatim, single quote still escaped.
1915        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        // The quoted path contains the exact `'\''` escape for the apostrophe.
1936        assert!(line.contains("'\\''"), "line: {line}");
1937        // And it round-trips: applying the demuxer's unescape (replace `'\''`
1938        // back with `'`) on the quoted inner yields the original absolute path.
1939        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        // Same output stem for both calls (e.g. `clip.mp4` and `clip.jpg`).
1953        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    // ── Segment discovery ───────────────────────────────────────────────────
1976
1977    /// Create `name` in `dir` with mtime `base + offset_secs`.
1978    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        // Two newest (s2, s3) returned oldest-first for concat.
2001        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        // seg0@0, seg1@10, seg2@20, seg3@30
2032        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); // seg2 is first >= anchor
2039        let lookback = Duration::from_secs(15); // cutoff = anchor - 15 = @5
2040
2041        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        // Anchor seg2, walk back while mtime > @5: seg1@10 included, seg0@0 excluded.
2048        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        // A stale prior-broadcast segment older than broadcast_started.
2056        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        // Anchor far in the future — no segment mtime reaches it.
2087        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        // Falls back to the newest remaining segment only (cutoff excludes a@0).
2099        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}