Compare commits

...
11 Commits
14 changed files with 828 additions and 377 deletions

No files matched your search

+3
View File
@@ -53,6 +53,9 @@ jobs:
- label: linux-x86_64 - label: linux-x86_64
os: ubuntu-latest os: ubuntu-latest
target: x86_64-unknown-linux-gnu target: x86_64-unknown-linux-gnu
- label: linux-arm64
os: ubuntu-24.04-arm
target: aarch64-unknown-linux-gnu
- label: macos-arm64 - label: macos-arm64
os: macos-14 os: macos-14
target: aarch64-apple-darwin target: aarch64-apple-darwin
+5 -18
View File
@@ -41,9 +41,6 @@ This project includes a [`mise.toml`](mise.toml) configuration that manages the
mise trust mise trust
mise install mise install
# Install Linux udev rules (one-time setup on Linux for non-root USB access)
mise run udev-install
# Build & launch the desktop GUI # Build & launch the desktop GUI
mise run gui mise run gui
@@ -70,28 +67,18 @@ The compiled binaries will be placed in `target/release/`:
--- ---
## Platform Setup Instructions ## Platform Notes
### Linux (`udev` Rules) ### Linux (`V4L2` & `uvcvideo`)
On Linux, `nusb` communicates directly with the kernel `usbfs` device node (`/dev/bus/usb/BBB/DDD`) and detaches/claims only the UVC VideoControl interface while leaving the V4L2 video streaming interface (`/dev/video*`) untouched for OBS, Zoom, or browsers. On Linux, `insta360-link-protocol` communicates directly with the primary V4L2 video capture node (`/dev/video*`) using `UVCIOC_CTRL_QUERY` for proprietary Insta360 Extension Unit (`XU1`/`XU2`) commands and standard V4L2 `VIDIOC_G_CTRL`/`VIDIOC_S_CTRL` ioctls for Processing Unit / Camera Terminal controls, while reading USB descriptors from sysfs. Because `systemd-logind` automatically grants the active desktop user session access to `/dev/video*`, **no custom `udev` rules or `sudo` privileges are required**.
To run the CLI, server, and GUI without `sudo`, install the bundled `udev` rules: #### Arch Linux (AUR)
```bash ```bash
sudo cp udev/99-insta360-link.rules /etc/udev/rules.d/99-insta360-link.rules yay -S insta360-link-controller-bin
sudo udevadm control --reload-rules
sudo udevadm trigger
``` ```
Unplug and reconnect your Insta360 Link camera if it is already connected.
### Windows (`WinUSB` / Zadig Note)
On Windows, the standard `usbvideo.sys` UVC driver claims the composite video interfaces. To allow direct USB control transfers via `nusb`:
- Use [Zadig](https://zadig.akeo.ie/) (Options → **List All Devices**, uncheck **Ignore Hubs or Composite Parents**) to bind the camera's **UVC VideoControl interface (Interface 0)** to the **WinUSB** driver while leaving the VideoStreaming interface on `usbvideo.sys`, **or**
- Run with `--mock` to test the CLI, server, and GUI using the built-in hardware simulator.
### macOS ### macOS
On macOS, `nusb` uses `IOKit` to send UVC class-specific control requests directly to Endpoint 0 without detaching the macOS `UVCAssistant` / `AVFoundation` camera stream. No kernel extensions or extra permissions are required. On macOS, `nusb` uses `IOKit` to send UVC class-specific control requests directly to Endpoint 0 without detaching the macOS `UVCAssistant` / `AVFoundation` camera stream. No kernel extensions or extra permissions are required.
+14
View File
@@ -0,0 +1,14 @@
[Desktop Entry]
Version=1.0
Type=Application
Name=Insta360 Link Controller
GenericName=Webcam Controller
Comment=Hardware PTZ, AI tracking, image settings, and live preview controller for Insta360 Link webcams
Exec=insta360-link-gui
TryExec=insta360-link-gui
Icon=insta360-link-controller
Terminal=false
Categories=AudioVideo;Video;
Keywords=insta360;link;webcam;camera;ptz;gimbal;uvc;video;tracking;
StartupNotify=true
StartupWMClass=insta360-link-controller
+18
View File
@@ -0,0 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" width="128" height="128">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#181c24"/>
<stop offset="100%" stop-color="#0e1117"/>
</linearGradient>
<linearGradient id="ring" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#00e676"/>
<stop offset="100%" stop-color="#00b248"/>
</linearGradient>
</defs>
<rect x="8" y="8" width="112" height="112" rx="26" fill="url(#bg)" stroke="#2a3140" stroke-width="3"/>
<rect x="34" y="24" width="60" height="46" rx="14" fill="#232936" stroke="url(#ring)" stroke-width="4"/>
<circle cx="64" cy="47" r="14" fill="#0d1016" stroke="#00e676" stroke-width="3"/>
<circle cx="64" cy="47" r="6" fill="#00e676"/>
<path d="M52 70 L52 86 L76 86 L76 70" fill="#232936" stroke="#3a4254" stroke-width="3" stroke-linejoin="round"/>
<rect x="36" y="86" width="56" height="14" rx="7" fill="#2b3242" stroke="#00e676" stroke-width="2.5"/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+3 -1
View File
@@ -1006,7 +1006,9 @@ fn run(cli: Cli) -> Result<(), TransportError> {
let device_info = ctrl.get_device_info()?; let device_info = ctrl.get_device_info()?;
let ptz_version = ctrl.get_ptz_version_and_angles()?; let ptz_version = ctrl.get_ptz_version_and_angles()?;
let status = ctrl.get_device_status()?; 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 extend_functions = ctrl.get_extend_function_status()?;
let report = FullCameraInfoReport { let report = FullCameraInfoReport {
+311 -139
View File
@@ -86,6 +86,12 @@ impl Insta360App {
fn auto_select_insta360_video_feed(&mut self) { fn auto_select_insta360_video_feed(&mut self) {
let (w, h) = self.state.format_preset.dimensions(); let (w, h) = self.state.format_preset.dimensions();
let fps = self.state.format_preset.fps(); 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 self.video_engine
.set_capture_format(w as usize, h as usize, fps); .set_capture_format(w as usize, h as usize, fps);
@@ -236,14 +242,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 {
@@ -254,20 +354,20 @@ impl Insta360App {
if mode_to_set == VideoMode::AutoTracking { if mode_to_set == VideoMode::AutoTracking {
// `Webcam::CameraInsta::autoTrackClicked` (`0x10006c400`) -> // `Webcam::CameraInsta::autoTrackClicked` (`0x10006c400`) ->
// `onHandleVideoModeChangeEvent` (`0x10005a4cc`) -> `readyAutoTrack` (`0x10006c8c8`): // `onHandleVideoModeChangeEvent` (`0x10005a400`..`0x10005a4cc`) -> `readyAutoTrack` (`0x10006c8c8`):
// 1. Ensure the camera is in `VideoMode::Normal` (`0`) so the firmware's person // 1. If the camera was in a non-Normal mode (`>= 2`: Whiteboard, Overhead, DeskView),
// detector (`GetTrackObjLists`, `XU1 0x14`) runs on the live video stream. // first exit to `VideoMode::Normal` (`0`) so `GetTrackObjLists` (`XU1 0x14`) runs.
// 2. Do NOT send `SetTrackObj(0.5, 0.5)` or `SetVideoMode(AutoTracking)` before // 2. Do NOT send `SetTrackObj(0.5, 0.5)` or `SetVideoMode(AutoTracking)` before
// `GetTrackObjLists` detects a person — doing so aborts firmware person detection! // `GetTrackObjLists` detects a person — doing so aborts firmware person detection!
// 3. Start the 200ms `ready_auto_track` polling loop (`SetCheckTrack(true)`). // 3. Start the 200ms `ready_auto_track` polling loop (`SetCheckTrack(true)`).
if prev_mode != VideoMode::Normal { if prev_mode != VideoMode::Normal && prev_mode != VideoMode::AutoTracking {
let _ = self let _ = self
.controller .controller
.set_video_mode_raw(VideoMode::Normal, &VideoModeAuxData::default()); .set_video_mode_raw(VideoMode::Normal, &VideoModeAuxData::default());
} }
self.state.video_mode = VideoMode::AutoTracking; self.state.video_mode = VideoMode::AutoTracking;
self.state.ready_auto_track = true; self.state.ready_auto_track = true;
self.state.last_track_poll = None; self.state.last_track_poll = Some(Instant::now());
self.state.show_toast( self.state.show_toast(
"AI Tracking ready — looking for person (or click on video to lock target)", "AI Tracking ready — looking for person (or click on video to lock target)",
false, false,
@@ -328,10 +428,27 @@ impl Insta360App {
ui.add_space(24.0_f32); ui.add_space(24.0_f32);
// Center: Camera Controller Selector // Center: Camera Controller Selector
let current_dev_label = self.selected_device.label(); let show_simulators = cfg!(debug_assertions)
|| std::env::var("INSTA360_SIMULATOR")
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
.unwrap_or(false);
let current_dev_label = match &self.selected_device {
DeviceSelection::Usb(_) => self.selected_device.label(),
DeviceSelection::Simulate(_) if show_simulators => {
self.selected_device.label()
}
DeviceSelection::Simulate(_) => "No Camera Connected".to_string(),
};
egui::ComboBox::from_id_salt("top_camera_selector") egui::ComboBox::from_id_salt("top_camera_selector")
.selected_text(&current_dev_label) .selected_text(&current_dev_label)
.show_ui(ui, |ui| { .show_ui(ui, |ui| {
if self.connected_cameras.is_empty() && !show_simulators {
ui.label(
RichText::new("No Camera Connected").color(theme.text03),
);
}
for cam in self.connected_cameras.clone() { for cam in self.connected_cameras.clone() {
let dev = DeviceSelection::Usb(cam); let dev = DeviceSelection::Usb(cam);
let label = dev.label(); let label = dev.label();
@@ -339,18 +456,20 @@ impl Insta360App {
self.switch_camera(dev); self.switch_camera(dev);
} }
} }
if !self.connected_cameras.is_empty() { if show_simulators {
ui.separator(); if !self.connected_cameras.is_empty() {
} ui.separator();
for model in [ }
CameraModel::Link2, for model in [
CameraModel::Link2C, CameraModel::Link2,
CameraModel::Link, CameraModel::Link2C,
] { CameraModel::Link,
let dev = DeviceSelection::Simulate(model); ] {
let label = dev.label(); let dev = DeviceSelection::Simulate(model);
if ui.selectable_label(self.selected_device == dev, label).clicked() { let label = dev.label();
self.switch_camera(dev); if ui.selectable_label(self.selected_device == dev, label).clicked() {
self.switch_camera(dev);
}
} }
} }
}); });
@@ -359,46 +478,53 @@ impl Insta360App {
if refresh_btn.on_hover_text("Refresh USB camera devices").clicked() { if refresh_btn.on_hover_text("Refresh USB camera devices").clicked() {
self.connected_cameras = list_connected_cameras().unwrap_or_default(); self.connected_cameras = list_connected_cameras().unwrap_or_default();
self.video_engine.refresh_capture_devices(); self.video_engine.refresh_capture_devices();
if !matches!(self.selected_device, DeviceSelection::Usb(_)) {
if let Some(first) = self.connected_cameras.first().cloned() {
self.switch_camera(DeviceSelection::Usb(first));
}
}
self.state.show_toast("Refreshed USB devices", false); self.state.show_toast("Refreshed USB devices", false);
} }
ui.add_space(10.0_f32); if show_simulators {
ui.add_space(10.0_f32);
// Video Feed Source Selector // Video Feed Source Selector (debug / simulator mode only)
let active_feed_label = if self.video_engine.is_using_physical_capture() { let active_feed_label = if self.video_engine.is_using_physical_capture() {
self.video_engine self.video_engine
.active_capture_device() .active_capture_device()
.map(|s| s.to_string()) .map(|s| s.to_string())
.unwrap_or_else(|| "Physical Webcam".to_string()) .unwrap_or_else(|| "Physical Webcam".to_string())
} else { } else {
"Studio Camera Feed (Live)".to_string() "Studio Camera Feed (Live)".to_string()
}; };
egui::ComboBox::from_id_salt("top_video_feed_selector") egui::ComboBox::from_id_salt("top_video_feed_selector")
.selected_text(&active_feed_label) .selected_text(&active_feed_label)
.show_ui(ui, |ui| { .show_ui(ui, |ui| {
if ui if ui
.selectable_label( .selectable_label(
!self.video_engine.is_using_physical_capture(), !self.video_engine.is_using_physical_capture(),
"Studio Camera Feed (Live)", "Studio Camera Feed (Live)",
) )
.clicked() .clicked()
{ {
self.video_engine.stop_physical_capture(); self.video_engine.stop_physical_capture();
}
let devs = self.video_engine.available_capture_devices().to_vec();
for dev_name in devs {
let is_active = self.video_engine.is_using_physical_capture()
&& self
.video_engine
.active_capture_device()
.map(|d| d == dev_name.as_str())
.unwrap_or(false);
if ui.selectable_label(is_active, dev_name.as_str()).clicked() {
self.video_engine.start_physical_capture(&dev_name);
} }
} let devs = self.video_engine.available_capture_devices().to_vec();
}); for dev_name in devs {
let is_active = self.video_engine.is_using_physical_capture()
&& self
.video_engine
.active_capture_device()
.map(|d| d == dev_name.as_str())
.unwrap_or(false);
if ui.selectable_label(is_active, dev_name.as_str()).clicked() {
self.video_engine.start_physical_capture(&dev_name);
}
}
});
}
// Right group // Right group
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
@@ -484,7 +610,13 @@ impl Insta360App {
{ {
let (w, h) = preset.dimensions(); let (w, h) = preset.dimensions();
let fps = preset.fps(); 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.state.low_resolution_mode = w == 640;
self.video_engine.set_capture_format(w as usize, h as usize, fps); self.video_engine.set_capture_format(w as usize, h as usize, fps);
self.state self.state
@@ -720,7 +852,7 @@ impl Insta360App {
} }
// Live video feed rendering with aspect ratio preservation // 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 9.0_f32 / 16.0_f32
} else { } else {
16.0_f32 / 9.0_f32 16.0_f32 / 9.0_f32
@@ -766,7 +898,12 @@ impl Insta360App {
} }
// Interactive click-and-drag pan/tilt (`DefCamera.qml` `dragMovePanTilt` + `dragDropTransition`) // Interactive click-and-drag pan/tilt (`DefCamera.qml` `dragMovePanTilt` + `dragDropTransition`)
if video_response.dragged() && self.controller.capabilities().support_ptz_drag { // Only active in Normal mode (`!ready_auto_track`) so clicking on the video during AI Tracking
// does not send a manual PTZ velocity/stop command that aborts tracking.
let manual_ptz_drag_allowed = self.controller.capabilities().support_ptz_drag
&& self.state.video_mode == VideoMode::Normal
&& !self.state.ready_auto_track;
if video_response.dragged() && manual_ptz_drag_allowed {
let drag_delta = video_response.drag_delta(); let drag_delta = video_response.drag_delta();
if drag_delta.length_sq() > 0.25_f32 { if drag_delta.length_sq() > 0.25_f32 {
self.state.pan_deg = self.state.pan_deg =
@@ -801,7 +938,7 @@ impl Insta360App {
self.state.last_ptz_send_time = Some(Instant::now()); self.state.last_ptz_send_time = Some(Instant::now());
} }
} }
} else if video_response.drag_stopped() && self.controller.capabilities().support_ptz_drag { } else if video_response.drag_stopped() && manual_ptz_drag_allowed {
let _ = self.controller.stop_pantilt_relative(); let _ = self.controller.stop_pantilt_relative();
self.state.last_ptz_cmd = (0, 0, 0, 0); self.state.last_ptz_cmd = (0, 0, 0, 0);
} }
@@ -1960,23 +2097,43 @@ impl Insta360App {
impl eframe::App for Insta360App { impl eframe::App for Insta360App {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
// Continuous repaint loop for smooth 30fps live video preview // Keep capture resolution and frame rate synced with selected format preset
if self.state.preview_open {
ctx.request_repaint_after(Duration::from_millis(33));
}
// Keep capture resolution synced with selected format preset
let (target_w, target_h) = self.state.format_preset.dimensions(); let (target_w, target_h) = self.state.format_preset.dimensions();
let target_fps = self.state.format_preset.fps(); 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 self.video_engine
.set_capture_format(target_w as usize, target_h as usize, target_fps); .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 // 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
@@ -1987,11 +2144,14 @@ impl eframe::App for Insta360App {
.set_video_mode_raw(VideoMode::Normal, &VideoModeAuxData::default()); .set_video_mode_raw(VideoMode::Normal, &VideoModeAuxData::default());
self.state.video_mode = VideoMode::Normal; self.state.video_mode = VideoMode::Normal;
} }
// If the camera was pointed straight down into its black neck/base, reset to (0, 0) }
if self.state.tilt_deg < -65.0_f32 && self.state.pan_deg.abs() < 15.0_f32 {
let _ = self.controller.set_pantilt_absolute(0, 0); // 1b. Persist user settings to ~/.config/insta360-link-controller/settings.json whenever modified
self.state.pan_deg = 0.0_f32; if self.state.stream_initialized && !self.self_test_enabled {
self.state.tilt_deg = 0.0_f32; 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);
} }
} }
@@ -2034,7 +2194,14 @@ impl eframe::App for Insta360App {
.clamp(0.05_f32, 0.95_f32); .clamp(0.05_f32, 0.95_f32);
let cy = (((r.y as f32) + (r.height as f32) * 0.5_f32) / 10000.0_f32) let cy = (((r.y as f32) + (r.height as f32) * 0.5_f32) / 10000.0_f32)
.clamp(0.05_f32, 0.95_f32); .clamp(0.05_f32, 0.95_f32);
if self.self_test_enabled {
println!(
"[GUI-SELF-TEST] Detected 1 person rect={:?}, locking onto ({:.3}, {:.3})",
r, cx, cy
);
}
let _ = self.controller.select_track_object_norm(cx, cy, 0.0_f32, 0.0_f32); let _ = self.controller.select_track_object_norm(cx, cy, 0.0_f32, 0.0_f32);
std::thread::sleep(Duration::from_millis(30));
self.state.ready_auto_track = false; self.state.ready_auto_track = false;
self.state.tracked_targets.clear(); self.state.tracked_targets.clear();
let _ = self.controller.set_video_mode_raw( let _ = self.controller.set_video_mode_raw(
@@ -2091,16 +2258,22 @@ impl eframe::App for Insta360App {
if self.self_test_enabled && self.state.stream_initialized { if self.self_test_enabled && self.state.stream_initialized {
let start = *self.self_test_start.get_or_insert_with(Instant::now); let start = *self.self_test_start.get_or_insert_with(Instant::now);
let elapsed = start.elapsed().as_secs_f32(); let elapsed = start.elapsed().as_secs_f32();
if self.self_test_step == 0 && elapsed >= 1.8 { if self.self_test_step == 0 && elapsed >= 3.2 {
self.self_test_step = 1; self.self_test_step = 1;
self.state.pan_deg = 70.0; if self.state.video_mode != VideoMode::Normal {
self.state.tilt_deg = 0.0; self.set_video_mode(VideoMode::Normal);
let _ = self.controller.set_pantilt_absolute(252_000, 0); }
if let Ok((p, t)) = self.controller.get_pantilt_absolute() {
self.state.pan_deg = p as f32 / 3600.0;
self.state.tilt_deg = t as f32 / 3600.0;
}
println!( println!(
"[GUI-SELF-TEST] Step 1: Stream initialized, device='{}', fw='{}', mode={:?}", "[GUI-SELF-TEST] Step 1: Stream initialized, device='{}', fw='{}', mode={:?}, pan={:.1}, tilt={:.1}",
self.state.device_info.device_name, self.state.device_info.device_name,
self.state.device_info.firmware_version, self.state.device_info.firmware_version,
self.state.video_mode self.state.video_mode,
self.state.pan_deg,
self.state.tilt_deg
); );
// Start 1080p video recording test // Start 1080p video recording test
@@ -2111,7 +2284,7 @@ impl eframe::App for Insta360App {
rec_path.display() rec_path.display()
); );
} }
} else if self.self_test_step == 1 && elapsed >= 3.5 { } else if self.self_test_step == 1 && elapsed >= 4.8 {
self.self_test_step = 2; self.self_test_step = 2;
let out_dir = std::env::temp_dir(); let out_dir = std::env::temp_dir();
if let Ok(rec_path) = self.video_engine.stop_recording() { if let Ok(rec_path) = self.video_engine.stop_recording() {
@@ -2189,43 +2362,6 @@ impl eframe::App for Insta360App {
let _ = self.controller.set_power_line_frequency(PowerLineFrequency::Hz60); let _ = self.controller.set_power_line_frequency(PowerLineFrequency::Hz60);
let pl_60 = self.controller.get_power_line_frequency().ok(); let pl_60 = self.controller.get_power_line_frequency().ok();
let _ = self.controller.set_hdr(true);
let hdr_on = self
.controller
.get_extend_function_status()
.unwrap_or_default()
.into_iter()
.find(|(f, _)| *f == insta360_link_protocol::ExtendFunction::Hdr)
.map(|(_, v)| v);
let _ = self.controller.set_hdr(false);
let hdr_off = self
.controller
.get_extend_function_status()
.unwrap_or_default()
.into_iter()
.find(|(f, _)| *f == insta360_link_protocol::ExtendFunction::Hdr)
.map(|(_, v)| v);
let _ = self.controller.set_horizontal_mirror(true);
let hmir_on = self
.controller
.get_extend_function_status()
.unwrap_or_default()
.into_iter()
.find(|(f, _)| *f == insta360_link_protocol::ExtendFunction::HorizontalMirror)
.map(|(_, v)| v);
let _ = self.controller.set_horizontal_mirror(false);
let _ = self.controller.set_vertical_mirror(true);
let vmir_on = self
.controller
.get_extend_function_status()
.unwrap_or_default()
.into_iter()
.find(|(f, _)| *f == insta360_link_protocol::ExtendFunction::VerticalMirror)
.map(|(_, v)| v);
let _ = self.controller.set_vertical_mirror(false);
let _ = self.controller.set_audio_noise_reduction(false); let _ = self.controller.set_audio_noise_reduction(false);
let nr_off = self.controller.get_audio_noise_reduction().ok(); let nr_off = self.controller.get_audio_noise_reduction().ok();
let _ = self.controller.set_audio_noise_reduction(true); let _ = self.controller.set_audio_noise_reduction(true);
@@ -2238,20 +2374,16 @@ impl eframe::App for Insta360App {
let gest = self.controller.get_gesture_status().ok(); let gest = self.controller.get_gesture_status().ok();
println!( println!(
"[GUI-SELF-TEST] Hardware Settings Verified:\n zoom={z_read}, track_speed=({ts_fast:?} -> {ts_norm:?})\n AE=({ae_off} -> {ae_on}), EV=({ev_15:.1} -> {ev_0:.1}), ISO={iso_400}, Shutter=1/{sh_120}s\n AF=({af_off} -> {af_on}), Focus={f_60}, AWB=({awb_off} -> {awb_on}), WB={wb_4200}K\n Bright={br_65}, Contrast={ct_65}, Sat={sat_65}, Sharp={sh_65}\n AntiFlicker=({pl_50:?} -> {pl_60:?}), HDR=({hdr_on:?} -> {hdr_off:?})\n HFlip={hmir_on:?}, VFlip={vmir_on:?}, AudioDenoise=({nr_off:?} -> {nr_on:?}), Gestures={gest:?}" "[GUI-SELF-TEST] Hardware Settings Verified:\n zoom={z_read}, track_speed=({ts_fast:?} -> {ts_norm:?})\n AE=({ae_off} -> {ae_on}), EV=({ev_15:.1} -> {ev_0:.1}), ISO={iso_400}, Shutter=1/{sh_120}s\n AF=({af_off} -> {af_on}), Focus={f_60}, AWB=({awb_off} -> {awb_on}), WB={wb_4200}K\n Bright={br_65}, Contrast={ct_65}, Sat={sat_65}, Sharp={sh_65}\n AntiFlicker=({pl_50:?} -> {pl_60:?}), AudioDenoise=({nr_off:?} -> {nr_on:?}), Gestures={gest:?}"
); );
// Switch resolution to 1280x720 to test live resolution switching
self.state.format_preset = VideoFormatPreset::Hd720p30;
self.video_engine.set_capture_format(1280, 720, 30);
println!( println!(
"[GUI-SELF-TEST] Step 2: Starting smooth joystick velocity pan(speed=4, dir=1) from pan={:.1} deg, tilt={:.1} deg", "[GUI-SELF-TEST] Step 2: Starting smooth joystick velocity pan(speed=3, dir=1) from pan={:.1} deg, tilt={:.1} deg",
self.state.pan_deg, self.state.tilt_deg self.state.pan_deg, self.state.tilt_deg
); );
let _ = self.controller.set_pantilt_relative_ext(4, 1, 0, 0); let _ = self.controller.set_pantilt_relative_ext(3, 1, 0, 0);
self.state.last_ptz_cmd = (4, 1, 0, 0); self.state.last_ptz_cmd = (3, 1, 0, 0);
} else if self.self_test_step == 2 && elapsed >= 5.2 { } else if self.self_test_step == 2 && elapsed >= 5.5 {
self.self_test_step = 3; self.self_test_step = 3;
let _ = self.controller.stop_pantilt_relative(); let _ = self.controller.stop_pantilt_relative();
self.state.last_ptz_cmd = (0, 0, 0, 0); self.state.last_ptz_cmd = (0, 0, 0, 0);
@@ -2259,43 +2391,33 @@ impl eframe::App for Insta360App {
self.state.pan_deg = p as f32 / 3600.0; self.state.pan_deg = p as f32 / 3600.0;
self.state.tilt_deg = t as f32 / 3600.0; self.state.tilt_deg = t as f32 / 3600.0;
} }
let out_dir = std::env::temp_dir();
if let Ok(snap_720) = self.video_engine.save_screenshot(&out_dir, &self.state) {
println!(
"[GUI-SELF-TEST] Saved 1280x720 screenshot: {}",
snap_720.display()
);
}
// Restore 1920x1080
self.state.format_preset = VideoFormatPreset::Fhd1080p30;
self.video_engine.set_capture_format(1920, 1080, 30);
println!( println!(
"[GUI-SELF-TEST] Step 3: Stopped joystick velocity, new pan={:.1} deg, tilt={:.1} deg; testing Toolbar Mode & activating AI Tracking", "[GUI-SELF-TEST] Step 3: Stopped joystick velocity (pan={:.1}, tilt={:.1}); testing Toolbar Mode & activating AI Tracking",
self.state.pan_deg, self.state.tilt_deg self.state.pan_deg, self.state.tilt_deg
); );
self.state.ui_mode = UiMode::Toolbar; self.state.ui_mode = UiMode::Toolbar;
self.set_video_mode(VideoMode::AutoTracking); self.set_video_mode(VideoMode::AutoTracking);
} else if self.self_test_step == 3 && elapsed >= 6.8 { } else if self.self_test_step == 3
&& ((!self.state.ready_auto_track && elapsed >= 7.0) || elapsed >= 10.5)
{
self.self_test_step = 4; self.self_test_step = 4;
std::thread::sleep(Duration::from_millis(500));
self.state.ui_mode = UiMode::MainWindow; self.state.ui_mode = UiMode::MainWindow;
let hw_mode = self.controller.get_video_mode().ok().map(|m| m.0); let hw_mode = self.controller.get_video_mode().ok().map(|m| m.0);
self.state.pan_deg = 70.0;
self.state.tilt_deg = 0.0;
println!( println!(
"[GUI-SELF-TEST] Step 4: AI Tracking active (ui_mode={:?}, hw_mode={:?}); switching to DeskView", "[GUI-SELF-TEST] Step 4: AI Tracking active (ui_mode={:?}, hw_mode={:?}); switching to DeskView",
self.state.video_mode, hw_mode self.state.video_mode, hw_mode
); );
self.set_video_mode(VideoMode::DeskView); self.set_video_mode(VideoMode::DeskView);
} else if self.self_test_step == 4 && elapsed >= 8.8 { } else if self.self_test_step == 4 && elapsed >= 12.5 {
self.self_test_step = 5; self.self_test_step = 5;
let hw_mode = self.controller.get_video_mode().ok().map(|m| m.0); let hw_mode = self.controller.get_video_mode().ok().map(|m| m.0);
println!( println!(
"[GUI-SELF-TEST] Step 5: DeskView active (hw_mode={:?}); restoring Normal mode and (pan=70 deg, tilt=0 deg)", "[GUI-SELF-TEST] Step 5: DeskView active (hw_mode={:?}); restoring Normal mode",
hw_mode hw_mode
); );
self.set_video_mode(VideoMode::Normal); self.set_video_mode(VideoMode::Normal);
} else if self.self_test_step == 5 && elapsed >= 11.5 { } else if self.self_test_step == 5 && elapsed >= 15.0 {
self.self_test_step = 6; self.self_test_step = 6;
if let Ok((p, t)) = self.controller.get_pantilt_absolute() { if let Ok((p, t)) = self.controller.get_pantilt_absolute() {
self.state.pan_deg = p as f32 / 3600.0; self.state.pan_deg = p as f32 / 3600.0;
@@ -2393,12 +2515,62 @@ impl eframe::App for Insta360App {
} }
} }
fn build_app_icon() -> egui::IconData {
let w = 64u32;
let h = 64u32;
let mut rgba = vec![0u8; (w * h * 4) as usize];
for y in 0..h {
for x in 0..w {
let idx = ((y * w + x) * 4) as usize;
let dx = x as f32 - 31.5_f32;
let dy = y as f32 - 26.0_f32;
let dist = (dx * dx + dy * dy).sqrt();
// Dark rounded card background
let in_card = x >= 4 && x < 60 && y >= 4 && y < 60;
if !in_card {
continue;
}
let (mut r, mut g, mut b) = (20u8, 24u8, 32u8);
// Green camera ring
if (10.5_f32..=14.0_f32).contains(&dist) {
r = 0;
g = 230;
b = 118;
} else if dist < 5.0_f32 {
r = 0;
g = 230;
b = 118;
} else if dist < 10.5_f32 {
r = 12;
g = 15;
b = 20;
} else if (18..=46).contains(&x) && (44..=52).contains(&y) {
// Gimbal base
r = 43;
g = 50;
b = 66;
}
rgba[idx] = r;
rgba[idx + 1] = g;
rgba[idx + 2] = b;
rgba[idx + 3] = 255;
}
}
egui::IconData {
rgba,
width: w,
height: h,
}
}
fn main() -> eframe::Result<()> { fn main() -> eframe::Result<()> {
tracing_subscriber::fmt::init(); tracing_subscriber::fmt::init();
// Matching webcam.qml: width: 1440, height: 714, minimumWidth: 1120, minimumHeight: 536 // Matching webcam.qml: width: 1440, height: 714, minimumWidth: 1120, minimumHeight: 536
let native_options = eframe::NativeOptions { let native_options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default() viewport: egui::ViewportBuilder::default()
.with_app_id("insta360-link-controller")
.with_icon(build_app_icon())
.with_inner_size([1440.0_f32, 714.0_f32]) .with_inner_size([1440.0_f32, 714.0_f32])
.with_min_inner_size([1120.0_f32, 536.0_f32]) .with_min_inner_size([1120.0_f32, 536.0_f32])
.with_title("Insta360 Link Controller"), .with_title("Insta360 Link Controller"),
+4 -4
View File
@@ -883,15 +883,15 @@ fn render_more_tab<T: UvcControlTransport>(
ui, ui,
"Portrait Resolution and High Frame Rate", "Portrait Resolution and High Frame Rate",
None, 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, &mut state.vertical_resolution_mode,
!state.is_recording, !state.is_recording,
theme, theme,
) { ) {
let _ = ctrl.set_vertical_screen(state.vertical_resolution_mode); let _ = ctrl.set_vertical_screen(state.vertical_resolution_mode);
if state.vertical_resolution_mode { if !state.vertical_resolution_mode
state.format_preset = crate::state::VideoFormatPreset::Vertical1080x1920p30; && (state.format_preset.is_vertical() || state.format_preset.fps() > 30)
} else { {
state.format_preset = crate::state::VideoFormatPreset::Fhd1080p30; state.format_preset = crate::state::VideoFormatPreset::Fhd1080p30;
} }
} }
+161 -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,81 @@ 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 || 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;
}
} }
+110 -56
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,
@@ -133,6 +139,11 @@ impl VideoPreviewEngine {
(self.capture_width, self.capture_height) (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. /// List of discovered hardware capture devices.
pub fn available_capture_devices(&self) -> &[String] { pub fn available_capture_devices(&self) -> &[String] {
&self.available_devices &self.available_devices
@@ -283,6 +294,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;
@@ -294,21 +306,32 @@ impl VideoPreviewEngine {
let frame_size = out_w * out_h * 3; let frame_size = out_w * out_h * 3;
let mut buffer = vec![0u8; frame_size]; let mut buffer = vec![0u8; frame_size];
for attempt in 0..2 { for attempt in 0..8 {
if stop_flag.load(Ordering::Relaxed) { if stop_flag.load(Ordering::Relaxed) {
break; 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"); let mut cmd = Command::new("ffmpeg");
#[cfg(target_os = "macos")] #[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 // in '420v' (nv12) and '2vuy' (uyvy422). Using nv12 matches both the official
// Insta360 Link Controller (`OpenDevice` at 0x10008770c) and Chrome/Google Meet. // 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) { let hw_size = match (out_w, out_h) {
(3840, 2160) => "3840x2160", (3840, 2160) => "3840x2160",
(1280, 720) => "1280x720", (1280, 720) => "1280x720",
(1080, 1920) if attempt < 6 => "1080x1920",
_ => "1920x1080", _ => "1920x1080",
}; };
cmd.args([ cmd.args([
@@ -320,7 +343,7 @@ impl VideoPreviewEngine {
"-pixel_format", "-pixel_format",
"nv12", "nv12",
]); ]);
if attempt == 0 { if attempt < 7 {
cmd.args(["-framerate", &hw_fps, "-video_size", hw_size]); cmd.args(["-framerate", &hw_fps, "-video_size", hw_size]);
} else { } else {
cmd.args(["-framerate", "30", "-video_size", "1280x720"]); cmd.args(["-framerate", "30", "-video_size", "1280x720"]);
@@ -344,14 +367,19 @@ impl VideoPreviewEngine {
#[cfg(target_os = "linux")] #[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) { let hw_size = match (out_w, out_h) {
(3840, 2160) => "3840x2160", (3840, 2160) => "3840x2160",
(1280, 720) => "1280x720", (1280, 720) => "1280x720",
(1080, 1920) if attempt < 6 => "1080x1920",
_ => "1920x1080", _ => "1920x1080",
}; };
cmd.args(["-hide_banner", "-loglevel", "error", "-f", "v4l2"]); cmd.args(["-hide_banner", "-loglevel", "error", "-f", "v4l2"]);
if attempt == 0 { if attempt < 7 {
cmd.args([ cmd.args([
"-input_format", "-input_format",
"mjpeg", "mjpeg",
@@ -438,6 +466,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 +495,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 +526,62 @@ 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 is_vert = state.format_preset.is_vertical();
let (out_w, out_h) = if state.low_resolution_mode {
if is_vert {
(270, 480)
} else {
(480, 270)
}
} else if is_vert {
(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 +598,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);
@@ -1078,7 +1132,7 @@ impl VideoPreviewEngine {
let w = self.capture_width; let w = self.capture_width;
let h = self.capture_height; 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 size_arg = format!("{w}x{h}");
let fps_arg = fps.to_string(); let fps_arg = fps.to_string();
@@ -111,6 +111,11 @@ pub fn decode_get_video_mode(data: &[u8]) -> Result<(VideoMode, VideoModeAuxData
actual: data.len(), actual: data.len(),
}); });
} }
if data[0] == 0xff {
return Err(CodecError::InvalidArgument(
"camera video mode is transitioning (0xff)",
));
}
let mode = VideoMode::from_u8(data[0]); let mode = VideoMode::from_u8(data[0]);
let mut aux = VideoModeAuxData { let mut aux = VideoModeAuxData {
flags: data[1], flags: data[1],
@@ -941,6 +941,9 @@ impl<T: UvcControlTransport> Insta360CameraController<T> {
} }
let mut bits = u16::from_le_bytes([cur[0], cur[1]]); let mut bits = u16::from_le_bytes([cur[0], cur[1]]);
let mask = 1u16 << (func as u8); let mask = 1u16 << (func as u8);
if ((bits & mask) != 0) == enable {
return Ok(());
}
if enable { if enable {
bits |= mask; bits |= mask;
} else { } else {
@@ -949,11 +952,20 @@ impl<T: UvcControlTransport> Insta360CameraController<T> {
let bytes = bits.to_le_bytes(); let bytes = bits.to_le_bytes();
cur[0] = bytes[0]; cur[0] = bytes[0];
cur[1] = bytes[1]; cur[1] = bytes[1];
return self.transport.set_cur( let res = self.transport.set_cur(
self.unit_ids().extension_unit_id, self.unit_ids().extension_unit_id,
0x1b, 0x1b,
&cur, &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 let cur = self
.transport .transport
+180 -114
View File
@@ -72,6 +72,12 @@ pub fn list_connected_cameras() -> Result<Vec<DiscoveredCamera>, TransportError>
unit_ids = parse_uvc_vc_descriptors(raw); unit_ids = parse_uvc_vc_descriptors(raw);
} }
} }
#[cfg(target_os = "linux")]
if unit_ids == UvcUnitIds::default() {
if let Some((_, parsed_ids)) = open_linux_v4l2_node(vid, pid, unit_ids.extension_unit_id) {
unit_ids = parsed_ids;
}
}
out.push(DiscoveredCamera { out.push(DiscoveredCamera {
vendor_id: vid, vendor_id: vid,
@@ -89,18 +95,18 @@ pub fn list_connected_cameras() -> Result<Vec<DiscoveredCamera>, TransportError>
} }
/// Pure-Rust cross-platform USB hardware transport backed by `nusb` /// Pure-Rust cross-platform USB hardware transport backed by `nusb`
/// (Linux `uvcvideo` `UVCIOC_CTRL_QUERY` + `usbfs`, Windows `WinUSB`, macOS `IOKit`). /// (Linux `uvcvideo` `UVCIOC_CTRL_QUERY` + V4L2 ioctls, Windows `WinUSB`, macOS `IOKit`).
pub struct NusbTransport { pub struct NusbTransport {
info: DiscoveredCamera, info: DiscoveredCamera,
device: nusb::Device, device: Option<nusb::Device>,
interface: Option<nusb::Interface>, interface: Option<nusb::Interface>,
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
v4l_file: Option<std::fs::File>, v4l_file: std::sync::Mutex<Option<std::fs::File>>,
timeout: Duration, timeout: Duration,
} }
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
fn open_linux_v4l2_node(vid: u16, pid: u16, xu1_id: u8) -> Option<std::fs::File> { fn open_linux_v4l2_node(vid: u16, pid: u16, xu1_id: u8) -> Option<(std::fs::File, UvcUnitIds)> {
let vid_hex = format!("{:04x}", vid); let vid_hex = format!("{:04x}", vid);
let pid_hex = format!("{:04x}", pid); let pid_hex = format!("{:04x}", pid);
let entries = std::fs::read_dir("/sys/class/video4linux").ok()?; let entries = std::fs::read_dir("/sys/class/video4linux").ok()?;
@@ -115,13 +121,13 @@ fn open_linux_v4l2_node(vid: u16, pid: u16, xu1_id: u8) -> Option<std::fs::File>
} }
candidates.sort_by_key(|(num, _, _)| *num); candidates.sort_by_key(|(num, _, _)| *num);
let mut fallback: Option<std::fs::File> = None; let mut fallback: Option<(std::fs::File, UvcUnitIds)> = None;
for (_, path, name) in candidates { for (_, path, name) in candidates {
let dev_link = path.join("device"); let dev_link = path.join("device");
let Ok(canon) = std::fs::canonicalize(&dev_link) else { let Ok(canon) = std::fs::canonicalize(&dev_link) else {
continue; continue;
}; };
// Parent directory of the USB interface (`B-P:1.0`) holds `idVendor` and `idProduct`. // Parent directory of the USB interface (`B-P:1.0`) holds `idVendor`, `idProduct`, and `descriptors`.
if let Some(usb_dev_dir) = canon.parent() { if let Some(usb_dev_dir) = canon.parent() {
let v = std::fs::read_to_string(usb_dev_dir.join("idVendor")) let v = std::fs::read_to_string(usb_dev_dir.join("idVendor"))
.unwrap_or_default() .unwrap_or_default()
@@ -132,6 +138,23 @@ fn open_linux_v4l2_node(vid: u16, pid: u16, xu1_id: u8) -> Option<std::fs::File>
.trim() .trim()
.to_lowercase(); .to_lowercase();
if v == vid_hex && p == pid_hex { if v == vid_hex && p == pid_hex {
let parsed_ids = std::fs::read(usb_dev_dir.join("descriptors"))
.ok()
.map(|raw| {
// Skip the 18-byte USB Device Descriptor if present at offset 0
let slice = if raw.len() > 18 && raw[0] == 18 && raw[1] == 1 {
&raw[18..]
} else {
&raw[..]
};
parse_uvc_vc_descriptors(slice)
})
.unwrap_or_default();
let probe_xu1 = if parsed_ids.extension_unit_id != 0 {
parsed_ids.extension_unit_id
} else {
xu1_id
};
let dev_path = format!("/dev/{}", name); let dev_path = format!("/dev/{}", name);
if let Ok(file) = std::fs::OpenOptions::new() if let Ok(file) = std::fs::OpenOptions::new()
.read(true) .read(true)
@@ -143,17 +166,17 @@ fn open_linux_v4l2_node(vid: u16, pid: u16, xu1_id: u8) -> Option<std::fs::File>
let mut len_buf = [0u8; 2]; let mut len_buf = [0u8; 2];
if linux_uvcioc_ctrl_query( if linux_uvcioc_ctrl_query(
&file, &file,
xu1_id, probe_xu1,
0x03, 0x03,
UvcRequest::GetLen as u8, UvcRequest::GetLen as u8,
&mut len_buf, &mut len_buf,
) )
.is_ok() .is_ok()
{ {
return Some(file); return Some((file, parsed_ids));
} }
if fallback.is_none() { if fallback.is_none() {
fallback = Some(file); fallback = Some((file, parsed_ids));
} }
} }
} }
@@ -352,35 +375,84 @@ impl NusbTransport {
}) })
.ok_or(TransportError::DeviceNotFound)?; .ok_or(TransportError::DeviceNotFound)?;
let device = dev_info.open().map_err(|e| TransportError::Usb(e.to_string()))?; let device = dev_info.open().ok();
if let Ok(cfg) = device.active_configuration() { if let Some(ref dev) = device {
let raw: &[u8] = cfg.descriptors().as_bytes(); if let Ok(cfg) = dev.active_configuration() {
info.unit_ids = parse_uvc_vc_descriptors(raw); let raw: &[u8] = cfg.descriptors().as_bytes();
info.unit_ids = parse_uvc_vc_descriptors(raw);
}
} }
// Claim the UVC VideoControl interface without detaching the OS kernel UVC video streaming // Claim the UVC VideoControl interface without detaching the OS kernel UVC video streaming
// driver (`uvcvideo` on Linux, `UVCAssistant` on macOS, `usbvideo.sys` on Windows) so that // driver (`uvcvideo` on Linux, `UVCAssistant` on macOS, `usbvideo.sys` on Windows) so that
// live webcam video capture and UVC/XU control transfers operate simultaneously. // live webcam video capture and UVC/XU control transfers operate simultaneously.
let interface = device let interface = device
.claim_interface(info.unit_ids.control_interface) .as_ref()
.ok(); .and_then(|d| d.claim_interface(info.unit_ids.control_interface).ok());
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
let v4l_file = open_linux_v4l2_node( let v4l_file = match open_linux_v4l2_node(
info.vendor_id, info.vendor_id,
info.product_id, info.product_id,
info.unit_ids.extension_unit_id, info.unit_ids.extension_unit_id,
); ) {
Some((f, parsed_ids)) => {
if device.is_none() {
info.unit_ids = parsed_ids;
}
Some(f)
}
None => None,
};
#[cfg(target_os = "linux")]
if device.is_none() && v4l_file.is_none() {
return Err(TransportError::Usb(
"Could not open V4L2 device node or USB device".to_string(),
));
}
#[cfg(not(target_os = "linux"))]
if device.is_none() {
return Err(TransportError::Usb(
"Could not open USB device".to_string(),
));
}
Ok(Self { Ok(Self {
info, info,
device, device,
interface, interface,
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
v4l_file, v4l_file: std::sync::Mutex::new(v4l_file),
timeout: Duration::from_millis(1500), timeout: Duration::from_millis(1500),
}) })
} }
#[cfg(target_os = "linux")]
fn with_v4l_file<R, F>(&self, mut op: F) -> Option<R>
where
F: FnMut(&std::fs::File) -> Result<R, TransportError>,
{
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 { impl UvcControlTransport for NusbTransport {
@@ -415,55 +487,55 @@ impl UvcControlTransport for NusbTransport {
}; };
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
if let Some(v4l) = &self.v4l_file { if self
if is_xu { .with_v4l_file(|v4l| {
let mut buf = payload.to_vec(); if is_xu {
if linux_uvcioc_ctrl_query( let mut buf = payload.to_vec();
v4l, linux_uvcioc_ctrl_query(
unit_id, v4l,
selector, unit_id,
UvcRequest::SetCur as u8, selector,
&mut buf, UvcRequest::SetCur as u8,
) &mut buf,
.is_ok() )
} else if unit_id == self.info.unit_ids.input_terminal_id
&& selector == ct_selectors::PANTILT_ABSOLUTE
&& payload.len() >= 8
{ {
return Ok(()); 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]]);
} else if unit_id == self.info.unit_ids.input_terminal_id let r1 = linux_v4l2_set_ctrl(v4l, V4L2_CID_PAN_ABSOLUTE, pan);
&& selector == ct_selectors::PANTILT_ABSOLUTE let r2 = linux_v4l2_set_ctrl(v4l, V4L2_CID_TILT_ABSOLUTE, tilt);
&& payload.len() >= 8 r1.or(r2)
{ } else if let Some(cid) =
let pan = i32::from_le_bytes([payload[0], payload[1], payload[2], payload[3]]); linux_uvc_to_v4l2_cid(&self.info.unit_ids, unit_id, selector)
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 val: i32 = match payload.len() {
let r2 = linux_v4l2_set_ctrl(v4l, V4L2_CID_TILT_ABSOLUTE, tilt); 1 => payload[0] as i32,
if r1.is_ok() || r2.is_ok() { 2 => {
return Ok(()); if cid == V4L2_CID_BRIGHTNESS {
} i16::from_le_bytes([payload[0], payload[1]]) as i32
} else if let Some(cid) = } else {
linux_uvc_to_v4l2_cid(&self.info.unit_ids, unit_id, selector) u16::from_le_bytes([payload[0], payload[1]]) as i32
{ }
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]]),
4 => i32::from_le_bytes([payload[0], payload[1], payload[2], payload[3]]), _ => 0,
_ => 0, };
}; if linux_v4l2_set_ctrl(v4l, cid, val).is_ok() {
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() {
return 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( let hdr = UvcControlPacketHeader::new(
@@ -486,18 +558,15 @@ impl UvcControlTransport for NusbTransport {
.map_err(|e| TransportError::Usb(e.to_string())) .map_err(|e| TransportError::Usb(e.to_string()))
} else { } else {
#[cfg(not(target_os = "windows"))] #[cfg(not(target_os = "windows"))]
{ if let Some(dev) = &self.device {
self.device return dev
.control_out_blocking(control, payload, self.timeout) .control_out_blocking(control, payload, self.timeout)
.map(|_| ()) .map(|_| ())
.map_err(|e| TransportError::Usb(e.to_string())) .map_err(|e| TransportError::Usb(e.to_string()));
}
#[cfg(target_os = "windows")]
{
Err(TransportError::Usb(
"UVC control interface could not be claimed on Windows".to_string(),
))
} }
Err(TransportError::Usb(
"UVC control transfer failed".to_string(),
))
} }
} }
@@ -523,51 +592,49 @@ impl UvcControlTransport for NusbTransport {
}; };
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
if let Some(v4l) = &self.v4l_file { if let Some(out) = self.with_v4l_file(|v4l| {
if is_xu { if is_xu {
let mut buf = vec![0u8; actual_len]; let mut buf = vec![0u8; actual_len];
if linux_uvcioc_ctrl_query(v4l, unit_id, selector, req as u8, &mut buf).is_ok() { linux_uvcioc_ctrl_query(v4l, unit_id, selector, req as u8, &mut buf)?;
return Ok(buf); Ok(buf)
}
} else if unit_id == self.info.unit_ids.input_terminal_id } else if unit_id == self.info.unit_ids.input_terminal_id
&& selector == ct_selectors::PANTILT_ABSOLUTE && selector == ct_selectors::PANTILT_ABSOLUTE
&& req == UvcRequest::GetCur && req == UvcRequest::GetCur
{ {
if let (Ok(pan), Ok(tilt)) = ( let pan = linux_v4l2_get_ctrl(v4l, V4L2_CID_PAN_ABSOLUTE)?;
linux_v4l2_get_ctrl(v4l, V4L2_CID_PAN_ABSOLUTE), let tilt = linux_v4l2_get_ctrl(v4l, V4L2_CID_TILT_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());
let mut out = vec![0u8; 8]; out[4..8].copy_from_slice(&tilt.to_le_bytes());
out[0..4].copy_from_slice(&pan.to_le_bytes()); Ok(out)
out[4..8].copy_from_slice(&tilt.to_le_bytes());
return Ok(out);
}
} else if let Some(cid) = } else if let Some(cid) =
linux_uvc_to_v4l2_cid(&self.info.unit_ids, unit_id, selector) linux_uvc_to_v4l2_cid(&self.info.unit_ids, unit_id, selector)
{ {
let v4l_val: Result<i32, TransportError> = match req { let val: i32 = match req {
UvcRequest::GetCur => linux_v4l2_get_ctrl(v4l, cid), UvcRequest::GetCur => linux_v4l2_get_ctrl(v4l, cid)?,
UvcRequest::GetMin => linux_v4l2_query_ctrl(v4l, cid).map(|q| q.minimum), UvcRequest::GetMin => linux_v4l2_query_ctrl(v4l, cid)?.minimum,
UvcRequest::GetMax => linux_v4l2_query_ctrl(v4l, cid).map(|q| q.maximum), UvcRequest::GetMax => linux_v4l2_query_ctrl(v4l, cid)?.maximum,
UvcRequest::GetRes => linux_v4l2_query_ctrl(v4l, cid).map(|q| q.step), UvcRequest::GetRes => linux_v4l2_query_ctrl(v4l, cid)?.step,
UvcRequest::GetDef => linux_v4l2_query_ctrl(v4l, cid).map(|q| q.default_value), UvcRequest::GetDef => linux_v4l2_query_ctrl(v4l, cid)?.default_value,
_ => Err(TransportError::Usb("unsupported V4L2 request".to_string())), _ => return Err(TransportError::Usb("unsupported V4L2 request".to_string())),
}; };
if let Ok(val) = v4l_val { Ok(match actual_len {
return Ok(match actual_len { 1 => vec![val as u8],
1 => vec![val as u8], 2 => (val as i16).to_le_bytes().to_vec(),
2 => (val as i16).to_le_bytes().to_vec(), 4 => val.to_le_bytes().to_vec(),
4 => val.to_le_bytes().to_vec(), _ => {
_ => { let mut b = vec![0u8; actual_len];
let mut b = vec![0u8; actual_len]; let le = val.to_le_bytes();
let le = val.to_le_bytes(); let n = actual_len.min(4);
let n = actual_len.min(4); b[..n].copy_from_slice(&le[..n]);
b[..n].copy_from_slice(&le[..n]); b
b }
} })
}); } else {
} Err(TransportError::Usb("unmapped V4L2 control".to_string()))
} }
}) {
return Ok(out);
} }
let hdr = UvcControlPacketHeader::new( let hdr = UvcControlPacketHeader::new(
@@ -590,17 +657,16 @@ impl UvcControlTransport for NusbTransport {
.map_err(|e| TransportError::Usb(e.to_string()))? .map_err(|e| TransportError::Usb(e.to_string()))?
} else { } else {
#[cfg(not(target_os = "windows"))] #[cfg(not(target_os = "windows"))]
{ if let Some(dev) = &self.device {
self.device let n = dev
.control_in_blocking(control, &mut buf, self.timeout) .control_in_blocking(control, &mut buf, self.timeout)
.map_err(|e| TransportError::Usb(e.to_string()))? .map_err(|e| TransportError::Usb(e.to_string()))?;
} buf.truncate(n);
#[cfg(target_os = "windows")] return Ok(buf);
{
return Err(TransportError::Usb(
"UVC control interface could not be claimed on Windows".to_string(),
));
} }
return Err(TransportError::Usb(
"UVC control transfer failed".to_string(),
));
}; };
buf.truncate(n); buf.truncate(n);
Ok(buf) Ok(buf)
+1 -5
View File
@@ -29,10 +29,6 @@ run = "cargo test --workspace"
description = "Run the automated live hardware GUI verification sequence" description = "Run the automated live hardware GUI verification sequence"
run = "INSTA360_GUI_SELF_TEST=1 cargo run --release -p insta360-link-gui" run = "INSTA360_GUI_SELF_TEST=1 cargo run --release -p insta360-link-gui"
[tasks.udev-install]
description = "Install Linux udev rules for non-root USB access to Insta360 Link webcams"
run = "sudo cp udev/99-insta360-link.rules /etc/udev/rules.d/99-insta360-link.rules && sudo udevadm control --reload-rules && sudo udevadm trigger"
[tasks.package-release] [tasks.package-release]
description = "Build and package release binaries for a target (usage: mise run package-release [target] [version])" description = "Build and package release binaries for a target (usage: mise run package-release [target] [version])"
run = ''' run = '''
@@ -96,7 +92,7 @@ else
cp "target/${TARGET}/release/insta360-link-ctl" "$STAGE_DIR/" cp "target/${TARGET}/release/insta360-link-ctl" "$STAGE_DIR/"
cp "target/${TARGET}/release/insta360-link-server" "$STAGE_DIR/" cp "target/${TARGET}/release/insta360-link-server" "$STAGE_DIR/"
cp README.md "$STAGE_DIR/" cp README.md "$STAGE_DIR/"
cp -r udev "$STAGE_DIR/" cp -r assets "$STAGE_DIR/"
tar -C dist -czf "dist/${STAGE_NAME}.tar.gz" "$STAGE_NAME" tar -C dist -czf "dist/${STAGE_NAME}.tar.gz" "$STAGE_NAME"
echo "Packaged dist/${STAGE_NAME}.tar.gz" echo "Packaged dist/${STAGE_NAME}.tar.gz"
fi fi
-37
View File
@@ -1,37 +0,0 @@
# Linux udev rules for Insta360 Link / Link 2 / Link 2C / Link 2 Pro (PUC3) webcams
#
# Grants non-root desktop users (via systemd-logind uaccess and plugdev/video groups)
# direct read/write access to the USB device nodes so `insta360-link-ctl`,
# `insta360-link-server`, and `insta360-link-gui` can issue UVC Extension Unit
# control transfers without requiring `sudo`.
#
# Installation:
# sudo cp udev/99-insta360-link.rules /etc/udev/rules.d/99-insta360-link.rules
# sudo udevadm control --reload-rules
# sudo udevadm trigger
ACTION!="add|change", GOTO="insta360_link_end"
SUBSYSTEM!="usb", GOTO="insta360_link_end"
# Insta360 Link (1st Gen - 0x4c01)
ATTRS{idVendor}=="2e1a", ATTRS{idProduct}=="4c01", MODE="0660", GROUP="plugdev", TAG+="uaccess"
# Insta360 Link 2 Pro / PUC3 Gimbal A (0x4c02)
ATTRS{idVendor}=="2e1a", ATTRS{idProduct}=="4c02", MODE="0660", GROUP="plugdev", TAG+="uaccess"
# Insta360 Link 2C (Fixed-Lens 4K - 0x4c03)
ATTRS{idVendor}=="2e1a", ATTRS{idProduct}=="4c03", MODE="0660", GROUP="plugdev", TAG+="uaccess"
# Insta360 Link 2 (2-Axis Gimbal 4K - 0x4c04)
ATTRS{idVendor}=="2e1a", ATTRS{idProduct}=="4c04", MODE="0660", GROUP="plugdev", TAG+="uaccess"
# Insta360 Link 2C Pro / PUC3 Host (0x4c05)
ATTRS{idVendor}=="2e1a", ATTRS{idProduct}=="4c05", MODE="0660", GROUP="plugdev", TAG+="uaccess"
# Insta360 Link 2 Pro / PUC3 Gimbal B (0x4c06)
ATTRS{idVendor}=="2e1a", ATTRS{idProduct}=="4c06", MODE="0660", GROUP="plugdev", TAG+="uaccess"
# Insta360 Link Simple-Class / Firmware Upgrade Mode (0x00c2)
ATTRS{idVendor}=="2e1a", ATTRS{idProduct}=="00c2", MODE="0660", GROUP="plugdev", TAG+="uaccess"
LABEL="insta360_link_end"