diff --git a/crates/insta360-link-cli/src/main.rs b/crates/insta360-link-cli/src/main.rs index 1933278..4cebc21 100644 --- a/crates/insta360-link-cli/src/main.rs +++ b/crates/insta360-link-cli/src/main.rs @@ -1006,7 +1006,9 @@ fn run(cli: Cli) -> Result<(), TransportError> { let device_info = ctrl.get_device_info()?; let ptz_version = ctrl.get_ptz_version_and_angles()?; let status = ctrl.get_device_status()?; - let (video_mode, video_mode_aux) = ctrl.get_video_mode()?; + let (video_mode, video_mode_aux) = ctrl + .get_video_mode() + .unwrap_or((VideoMode::Normal, VideoModeAuxData::default())); let extend_functions = ctrl.get_extend_function_status()?; let report = FullCameraInfoReport { diff --git a/crates/insta360-link-gui/src/main.rs b/crates/insta360-link-gui/src/main.rs index 86c8955..697bce9 100644 --- a/crates/insta360-link-gui/src/main.rs +++ b/crates/insta360-link-gui/src/main.rs @@ -86,6 +86,12 @@ impl Insta360App { fn auto_select_insta360_video_feed(&mut self) { let (w, h) = self.state.format_preset.dimensions(); let fps = self.state.format_preset.fps(); + if (fps > 30 || self.state.format_preset.is_vertical()) + && !self.state.vertical_resolution_mode + { + self.state.vertical_resolution_mode = true; + let _ = self.controller.set_vertical_screen(true); + } self.video_engine .set_capture_format(w as usize, h as usize, fps); @@ -604,7 +610,13 @@ impl Insta360App { { let (w, h) = preset.dimensions(); let fps = preset.fps(); - self.state.vertical_resolution_mode = preset.is_vertical(); + if (fps > 30 || preset.is_vertical()) + && !self.state.vertical_resolution_mode + { + self.video_engine.stop_physical_capture(); + self.state.vertical_resolution_mode = true; + let _ = self.controller.set_vertical_screen(true); + } self.state.low_resolution_mode = w == 640; self.video_engine.set_capture_format(w as usize, h as usize, fps); self.state @@ -840,7 +852,7 @@ impl Insta360App { } // Live video feed rendering with aspect ratio preservation - let target_aspect = if self.state.vertical_resolution_mode { + let target_aspect = if self.state.format_preset.is_vertical() { 9.0_f32 / 16.0_f32 } else { 16.0_f32 / 9.0_f32 @@ -2085,19 +2097,33 @@ impl Insta360App { impl eframe::App for Insta360App { fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { - // Continuous repaint loop for smooth 30fps live video preview - if self.state.preview_open { - ctx.request_repaint_after(Duration::from_millis(33)); - } - - // Keep capture resolution synced with selected format preset + // Keep capture resolution and frame rate synced with selected format preset let (target_w, target_h) = self.state.format_preset.dimensions(); let target_fps = self.state.format_preset.fps(); - if self.video_engine.capture_dimensions() != (target_w as usize, target_h as usize) { + if self.video_engine.capture_dimensions() != (target_w as usize, target_h as usize) + || self.video_engine.capture_fps() != target_fps + { + if (target_fps > 30 || self.state.format_preset.is_vertical()) + && !self.state.vertical_resolution_mode + { + self.video_engine.stop_physical_capture(); + self.state.vertical_resolution_mode = true; + let _ = self.controller.set_vertical_screen(true); + } self.video_engine .set_capture_format(target_w as usize, target_h as usize, target_fps); } + // Continuous repaint loop matching active capture framerate (16ms for 60fps, 33ms for 30fps) + if self.state.preview_open { + let repaint_ms = if self.video_engine.capture_fps() >= 50 { + 16 + } else { + 33 + }; + ctx.request_repaint_after(Duration::from_millis(repaint_ms)); + } + // 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; diff --git a/crates/insta360-link-gui/src/right_panel.rs b/crates/insta360-link-gui/src/right_panel.rs index c8acb2b..1da973b 100644 --- a/crates/insta360-link-gui/src/right_panel.rs +++ b/crates/insta360-link-gui/src/right_panel.rs @@ -883,15 +883,15 @@ fn render_more_tab( ui, "Portrait Resolution and High Frame Rate", None, - Some("Enable 9:16 portrait resolution output."), + Some("Unlock 60fps high frame rate and 9:16 portrait resolution in UVC."), &mut state.vertical_resolution_mode, !state.is_recording, theme, ) { let _ = ctrl.set_vertical_screen(state.vertical_resolution_mode); - if state.vertical_resolution_mode { - state.format_preset = crate::state::VideoFormatPreset::Vertical1080x1920p30; - } else { + if !state.vertical_resolution_mode + && (state.format_preset.is_vertical() || state.format_preset.fps() > 30) + { state.format_preset = crate::state::VideoFormatPreset::Fhd1080p30; } } diff --git a/crates/insta360-link-gui/src/state.rs b/crates/insta360-link-gui/src/state.rs index 161bd23..110d75d 100644 --- a/crates/insta360-link-gui/src/state.rs +++ b/crates/insta360-link-gui/src/state.rs @@ -651,7 +651,8 @@ impl CameraAppState { 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.vertical_resolution_mode = + p.vertical_resolution_mode || p.format_preset.fps() > 30 || p.format_preset.is_vertical(); 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 64e3db3..e943598 100644 --- a/crates/insta360-link-gui/src/video_engine.rs +++ b/crates/insta360-link-gui/src/video_engine.rs @@ -139,6 +139,11 @@ impl VideoPreviewEngine { (self.capture_width, self.capture_height) } + /// Returns the currently configured capture frame rate (`fps`). + pub fn capture_fps(&self) -> u32 { + self.capture_fps + } + /// List of discovered hardware capture devices. pub fn available_capture_devices(&self) -> &[String] { &self.available_devices @@ -301,21 +306,32 @@ impl VideoPreviewEngine { let frame_size = out_w * out_h * 3; let mut buffer = vec![0u8; frame_size]; - for attempt in 0..2 { + for attempt in 0..8 { if stop_flag.load(Ordering::Relaxed) { break; } + if attempt > 0 { + std::thread::sleep( std::time::Duration::from_millis(400)); + if stop_flag.load(Ordering::Relaxed) { + break; + } + } let mut cmd = Command::new("ffmpeg"); #[cfg(target_os = "macos")] { - // Insta360 Link AVFoundation natively supports 3840x2160, 1920x1080, 1280x720 at up to 30fps + // Insta360 Link AVFoundation natively supports 3840x2160, 1920x1080, 1280x720, 1080x1920 // in '420v' (nv12) and '2vuy' (uyvy422). Using nv12 matches both the official // Insta360 Link Controller (`OpenDevice` at 0x10008770c) and Chrome/Google Meet. - let hw_fps = fps.min(30).to_string(); + let hw_fps = if attempt < 6 { + fps.to_string() + } else { + fps.min(30).to_string() + }; let hw_size = match (out_w, out_h) { (3840, 2160) => "3840x2160", (1280, 720) => "1280x720", + (1080, 1920) if attempt < 6 => "1080x1920", _ => "1920x1080", }; cmd.args([ @@ -327,7 +343,7 @@ impl VideoPreviewEngine { "-pixel_format", "nv12", ]); - if attempt == 0 { + if attempt < 7 { cmd.args(["-framerate", &hw_fps, "-video_size", hw_size]); } else { cmd.args(["-framerate", "30", "-video_size", "1280x720"]); @@ -351,14 +367,19 @@ impl VideoPreviewEngine { #[cfg(target_os = "linux")] { - let hw_fps = fps.min(30).to_string(); + let hw_fps = if attempt < 6 { + fps.to_string() + } else { + fps.min(30).to_string() + }; let hw_size = match (out_w, out_h) { (3840, 2160) => "3840x2160", (1280, 720) => "1280x720", + (1080, 1920) if attempt < 6 => "1080x1920", _ => "1920x1080", }; cmd.args(["-hide_banner", "-loglevel", "error", "-f", "v4l2"]); - if attempt == 0 { + if attempt < 7 { cmd.args([ "-input_format", "mjpeg", @@ -543,13 +564,14 @@ impl VideoPreviewEngine { } } } else { + let is_vert = state.format_preset.is_vertical(); let (out_w, out_h) = if state.low_resolution_mode { - if state.vertical_resolution_mode { + if is_vert { (270, 480) } else { (480, 270) } - } else if state.vertical_resolution_mode { + } else if is_vert { (360, 640) } else { (640, 360) @@ -1110,7 +1132,7 @@ impl VideoPreviewEngine { let w = self.capture_width; let h = self.capture_height; - let fps = self.capture_fps.min(30).max(15); + let fps = self.capture_fps.clamp(15, 60); let size_arg = format!("{w}x{h}"); let fps_arg = fps.to_string(); diff --git a/crates/insta360-link-protocol/src/controller.rs b/crates/insta360-link-protocol/src/controller.rs index 179fbcd..0e1f6e2 100644 --- a/crates/insta360-link-protocol/src/controller.rs +++ b/crates/insta360-link-protocol/src/controller.rs @@ -941,6 +941,9 @@ impl Insta360CameraController { } let mut bits = u16::from_le_bytes([cur[0], cur[1]]); let mask = 1u16 << (func as u8); + if ((bits & mask) != 0) == enable { + return Ok(()); + } if enable { bits |= mask; } else { @@ -949,11 +952,20 @@ impl Insta360CameraController { let bytes = bits.to_le_bytes(); cur[0] = bytes[0]; cur[1] = bytes[1]; - return self.transport.set_cur( + let res = self.transport.set_cur( self.unit_ids().extension_unit_id, 0x1b, &cur, ); + // Toggling `VerticalMode` or `LowResolution` triggers an immediate USB re-enumeration + // inside the camera firmware to expose the updated UVC frame/FPS descriptors. + if matches!( + func, + ExtendFunction::VerticalMode | ExtendFunction::LowResolution + ) { + return Ok(()); + } + return res; } let cur = self .transport diff --git a/crates/insta360-link-protocol/src/transport.rs b/crates/insta360-link-protocol/src/transport.rs index 76c0a18..60f9047 100644 --- a/crates/insta360-link-protocol/src/transport.rs +++ b/crates/insta360-link-protocol/src/transport.rs @@ -101,7 +101,7 @@ pub struct NusbTransport { device: Option, interface: Option, #[cfg(target_os = "linux")] - v4l_file: Option, + v4l_file: std::sync::Mutex>, timeout: Duration, } @@ -424,10 +424,35 @@ impl NusbTransport { device, interface, #[cfg(target_os = "linux")] - v4l_file, + v4l_file: std::sync::Mutex::new(v4l_file), timeout: Duration::from_millis(1500), }) } + + #[cfg(target_os = "linux")] + fn with_v4l_file(&self, mut op: F) -> Option + where + F: FnMut(&std::fs::File) -> Result, + { + let mut guard = self.v4l_file.lock().ok()?; + if let Some(ref f) = *guard { + if let Ok(res) = op(f) { + return Some(res); + } + } + // Re-open V4L2 node if the camera re-enumerated over USB (e.g., when switching + // VerticalMode / High Frame Rate UVC descriptors). + if let Some((new_f, _)) = open_linux_v4l2_node( + self.info.vendor_id, + self.info.product_id, + self.info.unit_ids.extension_unit_id, + ) { + let res = op(&new_f).ok(); + *guard = Some(new_f); + return res; + } + None + } } impl UvcControlTransport for NusbTransport { @@ -462,55 +487,55 @@ impl UvcControlTransport for NusbTransport { }; #[cfg(target_os = "linux")] - if let Some(v4l) = &self.v4l_file { - if is_xu { - let mut buf = payload.to_vec(); - if linux_uvcioc_ctrl_query( - v4l, - unit_id, - selector, - UvcRequest::SetCur as u8, - &mut buf, - ) - .is_ok() + if self + .with_v4l_file(|v4l| { + if is_xu { + let mut buf = payload.to_vec(); + linux_uvcioc_ctrl_query( + v4l, + unit_id, + selector, + UvcRequest::SetCur as u8, + &mut buf, + ) + } else if unit_id == self.info.unit_ids.input_terminal_id + && selector == ct_selectors::PANTILT_ABSOLUTE + && payload.len() >= 8 { - return Ok(()); - } - } else if unit_id == self.info.unit_ids.input_terminal_id - && selector == ct_selectors::PANTILT_ABSOLUTE - && payload.len() >= 8 - { - let pan = i32::from_le_bytes([payload[0], payload[1], payload[2], payload[3]]); - let tilt = i32::from_le_bytes([payload[4], payload[5], payload[6], payload[7]]); - let r1 = linux_v4l2_set_ctrl(v4l, V4L2_CID_PAN_ABSOLUTE, pan); - let r2 = linux_v4l2_set_ctrl(v4l, V4L2_CID_TILT_ABSOLUTE, tilt); - if r1.is_ok() || r2.is_ok() { - return Ok(()); - } - } else if let Some(cid) = - linux_uvc_to_v4l2_cid(&self.info.unit_ids, unit_id, selector) - { - let val: i32 = match payload.len() { - 1 => payload[0] as i32, - 2 => { - if cid == V4L2_CID_BRIGHTNESS { - i16::from_le_bytes([payload[0], payload[1]]) as i32 - } else { - u16::from_le_bytes([payload[0], payload[1]]) as i32 + let pan = i32::from_le_bytes([payload[0], payload[1], payload[2], payload[3]]); + let tilt = i32::from_le_bytes([payload[4], payload[5], payload[6], payload[7]]); + let r1 = linux_v4l2_set_ctrl(v4l, V4L2_CID_PAN_ABSOLUTE, pan); + let r2 = linux_v4l2_set_ctrl(v4l, V4L2_CID_TILT_ABSOLUTE, tilt); + r1.or(r2) + } else if let Some(cid) = + linux_uvc_to_v4l2_cid(&self.info.unit_ids, unit_id, selector) + { + let val: i32 = match payload.len() { + 1 => payload[0] as i32, + 2 => { + if cid == V4L2_CID_BRIGHTNESS { + i16::from_le_bytes([payload[0], payload[1]]) as i32 + } else { + u16::from_le_bytes([payload[0], payload[1]]) as i32 + } } - } - 4 => i32::from_le_bytes([payload[0], payload[1], payload[2], payload[3]]), - _ => 0, - }; - if linux_v4l2_set_ctrl(v4l, cid, val).is_ok() { - return Ok(()); - } - if cid == V4L2_CID_POWER_LINE_FREQUENCY && val == 3 { - if linux_v4l2_set_ctrl(v4l, cid, 2).is_ok() { + 4 => i32::from_le_bytes([payload[0], payload[1], payload[2], payload[3]]), + _ => 0, + }; + if linux_v4l2_set_ctrl(v4l, cid, val).is_ok() { return Ok(()); } + if cid == V4L2_CID_POWER_LINE_FREQUENCY && val == 3 { + return linux_v4l2_set_ctrl(v4l, cid, 2); + } + Err(TransportError::Usb("V4L2 set_ctrl failed".to_string())) + } else { + Err(TransportError::Usb("unmapped V4L2 control".to_string())) } - } + }) + .is_some() + { + return Ok(()); } let hdr = UvcControlPacketHeader::new( @@ -567,51 +592,49 @@ impl UvcControlTransport for NusbTransport { }; #[cfg(target_os = "linux")] - if let Some(v4l) = &self.v4l_file { + if let Some(out) = self.with_v4l_file(|v4l| { if is_xu { let mut buf = vec![0u8; actual_len]; - if linux_uvcioc_ctrl_query(v4l, unit_id, selector, req as u8, &mut buf).is_ok() { - return Ok(buf); - } + linux_uvcioc_ctrl_query(v4l, unit_id, selector, req as u8, &mut buf)?; + Ok(buf) } else if unit_id == self.info.unit_ids.input_terminal_id && selector == ct_selectors::PANTILT_ABSOLUTE && req == UvcRequest::GetCur { - if let (Ok(pan), Ok(tilt)) = ( - linux_v4l2_get_ctrl(v4l, V4L2_CID_PAN_ABSOLUTE), - linux_v4l2_get_ctrl(v4l, V4L2_CID_TILT_ABSOLUTE), - ) { - let mut out = vec![0u8; 8]; - out[0..4].copy_from_slice(&pan.to_le_bytes()); - out[4..8].copy_from_slice(&tilt.to_le_bytes()); - return Ok(out); - } + let pan = linux_v4l2_get_ctrl(v4l, V4L2_CID_PAN_ABSOLUTE)?; + let tilt = linux_v4l2_get_ctrl(v4l, V4L2_CID_TILT_ABSOLUTE)?; + let mut out = vec![0u8; 8]; + out[0..4].copy_from_slice(&pan.to_le_bytes()); + out[4..8].copy_from_slice(&tilt.to_le_bytes()); + Ok(out) } else if let Some(cid) = linux_uvc_to_v4l2_cid(&self.info.unit_ids, unit_id, selector) { - let v4l_val: Result = match req { - UvcRequest::GetCur => linux_v4l2_get_ctrl(v4l, cid), - UvcRequest::GetMin => linux_v4l2_query_ctrl(v4l, cid).map(|q| q.minimum), - UvcRequest::GetMax => linux_v4l2_query_ctrl(v4l, cid).map(|q| q.maximum), - UvcRequest::GetRes => linux_v4l2_query_ctrl(v4l, cid).map(|q| q.step), - UvcRequest::GetDef => linux_v4l2_query_ctrl(v4l, cid).map(|q| q.default_value), - _ => Err(TransportError::Usb("unsupported V4L2 request".to_string())), + let val: i32 = match req { + UvcRequest::GetCur => linux_v4l2_get_ctrl(v4l, cid)?, + UvcRequest::GetMin => linux_v4l2_query_ctrl(v4l, cid)?.minimum, + UvcRequest::GetMax => linux_v4l2_query_ctrl(v4l, cid)?.maximum, + UvcRequest::GetRes => linux_v4l2_query_ctrl(v4l, cid)?.step, + UvcRequest::GetDef => linux_v4l2_query_ctrl(v4l, cid)?.default_value, + _ => return Err(TransportError::Usb("unsupported V4L2 request".to_string())), }; - if let Ok(val) = v4l_val { - return Ok(match actual_len { - 1 => vec![val as u8], - 2 => (val as i16).to_le_bytes().to_vec(), - 4 => val.to_le_bytes().to_vec(), - _ => { - let mut b = vec![0u8; actual_len]; - let le = val.to_le_bytes(); - let n = actual_len.min(4); - b[..n].copy_from_slice(&le[..n]); - b - } - }); - } + Ok(match actual_len { + 1 => vec![val as u8], + 2 => (val as i16).to_le_bytes().to_vec(), + 4 => val.to_le_bytes().to_vec(), + _ => { + let mut b = vec![0u8; actual_len]; + let le = val.to_le_bytes(); + let n = actual_len.min(4); + b[..n].copy_from_slice(&le[..n]); + b + } + }) + } else { + Err(TransportError::Usb("unmapped V4L2 control".to_string())) } + }) { + return Ok(out); } let hdr = UvcControlPacketHeader::new(