1#![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#[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
44fn 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#[tauri::command]
74pub async fn open_in_editor(app: tauri::AppHandle, path: String) -> Result<EditProject, String> {
75 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 if let Some(track) = app
97 .state::<crate::recording::RecordingState>()
98 .take_cursor_track()
99 {
100 project.cursor_track = Some(track);
101 }
102 if let Some(clicks) = app
105 .state::<crate::recording::RecordingState>()
106 .take_clicks()
107 {
108 project.clicks = Some(clicks);
109 }
110 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 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#[derive(Default)]
140pub struct EditorExportState {
141 cancel: Arc<AtomicBool>,
142 running: Arc<AtomicBool>,
143}
144
145struct 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 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 fn request_cancel(&self) {
169 self.cancel.store(true, Ordering::Relaxed);
170 }
171
172 fn cancel_flag(&self) -> Arc<AtomicBool> {
174 Arc::clone(&self.cancel)
175 }
176}
177
178#[derive(Clone, serde::Serialize)]
180struct ExportProgress {
181 done: u64,
182 total: u64,
183}
184
185#[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#[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 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#[tauri::command]
263pub fn editor_export_cancel(state: State<'_, EditorExportState>) {
264 state.request_cancel();
265}
266
267#[must_use]
270pub fn screenproj_path(source: &Path) -> PathBuf {
271 source.with_extension(edit::persist::SCREENPROJ_EXTENSION)
272}
273
274#[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#[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#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
302pub struct RecordingEntry {
303 pub path: String,
305 pub name: String,
307 pub has_project: bool,
309}
310
311#[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(); 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#[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 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 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}