Render live preview texture at native capture resolution and persist settings to settings.json

This commit is contained in:
Lucas Oskorep committed 2026-10-21 00:00:24 -04:00
1 parent 844835c22d
commit 51b30cbd79
3 files changed
+350 -51

No files matched your search

+109
View File
@@ -236,14 +236,108 @@ impl Insta360App {
if let Ok(ae) = self.controller.get_auto_exposure() { if let Ok(ae) = self.controller.get_auto_exposure() {
self.state.auto_exposure = ae; 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() { if let Ok(af) = self.controller.get_auto_focus() {
self.state.auto_focus = af; 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() { if let Ok(denoise) = self.controller.get_audio_noise_reduction() {
self.state.audio_denoise = denoise; 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) { fn set_video_mode(&mut self, target_mode: VideoMode) {
let prev_mode = self.state.video_mode; let prev_mode = self.state.video_mode;
let mode_to_set = if self.state.video_mode == target_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 // 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 { if self.video_engine.has_live_physical_frame() && !self.state.stream_initialized {
self.state.stream_initialized = true; self.state.stream_initialized = true;
let had_saved_settings = self.state.last_saved_settings.is_some();
self.sync_device_info(); 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), // 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. // restore Normal mode so the live video stream is right-side up.
if self.state.video_mode == VideoMode::OverheadShot 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) // 2. Auto-stop short PTZ directional nudges (`CameraInsta::addPanTilt` 100-120ms timer)
if let Some(stop_at) = self.state.nudge_stop_at { if let Some(stop_at) = self.state.nudge_stop_at {
if Instant::now() >= stop_at { if Instant::now() >= stop_at {
+160 -2
View File
@@ -2,6 +2,7 @@
use egui::{ColorImage, Pos2, TextureHandle}; use egui::{ColorImage, Pos2, TextureHandle};
pub use insta360_link_protocol::*; pub use insta360_link_protocol::*;
use serde::{Deserialize, Serialize};
use std::path::PathBuf; use std::path::PathBuf;
use std::time::Instant; use std::time::Instant;
@@ -52,7 +53,7 @@ pub enum BackgroundEffectMode {
} }
/// Resolution & frame rate preset (`DefFormatCombo.qml`). /// Resolution & frame rate preset (`DefFormatCombo.qml`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum VideoFormatPreset { pub enum VideoFormatPreset {
Uhd4k30, Uhd4k30,
Fhd1080p60, Fhd1080p60,
@@ -281,6 +282,81 @@ pub struct CameraAppState {
pub ready_auto_track: bool, pub ready_auto_track: bool,
pub last_track_poll: Option<Instant>, pub last_track_poll: Option<Instant>,
pub last_hw_sync: Option<Instant>, pub last_hw_sync: Option<Instant>,
pub last_saved_settings: Option<PersistedAppSettings>,
}
/// 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<Self> {
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 { impl Default for CameraAppState {
@@ -328,7 +404,7 @@ impl Default for CameraAppState {
.map(PathBuf::from) .map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from(".")); .unwrap_or_else(|_| PathBuf::from("."));
Self { let mut s = Self {
is_light_mode: false, is_light_mode: false,
ui_mode: UiMode::MainWindow, ui_mode: UiMode::MainWindow,
right_tab: RightSidebarTab::View, right_tab: RightSidebarTab::View,
@@ -489,7 +565,13 @@ impl Default for CameraAppState {
ready_auto_track: false, ready_auto_track: false,
last_track_poll: None, last_track_poll: None,
last_hw_sync: 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<String>, is_error: bool) { pub fn show_toast(&mut self, msg: impl Into<String>, is_error: bool) {
self.toast_message = Some((msg.into(), Instant::now(), is_error)); 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;
}
} }
+81 -49
View File
@@ -7,7 +7,7 @@ use insta360_link_protocol::{TrackSpeed, UvcRect, VideoMode};
use std::io::{Read, Write}; use std::io::{Read, Write};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::{Child, ChildStdin, Command, Stdio}; 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::sync::{Arc, Mutex};
use std::time::{Instant, SystemTime, UNIX_EPOCH}; use std::time::{Instant, SystemTime, UNIX_EPOCH};
@@ -60,6 +60,9 @@ pub struct VideoPreviewEngine {
capture_stop: Arc<AtomicBool>, capture_stop: Arc<AtomicBool>,
capture_child: Arc<Mutex<Option<Child>>>, capture_child: Arc<Mutex<Option<Child>>>,
capture_frame: Arc<Mutex<Option<(usize, usize, Vec<u8>)>>>, capture_frame: Arc<Mutex<Option<(usize, usize, Vec<u8>)>>>,
frame_seq: Arc<AtomicU64>,
last_rendered_seq: u64,
last_effects_key: (FilterMode, bool, u8),
recording_stdin: Arc<Mutex<Option<(ChildStdin, usize, usize)>>>, recording_stdin: Arc<Mutex<Option<(ChildStdin, usize, usize)>>>,
recording_child: Option<Child>, recording_child: Option<Child>,
recording_path: Option<PathBuf>, recording_path: Option<PathBuf>,
@@ -97,6 +100,9 @@ impl VideoPreviewEngine {
capture_stop: Arc::new(AtomicBool::new(false)), capture_stop: Arc::new(AtomicBool::new(false)),
capture_child: Arc::new(Mutex::new(None)), capture_child: Arc::new(Mutex::new(None)),
capture_frame: 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_stdin: Arc::new(Mutex::new(None)),
recording_child: None, recording_child: None,
recording_path: None, recording_path: None,
@@ -283,6 +289,7 @@ impl VideoPreviewEngine {
let stop_flag = self.capture_stop.clone(); let stop_flag = self.capture_stop.clone();
let frame_buf = self.capture_frame.clone(); let frame_buf = self.capture_frame.clone();
let frame_seq = self.frame_seq.clone();
let child_store = self.capture_child.clone(); let child_store = self.capture_child.clone();
let rec_stdin = self.recording_stdin.clone(); let rec_stdin = self.recording_stdin.clone();
let out_w = self.capture_width; let out_w = self.capture_width;
@@ -438,6 +445,7 @@ impl VideoPreviewEngine {
} }
if let Ok(mut lock) = frame_buf.lock() { if let Ok(mut lock) = frame_buf.lock() {
*lock = Some((out_w, out_h, buffer.clone())); *lock = Some((out_w, out_h, buffer.clone()));
frame_seq.fetch_add(1, Ordering::Release);
} }
} else { } else {
break; break;
@@ -466,28 +474,29 @@ impl VideoPreviewEngine {
self.active_device = None; self.active_device = None;
} }
/// Renders a full frame (either physical or virtual studio), processes all real-time /// Renders a full frame (either physical or virtual studio) at the selected capture resolution,
/// filters/effects, uploads to egui GPU texture, and returns the texture handle. /// 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 { pub fn render_frame(&mut self, ctx: &Context, state: &mut CameraAppState) -> TextureHandle {
let now = Instant::now(); let now = Instant::now();
let dt = (now - self.last_frame_time).as_secs_f32().clamp(0.001_f32, 0.1_f32); let dt = (now - self.last_frame_time).as_secs_f32().clamp(0.001_f32, 0.1_f32);
self.last_frame_time = now; self.last_frame_time = now;
let elapsed = self.start_time.elapsed().as_secs_f32(); let elapsed = self.start_time.elapsed().as_secs_f32();
// 1. Output dimensions let cur_seq = self.frame_seq.load(Ordering::Acquire);
let (out_w, out_h) = if state.low_resolution_mode { let effects_key = (state.filter_mode, state.makeup_enabled, state.makeup_intensity);
if state.vertical_resolution_mode {
(270, 480) // Fast-path: if physical capture is active and neither the frame nor host effects changed,
} else { // return the existing GPU texture handle without re-converting or re-uploading.
(480, 270) 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 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_physical_active = self.is_using_physical_capture() && physical_opt.is_some();
let is_insta360_hw_stream = self let is_insta360_hw_stream = self
@@ -496,37 +505,61 @@ impl VideoPreviewEngine {
.map(|d| d.to_lowercase().contains("insta360")) .map(|d| d.to_lowercase().contains("insta360"))
.unwrap_or(false); .unwrap_or(false);
let mut frame_pixels = if is_physical_active { let color_image = if is_physical_active {
let (src_w, src_h, ref raw_rgb) = physical_opt.unwrap(); let (src_w, src_h, raw_rgb) = physical_opt.unwrap();
self.crop_physical_frame( self.last_rendered_seq = cur_seq;
raw_rgb, self.last_effects_key = effects_key;
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)
};
// 3. Real-Time Image & Color Pipeline if is_insta360_hw_stream && !state.makeup_enabled && state.filter_mode == FilterMode::None {
if is_physical_active && is_insta360_hw_stream { // Zero-downscale native resolution fast path (360p, 720p, 1080p, 4K)
// Physical Insta360 camera applies PTZ, zoom, exposure, WB, brightness, contrast, ColorImage::from_rgb([src_w, src_h], &raw_rgb)
// saturation, sharpness, and mirror in hardware; apply host-side effects (filters, makeup). } else {
self.apply_host_effects_only(&mut frame_pixels, state); 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 { } 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); 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 {
// 4. Convert to egui::ColorImage pixels.push(p.to_color32());
let mut pixels = Vec::with_capacity(out_w * out_h); }
for p in frame_pixels {
pixels.push(p.to_color32());
}
if !is_physical_active {
if let Ok(mut rec_lock) = self.recording_stdin.lock() { if let Ok(mut rec_lock) = self.recording_stdin.lock() {
if let Some((ref mut stdin, rw, rh)) = *rec_lock { if let Some((ref mut stdin, rw, rh)) = *rec_lock {
let mut rgb24 = Vec::with_capacity(rw * rh * 3); let mut rgb24 = Vec::with_capacity(rw * rh * 3);
@@ -543,14 +576,13 @@ impl VideoPreviewEngine {
let _ = stdin.write_all(&rgb24); let _ = stdin.write_all(&rgb24);
} }
} }
} ColorImage {
size: [out_w, out_h],
let color_image = ColorImage { pixels,
size: [out_w, out_h], }
pixels,
}; };
// 5. Upload to TextureHandle // Upload to TextureHandle
let texture_options = TextureOptions::LINEAR; let texture_options = TextureOptions::LINEAR;
let handle = if let Some(ref mut th) = self.texture_handle { let handle = if let Some(ref mut th) = self.texture_handle {
th.set(color_image.clone(), texture_options); th.set(color_image.clone(), texture_options);