Skip to main content

screen_app/
recording.rs

1//! M-RECORD.0 — `RecordingSession` state machine + shared monotonic
2//! clock for the coordinated recording lifecycle (M-RECORD-EXPORT).
3//!
4//! This module is **pure Rust** — no Tauri types, no I/O. It defines:
5//!
6//! - [`SessionState`] — `Idle → Starting → Running → Stopping → Idle`.
7//!   Mirrors the per-channel `MicLifecycle` / `ScreenLifecycle` shape
8//!   so the M-RECORD.2 LED renderer can reuse the same colour map.
9//! - [`StreamKind`] — which of the four input streams (camera, screen,
10//!   microphone, system audio) a [`StreamHealth`] refers to.
11//! - [`StreamHealth`] — per-stream health snapshot (lifecycle +
12//!   cumulative frame count + last-frame timestamp). Built fresh by
13//!   M-RECORD.1's `recording_status` IPC every 500 ms.
14//! - [`SessionStreams`] — which streams the user enabled for this
15//!   session (boolean flags). Doesn't own the actual pipelines —
16//!   those stay in their existing Tauri-managed `State<>` handles;
17//!   the session just coordinates their lifecycles.
18//! - [`RecordingSession`] — the orchestrator type itself. Wraps the
19//!   four state pieces above into one immutable-once-started struct
20//!   with a shared `started_at: Instant` clock used by the M-EXPORT
21//!   encoder to compute per-frame PTS.
22//!
23//! ```admonish important title="What this commit ships vs. M-RECORD.1"
24//! M-RECORD.0 lands the **types + state machine** only. The Tauri
25//! `start_recording` / `stop_recording` / `recording_status` IPC and
26//! the 500 ms event-push task that consumes this state live in
27//! M-RECORD.1. Splitting the chunks keeps the state machine
28//! unit-testable without Tauri's `AppHandle`.
29//! ```
30
31use std::sync::Arc;
32use std::sync::Mutex;
33use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
34use std::time::{Duration, Instant};
35
36use edit::{ClickEvent, CursorSample};
37use media::audio_mix::AudioMixer;
38use media::encode::{LiveGstreamerEncoder, VideoEncoder};
39use serde::{Deserialize, Serialize};
40
41use crate::click_capture::ClickTap;
42use crate::cursor_capture::CursorPoller;
43
44/// Default audio mixer channel count (stereo). Matches the
45/// `EncoderConfig` default + the SCK / mic worker output formats.
46const DEFAULT_AUDIO_CHANNELS: u8 = 2;
47
48/// Latest-frame-wins slot the capture pipelines write into and the
49/// encoder feed thread reads from (M-PIX.0). `None` until the first
50/// frame; the capture pipeline overwrites with each new frame; the
51/// encoder reads (cloned) at render time.
52pub type FrameSlot = Arc<Mutex<Option<Vec<u8>>>>;
53
54/// Shared audio mixer (M-PIX.0) the mic worker + SCK audio
55/// delegate push samples into, and the encoder feed thread drains
56/// via `AudioMixer::pull()`.
57pub type SharedAudioMixer = Arc<Mutex<AudioMixer>>;
58
59/// Construct a fresh frame slot — `Arc<Mutex<None>>`.
60#[must_use]
61pub fn new_frame_slot() -> FrameSlot {
62    Arc::new(Mutex::new(None))
63}
64
65/// Construct a fresh shared mixer with the default channel count.
66#[must_use]
67pub fn new_audio_mixer() -> SharedAudioMixer {
68    Arc::new(Mutex::new(
69        AudioMixer::new(DEFAULT_AUDIO_CHANNELS).expect("DEFAULT_AUDIO_CHANNELS > 0"),
70    ))
71}
72
73/// Monotonically-increasing session id. Resets per process start;
74/// the id only needs to be unique within a single app run so the
75/// `recording-status` event consumer can ignore stale events from a
76/// previous session.
77static NEXT_SESSION_ID: AtomicU64 = AtomicU64::new(1);
78
79/// Master state of a [`RecordingSession`]. Mirrors the per-channel
80/// `MicLifecycle` / `ScreenLifecycle` shape (and renders with the
81/// same LED colour map in M-RECORD.2).
82#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
83pub enum SessionState {
84    /// No session active.
85    #[default]
86    Idle,
87    /// `start_recording` invoked; per-channel pipelines being
88    /// spawned. The session moves to `Running` once at least one
89    /// enabled stream has reported its first frame.
90    Starting,
91    /// All enabled streams have produced at least one frame.
92    Running,
93    /// `stop_recording` invoked; per-channel pipelines being torn
94    /// down. The session moves back to `Idle` after every enabled
95    /// stream's lifecycle has reached its Idle state.
96    Stopping,
97}
98
99impl SessionState {
100    /// `Idle → Starting`; other states unchanged.
101    #[must_use]
102    pub fn try_start(self) -> Self {
103        match self {
104            Self::Idle => Self::Starting,
105            other => other,
106        }
107    }
108
109    /// `Starting → Running`; idempotent on `Running` (subsequent
110    /// per-stream first-frame events don't re-trigger).
111    #[must_use]
112    pub fn mark_running(self) -> Self {
113        match self {
114            Self::Starting => Self::Running,
115            other => other,
116        }
117    }
118
119    /// `Starting | Running → Stopping`; `Idle | Stopping` unchanged.
120    #[must_use]
121    pub fn try_stop(self) -> Self {
122        match self {
123            Self::Running | Self::Starting => Self::Stopping,
124            other => other,
125        }
126    }
127
128    /// `Stopping → Idle`; other states unchanged.
129    #[must_use]
130    pub fn finish_stop(self) -> Self {
131        match self {
132            Self::Stopping => Self::Idle,
133            other => other,
134        }
135    }
136}
137
138/// Which of the four input streams a [`StreamHealth`] describes.
139/// Sent across the IPC seam so the Leptos `<RecorderControls />`
140/// can colour the right LED.
141#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, Serialize, Deserialize)]
142pub enum StreamKind {
143    /// Webcam capture (via gst `avfvideosrc` / `mfvideosrc` /
144    /// `v4l2src`).
145    Camera,
146    /// Screen / window capture (via macOS `ScreenCaptureKit`).
147    Screen,
148    /// Microphone input (via gst `osxaudiosrc` / `wasapisrc` /
149    /// `pulsesrc`).
150    Microphone,
151    /// System / per-application audio (via macOS SCK audio).
152    SystemAudio,
153}
154
155/// Per-stream health snapshot for the `recording-status` event push.
156/// Built fresh every 500 ms by M-RECORD.1.
157#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
158pub struct StreamHealth {
159    /// Which input stream this snapshot describes.
160    pub kind: StreamKind,
161    /// One of `"Idle"` / `"Starting"` / `"Running"` / `"Stopping"`
162    /// from the per-channel lifecycle enum. Kept as a string so
163    /// this struct doesn't need to import all four per-channel
164    /// enums.
165    pub lifecycle: String,
166    /// Cumulative frame / chunk count since the session started.
167    pub frame_count: u64,
168    /// Milliseconds since the last frame was observed. `None` if
169    /// no frame has arrived yet (still in `Starting`). The LED
170    /// colour ramp in M-RECORD.2 reads this directly:
171    /// green &lt; 1000 ms, yellow &lt; 5000 ms, red otherwise.
172    pub last_frame_ms_ago: Option<u64>,
173}
174
175/// Which streams the user enabled at session-start time. Doesn't
176/// own the actual pipeline handles — those stay in their existing
177/// Tauri-managed `State<>` wrappers; the session just remembers
178/// which channels to start + stop together.
179#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
180#[allow(
181    clippy::struct_excessive_bools,
182    reason = "Each bool maps to one of the four physical input channels (camera / screen / mic / system audio). They're inherently independent flags — a bitflag would be less readable across the IPC seam where Leptos consumes them as `{ camera: bool, screen: bool, ... }`."
183)]
184pub struct SessionStreams {
185    /// `true` if the camera channel should participate in this
186    /// session.
187    pub camera: bool,
188    /// `true` if the screen-capture channel should participate.
189    pub screen: bool,
190    /// `true` if the microphone channel should participate.
191    pub microphone: bool,
192    /// `true` if the system-audio channel should participate.
193    pub system_audio: bool,
194}
195
196impl SessionStreams {
197    /// `true` if at least one channel is enabled. M-RECORD.1's
198    /// `start_recording` rejects sessions with no streams selected.
199    #[must_use]
200    pub fn any_enabled(self) -> bool {
201        self.camera || self.screen || self.microphone || self.system_audio
202    }
203
204    /// Iterate over the enabled `StreamKind`s in canonical order
205    /// (camera → screen → microphone → system audio). Used by
206    /// M-RECORD.1's status assembler to walk the per-channel
207    /// `State<>` handles in a deterministic order.
208    pub fn enabled_kinds(self) -> impl Iterator<Item = StreamKind> {
209        [
210            (self.camera, StreamKind::Camera),
211            (self.screen, StreamKind::Screen),
212            (self.microphone, StreamKind::Microphone),
213            (self.system_audio, StreamKind::SystemAudio),
214        ]
215        .into_iter()
216        .filter_map(|(on, kind)| if on { Some(kind) } else { None })
217    }
218}
219
220/// One coordinated recording session — the orchestrator owned by
221/// `RecordingState` (M-RECORD.1) for the lifetime of one
222/// start → stop cycle.
223///
224/// Construction is staged so the Tauri-side `start_recording`
225/// command can do the heavy lifting (spawn per-channel pipelines,
226/// roll back on per-stream failure) without mutating session state
227/// mid-failure:
228///
229/// 1. `RecordingSession::starting(streams)` — allocates the session
230///    id, captures `Instant::now()` as `started_at`, sets state to
231///    `Starting`. No pipelines spawned yet.
232/// 2. Caller spawns each enabled per-channel pipeline; on any
233///    failure, calls `RecordingSession::abort()` and returns
234///    `Err(...)`.
235/// 3. Once all enabled streams have reported their first frame,
236///    `RecordingSession::mark_running()` flips state to `Running`.
237/// 4. On `stop_recording`, `RecordingSession::begin_stop()` flips
238///    to `Stopping`; caller tears down per-channel pipelines.
239/// 5. `RecordingSession::finish_stop()` flips back to `Idle`.
240#[derive(Clone, Debug, PartialEq, Eq)]
241pub struct RecordingSession {
242    /// Unique-per-process session id. Stamped on every emitted
243    /// `recording-status` event so a delayed event from a prior
244    /// session can be filtered.
245    pub id: u64,
246    /// Shared monotonic clock — every per-frame PTS pushed into the
247    /// M-EXPORT encoder is computed as `Instant::now() - started_at`.
248    /// Captured ONCE here so all four streams share the same origin.
249    pub started_at: Instant,
250    /// Wall-clock start time (Unix epoch seconds). Complements the
251    /// monotonic `started_at`; M-SAVE.1 carries it through to export
252    /// so the `Screen-YYYY-MM-DD-HHMMSS.<ext>` filename reflects when
253    /// the recording *started*, not when the user clicked Export.
254    pub started_at_unix_secs: u64,
255    /// Master lifecycle.
256    pub state: SessionState,
257    /// Which channels are part of this session.
258    pub streams: SessionStreams,
259}
260
261impl RecordingSession {
262    /// Begin a new session — allocates an id, captures the start
263    /// time, sets state to `Starting`. Caller is responsible for
264    /// spawning the enabled per-channel pipelines.
265    #[must_use]
266    pub fn starting(streams: SessionStreams) -> Self {
267        Self {
268            id: NEXT_SESSION_ID.fetch_add(1, Ordering::Relaxed),
269            started_at: Instant::now(),
270            started_at_unix_secs: std::time::SystemTime::now()
271                .duration_since(std::time::UNIX_EPOCH)
272                .map_or(0, |d| d.as_secs()),
273            state: SessionState::Starting,
274            streams,
275        }
276    }
277
278    /// Mark the session `Running`. Called by M-RECORD.1 once all
279    /// enabled streams have produced their first frame. Idempotent.
280    pub fn mark_running(&mut self) {
281        self.state = self.state.mark_running();
282    }
283
284    /// Begin tearing down — `Starting | Running → Stopping`.
285    /// Idempotent on `Stopping`; no-op on `Idle`.
286    pub fn begin_stop(&mut self) {
287        self.state = self.state.try_stop();
288    }
289
290    /// Finish teardown — `Stopping → Idle`. Idempotent on `Idle`;
291    /// no-op on `Starting | Running` (the `begin_stop` step has to
292    /// happen first).
293    pub fn finish_stop(&mut self) {
294        self.state = self.state.finish_stop();
295    }
296
297    /// Hard-abort the session — flip state straight to `Idle`
298    /// regardless of where it was. Used by M-RECORD.1 to roll back
299    /// when a per-channel start failed mid-Starting and the partial
300    /// pipelines have been torn down.
301    pub fn abort(&mut self) {
302        self.state = SessionState::Idle;
303    }
304
305    /// Elapsed time since `started_at`. Used by M-RECORD.2's
306    /// `mm:ss` display + by M-EXPORT's PTS math.
307    #[must_use]
308    pub fn elapsed(&self) -> Duration {
309        self.started_at.elapsed()
310    }
311}
312
313/// Tauri-managed wrapper around the optional active session. Held
314/// in `tauri::State` so the `start_recording` / `stop_recording` /
315/// `recording_status` IPC commands + the 500 ms event-push task
316/// share one source of truth. Mirror of `MicCaptureState` /
317/// `ScreenCaptureState`.
318///
319/// Two slots:
320/// - `session: Mutex<Option<RecordingSession>>` — the immutable
321///   snapshot (id, `started_at`, state, streams).
322/// - `encoder: Mutex<Option<EncoderHandle>>` — the live encoder +
323///   test-pattern feed thread. Separated from `session` so the
324///   500 ms `recording-status` event push can take a cheap clone
325///   of the session snapshot without holding the encoder mutex.
326pub struct RecordingState {
327    /// Immutable per-session snapshot.
328    pub session: Mutex<Option<RecordingSession>>,
329    /// Active encoder + its background feed thread. M-EXPORT.3.
330    pub encoder: Mutex<Option<EncoderHandle>>,
331    /// Latest BGRA frame from the camera capture worker (M-PIX.0).
332    /// Written by `crates/app/src/preview/pipeline.rs::run_pipeline`;
333    /// consumed by M-PIX.5's compose thread.
334    pub camera_frame_slot: FrameSlot,
335    /// Latest BGRA frame from the SCK screen-capture delegate
336    /// (M-PIX.0). Written by
337    /// `crates/media/src/sck_video.rs::ScreenOutputHandler`;
338    /// consumed by M-PIX.5's compose thread.
339    pub screen_frame_slot: FrameSlot,
340    /// Shared two-source mic + system-audio mixer (M-PIX.0). Mic
341    /// worker calls `push_mic`; SCK audio delegate calls
342    /// `push_sys_audio`; encoder feed thread calls `pull()`.
343    pub audio_mixer: SharedAudioMixer,
344    /// A finished recording sitting in scratch, awaiting the user's
345    /// format choice in the Save panel (M-SAVE.1). Set by
346    /// `stop_recording`; cleared by `export_recording` /
347    /// `discard_recording`. `Some` here is the "awaiting export"
348    /// signal the Save panel keys off — there is no `SessionState`
349    /// variant for it (keeps the state-machine matches + LED colour
350    /// map untouched).
351    pub pending_export: Mutex<Option<PendingExport>>,
352    /// Cursor-position capture worker (ED.17), live for the duration of a
353    /// recording. `Some` while recording; stopped + drained at
354    /// `stop_recording`. macOS-only in practice (the non-macOS poller is a
355    /// no-op).
356    pub cursor_poller: Mutex<Option<CursorPoller>>,
357    /// The cursor track from the most-recent recording, awaiting the
358    /// Record→Edit handoff (ED.17). Set at stop; consumed by `open_in_editor`
359    /// (attached to the project); cleared on a new recording / export /
360    /// discard so it never leaks onto an unrelated clip.
361    pub pending_cursor_track: Mutex<Option<Vec<CursorSample>>>,
362    /// Click-capture tap (ED.17 / ISS-16), live for the duration of a
363    /// recording. `Some` while recording (when Input-Monitoring is granted);
364    /// stopped + drained at `stop_recording`. macOS-only (the non-macOS tap is
365    /// a no-op).
366    pub click_tap: Mutex<Option<ClickTap>>,
367    /// The click log from the most-recent recording, awaiting the Record→Edit
368    /// handoff (ED.17). Same lifecycle as [`Self::pending_cursor_track`]: set
369    /// at stop, consumed by `open_in_editor`, cleared on a new recording /
370    /// export / discard. Feeds auto-zoom + the ED.19 click ripples.
371    pub pending_clicks: Mutex<Option<Vec<ClickEvent>>>,
372}
373
374impl Default for RecordingState {
375    fn default() -> Self {
376        Self {
377            session: Mutex::new(None),
378            encoder: Mutex::new(None),
379            camera_frame_slot: new_frame_slot(),
380            screen_frame_slot: new_frame_slot(),
381            audio_mixer: new_audio_mixer(),
382            pending_export: Mutex::new(None),
383            cursor_poller: Mutex::new(None),
384            pending_cursor_track: Mutex::new(None),
385            click_tap: Mutex::new(None),
386            pending_clicks: Mutex::new(None),
387        }
388    }
389}
390
391impl RecordingState {
392    /// `true` if a session is currently held.
393    #[must_use]
394    pub fn is_active(&self) -> bool {
395        self.session
396            .lock()
397            .unwrap_or_else(std::sync::PoisonError::into_inner)
398            .is_some()
399    }
400
401    /// Start cursor-position capture for a new recording (ED.17). Clears any
402    /// stale pending track first so it can't leak onto this recording's edit.
403    pub fn start_cursor_capture(&self, screen_source_id: Option<&str>) {
404        *self
405            .pending_cursor_track
406            .lock()
407            .unwrap_or_else(std::sync::PoisonError::into_inner) = None;
408        // ISS-17: normalize against the *captured* display's bounds, not always
409        // the main display.
410        let rect = crate::cursor_capture::display_bounds_for_source(screen_source_id);
411        *self
412            .cursor_poller
413            .lock()
414            .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(CursorPoller::start(rect));
415    }
416
417    /// Stop cursor capture and stash the resampled track for the Record→Edit
418    /// handoff (ED.17). `project_fps` is the editor's timeline authority.
419    pub fn finish_cursor_capture(&self, project_fps: u32) {
420        let poller = self
421            .cursor_poller
422            .lock()
423            .unwrap_or_else(std::sync::PoisonError::into_inner)
424            .take();
425        if let Some(poller) = poller {
426            let samples = poller.stop();
427            let track = crate::cursor_capture::samples_to_track(&samples, project_fps);
428            if !track.is_empty() {
429                *self
430                    .pending_cursor_track
431                    .lock()
432                    .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(track);
433            }
434        }
435    }
436
437    /// Take the pending cursor track (the Record→Edit handoff consumes it).
438    #[must_use]
439    pub fn take_cursor_track(&self) -> Option<Vec<CursorSample>> {
440        self.pending_cursor_track
441            .lock()
442            .unwrap_or_else(std::sync::PoisonError::into_inner)
443            .take()
444    }
445
446    /// Discard any pending cursor track (the export / discard paths, so a
447    /// track never leaks onto an unrelated later edit).
448    pub fn clear_cursor_track(&self) {
449        *self
450            .pending_cursor_track
451            .lock()
452            .unwrap_or_else(std::sync::PoisonError::into_inner) = None;
453    }
454
455    /// Start click capture for a new recording (ED.17 / ISS-16). Clears any
456    /// stale pending clicks first so they can't leak onto this recording's
457    /// edit. Normalizes against the *captured* display's bounds (ISS-17).
458    pub fn start_click_capture(&self, screen_source_id: Option<&str>) {
459        *self
460            .pending_clicks
461            .lock()
462            .unwrap_or_else(std::sync::PoisonError::into_inner) = None;
463        let rect = crate::cursor_capture::display_bounds_for_source(screen_source_id);
464        *self
465            .click_tap
466            .lock()
467            .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(ClickTap::start(rect));
468    }
469
470    /// Stop click capture and stash the resampled click log for the
471    /// Record→Edit handoff (ED.17 / ISS-16). `project_fps` is the editor's
472    /// timeline authority. A degraded (empty) tap leaves no pending clicks.
473    pub fn finish_click_capture(&self, project_fps: u32) {
474        let tap = self
475            .click_tap
476            .lock()
477            .unwrap_or_else(std::sync::PoisonError::into_inner)
478            .take();
479        if let Some(tap) = tap {
480            let samples = tap.stop();
481            let clicks = crate::click_capture::samples_to_clicks(&samples, project_fps);
482            if !clicks.is_empty() {
483                *self
484                    .pending_clicks
485                    .lock()
486                    .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(clicks);
487            }
488        }
489    }
490
491    /// Take the pending click log (the Record→Edit handoff consumes it).
492    #[must_use]
493    pub fn take_clicks(&self) -> Option<Vec<ClickEvent>> {
494        self.pending_clicks
495            .lock()
496            .unwrap_or_else(std::sync::PoisonError::into_inner)
497            .take()
498    }
499
500    /// Discard any pending click log (the export / discard paths, so a log
501    /// never leaks onto an unrelated later edit).
502    pub fn clear_clicks(&self) {
503        *self
504            .pending_clicks
505            .lock()
506            .unwrap_or_else(std::sync::PoisonError::into_inner) = None;
507    }
508
509    /// Snapshot of the active session, if any. Cloned so callers
510    /// don't hold the mutex across the rest of their work.
511    #[must_use]
512    pub fn snapshot(&self) -> Option<RecordingSession> {
513        self.session
514            .lock()
515            .unwrap_or_else(std::sync::PoisonError::into_inner)
516            .clone()
517    }
518
519    /// Install a fresh encoder handle (M-EXPORT.3). Replaces any
520    /// prior handle without finalising — caller is responsible for
521    /// having finalised the previous session first.
522    pub fn install_encoder(&self, handle: EncoderHandle) {
523        let mut guard = self
524            .encoder
525            .lock()
526            .unwrap_or_else(std::sync::PoisonError::into_inner);
527        *guard = Some(handle);
528    }
529
530    /// Take the encoder handle out for finalisation. Returns `None`
531    /// when no session was active.
532    #[must_use]
533    pub fn take_encoder(&self) -> Option<EncoderHandle> {
534        self.encoder
535            .lock()
536            .unwrap_or_else(std::sync::PoisonError::into_inner)
537            .take()
538    }
539
540    /// Stash a finished recording awaiting export (M-SAVE.1).
541    pub fn set_pending_export(&self, pending: PendingExport) {
542        let mut guard = self
543            .pending_export
544            .lock()
545            .unwrap_or_else(std::sync::PoisonError::into_inner);
546        *guard = Some(pending);
547    }
548
549    /// Take the pending export out (for `export_recording` /
550    /// `discard_recording`). Returns `None` when nothing is awaiting.
551    #[must_use]
552    pub fn take_pending_export(&self) -> Option<PendingExport> {
553        self.pending_export
554            .lock()
555            .unwrap_or_else(std::sync::PoisonError::into_inner)
556            .take()
557    }
558
559    /// `true` while a finished recording is awaiting export. Used by
560    /// `start_recording` to refuse starting a new session on top of
561    /// an un-exported one (which would orphan its scratch file).
562    #[must_use]
563    pub fn has_pending_export(&self) -> bool {
564        self.pending_export
565            .lock()
566            .unwrap_or_else(std::sync::PoisonError::into_inner)
567            .is_some()
568    }
569
570    /// IPC snapshot of the pending export, if any (M-SAVE.1). Cloned
571    /// so the caller doesn't hold the mutex.
572    #[must_use]
573    pub fn pending_export_view(&self) -> Option<PendingExportView> {
574        self.pending_export
575            .lock()
576            .unwrap_or_else(std::sync::PoisonError::into_inner)
577            .as_ref()
578            .map(PendingExport::view)
579    }
580}
581
582/// Live encoder + its background feed-thread cancel flag. Owned by
583/// [`RecordingState::encoder`] for the duration of a session.
584///
585/// The M-EXPORT.3 v0 ships with a **test-pattern feed thread** —
586/// pushes a solid-colour BGRA frame at 30 fps so the encoder
587/// produces a real (trivial) `.mp4` file the user can verify the
588/// orchestration end-to-end with. Real per-channel pixel forwarding
589/// (extending camera/screen/SCK pipelines to push frames) is the
590/// `M-EXPORT.3.1` follow-up.
591pub struct EncoderHandle {
592    /// Cooperative cancel flag for the feed thread.
593    pub cancel: Arc<AtomicBool>,
594    /// The encoder itself. Wrapped in `Mutex<Option<...>>` so the
595    /// feed thread can push from its lock; `finalize` calls `take()`
596    /// + invokes `Box::finalize` after the thread joins.
597    pub encoder: Arc<Mutex<Option<Box<dyn VideoEncoder>>>>,
598    /// Output path the encoder was configured to write to. Mirrored
599    /// here so `stop_recording` can include it in `RecordingSummary`
600    /// without re-querying.
601    pub output_path: std::path::PathBuf,
602    /// Background thread feeding the encoder. `Some` while active,
603    /// `None` after `take_for_finalize()` removes it.
604    pub feed_thread: Option<std::thread::JoinHandle<()>>,
605}
606
607impl std::fmt::Debug for EncoderHandle {
608    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
609        f.debug_struct("EncoderHandle")
610            .field("output_path", &self.output_path)
611            .field("feed_thread_alive", &self.feed_thread.is_some())
612            .finish_non_exhaustive()
613    }
614}
615
616impl EncoderHandle {
617    /// Start the encoder + a real-capture feed thread that pulls
618    /// composed frames from [`crate::recording_compose::RecordingCompose`]
619    /// and mixed audio from [`SharedAudioMixer`] (M-PIX.6).
620    ///
621    /// The compose pump is constructed *inside* the feed thread —
622    /// wisp's `Application` (wgpu Device + Queue) isn't `Send`
623    /// across the worker boundary in all configurations, and
624    /// constructing it on the thread that uses it avoids the
625    /// question entirely.
626    ///
627    /// # Errors
628    ///
629    /// Returns the underlying encode error if the encoder can't
630    /// be constructed (parent dir missing, scratch file open
631    /// failure). Wisp init errors at compose-pump construction
632    /// happen on the feed thread; logged but don't fail the
633    /// handle creation.
634    pub fn start_with_real_capture(
635        encoder_config: media::encode::EncoderConfig,
636        camera_slot: FrameSlot,
637        screen_slot: FrameSlot,
638        audio_mixer: SharedAudioMixer,
639        screen_dims: wisp::recording::StreamDimensions,
640        cam_dims: wisp::recording::StreamDimensions,
641    ) -> Result<Self, media::encode::EncodeError> {
642        crate::recording_paths::ensure_parent_dir(&encoder_config.output_path)
643            .map_err(media::encode::EncodeError::Io)?;
644
645        let width = encoder_config.width;
646        let height = encoder_config.height;
647        let framerate = encoder_config.framerate;
648        let output_path = encoder_config.output_path.clone();
649
650        let inner = LiveGstreamerEncoder::new(encoder_config)?;
651        let encoder: Arc<Mutex<Option<Box<dyn VideoEncoder>>>> =
652            Arc::new(Mutex::new(Some(Box::new(inner))));
653        let cancel = Arc::new(AtomicBool::new(false));
654
655        let cancel_thread = Arc::clone(&cancel);
656        let encoder_thread = Arc::clone(&encoder);
657        let feed_thread = std::thread::Builder::new()
658            .name(format!("recording-encoder-real-{width}x{height}"))
659            .spawn(move || {
660                feed_real_capture(
661                    encoder_thread,
662                    cancel_thread,
663                    camera_slot,
664                    screen_slot,
665                    audio_mixer,
666                    width,
667                    height,
668                    framerate,
669                    screen_dims,
670                    cam_dims,
671                );
672            })
673            .map_err(|err| media::encode::EncodeError::Spawn {
674                source: err,
675                path: std::env::var("PATH").unwrap_or_else(|_| "<unset>".into()),
676            })?;
677
678        Ok(Self {
679            cancel,
680            encoder,
681            output_path,
682            feed_thread: Some(feed_thread),
683        })
684    }
685
686    /// Start an encoder + spawn the test-pattern feed thread.
687    /// Returns a handle whose `finalize_now()` produces the final
688    /// container path.
689    ///
690    /// `palette_seed` controls the test-pattern colour (different
691    /// per session id so multiple recordings produce visually
692    /// distinct previews).
693    pub fn start_with_test_pattern(
694        encoder_config: media::encode::EncoderConfig,
695        palette_seed: u64,
696    ) -> Result<Self, media::encode::EncodeError> {
697        // Ensure the parent dir of the output exists (M-EXPORT.4
698        // helper). `EncoderConfig.output_path` is a file path.
699        crate::recording_paths::ensure_parent_dir(&encoder_config.output_path)
700            .map_err(media::encode::EncodeError::Io)?;
701
702        let width = encoder_config.width;
703        let height = encoder_config.height;
704        let framerate = encoder_config.framerate;
705        let channels = encoder_config.channels;
706        let sample_rate = encoder_config.sample_rate;
707        let output_path = encoder_config.output_path.clone();
708
709        let inner = LiveGstreamerEncoder::new(encoder_config)?;
710        let encoder: Arc<Mutex<Option<Box<dyn VideoEncoder>>>> =
711            Arc::new(Mutex::new(Some(Box::new(inner))));
712        let cancel = Arc::new(AtomicBool::new(false));
713
714        let cancel_thread = Arc::clone(&cancel);
715        let encoder_thread = Arc::clone(&encoder);
716        let feed_thread = std::thread::Builder::new()
717            .name(format!("recording-encoder-feed-{palette_seed}"))
718            .spawn(move || {
719                feed_test_pattern(
720                    encoder_thread,
721                    cancel_thread,
722                    width,
723                    height,
724                    framerate,
725                    channels,
726                    sample_rate,
727                    palette_seed,
728                );
729            })
730            .map_err(|err| media::encode::EncodeError::Spawn {
731                source: err,
732                path: std::env::var("PATH").unwrap_or_else(|_| "<unset>".into()),
733            })?;
734
735        Ok(Self {
736            cancel,
737            encoder,
738            output_path,
739            feed_thread: Some(feed_thread),
740        })
741    }
742
743    /// Stop the feed thread and finalize the encoder. Returns the
744    /// output path on success.
745    ///
746    /// # Errors
747    ///
748    /// Returns the underlying [`media::encode::EncodeError`] if the
749    /// gst-launch subprocess fails.
750    pub fn finalize_now(mut self) -> Result<std::path::PathBuf, media::encode::EncodeError> {
751        self.cancel.store(true, Ordering::Relaxed);
752        if let Some(handle) = self.feed_thread.take() {
753            let _ = handle.join();
754        }
755        let encoder = self
756            .encoder
757            .lock()
758            .unwrap_or_else(std::sync::PoisonError::into_inner)
759            .take();
760        match encoder {
761            Some(boxed) => boxed.finalize(),
762            None => Ok(self.output_path),
763        }
764    }
765}
766
767/// How many video frames a constant-frame-rate stream should have emitted
768/// by `elapsed` (at `frame_interval`), minus those already `pushed` — i.e.
769/// the number to push this tick to keep the encoder's frame count locked
770/// to wall-clock. The live encoder timestamps frames by *count* at a fixed
771/// rate, so a compose pump that can't sustain that rate must duplicate the
772/// last frame to fill the deficit, or the recording plays fast. Bounded by
773/// `max_burst` so a long stall can't emit an unbounded catch-up burst.
774fn cfr_catchup_frames(
775    elapsed: Duration,
776    frame_interval: Duration,
777    pushed: u64,
778    max_burst: u64,
779) -> u64 {
780    let interval_us = frame_interval.as_micros().max(1);
781    // Frames due by now, counting frame 0 at t=0: floor(elapsed/interval)+1.
782    let due = u64::try_from(elapsed.as_micros() / interval_us).unwrap_or(u64::MAX);
783    due.saturating_add(1).saturating_sub(pushed).min(max_burst)
784}
785
786/// Real-capture feed loop (M-PIX.6) — pulls composed frames from
787/// [`crate::recording_compose::RecordingCompose`] + mixed audio
788/// from the shared [`SharedAudioMixer`] until cancel fires.
789///
790/// Built inside the worker thread so wisp's wgpu Application
791/// doesn't cross thread boundaries. If the compose pump fails to
792/// init (no GPU adapter, etc.) the thread logs + exits cleanly —
793/// the encoder still finalizes whatever audio happened to arrive.
794#[allow(
795    clippy::too_many_arguments,
796    clippy::needless_pass_by_value,
797    reason = "arg list is the necessary capture-state surface; Arcs are taken by value because this fn IS the thread body."
798)]
799fn feed_real_capture(
800    encoder: Arc<Mutex<Option<Box<dyn VideoEncoder>>>>,
801    cancel: Arc<AtomicBool>,
802    camera_slot: FrameSlot,
803    screen_slot: FrameSlot,
804    audio_mixer: SharedAudioMixer,
805    width: u32,
806    height: u32,
807    framerate: u32,
808    screen_dims: wisp::recording::StreamDimensions,
809    cam_dims: wisp::recording::StreamDimensions,
810) {
811    let mut compose =
812        match crate::recording_compose::RecordingCompose::new(width, height, screen_dims, cam_dims)
813        {
814            Ok(c) => c,
815            Err(err) => {
816                tracing::error!(
817                    ?err,
818                    "feed_real_capture: RecordingCompose::new failed; exiting"
819                );
820                return;
821            }
822        };
823
824    let interval_us = 1_000_000_u64 / u64::from(framerate.max(1));
825    let frame_interval = Duration::from_micros(interval_us);
826    // Bound a single tick's catch-up to ~one second of frames so a long
827    // stall can't emit an unbounded duplicate burst.
828    let max_burst = u64::from(framerate.max(1));
829    // The wall-clock anchor starts at the FIRST composed frame so the
830    // pre-roll wait for the first capture isn't counted as a deficit.
831    let mut anchor: Option<Instant> = None;
832    let mut last_bytes: Option<Vec<u8>> = None;
833    let mut frames_pushed: u64 = 0;
834    let mut audio_chunks_pushed: u64 = 0;
835
836    while !cancel.load(Ordering::Relaxed) {
837        // Compose the latest content. `compose_frame` returns None only
838        // before the first real frame lands (pre-roll); after that it
839        // composes the current slot contents every tick.
840        if let Some(frame) = compose.compose_frame(&camera_slot, &screen_slot) {
841            last_bytes = Some(frame.bytes);
842            if anchor.is_none() {
843                anchor = Some(Instant::now());
844            }
845        }
846
847        // Constant-frame-rate fill: keep the pushed frame count locked to
848        // wall-clock by duplicating the last composed frame to cover any
849        // deficit (an unchanged frame encodes to a tiny P-frame). The live
850        // encoder timestamps frames by *count* at `framerate`, so a
851        // compose pump that can't sustain it would otherwise under-deliver
852        // and the recording would play fast.
853        if let (Some(start), Some(bytes)) = (anchor, last_bytes.as_ref()) {
854            let due = cfr_catchup_frames(start.elapsed(), frame_interval, frames_pushed, max_burst);
855            if due > 0 {
856                let mut guard = encoder
857                    .lock()
858                    .unwrap_or_else(std::sync::PoisonError::into_inner);
859                let Some(ref mut enc) = *guard else { break };
860                let mut failed = false;
861                for _ in 0..due {
862                    let pts = Duration::from_micros(frames_pushed * interval_us);
863                    if let Err(err) = enc.push_video_frame(bytes, pts) {
864                        tracing::warn!(?err, "feed_real_capture: push_video_frame failed");
865                        failed = true;
866                        break;
867                    }
868                    frames_pushed = frames_pushed.saturating_add(1);
869                }
870                drop(guard);
871                if failed {
872                    break;
873                }
874            }
875        }
876
877        // Pull any mixed audio for this tick (real samples → real-time
878        // length; the CFR-filled video now matches it instead of running
879        // ahead).
880        let audio_samples = {
881            let mut mixer_guard = audio_mixer
882                .lock()
883                .unwrap_or_else(std::sync::PoisonError::into_inner);
884            mixer_guard.pull()
885        };
886        if !audio_samples.is_empty() {
887            let mut guard = encoder
888                .lock()
889                .unwrap_or_else(std::sync::PoisonError::into_inner);
890            if let Some(ref mut enc) = *guard {
891                let pts = Duration::from_micros(frames_pushed.saturating_sub(1) * interval_us);
892                if let Err(err) = enc.push_audio_chunk(&audio_samples, pts) {
893                    tracing::warn!(
894                        ?err,
895                        "feed_real_capture: push_audio_chunk failed (continuing)"
896                    );
897                } else {
898                    audio_chunks_pushed = audio_chunks_pushed.saturating_add(1);
899                }
900            }
901        }
902
903        // Sleep until the next frame is due (a no-op when behind — the
904        // next iteration's catch-up then fills the deficit). Pre-roll (no
905        // frame yet) polls the slots at the frame interval.
906        match anchor {
907            Some(start) => {
908                let target = start + Duration::from_micros(frames_pushed * interval_us);
909                if let Some(sleep) = target.checked_duration_since(Instant::now()) {
910                    std::thread::sleep(sleep);
911                }
912            }
913            None => std::thread::sleep(frame_interval),
914        }
915    }
916    tracing::info!(
917        frames = frames_pushed,
918        audio_chunks_pushed,
919        "feed_real_capture: feed thread exiting"
920    );
921}
922
923/// Test-pattern feed loop running on a dedicated thread. Pushes a
924/// solid colour BGRA frame at the encoder's framerate (and a chunk
925/// of silence at the audio sample rate) until cancel fires.
926#[allow(
927    clippy::cast_possible_truncation,
928    clippy::cast_sign_loss,
929    clippy::too_many_arguments,
930    clippy::needless_pass_by_value,
931    reason = "test-pattern math: u64 seed → u8 channel via wrapping arithmetic; arg list is the necessary EncoderConfig surface for this self-contained feeder; Arc<...> args are taken by value because this fn IS the thread body — the caller move-spawns and the Arcs are dropped when the closure returns."
932)]
933fn feed_test_pattern(
934    encoder: Arc<Mutex<Option<Box<dyn VideoEncoder>>>>,
935    cancel: Arc<AtomicBool>,
936    width: u32,
937    height: u32,
938    framerate: u32,
939    channels: u8,
940    sample_rate: u32,
941    palette_seed: u64,
942) {
943    let frame_interval = Duration::from_micros(1_000_000_u64 / u64::from(framerate.max(1)));
944    let bytes_per_frame = (width as usize) * (height as usize) * 4;
945    let mut frame = vec![0u8; bytes_per_frame];
946    // Solid colour from the palette seed (different per session).
947    let b = (palette_seed.wrapping_mul(73) & 0xff) as u8;
948    let g = (palette_seed.wrapping_mul(151) & 0xff) as u8;
949    let r = (palette_seed.wrapping_mul(229) & 0xff) as u8;
950    for px in frame.chunks_exact_mut(4) {
951        px[0] = b;
952        px[1] = g;
953        px[2] = r;
954        px[3] = 255;
955    }
956    // Silence chunk sized for one frame interval at the configured
957    // audio caps.
958    let samples_per_frame =
959        (u64::from(sample_rate) / u64::from(framerate.max(1))) as usize * channels as usize;
960    let silence = vec![0.0_f32; samples_per_frame];
961
962    let started_at = Instant::now();
963    let mut next_pts_frames: u64 = 0;
964    while !cancel.load(Ordering::Relaxed) {
965        let pts =
966            Duration::from_micros(next_pts_frames * (1_000_000_u64 / u64::from(framerate.max(1))));
967        {
968            let mut guard = encoder
969                .lock()
970                .unwrap_or_else(std::sync::PoisonError::into_inner);
971            if let Some(ref mut enc) = *guard {
972                if let Err(err) = enc.push_video_frame(&frame, pts) {
973                    tracing::warn!(?err, "feed_test_pattern: push_video_frame failed");
974                    break;
975                }
976                if let Err(err) = enc.push_audio_chunk(&silence, pts) {
977                    tracing::trace!(
978                        ?err,
979                        "feed_test_pattern: push_audio_chunk failed (continuing)"
980                    );
981                }
982            } else {
983                break;
984            }
985        }
986        next_pts_frames = next_pts_frames.saturating_add(1);
987        // Pace at framerate without drifting too badly under load.
988        let target = started_at + frame_interval * (next_pts_frames as u32);
989        if let Some(sleep) = target.checked_duration_since(Instant::now()) {
990            std::thread::sleep(sleep);
991        }
992    }
993    tracing::info!(
994        frames = next_pts_frames,
995        "feed_test_pattern: feed thread exiting"
996    );
997}
998
999// ---- IPC view types (M-RECORD.1) ---------------------------------
1000
1001/// `start_recording` argument. Carries which streams to enable +
1002/// the per-channel picker selections + the output target. M-EXPORT.4
1003/// extends `output_path` / `format` semantics; here they're carried
1004/// through unchanged so the IPC seam doesn't need to break later.
1005#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
1006pub struct RecordingConfig {
1007    /// Which physical channels to coordinate.
1008    pub streams: SessionStreams,
1009    /// Camera picker selection — empty = OS default (M-CAM.4).
1010    #[serde(default)]
1011    pub camera_id: String,
1012    /// Microphone picker selection — empty = OS default (M-MIC.3).
1013    #[serde(default)]
1014    pub microphone_id: String,
1015    /// Screen-source picker selection — `None` / empty = primary
1016    /// display (M-SCK.0.1).
1017    #[serde(default)]
1018    pub screen_source_id: Option<String>,
1019    /// Output file path. `None` means "use the M-EXPORT.4 default
1020    /// location"; M-RECORD.1 just carries the string through.
1021    #[serde(default)]
1022    pub output_path: Option<String>,
1023    /// Output container/codec format slug (e.g. `"mp4-h264"`,
1024    /// `"webm-vp9"`). `None` means "use the default" — M-EXPORT.1
1025    /// owns the slug → `OutputFormat` mapping.
1026    #[serde(default)]
1027    pub format: Option<String>,
1028}
1029
1030/// Snapshot of the active session for the `recording_status` IPC
1031/// + the 500 ms `recording-status` event push.
1032#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
1033pub struct RecordingStatusView {
1034    /// `None` when no session is active.
1035    pub session_id: Option<u64>,
1036    /// Master lifecycle. `Idle` when no session is active.
1037    pub state: SessionState,
1038    /// Elapsed time in milliseconds since `started_at`. `0` when
1039    /// no session is active.
1040    pub elapsed_ms: u64,
1041    /// One entry per enabled stream.
1042    pub streams: Vec<StreamHealth>,
1043}
1044
1045impl RecordingStatusView {
1046    /// Empty / no-session snapshot. Returned by `recording_status`
1047    /// when nothing is recording.
1048    #[must_use]
1049    pub fn idle() -> Self {
1050        Self {
1051            session_id: None,
1052            state: SessionState::Idle,
1053            elapsed_ms: 0,
1054            streams: Vec::new(),
1055        }
1056    }
1057}
1058
1059/// Result of a successful `stop_recording`. Distinct from
1060/// `RecordingStatusView` because it's a final summary (no live
1061/// state) — fields the UI uses to show the post-record toast +
1062/// "Reveal in Finder" button (M-EXPORT.4).
1063#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
1064pub struct RecordingSummary {
1065    /// The session id that just stopped.
1066    pub session_id: u64,
1067    /// Total session duration in milliseconds (`started_at` →
1068    /// `stop_recording`).
1069    pub elapsed_ms: u64,
1070    /// Final per-stream tally.
1071    pub streams: Vec<StreamHealth>,
1072    /// Always `None` since M-SAVE.1 — stop defers the save, so
1073    /// `pending_export` carries the handoff instead.
1074    pub output_path: Option<String>,
1075    /// Set when the stopped recording is sitting in scratch awaiting
1076    /// the user's format choice (M-SAVE.1). The Save panel keys off
1077    /// this to appear; `None` only on the legacy/no-encoder path.
1078    #[serde(default)]
1079    pub pending_export: Option<PendingExportView>,
1080}
1081
1082/// A finished recording sitting in the scratch directory, awaiting
1083/// the user's format choice in the Save panel (M-SAVE.1). Held in
1084/// [`RecordingState::pending_export`] between `stop_recording` and
1085/// `export_recording` / `discard_recording`. **Pure Rust** — no
1086/// Tauri / serde; the IPC-facing mirror is [`PendingExportView`].
1087#[derive(Clone, Debug, PartialEq, Eq)]
1088pub struct PendingExport {
1089    /// Absolute path to the finalized scratch file. Always MP4 / H.264
1090    /// — the canonical intermediate; the Save panel's format choice
1091    /// decides whether export moves it (MP4) or transcodes it (`WebM`).
1092    pub scratch_path: std::path::PathBuf,
1093    /// Recording duration in ms, frozen at stop.
1094    pub duration_ms: u64,
1095    /// Wall-clock start time (Unix seconds) — drives the exported
1096    /// `Screen-YYYY-MM-DD-HHMMSS.<ext>` filename.
1097    pub started_at_unix_secs: u64,
1098}
1099
1100impl PendingExport {
1101    /// Project to the IPC mirror — duration + the suggested base
1102    /// filename (no extension), dropping the internal scratch path.
1103    #[must_use]
1104    pub fn view(&self) -> PendingExportView {
1105        PendingExportView {
1106            duration_ms: self.duration_ms,
1107            suggested_basename: crate::recording_paths::default_basename(self.started_at_unix_secs),
1108        }
1109    }
1110}
1111
1112/// IPC mirror of [`PendingExport`]. Carries only what the Save panel
1113/// needs — never the internal scratch path.
1114#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
1115pub struct PendingExportView {
1116    /// Recording duration in ms.
1117    pub duration_ms: u64,
1118    /// Suggested base filename (no extension), e.g.
1119    /// `"Screen-2026-05-25-123700"`. The panel appends the extension
1120    /// matching the chosen format.
1121    pub suggested_basename: String,
1122}
1123
1124#[cfg(test)]
1125mod tests {
1126    use super::*;
1127
1128    // ---- SessionState transitions ----
1129
1130    #[test]
1131    fn state_default_is_idle() {
1132        assert_eq!(SessionState::default(), SessionState::Idle);
1133    }
1134
1135    #[test]
1136    fn state_full_round_trip() {
1137        let mut s = SessionState::default();
1138        s = s.try_start();
1139        assert_eq!(s, SessionState::Starting);
1140        s = s.mark_running();
1141        assert_eq!(s, SessionState::Running);
1142        s = s.try_stop();
1143        assert_eq!(s, SessionState::Stopping);
1144        s = s.finish_stop();
1145        assert_eq!(s, SessionState::Idle);
1146    }
1147
1148    #[test]
1149    fn state_re_entrant_start_is_noop() {
1150        for s in [
1151            SessionState::Starting,
1152            SessionState::Running,
1153            SessionState::Stopping,
1154        ] {
1155            assert_eq!(s.try_start(), s);
1156        }
1157    }
1158
1159    #[test]
1160    fn state_mark_running_only_advances_from_starting() {
1161        assert_eq!(SessionState::Idle.mark_running(), SessionState::Idle);
1162        assert_eq!(SessionState::Starting.mark_running(), SessionState::Running);
1163        // Idempotent on Running — subsequent per-stream first-frame
1164        // events don't re-trigger.
1165        assert_eq!(SessionState::Running.mark_running(), SessionState::Running);
1166        assert_eq!(
1167            SessionState::Stopping.mark_running(),
1168            SessionState::Stopping
1169        );
1170    }
1171
1172    #[test]
1173    fn state_stop_only_advances_from_starting_or_running() {
1174        assert_eq!(SessionState::Idle.try_stop(), SessionState::Idle);
1175        assert_eq!(SessionState::Starting.try_stop(), SessionState::Stopping);
1176        assert_eq!(SessionState::Running.try_stop(), SessionState::Stopping);
1177        assert_eq!(SessionState::Stopping.try_stop(), SessionState::Stopping);
1178    }
1179
1180    #[test]
1181    fn state_finish_stop_only_advances_from_stopping() {
1182        assert_eq!(SessionState::Idle.finish_stop(), SessionState::Idle);
1183        assert_eq!(SessionState::Starting.finish_stop(), SessionState::Starting);
1184        assert_eq!(SessionState::Running.finish_stop(), SessionState::Running);
1185        assert_eq!(SessionState::Stopping.finish_stop(), SessionState::Idle);
1186    }
1187
1188    #[test]
1189    fn state_serde_round_trip() {
1190        for v in [
1191            SessionState::Idle,
1192            SessionState::Starting,
1193            SessionState::Running,
1194            SessionState::Stopping,
1195        ] {
1196            let json = serde_json::to_string(&v).unwrap();
1197            let back: SessionState = serde_json::from_str(&json).unwrap();
1198            assert_eq!(back, v);
1199        }
1200    }
1201
1202    // ---- SessionStreams ----
1203
1204    #[test]
1205    fn streams_default_is_all_off() {
1206        let s = SessionStreams::default();
1207        assert!(!s.any_enabled());
1208        assert_eq!(s.enabled_kinds().count(), 0);
1209    }
1210
1211    #[test]
1212    fn streams_any_enabled_true_when_any_field_on() {
1213        for s in [
1214            SessionStreams {
1215                camera: true,
1216                ..Default::default()
1217            },
1218            SessionStreams {
1219                screen: true,
1220                ..Default::default()
1221            },
1222            SessionStreams {
1223                microphone: true,
1224                ..Default::default()
1225            },
1226            SessionStreams {
1227                system_audio: true,
1228                ..Default::default()
1229            },
1230        ] {
1231            assert!(s.any_enabled());
1232        }
1233    }
1234
1235    #[test]
1236    fn streams_enabled_kinds_walks_in_canonical_order() {
1237        let all_on = SessionStreams {
1238            camera: true,
1239            screen: true,
1240            microphone: true,
1241            system_audio: true,
1242        };
1243        let kinds: Vec<_> = all_on.enabled_kinds().collect();
1244        assert_eq!(
1245            kinds,
1246            vec![
1247                StreamKind::Camera,
1248                StreamKind::Screen,
1249                StreamKind::Microphone,
1250                StreamKind::SystemAudio,
1251            ]
1252        );
1253    }
1254
1255    #[test]
1256    fn streams_enabled_kinds_filters_off_channels() {
1257        let cam_only = SessionStreams {
1258            camera: true,
1259            ..Default::default()
1260        };
1261        let kinds: Vec<_> = cam_only.enabled_kinds().collect();
1262        assert_eq!(kinds, vec![StreamKind::Camera]);
1263    }
1264
1265    // ---- RecordingSession orchestrator ----
1266
1267    #[test]
1268    fn session_starts_with_unique_increasing_ids() {
1269        let a = RecordingSession::starting(SessionStreams::default());
1270        let b = RecordingSession::starting(SessionStreams::default());
1271        let c = RecordingSession::starting(SessionStreams::default());
1272        assert!(b.id > a.id);
1273        assert!(c.id > b.id);
1274    }
1275
1276    #[test]
1277    fn session_starting_state_is_starting() {
1278        let s = RecordingSession::starting(SessionStreams::default());
1279        assert_eq!(s.state, SessionState::Starting);
1280    }
1281
1282    #[test]
1283    fn session_full_lifecycle_round_trip() {
1284        let mut s = RecordingSession::starting(SessionStreams {
1285            camera: true,
1286            microphone: true,
1287            ..Default::default()
1288        });
1289        assert_eq!(s.state, SessionState::Starting);
1290        s.mark_running();
1291        assert_eq!(s.state, SessionState::Running);
1292        s.begin_stop();
1293        assert_eq!(s.state, SessionState::Stopping);
1294        s.finish_stop();
1295        assert_eq!(s.state, SessionState::Idle);
1296    }
1297
1298    #[test]
1299    fn session_abort_flips_state_to_idle_from_any_state() {
1300        for start_state in [
1301            SessionState::Starting,
1302            SessionState::Running,
1303            SessionState::Stopping,
1304        ] {
1305            let mut s = RecordingSession::starting(SessionStreams::default());
1306            s.state = start_state;
1307            s.abort();
1308            assert_eq!(s.state, SessionState::Idle);
1309        }
1310    }
1311
1312    #[test]
1313    fn session_elapsed_is_monotonically_nondecreasing() {
1314        let s = RecordingSession::starting(SessionStreams::default());
1315        let first = s.elapsed();
1316        // Tiny busy-wait so the second sample is strictly after the
1317        // first on every clock granularity we care about.
1318        for _ in 0..10_000 {
1319            std::hint::spin_loop();
1320        }
1321        let second = s.elapsed();
1322        assert!(second >= first);
1323    }
1324
1325    // ---- StreamHealth + StreamKind ----
1326
1327    #[test]
1328    fn stream_kind_serde_round_trip() {
1329        for k in [
1330            StreamKind::Camera,
1331            StreamKind::Screen,
1332            StreamKind::Microphone,
1333            StreamKind::SystemAudio,
1334        ] {
1335            let json = serde_json::to_string(&k).unwrap();
1336            let back: StreamKind = serde_json::from_str(&json).unwrap();
1337            assert_eq!(back, k);
1338        }
1339    }
1340
1341    #[test]
1342    fn stream_health_serde_round_trip() {
1343        let h = StreamHealth {
1344            kind: StreamKind::Camera,
1345            lifecycle: "Running".to_string(),
1346            frame_count: 1234,
1347            last_frame_ms_ago: Some(42),
1348        };
1349        let json = serde_json::to_string(&h).unwrap();
1350        let back: StreamHealth = serde_json::from_str(&json).unwrap();
1351        assert_eq!(back, h);
1352    }
1353
1354    // ---- RecordingState wrapper ----
1355
1356    // ---- M-PIX.0 slot + mixer plumbing ----
1357
1358    #[test]
1359    fn new_frame_slot_starts_none() {
1360        let slot = new_frame_slot();
1361        assert!(slot.lock().unwrap().is_none());
1362    }
1363
1364    #[test]
1365    fn cursor_track_handoff_is_consume_once_and_clearable() {
1366        // ED.17: the Record→Edit handoff consumes the pending cursor track
1367        // exactly once (so it can't re-attach to a later clip), and the
1368        // export/discard paths can drop it.
1369        let state = RecordingState::default();
1370        assert!(
1371            state.take_cursor_track().is_none(),
1372            "none until a recording"
1373        );
1374
1375        *state
1376            .pending_cursor_track
1377            .lock()
1378            .unwrap_or_else(std::sync::PoisonError::into_inner) =
1379            Some(vec![CursorSample::new(0, 0.5, 0.5)]);
1380        assert_eq!(state.take_cursor_track().map(|t| t.len()), Some(1), "taken");
1381        assert!(state.take_cursor_track().is_none(), "consumed once");
1382
1383        *state
1384            .pending_cursor_track
1385            .lock()
1386            .unwrap_or_else(std::sync::PoisonError::into_inner) =
1387            Some(vec![CursorSample::new(1, 0.1, 0.1)]);
1388        state.clear_cursor_track();
1389        assert!(state.take_cursor_track().is_none(), "cleared");
1390    }
1391
1392    #[test]
1393    fn click_log_handoff_is_consume_once_and_clearable() {
1394        // ED.17 / ISS-16: the click log rides the same consume-once Record→Edit
1395        // handoff as the cursor track, and the export/discard paths drop it.
1396        let state = RecordingState::default();
1397        assert!(state.take_clicks().is_none(), "none until a recording");
1398
1399        *state
1400            .pending_clicks
1401            .lock()
1402            .unwrap_or_else(std::sync::PoisonError::into_inner) =
1403            Some(vec![ClickEvent::new(10, 0.4, 0.6)]);
1404        assert_eq!(state.take_clicks().map(|c| c.len()), Some(1), "taken");
1405        assert!(state.take_clicks().is_none(), "consumed once");
1406
1407        *state
1408            .pending_clicks
1409            .lock()
1410            .unwrap_or_else(std::sync::PoisonError::into_inner) =
1411            Some(vec![ClickEvent::new(20, 0.1, 0.1)]);
1412        state.clear_clicks();
1413        assert!(state.take_clicks().is_none(), "cleared");
1414    }
1415
1416    #[test]
1417    fn frame_slot_latest_wins_on_overwrite() {
1418        let slot = new_frame_slot();
1419        *slot.lock().unwrap() = Some(vec![1, 2, 3]);
1420        *slot.lock().unwrap() = Some(vec![4, 5, 6, 7]);
1421        let read = slot.lock().unwrap().clone();
1422        assert_eq!(read, Some(vec![4, 5, 6, 7]));
1423    }
1424
1425    #[test]
1426    fn frame_slot_take_clears() {
1427        let slot = new_frame_slot();
1428        *slot.lock().unwrap() = Some(vec![1, 2, 3]);
1429        let taken = slot.lock().unwrap().take();
1430        assert_eq!(taken, Some(vec![1, 2, 3]));
1431        assert!(slot.lock().unwrap().is_none());
1432    }
1433
1434    #[test]
1435    fn frame_slot_is_arc_cheap_to_clone() {
1436        let slot = new_frame_slot();
1437        let clone1 = Arc::clone(&slot);
1438        let clone2 = Arc::clone(&slot);
1439        *clone1.lock().unwrap() = Some(vec![0xff; 16]);
1440        assert_eq!(clone2.lock().unwrap().as_ref().unwrap().len(), 16);
1441        assert_eq!(slot.lock().unwrap().as_ref().unwrap().len(), 16);
1442    }
1443
1444    #[test]
1445    fn new_audio_mixer_has_stereo_channels() {
1446        let mixer = new_audio_mixer();
1447        assert_eq!(mixer.lock().unwrap().channels(), DEFAULT_AUDIO_CHANNELS);
1448        assert_eq!(mixer.lock().unwrap().channels(), 2);
1449    }
1450
1451    #[test]
1452    fn shared_audio_mixer_is_arc_cheap_to_clone() {
1453        let mixer = new_audio_mixer();
1454        let writer_clone = Arc::clone(&mixer);
1455        let reader_clone = Arc::clone(&mixer);
1456        writer_clone.lock().unwrap().push_mic(&[0.1, 0.2]).unwrap();
1457        assert_eq!(reader_clone.lock().unwrap().mic_queued(), 2);
1458    }
1459
1460    #[test]
1461    fn recording_state_default_wires_slots_and_mixer() {
1462        let state = RecordingState::default();
1463        assert!(state.camera_frame_slot.lock().unwrap().is_none());
1464        assert!(state.screen_frame_slot.lock().unwrap().is_none());
1465        assert_eq!(state.audio_mixer.lock().unwrap().channels(), 2);
1466    }
1467
1468    #[test]
1469    fn recording_state_starts_inactive() {
1470        let s = RecordingState::default();
1471        assert!(!s.is_active());
1472        assert!(s.snapshot().is_none());
1473    }
1474
1475    #[test]
1476    fn recording_state_holds_then_releases_session() {
1477        let s = RecordingState::default();
1478        {
1479            let mut guard = s.session.lock().unwrap();
1480            *guard = Some(RecordingSession::starting(SessionStreams {
1481                camera: true,
1482                ..Default::default()
1483            }));
1484        }
1485        assert!(s.is_active());
1486        let snap = s.snapshot().unwrap();
1487        assert_eq!(snap.state, SessionState::Starting);
1488        assert!(snap.streams.camera);
1489
1490        // Clear it
1491        s.session.lock().unwrap().take();
1492        assert!(!s.is_active());
1493    }
1494
1495    // ---- RecordingStatusView / RecordingSummary / RecordingConfig ----
1496
1497    #[test]
1498    fn status_view_idle_is_empty() {
1499        let view = RecordingStatusView::idle();
1500        assert!(view.session_id.is_none());
1501        assert_eq!(view.state, SessionState::Idle);
1502        assert_eq!(view.elapsed_ms, 0);
1503        assert!(view.streams.is_empty());
1504    }
1505
1506    #[test]
1507    fn status_view_serde_round_trip() {
1508        let view = RecordingStatusView {
1509            session_id: Some(7),
1510            state: SessionState::Running,
1511            elapsed_ms: 12_345,
1512            streams: vec![StreamHealth {
1513                kind: StreamKind::Camera,
1514                lifecycle: "Running".into(),
1515                frame_count: 90,
1516                last_frame_ms_ago: Some(33),
1517            }],
1518        };
1519        let json = serde_json::to_string(&view).unwrap();
1520        let back: RecordingStatusView = serde_json::from_str(&json).unwrap();
1521        assert_eq!(back, view);
1522    }
1523
1524    #[test]
1525    fn summary_serde_round_trip() {
1526        let summary = RecordingSummary {
1527            session_id: 42,
1528            elapsed_ms: 10_000,
1529            streams: vec![],
1530            output_path: Some("/tmp/screen.mp4".into()),
1531            pending_export: None,
1532        };
1533        let json = serde_json::to_string(&summary).unwrap();
1534        let back: RecordingSummary = serde_json::from_str(&json).unwrap();
1535        assert_eq!(back, summary);
1536    }
1537
1538    // ---- M-SAVE.1 pending-export state ----
1539
1540    fn sample_pending() -> PendingExport {
1541        PendingExport {
1542            scratch_path: std::path::PathBuf::from("/tmp/scratch-7.mp4"),
1543            duration_ms: 8_000,
1544            started_at_unix_secs: 1_763_402_400,
1545        }
1546    }
1547
1548    #[test]
1549    fn pending_export_set_take_round_trip() {
1550        let state = RecordingState::default();
1551        assert!(!state.has_pending_export());
1552        assert!(state.take_pending_export().is_none());
1553
1554        state.set_pending_export(sample_pending());
1555        assert!(state.has_pending_export());
1556
1557        let taken = state.take_pending_export().expect("pending present");
1558        assert_eq!(taken, sample_pending());
1559        // take() clears it — a second take yields None.
1560        assert!(!state.has_pending_export());
1561        assert!(state.take_pending_export().is_none());
1562    }
1563
1564    #[test]
1565    fn pending_export_view_drops_scratch_path_and_strips_extension() {
1566        let view = sample_pending().view();
1567        assert_eq!(view.duration_ms, 8_000);
1568        assert_eq!(view.suggested_basename, "Screen-2025-11-17-180000");
1569        // The view exposes no scratch path; the basename has no ext.
1570        assert!(
1571            std::path::Path::new(&view.suggested_basename)
1572                .extension()
1573                .is_none()
1574        );
1575    }
1576
1577    #[test]
1578    fn pending_export_view_matches_state_accessor() {
1579        let state = RecordingState::default();
1580        assert!(state.pending_export_view().is_none());
1581        state.set_pending_export(sample_pending());
1582        assert_eq!(state.pending_export_view(), Some(sample_pending().view()));
1583    }
1584
1585    #[test]
1586    fn pending_export_view_serde_round_trip() {
1587        let view = sample_pending().view();
1588        let json = serde_json::to_string(&view).unwrap();
1589        let back: PendingExportView = serde_json::from_str(&json).unwrap();
1590        assert_eq!(back, view);
1591    }
1592
1593    #[test]
1594    fn config_serde_round_trip_with_all_optionals() {
1595        let cfg = RecordingConfig {
1596            streams: SessionStreams {
1597                camera: true,
1598                microphone: true,
1599                ..Default::default()
1600            },
1601            camera_id: "cam-feedface".into(),
1602            microphone_id: "mic-cafebabe".into(),
1603            screen_source_id: Some("display-1".into()),
1604            output_path: Some("/tmp/test.mp4".into()),
1605            format: Some("mp4-h264".into()),
1606        };
1607        let json = serde_json::to_string(&cfg).unwrap();
1608        let back: RecordingConfig = serde_json::from_str(&json).unwrap();
1609        assert_eq!(back, cfg);
1610    }
1611
1612    #[test]
1613    fn config_serde_back_compat_when_optionals_absent() {
1614        // Frontend may omit any/all of the optional fields; the
1615        // #[serde(default)] on each must keep deserialization clean.
1616        let legacy =
1617            r#"{"streams":{"camera":true,"screen":false,"microphone":false,"system_audio":false}}"#;
1618        let parsed: RecordingConfig = serde_json::from_str(legacy).unwrap();
1619        assert!(parsed.streams.camera);
1620        assert_eq!(parsed.camera_id, "");
1621        assert_eq!(parsed.microphone_id, "");
1622        assert!(parsed.screen_source_id.is_none());
1623        assert!(parsed.output_path.is_none());
1624        assert!(parsed.format.is_none());
1625    }
1626
1627    #[test]
1628    fn stream_health_handles_none_last_frame() {
1629        // Still in Starting, no frame yet — None must round-trip.
1630        let h = StreamHealth {
1631            kind: StreamKind::SystemAudio,
1632            lifecycle: "Starting".to_string(),
1633            frame_count: 0,
1634            last_frame_ms_ago: None,
1635        };
1636        let json = serde_json::to_string(&h).unwrap();
1637        let back: StreamHealth = serde_json::from_str(&json).unwrap();
1638        assert_eq!(back, h);
1639    }
1640
1641    #[test]
1642    fn cfr_catchup_locks_frame_count_to_wallclock() {
1643        let fi = Duration::from_micros(1_000_000 / 30); // 30 fps
1644        // At t=0, exactly one frame is due (frame 0).
1645        assert_eq!(cfr_catchup_frames(Duration::ZERO, fi, 0, 100), 1);
1646        // After 1 s at 30 fps, frames 0..=30 are due (31); one already
1647        // pushed → 30 more this tick.
1648        assert_eq!(cfr_catchup_frames(Duration::from_secs(1), fi, 1, 100), 30);
1649        // A slow compose pump (only 5 pushed after 1 s) catches up toward
1650        // the wall-clock target rather than under-delivering (→ fast video).
1651        assert_eq!(cfr_catchup_frames(Duration::from_secs(1), fi, 5, 100), 26);
1652        // A long stall is bounded by max_burst so it can't burst unbounded.
1653        assert_eq!(cfr_catchup_frames(Duration::from_secs(10), fi, 0, 60), 60);
1654        // Already at/ahead of target → nothing to push (paced keep-up case).
1655        assert_eq!(cfr_catchup_frames(Duration::ZERO, fi, 1, 100), 0);
1656        assert_eq!(cfr_catchup_frames(Duration::from_secs(1), fi, 31, 100), 0);
1657    }
1658}