{ const DEFAULT_EYE_RECORD_FPS: u32 = 30; #[derive(Default)] struct EyeRecordState { save_dir_override: Option, timer: Option, frame_dir: Option, frame_writer_tx: Option>, finalize_rx: Option>>, next_frame_index: u32, } enum RecordFrameTask { Frame { texture: gtk::gdk::Texture, frame_path: PathBuf, }, Finish, } fn eye_slug(title: &str) -> &'static str { if title.to_ascii_lowercase().contains("left") { "left-eye" } else { "right-eye" } } fn timestamp_slug() -> String { if let Ok(now) = glib::DateTime::now_local() && let Ok(stamp) = now.format("%Y%m%d-%H%M%S") { return stamp.to_string(); } let now = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default(); format!("{}-{:03}", now.as_secs(), now.subsec_millis()) } fn expand_home_token(raw: &str) -> PathBuf { if raw.contains("$HOME") && let Some(home) = std::env::var_os("HOME") { return PathBuf::from(raw.replace("$HOME", &home.to_string_lossy())); } if let Some(rest) = raw.strip_prefix("~/") && let Some(home) = std::env::var_os("HOME") { return PathBuf::from(home).join(rest); } PathBuf::from(raw) } fn default_eye_capture_root() -> PathBuf { if let Some(raw) = std::env::var_os("XDG_PICTURES_DIR") { let path = expand_home_token(&raw.to_string_lossy()); if !path.as_os_str().is_empty() { return path.join("lesavka"); } } if let Some(home) = std::env::var_os("HOME") { return PathBuf::from(home).join("Pictures").join("lesavka"); } if let Some(profile) = std::env::var_os("USERPROFILE") { return PathBuf::from(profile).join("Pictures").join("lesavka"); } std::env::current_dir() .unwrap_or_else(|_| PathBuf::from(".")) .join("lesavka") } fn ensure_eye_capture_root(override_dir: Option<&Path>) -> Result { let root = override_dir .map(Path::to_path_buf) .unwrap_or_else(default_eye_capture_root); std::fs::create_dir_all(&root) .map_err(|err| format!("could not create {}: {err}", root.display()))?; Ok(root) } fn unique_capture_path(root: &Path, stem: &str, ext: &str) -> PathBuf { let mut candidate = root.join(format!("{stem}.{ext}")); if !candidate.exists() { return candidate; } for idx in 1..1000 { candidate = root.join(format!("{stem}-{idx}.{ext}")); if !candidate.exists() { break; } } candidate } fn current_eye_texture(picture: >k::Picture) -> Result { let paintable = picture .paintable() .ok_or_else(|| "no live frame is available yet".to_string())?; paintable .downcast::() .map_err(|_| "the current frame is not directly exportable".to_string()) } fn save_texture_png(texture: >k::gdk::Texture, output_path: &Path) -> Result<(), String> { texture .save_to_png(output_path) .map_err(|err| format!("could not write {}: {err}", output_path.display())) } fn recording_interval_ms(record_fps: u32) -> u64 { let fps = record_fps.max(1); (1000_u64 / fps as u64).max(1) } fn best_effort_recording_profile( state: &LauncherState, preview: Option<&LauncherPreview>, monitor_id: usize, ) -> (u32, u32) { let choice = state .display_capture_size_choice(monitor_id) .unwrap_or_else(|| state.capture_size_choice(monitor_id)); let mut fps = if choice.fps == 0 { DEFAULT_EYE_RECORD_FPS } else { choice.fps.max(1) }; if let Some(snapshot) = preview.and_then(|feed| feed.snapshot_metrics(monitor_id, PreviewSurface::Inline)) && snapshot.server_fps.is_finite() && snapshot.server_fps >= 1.0 { fps = snapshot.server_fps.round().clamp(1.0, 120.0) as u32; } let bitrate_kbit = choice.max_bitrate_kbit.max(800); (fps, bitrate_kbit) } fn queue_record_frame(state: &mut EyeRecordState, picture: >k::Picture) -> Result<(), String> { let frame_dir = state .frame_dir .as_ref() .ok_or_else(|| "recording session is not initialized".to_string())? .clone(); let frame_writer_tx = state .frame_writer_tx .as_ref() .ok_or_else(|| "recording worker is not initialized".to_string())? .clone(); let texture = current_eye_texture(picture)?; let frame_path = frame_dir.join(format!("frame-{:06}.png", state.next_frame_index)); frame_writer_tx .send(RecordFrameTask::Frame { texture, frame_path, }) .map_err(|_| "recording worker stopped unexpectedly".to_string())?; state.next_frame_index = state.next_frame_index.saturating_add(1); Ok(()) } fn encode_recording( frame_dir: &Path, output_path: &Path, encode_fps: u32, encode_bitrate_kbit: u32, ) -> Result<(), String> { let frame_pattern = frame_dir.join("frame-%06d.png"); let bitrate_arg = format!("{}k", encode_bitrate_kbit.max(800)); let encode = Command::new("ffmpeg") .args([ "-hide_banner", "-loglevel", "error", "-y", "-framerate", &encode_fps.max(1).to_string(), "-i", &frame_pattern.to_string_lossy(), "-c:v", "libx264", "-pix_fmt", "yuv420p", "-r", &encode_fps.max(1).to_string(), "-b:v", &bitrate_arg, &output_path.to_string_lossy(), ]) .status() .map_err(|err| format!("ffmpeg is unavailable: {err}"))?; if !encode.success() { return Err(format!( "ffmpeg failed while encoding {}; frame data is still in {}", output_path.display(), frame_dir.display() )); } Ok(()) } fn run_recording_worker( frame_rx: std::sync::mpsc::Receiver, frame_dir: PathBuf, output_path: PathBuf, encode_fps: u32, encode_bitrate_kbit: u32, ) -> Result { let mut captured_frames = 0_u32; while let Ok(task) = frame_rx.recv() { match task { RecordFrameTask::Frame { texture, frame_path, } => { save_texture_png(&texture, &frame_path)?; captured_frames = captured_frames.saturating_add(1); } RecordFrameTask::Finish => break, } } if captured_frames < 2 { let _ = std::fs::remove_dir_all(&frame_dir); return Err("need at least two captured frames to build a recording".to_string()); } encode_recording(&frame_dir, &output_path, encode_fps, encode_bitrate_kbit)?; let _ = std::fs::remove_dir_all(&frame_dir); Ok(output_path) } for monitor_id in 0..2 { let pane = widgets.display_panes[monitor_id].clone(); let widgets_for_ui = widgets.clone(); let save_state = Rc::new(RefCell::new(EyeRecordState::default())); { let pane = pane.clone(); let widgets = widgets_for_ui.clone(); let save_state = Rc::clone(&save_state); let window_for_save = window.clone(); pane.save_button.connect_clicked(move |_| { let chooser = gtk::FileChooserNative::new( Some("Choose Eye Capture Folder"), Some(&window_for_save), gtk::FileChooserAction::SelectFolder, Some("Select"), Some("Cancel"), ); chooser.set_modal(true); let save_state = Rc::clone(&save_state); let widgets = widgets.clone(); let eye_name = pane.title.clone(); chooser.connect_response(move |dialog, response| { if response == gtk::ResponseType::Accept { if let Some(folder) = dialog.file().and_then(|file| file.path()) { save_state.borrow_mut().save_dir_override = Some(folder.clone()); widgets.status_label.set_text(&format!( "{} saves now go to {}.", eye_name, folder.display() )); } else { widgets.status_label.set_text( "Capture folder selection did not return a filesystem path.", ); } } dialog.destroy(); }); chooser.show(); }); } { let pane = pane.clone(); let widgets = widgets_for_ui.clone(); let save_state = Rc::clone(&save_state); pane.clip_button.connect_clicked(move |_| { let root = { let borrowed = save_state.borrow(); match ensure_eye_capture_root(borrowed.save_dir_override.as_deref()) { Ok(path) => path, Err(err) => { widgets .status_label .set_text(&format!("Could not prepare capture folder: {err}")); return; } } }; let stem = format!("{}-clip-{}", eye_slug(&pane.title), timestamp_slug()); let clip_path = unique_capture_path(&root, &stem, "png"); match current_eye_texture(&pane.picture) .and_then(|texture| save_texture_png(&texture, &clip_path)) { Ok(()) => { widgets.status_label.set_text(&format!( "{} clip saved to {}.", pane.title, clip_path.display() )); } Err(err) => { widgets .status_label .set_text(&format!("{} clip failed: {err}", pane.title)); } } }); } { let pane = pane.clone(); let widgets = widgets_for_ui.clone(); let save_state = Rc::clone(&save_state); let state = Rc::clone(&state); let preview = preview.clone(); let record_button = pane.record_button.clone(); record_button.connect_clicked(move |button| { if save_state.borrow().timer.is_some() { let finalize_rx = { let mut state = save_state.borrow_mut(); if let Some(timer) = state.timer.take() { timer.remove(); } if let Some(frame_writer_tx) = state.frame_writer_tx.take() { let _ = frame_writer_tx.send(RecordFrameTask::Finish); } state.next_frame_index = 0; state.frame_dir = None; state.finalize_rx.take() }; let Some(finalize_rx) = finalize_rx else { button.set_label("Record"); widgets.status_label.set_text(&format!( "{} recording stop failed: recording worker state was missing.", pane.title )); return; }; button.set_sensitive(false); button.set_label("Finishing..."); let button = button.clone(); let widgets = widgets.clone(); let pane_title = pane.title.clone(); glib::timeout_add_local(Duration::from_millis(100), move || match finalize_rx .try_recv() { Ok(Ok(output)) => { button.set_sensitive(true); button.set_label("Record"); widgets.status_label.set_text(&format!( "{} recording saved to {}.", pane_title, output.display() )); glib::ControlFlow::Break } Ok(Err(err)) => { button.set_sensitive(true); button.set_label("Record"); widgets.status_label.set_text(&format!( "{} recording stop failed: {err}", pane_title, )); glib::ControlFlow::Break } Err(std::sync::mpsc::TryRecvError::Empty) => glib::ControlFlow::Continue, Err(std::sync::mpsc::TryRecvError::Disconnected) => { button.set_sensitive(true); button.set_label("Record"); widgets.status_label.set_text(&format!( "{} recording stop failed: recording worker disconnected.", pane_title )); glib::ControlFlow::Break } }); return; } let (record_fps, record_bitrate_kbit) = { let state = state.borrow(); best_effort_recording_profile(&state, preview.as_deref(), monitor_id) }; let root = { let borrowed = save_state.borrow(); match ensure_eye_capture_root(borrowed.save_dir_override.as_deref()) { Ok(path) => path, Err(err) => { widgets .status_label .set_text(&format!("Could not prepare capture folder: {err}")); return; } } }; let recording_stem = format!("{}-record-{}", eye_slug(&pane.title), timestamp_slug()); let output_path = unique_capture_path(&root, &recording_stem, "mp4"); let frame_dir = root.join(format!("{}.frames", recording_stem)); if let Err(err) = std::fs::create_dir_all(&frame_dir) { widgets.status_label.set_text(&format!( "{} record failed creating frame cache {}: {err}", pane.title, frame_dir.display() )); return; } let (frame_tx, frame_rx) = std::sync::mpsc::channel::(); let (result_tx, result_rx) = std::sync::mpsc::channel::>(); let frame_dir_worker = frame_dir.clone(); let output_path_worker = output_path.clone(); std::thread::spawn(move || { let result = run_recording_worker( frame_rx, frame_dir_worker, output_path_worker, record_fps, record_bitrate_kbit, ); let _ = result_tx.send(result); }); { let mut state = save_state.borrow_mut(); state.frame_dir = Some(frame_dir); state.frame_writer_tx = Some(frame_tx); state.finalize_rx = Some(result_rx); state.next_frame_index = 0; } let pane_for_tick = pane.clone(); let widgets_for_tick = widgets.clone(); let save_state_for_tick = Rc::clone(&save_state); let timer = glib::timeout_add_local( Duration::from_millis(recording_interval_ms(record_fps)), move || { let mut state = save_state_for_tick.borrow_mut(); if state.frame_dir.is_none() { return glib::ControlFlow::Break; } if let Err(err) = queue_record_frame(&mut state, &pane_for_tick.picture) { if let Some(frame_writer_tx) = state.frame_writer_tx.take() { let _ = frame_writer_tx.send(RecordFrameTask::Finish); } widgets_for_tick.status_label.set_text(&format!( "{} recording frame skipped: {err}", pane_for_tick.title )); return glib::ControlFlow::Break; } glib::ControlFlow::Continue }, ); save_state.borrow_mut().timer = Some(timer); button.set_sensitive(true); button.set_label("Stop"); widgets.status_label.set_text(&format!( "Recording {} at {} fps (~{} kbit)... press Stop to finish.", pane.title, record_fps, record_bitrate_kbit )); }); } } }