diff --git a/crates/insta360-link-gui/src/main.rs b/crates/insta360-link-gui/src/main.rs index 2a62ec2..86c8955 100644 --- a/crates/insta360-link-gui/src/main.rs +++ b/crates/insta360-link-gui/src/main.rs @@ -236,14 +236,108 @@ impl Insta360App { if let Ok(ae) = self.controller.get_auto_exposure() { self.state.auto_exposure = ae; } + if let Ok(ev) = self.controller.get_exposure_compensation() { + self.state.ev_compensation = ev.clamp(-3.0_f32, 3.0_f32); + } + if let Ok(iso) = self.controller.get_iso() { + if let Some(idx) = crate::state::ISO_LIST.iter().position(|&v| v == iso) { + self.state.iso_index = idx; + } + } + if let Ok(shutter) = self.controller.get_shutter_speed() { + if let Some(idx) = crate::state::SHUTTER_DENOM_LIST + .iter() + .position(|&v| v == shutter) + { + self.state.shutter_index = idx; + } + } if let Ok(af) = self.controller.get_auto_focus() { self.state.auto_focus = af; } + if let Ok(focus) = self.controller.get_focus() { + self.state.manual_focus = focus.min(100); + } + if let Ok(pl) = self.controller.get_power_line_frequency() { + self.state.anti_flicker = pl; + } + if let Ok(roll) = self.controller.get_roll() { + self.state.roll_deg = (roll as f32 / 10.0_f32).clamp(-1.0_f32, 1.0_f32); + } if let Ok(denoise) = self.controller.get_audio_noise_reduction() { self.state.audio_denoise = denoise; } } + fn apply_saved_settings_to_hardware(&mut self) { + let Some(saved) = self.state.last_saved_settings.clone() else { + self.state.last_saved_settings = Some(self.state.to_persisted()); + return; + }; + self.state.apply_persisted(&saved); + let _ = self.controller.set_brightness(self.state.brightness); + let _ = self.controller.set_contrast(self.state.contrast); + let _ = self.controller.set_saturation(self.state.saturation); + let _ = self.controller.set_sharpness(self.state.sharpness); + let _ = self + .controller + .set_auto_white_balance(self.state.auto_white_balance); + if !self.state.auto_white_balance { + let _ = self + .controller + .set_white_balance_temperature(self.state.wb_kelvin); + } + let _ = self.controller.set_auto_focus(self.state.auto_focus); + if !self.state.auto_focus { + let _ = self.controller.set_focus(self.state.manual_focus); + } + let _ = self + .controller + .set_power_line_frequency(self.state.anti_flicker); + let _ = self.controller.set_hdr(self.state.hdr_enabled); + if !self.state.hdr_enabled { + let _ = self.controller.set_auto_exposure(self.state.auto_exposure); + if self.state.auto_exposure { + let _ = self + .controller + .set_exposure_compensation(self.state.ev_compensation); + } else { + if let Some(&iso) = crate::state::ISO_LIST.get(self.state.iso_index) { + let _ = self.controller.set_iso(iso); + } + if let Some(&shutter) = crate::state::SHUTTER_DENOM_LIST.get(self.state.shutter_index) + { + let _ = self.controller.set_shutter_speed(shutter); + } + } + } + let _ = self.controller.set_track_speed(self.state.track_speed); + let _ = self + .controller + .set_single_tap_tracking_enabled(self.state.enable_single_tap_tracking); + let _ = self + .controller + .set_all_gestures_enabled(self.state.enable_all_gestures); + let _ = self.controller.set_gesture_enabled( + insta360_link_protocol::Gesture::Palm, + self.state.use_gesture_palm, + ); + let _ = self.controller.set_gesture_enabled( + insta360_link_protocol::Gesture::VShape, + self.state.use_gesture_v, + ); + let _ = self.controller.set_gesture_enabled( + insta360_link_protocol::Gesture::LShape, + self.state.use_gesture_l, + ); + let _ = self + .controller + .set_audio_noise_reduction(self.state.audio_denoise); + let _ = self + .controller + .set_horizontal_mirror(self.state.horizontal_mirror); + } + fn set_video_mode(&mut self, target_mode: VideoMode) { let prev_mode = self.state.video_mode; let mode_to_set = if self.state.video_mode == target_mode { @@ -2007,7 +2101,13 @@ impl eframe::App for Insta360App { // 1. One-time hardware initialization once the physical video stream is open and delivering frames if self.video_engine.has_live_physical_frame() && !self.state.stream_initialized { self.state.stream_initialized = true; + let had_saved_settings = self.state.last_saved_settings.is_some(); self.sync_device_info(); + if had_saved_settings && !self.self_test_enabled { + self.apply_saved_settings_to_hardware(); + } else { + self.state.last_saved_settings = Some(self.state.to_persisted()); + } // If the camera was left in Overhead or DeskView mode (which flips the ISP 180 degrees), // restore Normal mode so the live video stream is right-side up. if self.state.video_mode == VideoMode::OverheadShot @@ -2020,6 +2120,15 @@ impl eframe::App for Insta360App { } } + // 1b. Persist user settings to ~/.config/insta360-link-controller/settings.json whenever modified + if self.state.stream_initialized && !self.self_test_enabled { + let cur_persisted = self.state.to_persisted(); + if self.state.last_saved_settings.as_ref() != Some(&cur_persisted) { + cur_persisted.save(); + self.state.last_saved_settings = Some(cur_persisted); + } + } + // 2. Auto-stop short PTZ directional nudges (`CameraInsta::addPanTilt` 100-120ms timer) if let Some(stop_at) = self.state.nudge_stop_at { if Instant::now() >= stop_at { diff --git a/crates/insta360-link-gui/src/state.rs b/crates/insta360-link-gui/src/state.rs index c68ba69..161bd23 100644 --- a/crates/insta360-link-gui/src/state.rs +++ b/crates/insta360-link-gui/src/state.rs @@ -2,6 +2,7 @@ use egui::{ColorImage, Pos2, TextureHandle}; pub use insta360_link_protocol::*; +use serde::{Deserialize, Serialize}; use std::path::PathBuf; use std::time::Instant; @@ -52,7 +53,7 @@ pub enum BackgroundEffectMode { } /// Resolution & frame rate preset (`DefFormatCombo.qml`). -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum VideoFormatPreset { Uhd4k30, Fhd1080p60, @@ -281,6 +282,81 @@ pub struct CameraAppState { pub ready_auto_track: bool, pub last_track_poll: Option, pub last_hw_sync: Option, + pub last_saved_settings: Option, +} + +/// User settings persisted across sessions in `~/.config/insta360-link-controller/settings.json`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct PersistedAppSettings { + pub format_preset: VideoFormatPreset, + pub auto_exposure: bool, + pub ev_compensation: f32, + pub iso_index: usize, + pub shutter_index: usize, + pub auto_focus: bool, + pub manual_focus: u16, + pub auto_white_balance: bool, + pub wb_kelvin: u16, + pub brightness: i16, + pub contrast: u16, + pub saturation: u16, + pub sharpness: u16, + pub hdr_enabled: bool, + pub anti_flicker: PowerLineFrequency, + pub startup_params_enabled: bool, + pub filter_mode: FilterMode, + pub makeup_enabled: bool, + pub makeup_intensity: u8, + pub track_speed: TrackSpeed, + pub enable_single_tap_tracking: bool, + pub enable_all_gestures: bool, + pub use_gesture_palm: bool, + pub use_gesture_v: bool, + pub use_gesture_l: bool, + pub audio_denoise: bool, + pub horizontal_mirror: bool, + pub vertical_mirror: bool, + pub smart_adjustment: bool, + pub horizontal_correction: bool, + pub roll_deg: f32, + pub vertical_resolution_mode: bool, + pub forced_vertical_lock: bool, + pub low_resolution_mode: bool, +} + +impl PersistedAppSettings { + pub fn config_path() -> PathBuf { + if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") { + if !xdg.trim().is_empty() { + return PathBuf::from(xdg) + .join("insta360-link-controller") + .join("settings.json"); + } + } + let home = std::env::var("HOME") + .or_else(|_| std::env::var("USERPROFILE")) + .map(PathBuf::from) + .unwrap_or_else(|_| PathBuf::from(".")); + home.join(".config") + .join("insta360-link-controller") + .join("settings.json") + } + + pub fn load() -> Option { + let path = Self::config_path(); + let data = std::fs::read_to_string(path).ok()?; + serde_json::from_str(&data).ok() + } + + pub fn save(&self) { + let path = Self::config_path(); + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + if let Ok(json) = serde_json::to_string_pretty(self) { + let _ = std::fs::write(path, json); + } + } } impl Default for CameraAppState { @@ -328,7 +404,7 @@ impl Default for CameraAppState { .map(PathBuf::from) .unwrap_or_else(|_| PathBuf::from(".")); - Self { + let mut s = Self { is_light_mode: false, ui_mode: UiMode::MainWindow, right_tab: RightSidebarTab::View, @@ -489,7 +565,13 @@ impl Default for CameraAppState { ready_auto_track: false, last_track_poll: None, last_hw_sync: None, + last_saved_settings: None, + }; + if let Some(saved) = PersistedAppSettings::load() { + s.apply_persisted(&saved); + s.last_saved_settings = Some(saved); } + s } } @@ -497,4 +579,80 @@ impl CameraAppState { pub fn show_toast(&mut self, msg: impl Into, is_error: bool) { self.toast_message = Some((msg.into(), Instant::now(), is_error)); } + + pub fn to_persisted(&self) -> PersistedAppSettings { + PersistedAppSettings { + format_preset: self.format_preset, + auto_exposure: self.auto_exposure, + ev_compensation: (self.ev_compensation * 10.0).round() / 10.0, + iso_index: self.iso_index, + shutter_index: self.shutter_index, + auto_focus: self.auto_focus, + manual_focus: self.manual_focus, + auto_white_balance: self.auto_white_balance, + wb_kelvin: self.wb_kelvin, + brightness: self.brightness, + contrast: self.contrast, + saturation: self.saturation, + sharpness: self.sharpness, + hdr_enabled: self.hdr_enabled, + anti_flicker: self.anti_flicker, + startup_params_enabled: self.startup_params_enabled, + filter_mode: self.filter_mode, + makeup_enabled: self.makeup_enabled, + makeup_intensity: self.makeup_intensity, + track_speed: self.track_speed, + enable_single_tap_tracking: self.enable_single_tap_tracking, + enable_all_gestures: self.enable_all_gestures, + use_gesture_palm: self.use_gesture_palm, + use_gesture_v: self.use_gesture_v, + use_gesture_l: self.use_gesture_l, + audio_denoise: self.audio_denoise, + horizontal_mirror: self.horizontal_mirror, + vertical_mirror: self.vertical_mirror, + smart_adjustment: self.smart_adjustment, + horizontal_correction: self.horizontal_correction, + roll_deg: (self.roll_deg * 10.0).round() / 10.0, + vertical_resolution_mode: self.vertical_resolution_mode, + forced_vertical_lock: self.forced_vertical_lock, + low_resolution_mode: self.low_resolution_mode, + } + } + + pub fn apply_persisted(&mut self, p: &PersistedAppSettings) { + self.format_preset = p.format_preset; + self.auto_exposure = p.auto_exposure; + self.ev_compensation = p.ev_compensation; + self.iso_index = p.iso_index.min(ISO_LIST.len() - 1); + self.shutter_index = p.shutter_index.min(SHUTTER_DENOM_LIST.len() - 1); + self.auto_focus = p.auto_focus; + self.manual_focus = p.manual_focus.min(100); + self.auto_white_balance = p.auto_white_balance; + self.wb_kelvin = p.wb_kelvin.clamp(2000, 10000); + self.brightness = p.brightness.clamp(0, 100); + self.contrast = p.contrast.min(100); + self.saturation = p.saturation.min(100); + self.sharpness = p.sharpness.min(100); + self.hdr_enabled = p.hdr_enabled; + self.anti_flicker = p.anti_flicker; + self.startup_params_enabled = p.startup_params_enabled; + self.filter_mode = p.filter_mode; + self.makeup_enabled = p.makeup_enabled; + self.makeup_intensity = p.makeup_intensity.min(100); + self.track_speed = p.track_speed; + self.enable_single_tap_tracking = p.enable_single_tap_tracking; + self.enable_all_gestures = p.enable_all_gestures; + self.use_gesture_palm = p.use_gesture_palm; + self.use_gesture_v = p.use_gesture_v; + self.use_gesture_l = p.use_gesture_l; + self.audio_denoise = p.audio_denoise; + self.horizontal_mirror = p.horizontal_mirror; + self.vertical_mirror = p.vertical_mirror; + self.smart_adjustment = p.smart_adjustment; + self.horizontal_correction = p.horizontal_correction; + self.roll_deg = p.roll_deg.clamp(-1.0, 1.0); + self.vertical_resolution_mode = p.vertical_resolution_mode; + self.forced_vertical_lock = p.forced_vertical_lock; + self.low_resolution_mode = p.low_resolution_mode; + } } diff --git a/crates/insta360-link-gui/src/video_engine.rs b/crates/insta360-link-gui/src/video_engine.rs index b827a95..64e3db3 100644 --- a/crates/insta360-link-gui/src/video_engine.rs +++ b/crates/insta360-link-gui/src/video_engine.rs @@ -7,7 +7,7 @@ use insta360_link_protocol::{TrackSpeed, UvcRect, VideoMode}; use std::io::{Read, Write}; use std::path::{Path, PathBuf}; use std::process::{Child, ChildStdin, Command, Stdio}; -use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; use std::time::{Instant, SystemTime, UNIX_EPOCH}; @@ -60,6 +60,9 @@ pub struct VideoPreviewEngine { capture_stop: Arc, capture_child: Arc>>, capture_frame: Arc)>>>, + frame_seq: Arc, + last_rendered_seq: u64, + last_effects_key: (FilterMode, bool, u8), recording_stdin: Arc>>, recording_child: Option, recording_path: Option, @@ -97,6 +100,9 @@ impl VideoPreviewEngine { capture_stop: Arc::new(AtomicBool::new(false)), capture_child: Arc::new(Mutex::new(None)), capture_frame: Arc::new(Mutex::new(None)), + frame_seq: Arc::new(AtomicU64::new(0)), + last_rendered_seq: 0, + last_effects_key: (FilterMode::None, false, 0), recording_stdin: Arc::new(Mutex::new(None)), recording_child: None, recording_path: None, @@ -283,6 +289,7 @@ impl VideoPreviewEngine { let stop_flag = self.capture_stop.clone(); let frame_buf = self.capture_frame.clone(); + let frame_seq = self.frame_seq.clone(); let child_store = self.capture_child.clone(); let rec_stdin = self.recording_stdin.clone(); let out_w = self.capture_width; @@ -438,6 +445,7 @@ impl VideoPreviewEngine { } if let Ok(mut lock) = frame_buf.lock() { *lock = Some((out_w, out_h, buffer.clone())); + frame_seq.fetch_add(1, Ordering::Release); } } else { break; @@ -466,28 +474,29 @@ impl VideoPreviewEngine { self.active_device = None; } - /// Renders a full frame (either physical or virtual studio), processes all real-time - /// filters/effects, uploads to egui GPU texture, and returns the texture handle. + /// Renders a full frame (either physical or virtual studio) at the selected capture resolution, + /// processes real-time filters/effects, uploads to egui GPU texture, and returns the texture handle. pub fn render_frame(&mut self, ctx: &Context, state: &mut CameraAppState) -> TextureHandle { let now = Instant::now(); let dt = (now - self.last_frame_time).as_secs_f32().clamp(0.001_f32, 0.1_f32); self.last_frame_time = now; let elapsed = self.start_time.elapsed().as_secs_f32(); - // 1. Output dimensions - let (out_w, out_h) = if state.low_resolution_mode { - if state.vertical_resolution_mode { - (270, 480) - } else { - (480, 270) + let cur_seq = self.frame_seq.load(Ordering::Acquire); + let effects_key = (state.filter_mode, state.makeup_enabled, state.makeup_intensity); + + // Fast-path: if physical capture is active and neither the frame nor host effects changed, + // return the existing GPU texture handle without re-converting or re-uploading. + if self.is_using_physical_capture() + && cur_seq > 0 + && cur_seq == self.last_rendered_seq + && effects_key == self.last_effects_key + { + if let Some(ref th) = self.texture_handle { + return th.clone(); } - } else if state.vertical_resolution_mode { - (360, 640) - } else { - (640, 360) - }; + } - // 2. Obtain raw RGB frame let physical_opt = self.capture_frame.lock().ok().and_then(|guard| guard.clone()); let is_physical_active = self.is_using_physical_capture() && physical_opt.is_some(); let is_insta360_hw_stream = self @@ -496,37 +505,61 @@ impl VideoPreviewEngine { .map(|d| d.to_lowercase().contains("insta360")) .unwrap_or(false); - let mut frame_pixels = if is_physical_active { - let (src_w, src_h, ref raw_rgb) = physical_opt.unwrap(); - self.crop_physical_frame( - raw_rgb, - src_w, - src_h, - out_w, - out_h, - state, - is_insta360_hw_stream, - ) - } else { - self.render_virtual_studio_frame(out_w, out_h, elapsed, dt, state) - }; + let color_image = if is_physical_active { + let (src_w, src_h, raw_rgb) = physical_opt.unwrap(); + self.last_rendered_seq = cur_seq; + self.last_effects_key = effects_key; - // 3. Real-Time Image & Color Pipeline - if is_physical_active && is_insta360_hw_stream { - // Physical Insta360 camera applies PTZ, zoom, exposure, WB, brightness, contrast, - // saturation, sharpness, and mirror in hardware; apply host-side effects (filters, makeup). - self.apply_host_effects_only(&mut frame_pixels, state); + if is_insta360_hw_stream && !state.makeup_enabled && state.filter_mode == FilterMode::None { + // Zero-downscale native resolution fast path (360p, 720p, 1080p, 4K) + ColorImage::from_rgb([src_w, src_h], &raw_rgb) + } else { + let (out_w, out_h) = if src_w > 1920 { + (1920, (src_h * 1920) / src_w.max(1)) + } else { + (src_w, src_h) + }; + let mut frame_pixels = self.crop_physical_frame( + &raw_rgb, + src_w, + src_h, + out_w, + out_h, + state, + is_insta360_hw_stream, + ); + if is_insta360_hw_stream { + self.apply_host_effects_only(&mut frame_pixels, state); + } else { + self.apply_image_pipeline(&mut frame_pixels, out_w, out_h, state); + } + let mut pixels = Vec::with_capacity(out_w * out_h); + for p in frame_pixels { + pixels.push(p.to_color32()); + } + ColorImage { + size: [out_w, out_h], + pixels, + } + } } else { + let (out_w, out_h) = if state.low_resolution_mode { + if state.vertical_resolution_mode { + (270, 480) + } else { + (480, 270) + } + } else if state.vertical_resolution_mode { + (360, 640) + } else { + (640, 360) + }; + let mut frame_pixels = self.render_virtual_studio_frame(out_w, out_h, elapsed, dt, state); self.apply_image_pipeline(&mut frame_pixels, out_w, out_h, state); - } - - // 4. Convert to egui::ColorImage - let mut pixels = Vec::with_capacity(out_w * out_h); - for p in frame_pixels { - pixels.push(p.to_color32()); - } - - if !is_physical_active { + let mut pixels = Vec::with_capacity(out_w * out_h); + for p in frame_pixels { + pixels.push(p.to_color32()); + } if let Ok(mut rec_lock) = self.recording_stdin.lock() { if let Some((ref mut stdin, rw, rh)) = *rec_lock { let mut rgb24 = Vec::with_capacity(rw * rh * 3); @@ -543,14 +576,13 @@ impl VideoPreviewEngine { let _ = stdin.write_all(&rgb24); } } - } - - let color_image = ColorImage { - size: [out_w, out_h], - pixels, + ColorImage { + size: [out_w, out_h], + pixels, + } }; - // 5. Upload to TextureHandle + // Upload to TextureHandle let texture_options = TextureOptions::LINEAR; let handle = if let Some(ref mut th) = self.texture_handle { th.set(color_image.clone(), texture_options);