Skip to main content

screen_app/
editor_command.rs

1//! `open_in_editor` — the Record→Edit handoff command (ED.5 / M-EDIT).
2//!
3//! Probes a finished recording's metadata and builds a fresh, untouched
4//! [`edit::EditProject`] (one full-length real-time segment) for the
5//! editor UI to load. The heavy lifting — the edit model itself — lives in
6//! the pure `edit` crate; this is the thin Tauri wrapper.
7
8#![allow(
9    clippy::needless_pass_by_value,
10    reason = "Tauri injects State<'_, T> into #[command] fns by value; it is borrowed, not moved"
11)]
12
13use std::path::{Path, PathBuf};
14use std::sync::Arc;
15use std::sync::atomic::{AtomicBool, Ordering};
16
17use decode::gstreamer_pipe::GstreamerPipeStream;
18use edit::{ClipRef, EditProject};
19use tauri::{Emitter, Manager, State};
20
21use crate::editor_session::{EditorSession, EditorSessionState};
22
23/// Build a default editor project from a recording's probed metadata.
24///
25/// Split out from the command so it's unit-testable without spawning
26/// `GStreamer`.
27#[must_use]
28pub fn project_from_metadata(
29    path: PathBuf,
30    width: u32,
31    height: u32,
32    frame_rate: f32,
33    frame_count: u64,
34) -> EditProject {
35    EditProject::from_recording(ClipRef::new(
36        path,
37        width,
38        height,
39        fps_round(frame_rate),
40        frame_count,
41    ))
42}
43
44/// Round a reported (possibly fractional / NTSC) frame rate to a whole
45/// fps, clamped to at least 1. Non-finite / non-positive rates fall back
46/// to 30.
47fn fps_round(frame_rate: f32) -> u32 {
48    if !(frame_rate.is_finite() && frame_rate > 0.0) {
49        return 30;
50    }
51    let rounded = frame_rate.round();
52    if rounded < 1.0 {
53        1
54    } else {
55        #[allow(
56            clippy::cast_possible_truncation,
57            clippy::cast_sign_loss,
58            reason = "rounded is finite and >= 1.0; real frame rates fit u32"
59        )]
60        let fps = rounded as u32;
61        fps
62    }
63}
64
65/// Open a finished recording in the editor: probe it with
66/// `gst-discoverer-1.0` and return a default [`EditProject`] (the
67/// recording, untouched, ready to edit).
68///
69/// # Errors
70///
71/// Returns the probe error string if the file can't be read (missing
72/// `GStreamer`, unreadable media).
73#[tauri::command]
74pub async fn open_in_editor(app: tauri::AppHandle, path: String) -> Result<EditProject, String> {
75    // Probe off the UI worker thread — `gst-discoverer-1.0` can stall on a
76    // large or remote clip, and the Record→Edit handoff promise shouldn't
77    // block the webview while it does.
78    let probe_path = path.clone();
79    let meta = tauri::async_runtime::spawn_blocking(move || {
80        GstreamerPipeStream::probe(Path::new(&probe_path)).map_err(|err| err.to_string())
81    })
82    .await
83    .map_err(|err| format!("probe task failed to join: {err}"))??;
84
85    let mut project = project_from_metadata(
86        PathBuf::from(path),
87        meta.width,
88        meta.height,
89        meta.frame_rate,
90        meta.frame_count.unwrap_or(0),
91    );
92    // ED.17: attach the cursor track captured during the just-finished
93    // recording (the Record→Edit handoff). `take` consumes it so it can't
94    // re-attach to a later, unrelated clip; `None` for clips opened without a
95    // fresh recording (e.g. a re-opened file).
96    if let Some(track) = app
97        .state::<crate::recording::RecordingState>()
98        .take_cursor_track()
99    {
100        project.cursor_track = Some(track);
101    }
102    // ED.17 / ISS-16: attach the click log captured during the recording — it
103    // feeds auto-zoom + the ED.19 click ripples. Same consume-once handoff.
104    if let Some(clicks) = app
105        .state::<crate::recording::RecordingState>()
106        .take_clicks()
107    {
108        project.clicks = Some(clicks);
109    }
110    // ED.17 (AUT-352 headline): turn the click log into auto-zoom blocks so the
111    // recording arrives already punched-in on its click clusters. No-op when
112    // detection is off, there are no clicks, or zooms already exist (a re-opened
113    // edit keeps its tuned zooms). The blocks are normal editable ZoomSegments.
114    let auto_zoomed = project.generate_auto_zooms();
115    if auto_zoomed > 0 {
116        tracing::info!(
117            count = auto_zoomed,
118            "open_in_editor: generated auto-zoom blocks from clicks"
119        );
120    }
121    // Spin up the playhead session for this clip (ED.7 transport drives it).
122    // Resolved by handle since the `.await` above rules out holding a
123    // `State<'_>` borrow across it.
124    let state = app.state::<EditorSessionState>();
125    let mut guard = state
126        .0
127        .lock()
128        .unwrap_or_else(std::sync::PoisonError::into_inner);
129    *guard = Some(EditorSession::new(
130        project.project_fps,
131        project.project_duration(),
132    ));
133    Ok(project)
134}
135
136/// Shared state for the editor export command (ED.22): a cooperative
137/// cancel flag plus a single-run guard so a second export can't un-cancel
138/// the first and race the same output file.
139#[derive(Default)]
140pub struct EditorExportState {
141    cancel: Arc<AtomicBool>,
142    running: Arc<AtomicBool>,
143}
144
145/// RAII guard releasing the single-export slot when an export finishes
146/// (on any return path, including error/panic).
147struct ExportRunGuard(Arc<AtomicBool>);
148
149impl Drop for ExportRunGuard {
150    fn drop(&mut self) {
151        self.0.store(false, Ordering::Release);
152    }
153}
154
155impl EditorExportState {
156    /// Claim the single export slot, resetting the cancel flag for the new
157    /// run. Returns a guard that releases the slot on drop, or `None` if an
158    /// export is already in flight.
159    fn try_begin(&self) -> Option<ExportRunGuard> {
160        self.running
161            .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
162            .ok()?;
163        self.cancel.store(false, Ordering::Relaxed);
164        Some(ExportRunGuard(Arc::clone(&self.running)))
165    }
166
167    /// Raise the cancel flag for an in-flight export.
168    fn request_cancel(&self) {
169        self.cancel.store(true, Ordering::Relaxed);
170    }
171
172    /// A clone of the cancel flag for the export job to poll.
173    fn cancel_flag(&self) -> Arc<AtomicBool> {
174        Arc::clone(&self.cancel)
175    }
176}
177
178/// `editor-export-progress` event payload.
179#[derive(Clone, serde::Serialize)]
180struct ExportProgress {
181    done: u64,
182    total: u64,
183}
184
185/// Derive the export output path for `source` + `format`: alongside the
186/// recordings folder, named `<source-stem>-edited.<ext>` so it never
187/// clobbers the source recording. Pure (no I/O) for testability.
188#[must_use]
189pub fn edited_export_path(output_dir: &Path, source: &Path, extension: &str) -> PathBuf {
190    let stem = source
191        .file_stem()
192        .and_then(|s| s.to_str())
193        .unwrap_or("export");
194    output_dir.join(format!("{stem}-edited.{extension}"))
195}
196
197/// Export the edited `project` to an `.mp4`, streaming `editor-export-progress`
198/// events and honoring [`editor_export_cancel`]. Returns the output path.
199///
200/// Runs the compose + encode on the blocking pool so the webview stays
201/// responsive; progress is throttled to ~100 events over the export.
202///
203/// # Errors
204///
205/// Returns a message if the source can't be opened, the encoder fails, or
206/// the export was cancelled.
207#[tauri::command]
208pub async fn editor_export(
209    app: tauri::AppHandle,
210    project: EditProject,
211    format: Option<String>,
212) -> Result<String, String> {
213    use media::encode::OutputFormat;
214
215    let format = format
216        .as_deref()
217        .and_then(OutputFormat::from_slug)
218        .unwrap_or_default();
219    // Resolve state by handle (an async command can't hold a `State<'_>`
220    // borrow across `.await`). Claim the single export slot — `_guard`
221    // lives to the end of the command and releases it on drop; a second
222    // concurrent export is rejected rather than racing the same file.
223    let (cancel, _guard) = {
224        let st = app.state::<EditorExportState>();
225        let Some(guard) = st.try_begin() else {
226            return Err("an export is already in progress".to_owned());
227        };
228        (st.cancel_flag(), guard)
229    };
230
231    let source = project.source.path.clone();
232    let dir = crate::recorder_settings::resolved_output_dir(&app);
233    let out = edited_export_path(&dir, &source, format.extension());
234
235    let app_emit = app.clone();
236    let out_job = out.clone();
237    let job = tauri::async_runtime::spawn_blocking(move || -> Result<PathBuf, String> {
238        crate::recording_paths::ensure_parent_dir(&out_job)
239            .map_err(|err| format!("failed to create output dir: {err}"))?;
240        crate::editor_export::export_edited_project(
241            project,
242            &source,
243            out_job,
244            format,
245            &cancel,
246            |done, total| {
247                let step = (total / 100).max(1);
248                if done % step == 0 || done == total {
249                    let _ = app_emit.emit("editor-export-progress", ExportProgress { done, total });
250                }
251            },
252        )
253    })
254    .await
255    .map_err(|err| format!("export task failed to join: {err}"))?;
256
257    let path = job?;
258    Ok(path.to_string_lossy().into_owned())
259}
260
261/// Request cancellation of an in-flight [`editor_export`].
262#[tauri::command]
263pub fn editor_export_cancel(state: State<'_, EditorExportState>) {
264    state.request_cancel();
265}
266
267/// The `.screenproj` path for a `source` recording: the source path with its
268/// extension swapped, so the project file sits beside the recording. Pure.
269#[must_use]
270pub fn screenproj_path(source: &Path) -> PathBuf {
271    source.with_extension(edit::persist::SCREENPROJ_EXTENSION)
272}
273
274/// Save `project` to its `.screenproj` (beside the source recording);
275/// returns the written path.
276///
277/// # Errors
278///
279/// Returns a message if serialization or the file write fails.
280#[tauri::command]
281pub fn editor_save_project(project: EditProject) -> Result<String, String> {
282    let path = screenproj_path(&project.source.path);
283    let json = edit::persist::to_screenproj(&project)?;
284    std::fs::write(&path, json).map_err(|err| format!("write {}: {err}", path.display()))?;
285    Ok(path.to_string_lossy().into_owned())
286}
287
288/// Load an editor project from a `.screenproj` `path`.
289///
290/// # Errors
291///
292/// Returns a message if the file can't be read or parsed.
293#[tauri::command]
294pub fn editor_load_project(path: String) -> Result<EditProject, String> {
295    let json = std::fs::read_to_string(&path).map_err(|err| format!("read {path}: {err}"))?;
296    edit::persist::from_screenproj(&json)
297}
298
299/// A recording in the library: its file path, display name, and whether a
300/// saved `.screenproj` sits beside it.
301#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
302pub struct RecordingEntry {
303    /// Absolute path to the `.mp4`.
304    pub path: String,
305    /// Display name (the file stem).
306    pub name: String,
307    /// Whether a `<stem>.screenproj` exists beside the recording.
308    pub has_project: bool,
309}
310
311/// Build library entries from a directory listing: each `.mp4` becomes an
312/// entry, newest first (lexical-descending on the timestamped filename),
313/// flagged if a sibling `.screenproj` is present. Pure (no I/O).
314#[must_use]
315pub fn recording_entries(filenames: &[String], dir: &Path) -> Vec<RecordingEntry> {
316    let mut mp4s: Vec<&String> = filenames
317        .iter()
318        .filter(|n| {
319            Path::new(n.as_str())
320                .extension()
321                .is_some_and(|e| e.eq_ignore_ascii_case("mp4"))
322        })
323        .collect();
324    mp4s.sort_unstable();
325    mp4s.reverse(); // newest (highest timestamp in the name) first
326    mp4s.into_iter()
327        .map(|name| {
328            let stem = Path::new(name)
329                .file_stem()
330                .and_then(|s| s.to_str())
331                .unwrap_or(name);
332            let proj = format!("{stem}.{}", edit::persist::SCREENPROJ_EXTENSION);
333            RecordingEntry {
334                path: dir.join(name).to_string_lossy().into_owned(),
335                name: stem.to_owned(),
336                has_project: filenames.iter().any(|f| f == &proj),
337            }
338        })
339        .collect()
340}
341
342/// List the recordings in the user's output folder (newest first). Returns
343/// an empty list if the folder can't be read.
344#[tauri::command]
345pub fn list_recordings(app: tauri::AppHandle) -> Vec<RecordingEntry> {
346    let dir = crate::recorder_settings::resolved_output_dir(&app);
347    let Ok(read) = std::fs::read_dir(&dir) else {
348        return Vec::new();
349    };
350    let names: Vec<String> = read
351        .filter_map(Result::ok)
352        .filter_map(|e| e.file_name().to_str().map(ToOwned::to_owned))
353        .collect();
354    recording_entries(&names, &dir)
355}
356
357#[cfg(test)]
358mod tests {
359    use super::*;
360
361    #[test]
362    fn project_from_metadata_builds_one_full_length_segment() {
363        let p = project_from_metadata(PathBuf::from("/tmp/rec.mp4"), 1920, 1080, 29.97, 600);
364        assert_eq!(p.source.width, 1920);
365        assert_eq!(p.source.height, 1080);
366        assert_eq!(p.source.source_fps, 30, "29.97 rounds to 30");
367        assert_eq!(p.source.frame_count, 600);
368        assert_eq!(p.segments.len(), 1);
369        assert_eq!(p.project_duration(), 600);
370        assert!(p.zooms.is_empty());
371    }
372
373    #[test]
374    fn recording_entries_filters_sorts_and_flags_projects() {
375        let names = vec![
376            "rec-2026-01-01.mp4".to_owned(),
377            "rec-2026-02-01.mp4".to_owned(),
378            "rec-2026-02-01.screenproj".to_owned(),
379            "notes.txt".to_owned(),
380        ];
381        let entries = recording_entries(&names, Path::new("/out"));
382        assert_eq!(entries.len(), 2, "only .mp4 files become entries");
383        assert_eq!(entries[0].name, "rec-2026-02-01", "newest first");
384        assert!(entries[0].has_project, "Feb has a sibling .screenproj");
385        // Path-separator-agnostic (Windows joins with `\`).
386        assert!(entries[0].path.ends_with("rec-2026-02-01.mp4"));
387        assert_eq!(entries[1].name, "rec-2026-01-01");
388        assert!(!entries[1].has_project);
389    }
390
391    #[test]
392    fn screenproj_path_swaps_extension() {
393        assert_eq!(
394            screenproj_path(Path::new("/rec/clip.mp4")),
395            PathBuf::from("/rec/clip.screenproj")
396        );
397    }
398
399    #[test]
400    fn edited_export_path_names_alongside_source() {
401        let out = edited_export_path(Path::new("/out"), Path::new("/rec/clip.mp4"), "mp4");
402        assert_eq!(out, PathBuf::from("/out/clip-edited.mp4"));
403        // Missing stem falls back to a stable name.
404        let fallback = edited_export_path(Path::new("/out"), Path::new("/"), "mp4");
405        assert_eq!(fallback, PathBuf::from("/out/export-edited.mp4"));
406    }
407
408    #[test]
409    fn editor_export_state_allows_one_run_at_a_time() {
410        let st = EditorExportState::default();
411        let g1 = st.try_begin();
412        assert!(g1.is_some(), "first export claims the slot");
413        assert!(
414            st.try_begin().is_none(),
415            "a second export is rejected while one is in flight"
416        );
417        drop(g1);
418        assert!(
419            st.try_begin().is_some(),
420            "the slot is released once the first export finishes"
421        );
422    }
423
424    #[test]
425    fn begin_clears_a_stale_cancel_flag() {
426        let st = EditorExportState::default();
427        st.request_cancel();
428        assert!(st.cancel.load(Ordering::Relaxed));
429        let _g = st.try_begin().expect("claim slot");
430        assert!(
431            !st.cancel.load(Ordering::Relaxed),
432            "begin resets cancel so a prior cancel doesn't kill the new run"
433        );
434    }
435
436    #[test]
437    fn fps_round_handles_edge_cases() {
438        assert_eq!(fps_round(29.97), 30);
439        assert_eq!(fps_round(30.0), 30);
440        assert_eq!(fps_round(59.94), 60);
441        assert_eq!(fps_round(0.0), 30, "zero falls back");
442        assert_eq!(fps_round(-5.0), 30, "negative falls back");
443        assert_eq!(fps_round(f32::NAN), 30, "NaN falls back");
444        assert_eq!(fps_round(0.4), 1, "sub-1 clamps to 1");
445    }
446}