Skip to main content

media/
encode.rs

1//! `VideoEncoder` trait + `OutputFormat` enum + per-OS GStreamer
2//! pipeline builders (M-EXPORT.1 of M-RECORD-EXPORT; live variant
3//! M-QUAL.1).
4//!
5//! [`LiveGstreamerEncoder`] (M-QUAL.1) is the [`VideoEncoder`] impl —
6//! **streaming**: it spawns the encode pipeline up front and streams
7//! BGRA frames into its stdin, so only *compressed* video lands on
8//! disk during capture. Bounding the on-disk footprint to the encoded
9//! bitrate (raw BGRA is `w × h × 4 × fps` — >1 GB/s at Retina) is the
10//! prerequisite for native-resolution capture.
11//!
12//! ```admonish important title="CLI-pipe, not gstreamer-rs"
13//! The encoder doesn't link `gstreamer-rs` — it streams over the
14//! child's stdin (`fdsrc fd=0`) rather than via `appsrc`, keeping the
15//! project's "CLI-pipe over Rust bindings" convention (no compile-time
16//! libgstreamer dep, no Windows-build breakage). A programmatic
17//! `appsrc` pipeline remains a possible future swap behind the trait.
18//! ```
19//!
20//! ## Per-OS encoder coverage
21//!
22//! | OS       | H.264              | H.265              | VP9 (WebM)         | AV1                                  |
23//! |----------|--------------------|--------------------|--------------------|--------------------------------------|
24//! | macOS    | `vtenc_h264_hw`    | `vtenc_h265_hw`    | `vp9enc` (sw)      | `vtenc_av1_hw` (M3+) → `svtav1enc`  |
25//! | Windows  | `mfh264enc`        | `mfhevcenc`        | `mfvp9enc`         | `qsvav1enc`                          |
26//! | Linux    | `vaapih264enc`     | `vaapih265enc`     | `vaapivp9enc`      | `vaapiav1enc`                        |
27//!
28//! macOS is the hot path for M-RECORD-EXPORT; Win/Linux encoder
29//! strings are present so the cross-OS build + the arg-builder unit
30//! tests pass, but the runtime spawn returns
31//! `EncodeError::Unsupported` outside macOS until those ports land.
32
33use std::fs::File;
34use std::io::{BufWriter, Read, Write};
35use std::path::{Path, PathBuf};
36use std::process::{Child, ChildStdin, Command, Stdio};
37use std::thread::JoinHandle;
38
39use serde::{Deserialize, Serialize};
40
41/// Output container + codec selection. Carries the (format → codec
42/// → muxer → file extension) tuple as one type so the rest of the
43/// pipeline can switch on a single value.
44///
45/// Default is [`OutputFormat::Mp4H264Aac`] — the universally-
46/// compatible "just give me an .mp4" choice. Other variants are
47/// opt-in via the M-EXPORT.4 format dropdown.
48#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
49pub enum OutputFormat {
50    /// `.mp4` container, H.264 video, AAC audio. The default for
51    /// universal compatibility (QuickTime, every browser, every
52    /// editor). macOS uses `vtenc_h264_hw` (hardware), Windows
53    /// `mfh264enc`, Linux `vaapih264enc` / `x264enc` fallback.
54    #[default]
55    Mp4H264Aac,
56    /// `.mp4` container, H.265 (HEVC) video, AAC audio. ~30%
57    /// smaller files at equivalent quality vs. H.264; requires
58    /// macOS 11+, Windows 10+, Linux with VAAPI HEVC support.
59    Mp4H265Aac,
60    /// `.webm` container, VP9 video, Opus audio. Open codec
61    /// (royalty-free), good browser support. macOS has no HW VP9
62    /// encoder so uses libvpx-vp9 (slow).
63    WebmVp9Opus,
64    /// `.webm` container, AV1 video, Opus audio. Best compression
65    /// of the four; HW encoders are M3+ Macs, Intel Arc, NVIDIA
66    /// RTX 40+. Software fallback via `svtav1enc` (slow).
67    WebmAv1Opus,
68}
69
70impl OutputFormat {
71    /// File extension this format writes (without leading `.`).
72    #[must_use]
73    pub fn extension(self) -> &'static str {
74        match self {
75            Self::Mp4H264Aac | Self::Mp4H265Aac => "mp4",
76            Self::WebmVp9Opus | Self::WebmAv1Opus => "webm",
77        }
78    }
79
80    /// URL-safe slug for the M-EXPORT.4 format dropdown +
81    /// `RecordingConfig.format` IPC field.
82    #[must_use]
83    pub fn slug(self) -> &'static str {
84        match self {
85            Self::Mp4H264Aac => "mp4-h264",
86            Self::Mp4H265Aac => "mp4-h265",
87            Self::WebmVp9Opus => "webm-vp9",
88            Self::WebmAv1Opus => "webm-av1",
89        }
90    }
91
92    /// Parse the slug back to an `OutputFormat`. Inverse of
93    /// [`Self::slug`]. Unknown slugs return `None` so the caller can
94    /// surface a typed error.
95    #[must_use]
96    pub fn from_slug(slug: &str) -> Option<Self> {
97        match slug {
98            "mp4-h264" => Some(Self::Mp4H264Aac),
99            "mp4-h265" => Some(Self::Mp4H265Aac),
100            "webm-vp9" => Some(Self::WebmVp9Opus),
101            "webm-av1" => Some(Self::WebmAv1Opus),
102            _ => None,
103        }
104    }
105
106    /// Maximum encodable edge length (px) for this format's hardware
107    /// video encoder, or `None` when the encoder imposes no hard
108    /// dimension cap.
109    ///
110    /// Empirically probed on Apple Silicon (M1, GStreamer 1.26.8):
111    /// VideoToolbox's `vtenc_h264_hw` rejects caps negotiation
112    /// (`not-negotiated (-4)`, then the pipeline refuses to preroll)
113    /// the instant *either* edge exceeds 4096 — the H.264 Level 5.2
114    /// frame-side ceiling. It is a *per-axis* cap, not a pixel-area
115    /// cap (`4096×4096` succeeds while the smaller `5120×1440` fails).
116    /// `vtenc_h265_hw` accepts at least `8192×4320` (HEVC Level 6).
117    /// The software WebM encoders (`vp9enc` / `svtav1enc`) have no
118    /// hard cap — they only get slower. See AUT-334.
119    #[must_use]
120    pub const fn max_encode_edge(self) -> Option<u32> {
121        match self {
122            Self::Mp4H264Aac => Some(4096),
123            Self::Mp4H265Aac => Some(8192),
124            Self::WebmVp9Opus | Self::WebmAv1Opus => None,
125        }
126    }
127}
128
129/// Encoder configuration. Width + height + framerate are the video
130/// caps; `output_path` is the final container path the encoder
131/// writes to on `finalize`.
132#[derive(Clone, Debug, PartialEq, Eq)]
133pub struct EncoderConfig {
134    /// Output container path. Must end with the extension matching
135    /// `format` (`.mp4` for MP4 variants, `.webm` for WebM).
136    pub output_path: PathBuf,
137    /// Video pixel width.
138    pub width: u32,
139    /// Video pixel height.
140    pub height: u32,
141    /// Video framerate (`30` is the M-EXPORT.1 default).
142    pub framerate: u32,
143    /// Audio sample rate (`48000` is the SCK / GStreamer default).
144    pub sample_rate: u32,
145    /// Audio channel count (`2` = stereo).
146    pub channels: u8,
147    /// Format selection.
148    pub format: OutputFormat,
149}
150
151impl EncoderConfig {
152    /// Construct a sensible default for the given output path +
153    /// format. 1920×1080 @ 30 fps video, 48 kHz stereo audio.
154    #[must_use]
155    pub fn for_output(output_path: PathBuf, format: OutputFormat) -> Self {
156        Self {
157            output_path,
158            width: 1920,
159            height: 1080,
160            framerate: 30,
161            sample_rate: 48_000,
162            channels: 2,
163            format,
164        }
165    }
166}
167
168/// Clamp `(width, height)` so neither edge exceeds `format`'s hardware
169/// encoder limit ([`OutputFormat::max_encode_edge`]), **preserving the
170/// aspect ratio** and keeping both edges even (H.264 / HEVC require
171/// mod-2 dimensions).
172///
173/// Returns the input — even-rounded — unchanged when it already fits,
174/// or when the format has no hard cap (the software WebM encoders).
175/// When a clamp is needed, both edges are scaled by the *same* factor
176/// `max_edge / max(width, height)`, so the frame is shrunk uniformly
177/// and never stretched or squished: the screen content keeps its shape
178/// and the camera bubble stays a perfect circle. Integer arithmetic
179/// throughout (`u64` intermediate) — deterministic, no float casts.
180///
181/// This is the fix for AUT-334: a 5K display fed `vtenc_h264_hw` a
182/// 5120-wide frame, which fails caps negotiation and discards the
183/// whole recording. The live scratch is always H.264, so the recorder
184/// clamps capture dims to the H.264 limit before any pipeline starts.
185///
186/// # Examples
187///
188/// ```
189/// use media::encode::{fit_within_encoder_limits, OutputFormat};
190/// // 5K and 6K displays (both 16:9) clamp to exactly 4096×2304 for H.264:
191/// assert_eq!(fit_within_encoder_limits(5120, 2880, OutputFormat::Mp4H264Aac), (4096, 2304));
192/// assert_eq!(fit_within_encoder_limits(6016, 3384, OutputFormat::Mp4H264Aac), (4096, 2304));
193/// // Already within the limit → unchanged:
194/// assert_eq!(fit_within_encoder_limits(3840, 2160, OutputFormat::Mp4H264Aac), (3840, 2160));
195/// // H.265's higher ceiling keeps full 5K:
196/// assert_eq!(fit_within_encoder_limits(5120, 2880, OutputFormat::Mp4H265Aac), (5120, 2880));
197/// ```
198#[must_use]
199pub fn fit_within_encoder_limits(width: u32, height: u32, format: OutputFormat) -> (u32, u32) {
200    let to_even = |v: u32| (v & !1u32).max(2);
201    let Some(max_edge) = format.max_encode_edge() else {
202        return (to_even(width), to_even(height));
203    };
204    let longest = width.max(height);
205    if longest <= max_edge {
206        return (to_even(width), to_even(height));
207    }
208    // Uniform downscale: the longest edge becomes exactly `max_edge`,
209    // the shorter edge shrinks by the same ratio. `u64` intermediate
210    // avoids overflow for 8K-class inputs; `try_from` keeps it cast-free.
211    let scale = |v: u32| -> u32 {
212        let scaled = u64::from(v) * u64::from(max_edge) / u64::from(longest);
213        to_even(u32::try_from(scaled).unwrap_or(max_edge))
214    };
215    (scale(width), scale(height))
216}
217
218/// Longer-edge bound of the recording resolution cap (the "1080p" cap).
219pub const RECORDING_MAX_LONG_EDGE: u32 = 1920;
220/// Shorter-edge bound of the recording resolution cap.
221pub const RECORDING_MAX_SHORT_EDGE: u32 = 1080;
222
223/// Cap capture/encode dims to a 1080p box (`1920×1080`), aspect-preserving
224/// and orientation-agnostic: the longer edge is bounded to
225/// [`RECORDING_MAX_LONG_EDGE`] and the shorter to [`RECORDING_MAX_SHORT_EDGE`],
226/// both edges scaled by the *same* factor (never stretched — the camera
227/// bubble stays circular) and rounded down to even (H.264 chroma
228/// subsampling). Dims already inside the box are returned unchanged (never
229/// upscaled), only evened. Integer arithmetic throughout.
230///
231/// Recording at native Retina resolution is too heavy for the live
232/// compose → GPU read-back → encode loop to sustain the configured frame
233/// rate; the encoder timestamps frames by *count* at that rate, so an
234/// under-delivering loop makes the recording play fast. Capping to 1080p
235/// keeps the pipeline comfortably real-time regardless of monitor, which
236/// is the deliberate tradeoff (reliable 1080p over a fast, half-lost 5K).
237///
238/// # Examples
239///
240/// ```
241/// use media::encode::cap_recording_dims;
242/// assert_eq!(cap_recording_dims(5120, 2880), (1920, 1080)); // 5K 16:9 → 1080p
243/// assert_eq!(cap_recording_dims(3840, 2160), (1920, 1080)); // 4K 16:9 → 1080p
244/// assert_eq!(cap_recording_dims(2880, 1800), (1728, 1080)); // 16:10 → fits the box
245/// assert_eq!(cap_recording_dims(1920, 1080), (1920, 1080)); // already 1080p
246/// assert_eq!(cap_recording_dims(1280, 720), (1280, 720));   // smaller → unchanged
247/// assert_eq!(cap_recording_dims(1080, 1920), (1080, 1920)); // portrait → long edge 1920
248/// ```
249#[must_use]
250pub fn cap_recording_dims(width: u32, height: u32) -> (u32, u32) {
251    let to_even = |v: u32| (v & !1u32).max(2);
252    let long = width.max(height);
253    let short = width.min(height);
254    if long <= RECORDING_MAX_LONG_EDGE && short <= RECORDING_MAX_SHORT_EDGE {
255        return (to_even(width), to_even(height));
256    }
257    // Uniform downscale fitting BOTH bounds — pick the tighter ratio so
258    // neither edge exceeds its cap. `u64` intermediate avoids overflow.
259    let scale = |v: u32| -> u32 {
260        let by_long = u64::from(v) * u64::from(RECORDING_MAX_LONG_EDGE) / u64::from(long);
261        let by_short = u64::from(v) * u64::from(RECORDING_MAX_SHORT_EDGE) / u64::from(short);
262        to_even(u32::try_from(by_long.min(by_short)).unwrap_or(RECORDING_MAX_SHORT_EDGE))
263    };
264    (scale(width), scale(height))
265}
266
267/// Failure modes for the encoder.
268#[derive(Debug, thiserror::Error)]
269pub enum EncodeError {
270    /// `gst-launch-1.0` could not be spawned (missing from PATH).
271    #[error("failed to spawn `gst-launch-1.0`: {source} (PATH={path})")]
272    Spawn {
273        /// OS-level reason.
274        #[source]
275        source: std::io::Error,
276        /// `$PATH` snapshot at failure.
277        path: String,
278    },
279    /// I/O failure on the scratch / output file.
280    #[error("encoder I/O: {0}")]
281    Io(#[from] std::io::Error),
282    /// `gst-launch-1.0` exited non-zero. `stderr` carries GStreamer's
283    /// own diagnostic for the user / log.
284    #[error("encode pipeline failed (exit {exit:?}): {stderr}")]
285    PipelineFailed {
286        /// Exit status of the gst-launch child.
287        exit: Option<i32>,
288        /// Captured stderr.
289        stderr: String,
290    },
291    /// Format / OS combo not yet implemented (e.g. Linux real
292    /// encoders pending). The trait still constructs cleanly so
293    /// callers can verify configuration; runtime invocation surfaces
294    /// this error.
295    #[error("encoder not yet wired for ({format:?}, {os}): {reason}")]
296    Unsupported {
297        /// Format that was requested.
298        format: OutputFormat,
299        /// OS name (`target_os` value).
300        os: &'static str,
301        /// Human-readable reason / pointer to follow-up ticket.
302        reason: &'static str,
303    },
304    /// Validated config rejected (e.g. width=0, framerate=0).
305    #[error("invalid encoder config: {0}")]
306    InvalidConfig(String),
307}
308
309/// Encoder trait — the seam M-EXPORT.3 hooks the per-channel
310/// capture callbacks into. Pure-sync interface; the
311/// [`LiveGstreamerEncoder`] impl streams each pushed frame into a
312/// `gst-launch-1.0` child and remuxes in the audio at `finalize`.
313pub trait VideoEncoder: Send + Sync {
314    /// Push one BGRA frame at the given monotonic PTS (measured from
315    /// session start). `bgra.len()` must equal `width * height * 4`.
316    ///
317    /// # Errors
318    ///
319    /// Returns [`EncodeError::Io`] on scratch-file write failure.
320    fn push_video_frame(
321        &mut self,
322        bgra: &[u8],
323        pts: std::time::Duration,
324    ) -> Result<(), EncodeError>;
325
326    /// Push interleaved F32LE audio samples at the given PTS.
327    /// Sample layout is `[ch0, ch1, ch0, ch1, ...]` for stereo.
328    ///
329    /// # Errors
330    ///
331    /// Returns [`EncodeError::Io`] on scratch-file write failure.
332    fn push_audio_chunk(
333        &mut self,
334        samples: &[f32],
335        pts: std::time::Duration,
336    ) -> Result<(), EncodeError>;
337
338    /// Finalize: spawn the gst-launch pipeline that consumes the
339    /// scratch files and writes the final container at the configured
340    /// output path. Consumes `self` because the encoder is single-use.
341    ///
342    /// # Errors
343    ///
344    /// - [`EncodeError::Spawn`] — gst-launch missing from PATH.
345    /// - [`EncodeError::PipelineFailed`] — gst-launch exited non-zero.
346    /// - [`EncodeError::Unsupported`] — format/OS combo not wired.
347    fn finalize(self: Box<Self>) -> Result<PathBuf, EncodeError>;
348}
349
350// ---- M-QUAL.1 — live (streaming) video encoder ----------------------
351
352/// Live (streaming) video encoder. Spawns the encode pipeline up front
353/// and streams BGRA frames into its stdin, so the only video on disk
354/// during capture is *already compressed*. That bounds the scratch
355/// footprint to the encoded bitrate instead of the raw firehose
356/// (`width × height × 4 × fps` ≈ 250 MB/s at 1080p, >1 GB/s at Retina)
357/// — the prerequisite for capturing at native resolution.
358///
359/// Audio is still buffered to a small raw `.f32.scratch` (≈0.4 MB/s)
360/// and muxed in at [`finalize`](VideoEncoder::finalize) via
361/// [`build_remux_args`] (the video leg is stream-copied, not
362/// re-encoded). Keeping audio out of the live pipeline lets the
363/// recorder feed a single fd (stdin) with no extra fd plumbing.
364///
365/// Real recording is macOS-only; the pipeline strings are cross-OS so
366/// the unit tests + clippy pass everywhere, but the spawn only
367/// succeeds where the encoder element + `gst-launch-1.0` exist.
368#[derive(Debug)]
369pub struct LiveGstreamerEncoder {
370    config: EncoderConfig,
371    /// Compressed video-only intermediate the live child writes to.
372    /// Remuxed with audio at finalize, then deleted.
373    video_intermediate_path: PathBuf,
374    /// Raw F32LE audio scratch — encoded to the container codec at
375    /// finalize.
376    audio_scratch_path: PathBuf,
377    /// The live `gst-launch-1.0` child (stdin → intermediate).
378    video_child: Child,
379    /// Write end of the child's stdin; `None` once finalize closes it.
380    video_stdin: Option<ChildStdin>,
381    /// Drains the child's stderr on a side thread so a chatty pipeline
382    /// can't deadlock on a full stderr pipe; joined at finalize for
383    /// diagnostics.
384    stderr_drain: Option<JoinHandle<String>>,
385    audio_writer: BufWriter<File>,
386    expected_video_bytes_per_frame: usize,
387    frames_pushed: u64,
388    audio_chunks_pushed: u64,
389}
390
391impl LiveGstreamerEncoder {
392    /// Spawn the live video-encode pipeline + open the audio scratch.
393    ///
394    /// # Errors
395    ///
396    /// - [`EncodeError::InvalidConfig`] — any of width / height /
397    ///   framerate / channels / sample_rate is zero.
398    /// - [`EncodeError::Unsupported`] — no encoder wired for (format, OS).
399    /// - [`EncodeError::Spawn`] — `gst-launch-1.0` missing from PATH.
400    /// - [`EncodeError::Io`] — the audio scratch can't be created.
401    pub fn new(config: EncoderConfig) -> Result<Self, EncodeError> {
402        if config.width == 0 || config.height == 0 || config.framerate == 0 {
403            return Err(EncodeError::InvalidConfig(format!(
404                "width={}, height={}, framerate={} — none may be zero",
405                config.width, config.height, config.framerate
406            )));
407        }
408        if config.channels == 0 || config.sample_rate == 0 {
409            return Err(EncodeError::InvalidConfig(format!(
410                "channels={}, sample_rate={} — neither may be zero",
411                config.channels, config.sample_rate
412            )));
413        }
414
415        let video_intermediate_path = scratch_path(&config.output_path, ".live-video.scratch");
416        let audio_scratch_path = scratch_path(&config.output_path, ".f32.scratch");
417
418        // Build + spawn the live video pipeline before opening the
419        // audio scratch so an Unsupported/Spawn failure doesn't leave a
420        // dangling file.
421        let video_args = build_live_video_args(&config, &video_intermediate_path)?;
422        let mut child = Command::new("gst-launch-1.0")
423            .args(&video_args)
424            .stdin(Stdio::piped())
425            .stdout(Stdio::null())
426            .stderr(Stdio::piped())
427            .spawn()
428            .map_err(|err| EncodeError::Spawn {
429                source: err,
430                path: std::env::var("PATH").unwrap_or_else(|_| "<unset>".into()),
431            })?;
432
433        let video_stdin = child.stdin.take();
434        let stderr_drain = child.stderr.take().map(|mut stderr| {
435            std::thread::spawn(move || {
436                let mut buf = String::new();
437                let _ = stderr.read_to_string(&mut buf);
438                buf
439            })
440        });
441
442        let audio_file = File::create(&audio_scratch_path)?;
443        let expected_video_bytes_per_frame = (config.width as usize) * (config.height as usize) * 4;
444
445        Ok(Self {
446            config,
447            video_intermediate_path,
448            audio_scratch_path,
449            video_child: child,
450            video_stdin,
451            stderr_drain,
452            audio_writer: BufWriter::new(audio_file),
453            expected_video_bytes_per_frame,
454            frames_pushed: 0,
455            audio_chunks_pushed: 0,
456        })
457    }
458
459    /// Frame count pushed so far.
460    #[must_use]
461    pub fn frames_pushed(&self) -> u64 {
462        self.frames_pushed
463    }
464
465    /// Audio chunks pushed so far.
466    #[must_use]
467    pub fn audio_chunks_pushed(&self) -> u64 {
468        self.audio_chunks_pushed
469    }
470
471    /// Borrow the resolved encoder config.
472    #[must_use]
473    pub fn config(&self) -> &EncoderConfig {
474        &self.config
475    }
476}
477
478impl VideoEncoder for LiveGstreamerEncoder {
479    fn push_video_frame(
480        &mut self,
481        bgra: &[u8],
482        _pts: std::time::Duration,
483    ) -> Result<(), EncodeError> {
484        if bgra.len() != self.expected_video_bytes_per_frame {
485            return Err(EncodeError::InvalidConfig(format!(
486                "frame byte length mismatch: got {}, expected {}",
487                bgra.len(),
488                self.expected_video_bytes_per_frame
489            )));
490        }
491        let Some(stdin) = self.video_stdin.as_mut() else {
492            return Err(EncodeError::Io(std::io::Error::new(
493                std::io::ErrorKind::BrokenPipe,
494                "live encoder stdin already closed",
495            )));
496        };
497        // A full pipe blocks here — natural backpressure if the HW
498        // encoder can't keep up with the compose framerate.
499        stdin.write_all(bgra)?;
500        self.frames_pushed = self.frames_pushed.saturating_add(1);
501        Ok(())
502    }
503
504    fn push_audio_chunk(
505        &mut self,
506        samples: &[f32],
507        _pts: std::time::Duration,
508    ) -> Result<(), EncodeError> {
509        for sample in samples {
510            self.audio_writer.write_all(&sample.to_le_bytes())?;
511        }
512        self.audio_chunks_pushed = self.audio_chunks_pushed.saturating_add(1);
513        Ok(())
514    }
515
516    fn finalize(mut self: Box<Self>) -> Result<PathBuf, EncodeError> {
517        // 1. Close the live child's stdin → fdsrc sees EOF → EOS →
518        //    mp4mux writes its moov atom → child exits.
519        drop(self.video_stdin.take());
520        let status = self.video_child.wait()?;
521        let stderr = self
522            .stderr_drain
523            .take()
524            .and_then(|h| h.join().ok())
525            .unwrap_or_default();
526        if !status.success() {
527            let _ = std::fs::remove_file(&self.video_intermediate_path);
528            let _ = std::fs::remove_file(&self.audio_scratch_path);
529            return Err(EncodeError::PipelineFailed {
530                exit: status.code(),
531                stderr,
532            });
533        }
534
535        // 2. Flush the audio scratch so the remux reads complete data.
536        //    The File handle itself closes when `self` drops at the end
537        //    of finalize; the flushed bytes are already visible to the
538        //    gst child reading the same path (can't move it out — Drop).
539        self.audio_writer.flush()?;
540
541        let has_video = self.frames_pushed > 0;
542        let has_audio = self.audio_chunks_pushed > 0;
543
544        // 3. Produce the final container.
545        if has_video && !has_audio {
546            // Video-only: the live intermediate already IS the final
547            // container — move it into place (no remux / re-encode).
548            // The scratch sits next to the output (same dir), so the
549            // rename stays on one filesystem; copy is a paranoia
550            // fallback.
551            if std::fs::rename(&self.video_intermediate_path, &self.config.output_path).is_err() {
552                std::fs::copy(&self.video_intermediate_path, &self.config.output_path)?;
553                let _ = std::fs::remove_file(&self.video_intermediate_path);
554            }
555            let _ = std::fs::remove_file(&self.audio_scratch_path);
556        } else {
557            let args = build_remux_args(
558                &self.config,
559                &self.video_intermediate_path,
560                &self.audio_scratch_path,
561                has_video,
562                has_audio,
563            );
564            tracing::info!(
565                output = %self.config.output_path.display(),
566                format = ?self.config.format,
567                video_frames = self.frames_pushed,
568                audio_chunks = self.audio_chunks_pushed,
569                remux_args = ?args,
570                "LiveGstreamerEncoder::finalize remuxing"
571            );
572            let output = Command::new("gst-launch-1.0")
573                .args(&args)
574                .output()
575                .map_err(|err| EncodeError::Spawn {
576                    source: err,
577                    path: std::env::var("PATH").unwrap_or_else(|_| "<unset>".into()),
578                })?;
579            if !output.status.success() {
580                return Err(EncodeError::PipelineFailed {
581                    exit: output.status.code(),
582                    stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
583                });
584            }
585            let _ = std::fs::remove_file(&self.video_intermediate_path);
586            let _ = std::fs::remove_file(&self.audio_scratch_path);
587        }
588
589        Ok(self.config.output_path.clone())
590    }
591}
592
593impl Drop for LiveGstreamerEncoder {
594    fn drop(&mut self) {
595        // `finalize()` already took stdin + waited the child. On an
596        // early drop (error / cancel path) close stdin and kill the
597        // child so we don't orphan a gst-launch feeding a dead pipe
598        // (mirrors decode::gstreamer_pipe's drop-kill).
599        if self.video_stdin.take().is_some() {
600            let _ = self.video_child.kill();
601            let _ = self.video_child.wait();
602        }
603    }
604}
605
606/// Build the `gst-launch-1.0` argv for the **live video-only** encode
607/// (M-QUAL.1). Frames arrive on the child's stdin (`fd=0`) as raw
608/// BGRA; the pipeline encodes them straight to a compressed
609/// video-only container at `intermediate`. The audio leg is handled
610/// separately at finalize ([`build_remux_args`]).
611///
612/// Shape: `-q -e fdsrc fd=0 ! rawvideoparse format=bgra width=W
613/// height=H framerate=F/1 ! videoconvert ! <encoder> ! <parser> !
614/// <mux> ! filesink location=<intermediate>`.
615///
616/// # Errors
617///
618/// [`EncodeError::Unsupported`] if the (format, OS) combo has no wired
619/// encoder.
620pub fn build_live_video_args(
621    config: &EncoderConfig,
622    intermediate: &Path,
623) -> Result<Vec<String>, EncodeError> {
624    let (video_encoder_elements, mux_element) =
625        encoder_and_mux_elements(config.format, std::env::consts::OS)?;
626
627    // `-e` makes gst-launch forward EOS on a stop signal; the EOF from
628    // a closed stdin already triggers a clean EOS, but `-e` keeps the
629    // moov-atom-on-stop guarantee if we ever stop via signal instead.
630    let mut args: Vec<String> = vec![
631        "-q".to_string(),
632        "-e".to_string(),
633        "fdsrc".to_string(),
634        "fd=0".to_string(),
635        "!".to_string(),
636        "rawvideoparse".to_string(),
637        "format=bgra".to_string(),
638        format!("width={}", config.width),
639        format!("height={}", config.height),
640        format!("framerate={}/1", config.framerate),
641        "!".to_string(),
642        "videoconvert".to_string(),
643        "!".to_string(),
644    ];
645    for elem in &video_encoder_elements {
646        // An encoder entry may carry properties (e.g. `vtenc_h264_hw
647        // allow-frame-reordering=false`); push each whitespace-separated
648        // token as its own argv item so gst-launch parses them as an
649        // element + its properties rather than one bogus element name.
650        for token in elem.split_whitespace() {
651            args.push(token.to_string());
652        }
653        args.push("!".to_string());
654    }
655    args.push(mux_to_parser(config.format).to_string());
656    args.push("!".to_string());
657    args.push(mux_element.to_string());
658    args.push("!".to_string());
659    args.push("filesink".to_string());
660    args.push(format!("location={}", intermediate.display()));
661    Ok(args)
662}
663
664/// Build the `gst-launch-1.0` argv for the **finalize remux**
665/// (M-QUAL.1). The live video `intermediate` is stream-copied (no
666/// re-encode) and the raw F32LE `audio_scratch` is encoded to the
667/// container's audio codec, both muxed into `config.output_path`.
668///
669/// `has_video` / `has_audio` gate the legs. In the normal recording
670/// case both are true; a video-only recording skips this entirely (the
671/// intermediate is moved into place by `finalize`), so the
672/// `!has_video` arm only exists for the (unusual) audio-only case.
673///
674/// Shape (video + audio): `-q -e <mux> name=mux ! filesink
675/// location=<output>  filesrc location=<intermediate> ! <demux> !
676/// <parser> ! mux.  filesrc location=<audio> ! rawaudioparse … !
677/// audioconvert ! audioresample ! <audio-encoder> ! mux.`
678#[must_use]
679pub fn build_remux_args(
680    config: &EncoderConfig,
681    intermediate: &Path,
682    audio_scratch: &Path,
683    has_video: bool,
684    has_audio: bool,
685) -> Vec<String> {
686    let mut args: Vec<String> = vec![
687        "-q".to_string(),
688        "-e".to_string(),
689        mux_element_for(config.format).to_string(),
690        "name=mux".to_string(),
691        "!".to_string(),
692        "filesink".to_string(),
693        format!("location={}", config.output_path.display()),
694    ];
695    if has_video {
696        args.extend([
697            "filesrc".to_string(),
698            format!("location={}", intermediate.display()),
699            "!".to_string(),
700            demux_for(config.format).to_string(),
701            "!".to_string(),
702            mux_to_parser(config.format).to_string(),
703            "!".to_string(),
704            "mux.".to_string(),
705        ]);
706    }
707    if has_audio {
708        args.extend([
709            "filesrc".to_string(),
710            format!("location={}", audio_scratch.display()),
711            "!".to_string(),
712            "rawaudioparse".to_string(),
713            "pcm-format=f32le".to_string(),
714            format!("sample-rate={}", config.sample_rate),
715            format!("num-channels={}", config.channels),
716            "!".to_string(),
717            "audioconvert".to_string(),
718            "!".to_string(),
719            "audioresample".to_string(),
720            "!".to_string(),
721            audio_encoder_element(config.format).to_string(),
722            "!".to_string(),
723            "mux.".to_string(),
724        ]);
725    }
726    args
727}
728
729// ---- ED.21 — edited-export audio (decode → retime → remux) ----------
730
731/// Build the `gst-launch-1.0` argv that decodes `source`'s audio track to
732/// raw **interleaved F32LE** on stdout at `sample_rate` / `channels` (ED.21).
733///
734/// The edited export has no `.f32` scratch at edit time (the audio lives
735/// inside the source MP4), so the per-segment retime can't be expressed as a
736/// live capture. Instead we decode the whole source audio to raw samples with
737/// this one pass, slice + retime it per the project's segments in pure Rust
738/// (`screen_app::editor_export::retime_audio`), then feed the result to the
739/// encoder's audio scratch via [`VideoEncoder::push_audio_chunk`] — so the
740/// existing [`build_remux_args`] finalize muxes it exactly as for a live
741/// recording. GStreamer owns the intake; Rust owns the edit arithmetic.
742///
743/// Shape: `-q filesrc location=SRC ! decodebin ! audioconvert ! audioresample
744/// ! audio/x-raw,format=F32LE,rate=R,channels=C,layout=interleaved ! fdsink
745/// fd=1` — the input mirror of the `fdsink fd=1` decode pattern the `decode`
746/// crate uses for video.
747#[must_use]
748pub fn build_audio_decode_args(source: &Path, sample_rate: u32, channels: u8) -> Vec<String> {
749    vec![
750        "-q".to_string(),
751        "filesrc".to_string(),
752        format!("location={}", source.display()),
753        "!".to_string(),
754        "decodebin".to_string(),
755        "!".to_string(),
756        "audioconvert".to_string(),
757        "!".to_string(),
758        "audioresample".to_string(),
759        "!".to_string(),
760        format!(
761            "audio/x-raw,format=F32LE,rate={sample_rate},channels={channels},layout=interleaved"
762        ),
763        "!".to_string(),
764        "fdsink".to_string(),
765        "fd=1".to_string(),
766    ]
767}
768
769/// Decode `source`'s audio track to interleaved F32LE samples at
770/// `sample_rate` / `channels` (ED.21). Returns an **empty** `Vec` when the
771/// source has no audio track (probed via [`scratch_has_audio`]) — the caller
772/// then exports video-only.
773///
774/// # Errors
775///
776/// [`EncodeError::Spawn`] if `gst-launch-1.0` isn't on PATH;
777/// [`EncodeError::PipelineFailed`] if the decode pipeline exits non-zero.
778pub fn decode_source_audio_f32(
779    source: &Path,
780    sample_rate: u32,
781    channels: u8,
782) -> Result<Vec<f32>, EncodeError> {
783    if !scratch_has_audio(source) {
784        return Ok(Vec::new());
785    }
786    let args = build_audio_decode_args(source, sample_rate, channels);
787    let output = Command::new("gst-launch-1.0")
788        .args(&args)
789        .output()
790        .map_err(|err| EncodeError::Spawn {
791            source: err,
792            path: std::env::var("PATH").unwrap_or_else(|_| "<unset>".into()),
793        })?;
794    if !output.status.success() {
795        return Err(EncodeError::PipelineFailed {
796            exit: output.status.code(),
797            stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
798        });
799    }
800    // Reinterpret the raw F32LE byte stream as `f32` samples (drop any
801    // trailing partial sample the pipe may leave on an abrupt EOS).
802    let samples = output
803        .stdout
804        .chunks_exact(4)
805        .map(|b| f32::from_le_bytes([b[0], b[1], b[2], b[3]]))
806        .collect();
807    Ok(samples)
808}
809
810// ---- M-EXPORT.5 — AVIF poster-frame thumbnail -----------------------
811
812/// Generate an AVIF poster image next to the encoded video.
813/// Spawns a one-shot `gst-launch-1.0` pipeline that extracts a
814/// single frame from `video_path`, scales it to ≤640 px wide, and
815/// writes it to `<video_path-without-ext>.avif`.
816///
817/// Returns `Ok(Some(path))` on success, `Ok(None)` when the
818/// `avifenc` GStreamer element isn't installed (silent skip with a
819/// `tracing::warn` — poster is a free side-benefit, not a hard
820/// requirement). Returns `Err` on any other failure (e.g. video
821/// file missing).
822///
823/// # Errors
824///
825/// Returns [`EncodeError::Spawn`] if `gst-launch-1.0` isn't on PATH,
826/// or [`EncodeError::PipelineFailed`] if the spawn ran but exited
827/// non-zero for a reason other than "missing avifenc."
828pub fn generate_poster(video_path: &Path) -> Result<Option<PathBuf>, EncodeError> {
829    if !video_path.exists() {
830        return Err(EncodeError::InvalidConfig(format!(
831            "video file does not exist: {}",
832            video_path.display()
833        )));
834    }
835    let poster_path = poster_path_for(video_path);
836    let args = poster_pipeline_args(video_path, &poster_path);
837
838    let output = Command::new("gst-launch-1.0")
839        .args(&args)
840        .output()
841        .map_err(|err| EncodeError::Spawn {
842            source: err,
843            path: std::env::var("PATH").unwrap_or_else(|_| "<unset>".into()),
844        })?;
845
846    if output.status.success() {
847        tracing::info!(
848            video = %video_path.display(),
849            poster = %poster_path.display(),
850            "generate_poster: AVIF thumbnail written"
851        );
852        Ok(Some(poster_path))
853    } else {
854        let stderr = String::from_utf8_lossy(&output.stderr);
855        // Heuristic: "no such element" / "Unknown element" / "no
856        // element ... avifenc" — silent-skip the missing-encoder
857        // case so the recorder UX doesn't break on machines without
858        // gst-plugins-bad. The poster is a free side-benefit.
859        if stderr.contains("avifenc") && stderr.to_lowercase().contains("no such element")
860            || stderr.contains("no element \"avifenc\"")
861        {
862            tracing::warn!(
863                "generate_poster: avifenc GStreamer element not installed — \
864                 skipping AVIF poster (install gst-plugins-bad to enable)"
865            );
866            return Ok(None);
867        }
868        Err(EncodeError::PipelineFailed {
869            exit: output.status.code(),
870            stderr: stderr.into_owned(),
871        })
872    }
873}
874
875/// Build the gst-launch argv for the poster pipeline. Split out so
876/// tests can assert the shape without spawning gst.
877#[must_use]
878pub fn poster_pipeline_args(video_path: &Path, poster_path: &Path) -> Vec<String> {
879    vec![
880        "-q".to_string(),
881        "filesrc".to_string(),
882        format!("location={}", video_path.display()),
883        "!".to_string(),
884        "decodebin".to_string(),
885        "!".to_string(),
886        "videoconvert".to_string(),
887        "!".to_string(),
888        "videoscale".to_string(),
889        "!".to_string(),
890        "video/x-raw,width=640".to_string(),
891        "!".to_string(),
892        "avifenc".to_string(),
893        "!".to_string(),
894        "filesink".to_string(),
895        format!("location={}", poster_path.display()),
896    ]
897}
898
899/// Compute the poster path for `<video>.<ext>` → `<video>.avif`.
900/// Replaces the video extension entirely (so `Screen-...mp4` →
901/// `Screen-...avif`, not `Screen-...mp4.avif`).
902#[must_use]
903pub fn poster_path_for(video_path: &Path) -> PathBuf {
904    let mut p = video_path.to_path_buf();
905    p.set_extension("avif");
906    p
907}
908
909// ---- M-SAVE.2 — MP4 → WebM transcode (deferred export) --------------
910
911/// Transcode an existing video file (the MP4/H.264 recording scratch)
912/// into a VP9 + Opus `.webm` at `output`. Drives the Save panel's
913/// "WebM" export: the recorder always captures to an MP4/H.264 scratch
914/// (the canonical intermediate), and a WebM export re-encodes it here.
915///
916/// Probes `input` for an audio track via [`scratch_has_audio`] and
917/// includes the Opus leg only when one is present — a screen-only
918/// recording's scratch has no audio track, and wiring an audio branch
919/// to a `decodebin` pad that never appears would hang `webmmux`
920/// waiting for EOS on it.
921///
922/// VP9 has no Apple HW encoder, so this uses the `vp9enc` software
923/// encoder (same as the live WebM encode path) — a short clip takes a
924/// few seconds. **Call it off the main thread** (the recorder runs it
925/// via `spawn_blocking`).
926///
927/// # Errors
928///
929/// - [`EncodeError::InvalidConfig`] — `input` doesn't exist.
930/// - [`EncodeError::Spawn`] — `gst-launch-1.0` missing from PATH.
931/// - [`EncodeError::PipelineFailed`] — the transcode exited non-zero.
932pub fn transcode_to_webm(input: &Path, output: &Path) -> Result<(), EncodeError> {
933    if !input.exists() {
934        return Err(EncodeError::InvalidConfig(format!(
935            "transcode input does not exist: {}",
936            input.display()
937        )));
938    }
939    let has_audio = scratch_has_audio(input);
940    let args = build_webm_transcode_args(input, output, has_audio);
941
942    tracing::info!(
943        input = %input.display(),
944        output = %output.display(),
945        has_audio,
946        "transcode_to_webm: spawning gst-launch-1.0"
947    );
948
949    let result = Command::new("gst-launch-1.0")
950        .args(&args)
951        .output()
952        .map_err(|err| EncodeError::Spawn {
953            source: err,
954            path: std::env::var("PATH").unwrap_or_else(|_| "<unset>".into()),
955        })?;
956
957    if result.status.success() {
958        Ok(())
959    } else {
960        Err(EncodeError::PipelineFailed {
961            exit: result.status.code(),
962            stderr: String::from_utf8_lossy(&result.stderr).into_owned(),
963        })
964    }
965}
966
967/// Build the gst-launch argv for the MP4 → WebM transcode. Split out
968/// so tests assert the pipeline shape without spawning gst. The Opus
969/// audio leg is present only when `has_audio` (see
970/// [`transcode_to_webm`] for why).
971///
972/// Shape: `filesrc ! decodebin name=d  webmmux name=mux ! filesink
973/// d. ! queue ! videoconvert ! vp9enc ! mux.  [d. ! queue !
974/// audioconvert ! audioresample ! opusenc ! mux.]`
975#[must_use]
976pub fn build_webm_transcode_args(input: &Path, output: &Path, has_audio: bool) -> Vec<String> {
977    // `-e` forces EOS so the muxer writes its cues on completion;
978    // `-q` quiets per-buffer chatter.
979    let mut args = vec![
980        "-q".to_string(),
981        "-e".to_string(),
982        "filesrc".to_string(),
983        format!("location={}", input.display()),
984        "!".to_string(),
985        "decodebin".to_string(),
986        "name=d".to_string(),
987        // Muxer + sink declared up front so the `mux.` back-references
988        // below resolve at parse time.
989        "webmmux".to_string(),
990        "name=mux".to_string(),
991        "!".to_string(),
992        "filesink".to_string(),
993        format!("location={}", output.display()),
994        // Video leg.
995        "d.".to_string(),
996        "!".to_string(),
997        "queue".to_string(),
998        "!".to_string(),
999        "videoconvert".to_string(),
1000        "!".to_string(),
1001        "vp9enc".to_string(),
1002        "!".to_string(),
1003        "mux.".to_string(),
1004    ];
1005    if has_audio {
1006        args.extend(
1007            [
1008                "d.",
1009                "!",
1010                "queue",
1011                "!",
1012                "audioconvert",
1013                "!",
1014                "audioresample",
1015                "!",
1016                "opusenc",
1017                "!",
1018                "mux.",
1019            ]
1020            .into_iter()
1021            .map(String::from),
1022        );
1023    }
1024    args
1025}
1026
1027/// Probe `input` for an audio track via `gst-discoverer-1.0`. Returns
1028/// `true` only when an audio stream is reported; any failure (binary
1029/// missing, probe error, no audio) returns `false`, so the transcode
1030/// falls back to a video-only pipeline rather than hang on a
1031/// `decodebin` audio pad that never fires.
1032#[must_use]
1033pub fn scratch_has_audio(input: &Path) -> bool {
1034    let Ok(output) = Command::new("gst-discoverer-1.0").arg(input).output() else {
1035        return false;
1036    };
1037    if !output.status.success() {
1038        return false;
1039    }
1040    // gst-discoverer prints `Audio #0: …` per stream + an `audio:` line
1041    // in the topology — either marks an audio track.
1042    let lower = String::from_utf8_lossy(&output.stdout).to_lowercase();
1043    lower.contains("audio #") || lower.contains("audio:")
1044}
1045
1046/// Per-(format, OS) video encoder element(s) + muxer element, used by
1047/// [`build_live_video_args`] so the encoder coverage table lives in
1048/// one place.
1049///
1050/// # Errors
1051///
1052/// [`EncodeError::Unsupported`] for any (format, OS) without a wired
1053/// encoder.
1054fn encoder_and_mux_elements(
1055    format: OutputFormat,
1056    os: &str,
1057) -> Result<(Vec<&'static str>, &'static str), EncodeError> {
1058    let pair = match (format, os) {
1059        // `allow-frame-reordering=false` disables B-frames so PTS == DTS.
1060        // The live scratch is re-demuxed at finalize to mux in the audio
1061        // track, and VideoToolbox's default B-frame reordering produces
1062        // buffers that demux with no PTS — mp4mux then rejects them
1063        // ("Could not multiplex stream / Buffer has no PTS"), silently
1064        // failing the finalize of EVERY recording that has audio (the
1065        // video-only path escapes it by renaming the scratch, not
1066        // re-muxing). B-frames are negligible for screen content and add
1067        // encode latency, so disabling them is the right call regardless.
1068        (OutputFormat::Mp4H264Aac, "macos") => {
1069            (vec!["vtenc_h264_hw allow-frame-reordering=false"], "mp4mux")
1070        }
1071        (OutputFormat::Mp4H265Aac, "macos") => {
1072            (vec!["vtenc_h265_hw allow-frame-reordering=false"], "mp4mux")
1073        }
1074        (OutputFormat::WebmVp9Opus, "macos") => (vec!["vp9enc"], "webmmux"),
1075        (OutputFormat::WebmAv1Opus, "macos") => (vec!["svtav1enc"], "webmmux"),
1076        (OutputFormat::Mp4H264Aac, "windows") => (vec!["mfh264enc"], "mp4mux"),
1077        (OutputFormat::Mp4H265Aac, "windows") => (vec!["mfhevcenc"], "mp4mux"),
1078        (OutputFormat::WebmVp9Opus, "windows") => (vec!["mfvp9enc"], "webmmux"),
1079        (OutputFormat::WebmAv1Opus, "windows") => (vec!["qsvav1enc"], "webmmux"),
1080        (OutputFormat::Mp4H264Aac, "linux") => (vec!["vaapih264enc"], "mp4mux"),
1081        (OutputFormat::Mp4H265Aac, "linux") => (vec!["vaapih265enc"], "mp4mux"),
1082        (OutputFormat::WebmVp9Opus, "linux") => (vec!["vaapivp9enc"], "webmmux"),
1083        (OutputFormat::WebmAv1Opus, "linux") => (vec!["vaapiav1enc"], "webmmux"),
1084        (format, other) => {
1085            return Err(EncodeError::Unsupported {
1086                format,
1087                os: leak_os_name(other),
1088                reason: "no encoder wired for this OS/format combo",
1089            });
1090        }
1091    };
1092    Ok(pair)
1093}
1094
1095/// Audio encoder element for the container family.
1096fn audio_encoder_element(format: OutputFormat) -> &'static str {
1097    match format {
1098        OutputFormat::Mp4H264Aac | OutputFormat::Mp4H265Aac => "avenc_aac",
1099        OutputFormat::WebmVp9Opus | OutputFormat::WebmAv1Opus => "opusenc",
1100    }
1101}
1102
1103/// Muxer element for the container family (OS-independent).
1104fn mux_element_for(format: OutputFormat) -> &'static str {
1105    match format {
1106        OutputFormat::Mp4H264Aac | OutputFormat::Mp4H265Aac => "mp4mux",
1107        OutputFormat::WebmVp9Opus | OutputFormat::WebmAv1Opus => "webmmux",
1108    }
1109}
1110
1111/// Demuxer element that reads the live video intermediate back for the
1112/// finalize remux.
1113fn demux_for(format: OutputFormat) -> &'static str {
1114    match format {
1115        OutputFormat::Mp4H264Aac | OutputFormat::Mp4H265Aac => "qtdemux",
1116        OutputFormat::WebmVp9Opus | OutputFormat::WebmAv1Opus => "matroskademux",
1117    }
1118}
1119
1120fn mux_to_parser(format: OutputFormat) -> &'static str {
1121    match format {
1122        OutputFormat::Mp4H264Aac => "h264parse",
1123        OutputFormat::Mp4H265Aac => "h265parse",
1124        OutputFormat::WebmVp9Opus => "vp9parse",
1125        OutputFormat::WebmAv1Opus => "av1parse",
1126    }
1127}
1128
1129fn scratch_path(output_path: &Path, suffix: &str) -> PathBuf {
1130    let mut s = output_path.as_os_str().to_owned();
1131    s.push(suffix);
1132    PathBuf::from(s)
1133}
1134
1135/// Convert a runtime OS string (`std::env::consts::OS` produces `&str`)
1136/// to a `&'static str` suitable for the [`EncodeError::Unsupported`]
1137/// field. The set of possible values is bounded so we map each known
1138/// one explicitly; anything else falls through to `"other"`.
1139fn leak_os_name(os: &str) -> &'static str {
1140    match os {
1141        "macos" => "macos",
1142        "windows" => "windows",
1143        "linux" => "linux",
1144        "freebsd" => "freebsd",
1145        _ => "other",
1146    }
1147}
1148
1149#[cfg(test)]
1150mod tests {
1151    use super::*;
1152
1153    // ---- OutputFormat enum ----
1154
1155    #[test]
1156    fn output_format_default_is_mp4_h264() {
1157        assert_eq!(OutputFormat::default(), OutputFormat::Mp4H264Aac);
1158    }
1159
1160    #[test]
1161    fn output_format_extension_matches_container() {
1162        assert_eq!(OutputFormat::Mp4H264Aac.extension(), "mp4");
1163        assert_eq!(OutputFormat::Mp4H265Aac.extension(), "mp4");
1164        assert_eq!(OutputFormat::WebmVp9Opus.extension(), "webm");
1165        assert_eq!(OutputFormat::WebmAv1Opus.extension(), "webm");
1166    }
1167
1168    #[test]
1169    fn output_format_slug_round_trips() {
1170        for f in [
1171            OutputFormat::Mp4H264Aac,
1172            OutputFormat::Mp4H265Aac,
1173            OutputFormat::WebmVp9Opus,
1174            OutputFormat::WebmAv1Opus,
1175        ] {
1176            assert_eq!(OutputFormat::from_slug(f.slug()), Some(f));
1177        }
1178    }
1179
1180    #[test]
1181    fn output_format_from_slug_rejects_unknown() {
1182        assert!(OutputFormat::from_slug("mp3").is_none());
1183        assert!(OutputFormat::from_slug("").is_none());
1184        assert!(OutputFormat::from_slug("mp4").is_none());
1185    }
1186
1187    #[test]
1188    fn output_format_serde_round_trip() {
1189        for f in [
1190            OutputFormat::Mp4H264Aac,
1191            OutputFormat::Mp4H265Aac,
1192            OutputFormat::WebmVp9Opus,
1193            OutputFormat::WebmAv1Opus,
1194        ] {
1195            let json = serde_json::to_string(&f).unwrap();
1196            let back: OutputFormat = serde_json::from_str(&json).unwrap();
1197            assert_eq!(back, f);
1198        }
1199    }
1200
1201    // ---- EncoderConfig ----
1202
1203    #[test]
1204    fn config_for_output_uses_1920_1080_30fps_48k_stereo() {
1205        let cfg = EncoderConfig::for_output(PathBuf::from("/tmp/x.mp4"), OutputFormat::Mp4H264Aac);
1206        assert_eq!(cfg.width, 1920);
1207        assert_eq!(cfg.height, 1080);
1208        assert_eq!(cfg.framerate, 30);
1209        assert_eq!(cfg.sample_rate, 48_000);
1210        assert_eq!(cfg.channels, 2);
1211    }
1212
1213    // ---- shared arg-builder test config ----
1214
1215    fn test_config(format: OutputFormat) -> EncoderConfig {
1216        EncoderConfig::for_output(PathBuf::from("/tmp/test.x"), format)
1217    }
1218
1219    // ---- scratch_path ----
1220
1221    #[test]
1222    fn scratch_path_appends_suffix_to_full_filename() {
1223        let p = scratch_path(Path::new("/tmp/out.mp4"), ".bgra.scratch");
1224        assert_eq!(p, PathBuf::from("/tmp/out.mp4.bgra.scratch"));
1225    }
1226
1227    #[test]
1228    fn scratch_path_handles_no_extension() {
1229        let p = scratch_path(Path::new("/tmp/outfile"), ".scratch");
1230        assert_eq!(p, PathBuf::from("/tmp/outfile.scratch"));
1231    }
1232
1233    // ---- M-EXPORT.5 — poster helpers ----
1234
1235    #[test]
1236    fn poster_path_replaces_extension() {
1237        assert_eq!(
1238            poster_path_for(Path::new("/tmp/Screen-2026-05-17-180000.mp4")),
1239            PathBuf::from("/tmp/Screen-2026-05-17-180000.avif")
1240        );
1241        assert_eq!(
1242            poster_path_for(Path::new("/tmp/Screen-2026-05-17-180000.webm")),
1243            PathBuf::from("/tmp/Screen-2026-05-17-180000.avif")
1244        );
1245    }
1246
1247    #[test]
1248    fn poster_pipeline_args_contains_required_elements() {
1249        let args = poster_pipeline_args(Path::new("/tmp/test.mp4"), Path::new("/tmp/test.avif"));
1250        // filesrc location=<video>
1251        assert!(args.iter().any(|a| a == "filesrc"));
1252        assert!(args.iter().any(|a| a == "location=/tmp/test.mp4"));
1253        // decodebin → videoconvert → videoscale chain
1254        assert!(args.iter().any(|a| a == "decodebin"));
1255        assert!(args.iter().any(|a| a == "videoconvert"));
1256        assert!(args.iter().any(|a| a == "videoscale"));
1257        // scale to 640 wide
1258        assert!(args.iter().any(|a| a == "video/x-raw,width=640"));
1259        // avifenc → filesink location=<poster>
1260        assert!(args.iter().any(|a| a == "avifenc"));
1261        assert!(args.iter().any(|a| a == "location=/tmp/test.avif"));
1262    }
1263
1264    #[test]
1265    fn generate_poster_rejects_missing_video_file() {
1266        let result = generate_poster(Path::new("/tmp/definitely-not-a-real-video.mp4"));
1267        assert!(matches!(result, Err(EncodeError::InvalidConfig(_))));
1268    }
1269
1270    // ---- M-SAVE.2 — WebM transcode argv ----
1271
1272    #[test]
1273    fn webm_transcode_args_video_only_omits_audio_leg() {
1274        let args =
1275            build_webm_transcode_args(Path::new("/tmp/in.mp4"), Path::new("/tmp/out.webm"), false);
1276        // filesrc → decodebin → vp9enc → webmmux → filesink
1277        assert!(args.iter().any(|a| a == "filesrc"));
1278        assert!(args.iter().any(|a| a == "location=/tmp/in.mp4"));
1279        assert!(args.iter().any(|a| a == "decodebin"));
1280        assert!(args.iter().any(|a| a == "vp9enc"));
1281        assert!(args.iter().any(|a| a == "webmmux"));
1282        assert!(args.iter().any(|a| a == "location=/tmp/out.webm"));
1283        // No audio leg for a video-only scratch.
1284        assert!(!args.iter().any(|a| a == "opusenc"));
1285        assert!(!args.iter().any(|a| a == "audioconvert"));
1286        // Exactly one decodebin back-reference (video only).
1287        assert_eq!(args.iter().filter(|a| a.as_str() == "d.").count(), 1);
1288    }
1289
1290    #[test]
1291    fn webm_transcode_args_with_audio_includes_opus_leg() {
1292        let args =
1293            build_webm_transcode_args(Path::new("/tmp/in.mp4"), Path::new("/tmp/out.webm"), true);
1294        assert!(args.iter().any(|a| a == "vp9enc"));
1295        assert!(args.iter().any(|a| a == "opusenc"));
1296        assert!(args.iter().any(|a| a == "audioconvert"));
1297        assert!(args.iter().any(|a| a == "audioresample"));
1298        // Two decodebin back-references: video + audio legs.
1299        assert_eq!(args.iter().filter(|a| a.as_str() == "d.").count(), 2);
1300    }
1301
1302    #[test]
1303    fn transcode_to_webm_rejects_missing_input() {
1304        let result = transcode_to_webm(
1305            Path::new("/tmp/definitely-not-a-real-scratch.mp4"),
1306            Path::new("/tmp/out.webm"),
1307        );
1308        assert!(matches!(result, Err(EncodeError::InvalidConfig(_))));
1309    }
1310
1311    // ---- M-QUAL.1 — live video + remux args ----
1312
1313    #[test]
1314    fn live_video_args_stream_from_stdin_with_caps() {
1315        if std::env::consts::OS != "macos"
1316            && std::env::consts::OS != "windows"
1317            && std::env::consts::OS != "linux"
1318        {
1319            return;
1320        }
1321        let cfg = test_config(OutputFormat::Mp4H264Aac);
1322        let args =
1323            build_live_video_args(&cfg, Path::new("/tmp/inter.scratch")).expect("supported OS");
1324        // Frames arrive on the child's stdin.
1325        assert!(args.iter().any(|a| a == "fdsrc"));
1326        assert!(args.iter().any(|a| a == "fd=0"));
1327        // Raw BGRA caps reflect the config.
1328        assert!(args.iter().any(|a| a == "format=bgra"));
1329        assert!(args.iter().any(|a| a.starts_with("width=1920")));
1330        assert!(args.iter().any(|a| a.starts_with("height=1080")));
1331        assert!(args.iter().any(|a| a.starts_with("framerate=30/1")));
1332        // mp4 container + h264 parser, written to the intermediate.
1333        assert!(args.iter().any(|a| a == "mp4mux"));
1334        assert!(args.iter().any(|a| a == "h264parse"));
1335        assert!(args.iter().any(|a| a == "location=/tmp/inter.scratch"));
1336        // No audio leg in the live pipeline — audio is muxed at finalize.
1337        assert!(!args.iter().any(|a| a == "audioconvert"));
1338    }
1339
1340    #[test]
1341    fn live_h264_disables_frame_reordering_so_the_scratch_remuxes() {
1342        // macOS vtenc must disable B-frame reordering: the live H.264
1343        // scratch is re-demuxed at finalize to mux in audio, and reordered
1344        // frames demux with no PTS → mp4mux rejects them, failing the
1345        // finalize of every audio recording. The encoder element + its
1346        // property must each be their own argv token.
1347        if std::env::consts::OS != "macos" {
1348            return;
1349        }
1350        let cfg = test_config(OutputFormat::Mp4H264Aac);
1351        let args = build_live_video_args(&cfg, Path::new("/tmp/v.scratch")).expect("macos");
1352        assert!(
1353            args.iter().any(|a| a == "vtenc_h264_hw"),
1354            "encoder element is its own token"
1355        );
1356        assert!(
1357            args.iter().any(|a| a == "allow-frame-reordering=false"),
1358            "B-frame reordering off so the scratch demuxes with PTS for the finalize remux"
1359        );
1360    }
1361
1362    #[test]
1363    fn remux_args_copy_video_and_encode_audio() {
1364        let cfg = test_config(OutputFormat::Mp4H264Aac);
1365        let args = build_remux_args(
1366            &cfg,
1367            Path::new("/tmp/inter.scratch"),
1368            Path::new("/tmp/a.f32.scratch"),
1369            true,
1370            true,
1371        );
1372        // Video leg: demux the intermediate + parse (stream-copy, no re-encode).
1373        assert!(args.iter().any(|a| a == "qtdemux"));
1374        assert!(args.iter().any(|a| a == "h264parse"));
1375        assert!(args.iter().any(|a| a == "location=/tmp/inter.scratch"));
1376        // The video is copied — there must be NO video encoder element.
1377        assert!(!args.iter().any(|a| a == "vtenc_h264_hw"));
1378        // Audio leg: raw F32 → AAC.
1379        assert!(args.iter().any(|a| a == "rawaudioparse"));
1380        assert!(args.iter().any(|a| a == "pcm-format=f32le"));
1381        assert!(args.iter().any(|a| a == "avenc_aac"));
1382        // Mux + sink to the final output.
1383        assert!(args.iter().any(|a| a == "mp4mux"));
1384        assert!(args.iter().any(|a| a == "location=/tmp/test.x"));
1385    }
1386
1387    #[test]
1388    fn audio_decode_args_pipe_raw_f32le_to_stdout() {
1389        // ED.21: the edited-export audio intake decodes the source's audio to
1390        // raw interleaved F32LE on stdout at the encoder's caps, so the
1391        // per-segment retime can slice it in Rust.
1392        let args = build_audio_decode_args(Path::new("/tmp/source.mp4"), 48_000, 2);
1393        assert_eq!(args.first().map(String::as_str), Some("-q"));
1394        assert!(args.iter().any(|a| a == "location=/tmp/source.mp4"));
1395        assert!(args.iter().any(|a| a == "decodebin"));
1396        assert!(args.iter().any(|a| a == "audioconvert"));
1397        assert!(args.iter().any(|a| a == "audioresample"));
1398        // Caps must match the encoder's audio scratch (F32LE @ 48k stereo)
1399        // so the finalize remux's rawaudioparse reads them correctly.
1400        assert!(args.iter().any(|a| a.contains("format=F32LE")
1401            && a.contains("rate=48000")
1402            && a.contains("channels=2")
1403            && a.contains("layout=interleaved")));
1404        // Raw samples come out on stdout (fd 1), the input mirror of the
1405        // video decode pattern.
1406        assert!(args.iter().any(|a| a == "fdsink"));
1407        assert!(args.iter().any(|a| a == "fd=1"));
1408        // It's a decode, not an encode — no encoder / mux elements.
1409        assert!(!args.iter().any(|a| a == "avenc_aac"));
1410        assert!(!args.iter().any(|a| a == "mp4mux"));
1411    }
1412
1413    #[test]
1414    fn remux_args_video_only_omits_audio_leg() {
1415        let cfg = test_config(OutputFormat::Mp4H264Aac);
1416        let args = build_remux_args(
1417            &cfg,
1418            Path::new("/tmp/inter.scratch"),
1419            Path::new("/tmp/a.f32.scratch"),
1420            true,
1421            false,
1422        );
1423        assert!(args.iter().any(|a| a == "qtdemux"));
1424        assert!(!args.iter().any(|a| a == "avenc_aac"));
1425        assert!(!args.iter().any(|a| a == "rawaudioparse"));
1426    }
1427
1428    /// Anti-regression mirror of
1429    /// [`pipeline_args_never_use_legacy_pcm_f32le_token`] for the
1430    /// remux audio leg — `format=pcm-f32le` is invalid on
1431    /// `rawaudioparse` and silently drops every audio recording.
1432    #[test]
1433    fn remux_args_never_use_legacy_pcm_f32le_token() {
1434        let cfg = test_config(OutputFormat::Mp4H264Aac);
1435        let args = build_remux_args(
1436            &cfg,
1437            Path::new("/tmp/inter.scratch"),
1438            Path::new("/tmp/a.f32.scratch"),
1439            true,
1440            true,
1441        );
1442        assert!(!args.iter().any(|a| a == "format=pcm-f32le"));
1443        assert!(args.iter().any(|a| a == "pcm-format=f32le"));
1444    }
1445
1446    #[test]
1447    fn encoder_and_mux_elements_rejects_unknown_os() {
1448        let result = encoder_and_mux_elements(OutputFormat::Mp4H264Aac, "plan9");
1449        assert!(matches!(result, Err(EncodeError::Unsupported { .. })));
1450    }
1451
1452    #[test]
1453    fn encoder_and_mux_elements_maps_each_format() {
1454        // Per-format encoder/mux/audio selection on the current OS
1455        // (retains the coverage the removed batch pipeline_args tests
1456        // had). Other OSes' match arms are checked at compile time.
1457        let os = std::env::consts::OS;
1458        if os != "macos" && os != "windows" && os != "linux" {
1459            return;
1460        }
1461        for (fmt, want_mux, want_audio) in [
1462            (OutputFormat::Mp4H264Aac, "mp4mux", "avenc_aac"),
1463            (OutputFormat::Mp4H265Aac, "mp4mux", "avenc_aac"),
1464            (OutputFormat::WebmVp9Opus, "webmmux", "opusenc"),
1465            (OutputFormat::WebmAv1Opus, "webmmux", "opusenc"),
1466        ] {
1467            let (encoders, mux) = encoder_and_mux_elements(fmt, os).expect("supported OS");
1468            assert!(!encoders.is_empty(), "{fmt:?}: needs an encoder element");
1469            assert_eq!(mux, want_mux, "{fmt:?}: muxer");
1470            assert_eq!(
1471                audio_encoder_element(fmt),
1472                want_audio,
1473                "{fmt:?}: audio encoder"
1474            );
1475        }
1476    }
1477
1478    #[test]
1479    fn live_encoder_new_rejects_zero_dimensions() {
1480        // Validation runs before any gst spawn, so this needs no
1481        // gstreamer on PATH (runs on every CI OS).
1482        let mut cfg = test_config(OutputFormat::Mp4H264Aac);
1483        cfg.width = 0;
1484        let result = LiveGstreamerEncoder::new(cfg);
1485        assert!(matches!(result, Err(EncodeError::InvalidConfig(_))));
1486    }
1487
1488    // ---- AUT-334 — encoder-limit clamp (aspect-preserving) ----
1489
1490    #[test]
1491    fn max_encode_edge_is_codec_specific() {
1492        assert_eq!(OutputFormat::Mp4H264Aac.max_encode_edge(), Some(4096));
1493        assert_eq!(OutputFormat::Mp4H265Aac.max_encode_edge(), Some(8192));
1494        assert_eq!(OutputFormat::WebmVp9Opus.max_encode_edge(), None);
1495        assert_eq!(OutputFormat::WebmAv1Opus.max_encode_edge(), None);
1496    }
1497
1498    #[test]
1499    fn fit_within_limits_passes_through_when_within_h264_cap() {
1500        // ≤4096 on both edges → unchanged (even inputs stay identical).
1501        for (w, h) in [
1502            (1920, 1080),
1503            (3840, 2160),
1504            (4096, 2160),
1505            (4096, 2304),
1506            (4096, 4096),
1507        ] {
1508            assert_eq!(
1509                fit_within_encoder_limits(w, h, OutputFormat::Mp4H264Aac),
1510                (w, h),
1511                "{w}x{h} is within the H.264 cap and must pass through"
1512            );
1513        }
1514    }
1515
1516    #[test]
1517    fn fit_within_limits_downscales_real_over_4k_displays_to_4096x2304() {
1518        // Every real >4K display is 16:9 (5K / 6K / 8K), so each clamps
1519        // to exactly 4096×2304 with zero aspect drift.
1520        for (w, h) in [(5120, 2880), (6016, 3384), (7680, 4320)] {
1521            let (cw, ch) = fit_within_encoder_limits(w, h, OutputFormat::Mp4H264Aac);
1522            assert!(cw <= 4096 && ch <= 4096, "{w}x{h} -> {cw}x{ch} exceeds cap");
1523            assert_eq!(cw % 2, 0, "width must be even");
1524            assert_eq!(ch % 2, 0, "height must be even");
1525            assert_eq!((cw, ch), (4096, 2304), "{w}x{h} should clamp to 4096x2304");
1526        }
1527    }
1528
1529    #[test]
1530    fn fit_within_limits_clamps_longest_edge_and_keeps_ratio() {
1531        // Ultrawide (21:9), portrait, and a tall sliver — the longest
1532        // edge becomes exactly 4096; the other shrinks by the same
1533        // factor. Even-flooring may drift the shorter edge by <1px;
1534        // assert the aspect skew stays sub-pixel via cross-multiply.
1535        for (w, h) in [(5120, 2160), (2880, 5120), (5120, 1440)] {
1536            let (cw, ch) = fit_within_encoder_limits(w, h, OutputFormat::Mp4H264Aac);
1537            assert!(cw <= 4096 && ch <= 4096, "{w}x{h} -> {cw}x{ch} exceeds cap");
1538            assert_eq!(cw.max(ch), 4096, "longest edge scales to the 4096 cap");
1539            assert_eq!(cw % 2, 0);
1540            assert_eq!(ch % 2, 0);
1541            // |w·ch − h·cw| is the aspect error scaled by the source's
1542            // longest edge; keep it under ~2px of drift.
1543            let drift = (i64::from(w) * i64::from(ch) - i64::from(h) * i64::from(cw)).abs();
1544            assert!(
1545                drift <= 2 * i64::from(w.max(h)),
1546                "{w}x{h} -> {cw}x{ch} skews the aspect ratio"
1547            );
1548        }
1549    }
1550
1551    #[test]
1552    fn fit_within_limits_h265_keeps_5k_and_caps_at_8192() {
1553        // HEVC's 8192 ceiling keeps full 5K and 8K; only >8192 clamps.
1554        assert_eq!(
1555            fit_within_encoder_limits(5120, 2880, OutputFormat::Mp4H265Aac),
1556            (5120, 2880)
1557        );
1558        assert_eq!(
1559            fit_within_encoder_limits(8192, 4320, OutputFormat::Mp4H265Aac),
1560            (8192, 4320)
1561        );
1562        let (cw, ch) = fit_within_encoder_limits(10240, 4320, OutputFormat::Mp4H265Aac);
1563        assert!(cw <= 8192 && ch <= 8192);
1564        assert_eq!(cw, 8192, "longest edge clamps to the HEVC 8192 cap");
1565    }
1566
1567    #[test]
1568    fn fit_within_limits_never_clamps_software_webm() {
1569        // libvpx / SVT-AV1 accept any size (no negotiation cap).
1570        assert_eq!(
1571            fit_within_encoder_limits(5120, 2880, OutputFormat::WebmVp9Opus),
1572            (5120, 2880)
1573        );
1574        assert_eq!(
1575            fit_within_encoder_limits(7680, 4320, OutputFormat::WebmAv1Opus),
1576            (7680, 4320)
1577        );
1578    }
1579
1580    #[test]
1581    fn fit_within_limits_evens_odd_input() {
1582        // Odd dims shouldn't occur post-`sanitize_dims`, but the encoder
1583        // requires mod-2, so the clamp floors to even defensively.
1584        let (cw, ch) = fit_within_encoder_limits(4097, 2161, OutputFormat::Mp4H264Aac);
1585        assert!(cw <= 4096 && ch <= 4096);
1586        assert_eq!(cw % 2, 0);
1587        assert_eq!(ch % 2, 0);
1588    }
1589}