Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef641ac31b | ||
|
|
51b30cbd79 | ||
|
|
844835c22d | ||
|
|
734e1db43c | ||
|
|
a093688e9b | ||
|
|
73d4661743 | ||
|
|
fd757f2d78 | ||
|
|
60935c34ad | ||
|
|
4d7299747c | ||
|
|
f0e21a7e74 | ||
|
|
3422b14e1d |
No files matched your search
@@ -53,6 +53,9 @@ jobs:
|
||||
- label: linux-x86_64
|
||||
os: ubuntu-latest
|
||||
target: x86_64-unknown-linux-gnu
|
||||
- label: linux-arm64
|
||||
os: ubuntu-24.04-arm
|
||||
target: aarch64-unknown-linux-gnu
|
||||
- label: macos-arm64
|
||||
os: macos-14
|
||||
target: aarch64-apple-darwin
|
||||
|
||||
@@ -41,9 +41,6 @@ This project includes a [`mise.toml`](mise.toml) configuration that manages the
|
||||
mise trust
|
||||
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
|
||||
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
|
||||
sudo cp udev/99-insta360-link.rules /etc/udev/rules.d/99-insta360-link.rules
|
||||
sudo udevadm control --reload-rules
|
||||
sudo udevadm trigger
|
||||
yay -S insta360-link-controller-bin
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
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.
|
||||
|
||||
@@ -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
|
||||
@@ -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 |
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -236,14 +242,108 @@ impl Insta360App {
|
||||
if let Ok(ae) = self.controller.get_auto_exposure() {
|
||||
self.state.auto_exposure = ae;
|
||||
}
|
||||
if let Ok(ev) = self.controller.get_exposure_compensation() {
|
||||
self.state.ev_compensation = ev.clamp(-3.0_f32, 3.0_f32);
|
||||
}
|
||||
if let Ok(iso) = self.controller.get_iso() {
|
||||
if let Some(idx) = crate::state::ISO_LIST.iter().position(|&v| v == iso) {
|
||||
self.state.iso_index = idx;
|
||||
}
|
||||
}
|
||||
if let Ok(shutter) = self.controller.get_shutter_speed() {
|
||||
if let Some(idx) = crate::state::SHUTTER_DENOM_LIST
|
||||
.iter()
|
||||
.position(|&v| v == shutter)
|
||||
{
|
||||
self.state.shutter_index = idx;
|
||||
}
|
||||
}
|
||||
if let Ok(af) = self.controller.get_auto_focus() {
|
||||
self.state.auto_focus = af;
|
||||
}
|
||||
if let Ok(focus) = self.controller.get_focus() {
|
||||
self.state.manual_focus = focus.min(100);
|
||||
}
|
||||
if let Ok(pl) = self.controller.get_power_line_frequency() {
|
||||
self.state.anti_flicker = pl;
|
||||
}
|
||||
if let Ok(roll) = self.controller.get_roll() {
|
||||
self.state.roll_deg = (roll as f32 / 10.0_f32).clamp(-1.0_f32, 1.0_f32);
|
||||
}
|
||||
if let Ok(denoise) = self.controller.get_audio_noise_reduction() {
|
||||
self.state.audio_denoise = denoise;
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_saved_settings_to_hardware(&mut self) {
|
||||
let Some(saved) = self.state.last_saved_settings.clone() else {
|
||||
self.state.last_saved_settings = Some(self.state.to_persisted());
|
||||
return;
|
||||
};
|
||||
self.state.apply_persisted(&saved);
|
||||
let _ = self.controller.set_brightness(self.state.brightness);
|
||||
let _ = self.controller.set_contrast(self.state.contrast);
|
||||
let _ = self.controller.set_saturation(self.state.saturation);
|
||||
let _ = self.controller.set_sharpness(self.state.sharpness);
|
||||
let _ = self
|
||||
.controller
|
||||
.set_auto_white_balance(self.state.auto_white_balance);
|
||||
if !self.state.auto_white_balance {
|
||||
let _ = self
|
||||
.controller
|
||||
.set_white_balance_temperature(self.state.wb_kelvin);
|
||||
}
|
||||
let _ = self.controller.set_auto_focus(self.state.auto_focus);
|
||||
if !self.state.auto_focus {
|
||||
let _ = self.controller.set_focus(self.state.manual_focus);
|
||||
}
|
||||
let _ = self
|
||||
.controller
|
||||
.set_power_line_frequency(self.state.anti_flicker);
|
||||
let _ = self.controller.set_hdr(self.state.hdr_enabled);
|
||||
if !self.state.hdr_enabled {
|
||||
let _ = self.controller.set_auto_exposure(self.state.auto_exposure);
|
||||
if self.state.auto_exposure {
|
||||
let _ = self
|
||||
.controller
|
||||
.set_exposure_compensation(self.state.ev_compensation);
|
||||
} else {
|
||||
if let Some(&iso) = crate::state::ISO_LIST.get(self.state.iso_index) {
|
||||
let _ = self.controller.set_iso(iso);
|
||||
}
|
||||
if let Some(&shutter) = crate::state::SHUTTER_DENOM_LIST.get(self.state.shutter_index)
|
||||
{
|
||||
let _ = self.controller.set_shutter_speed(shutter);
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = self.controller.set_track_speed(self.state.track_speed);
|
||||
let _ = self
|
||||
.controller
|
||||
.set_single_tap_tracking_enabled(self.state.enable_single_tap_tracking);
|
||||
let _ = self
|
||||
.controller
|
||||
.set_all_gestures_enabled(self.state.enable_all_gestures);
|
||||
let _ = self.controller.set_gesture_enabled(
|
||||
insta360_link_protocol::Gesture::Palm,
|
||||
self.state.use_gesture_palm,
|
||||
);
|
||||
let _ = self.controller.set_gesture_enabled(
|
||||
insta360_link_protocol::Gesture::VShape,
|
||||
self.state.use_gesture_v,
|
||||
);
|
||||
let _ = self.controller.set_gesture_enabled(
|
||||
insta360_link_protocol::Gesture::LShape,
|
||||
self.state.use_gesture_l,
|
||||
);
|
||||
let _ = self
|
||||
.controller
|
||||
.set_audio_noise_reduction(self.state.audio_denoise);
|
||||
let _ = self
|
||||
.controller
|
||||
.set_horizontal_mirror(self.state.horizontal_mirror);
|
||||
}
|
||||
|
||||
fn set_video_mode(&mut self, target_mode: VideoMode) {
|
||||
let prev_mode = self.state.video_mode;
|
||||
let mode_to_set = if self.state.video_mode == target_mode {
|
||||
@@ -254,20 +354,20 @@ impl Insta360App {
|
||||
|
||||
if mode_to_set == VideoMode::AutoTracking {
|
||||
// `Webcam::CameraInsta::autoTrackClicked` (`0x10006c400`) ->
|
||||
// `onHandleVideoModeChangeEvent` (`0x10005a4cc`) -> `readyAutoTrack` (`0x10006c8c8`):
|
||||
// 1. Ensure the camera is in `VideoMode::Normal` (`0`) so the firmware's person
|
||||
// detector (`GetTrackObjLists`, `XU1 0x14`) runs on the live video stream.
|
||||
// `onHandleVideoModeChangeEvent` (`0x10005a400`..`0x10005a4cc`) -> `readyAutoTrack` (`0x10006c8c8`):
|
||||
// 1. If the camera was in a non-Normal mode (`>= 2`: Whiteboard, Overhead, DeskView),
|
||||
// first exit to `VideoMode::Normal` (`0`) so `GetTrackObjLists` (`XU1 0x14`) runs.
|
||||
// 2. Do NOT send `SetTrackObj(0.5, 0.5)` or `SetVideoMode(AutoTracking)` before
|
||||
// `GetTrackObjLists` detects a person — doing so aborts firmware person detection!
|
||||
// 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
|
||||
.controller
|
||||
.set_video_mode_raw(VideoMode::Normal, &VideoModeAuxData::default());
|
||||
}
|
||||
self.state.video_mode = VideoMode::AutoTracking;
|
||||
self.state.ready_auto_track = true;
|
||||
self.state.last_track_poll = None;
|
||||
self.state.last_track_poll = Some(Instant::now());
|
||||
self.state.show_toast(
|
||||
"AI Tracking ready — looking for person (or click on video to lock target)",
|
||||
false,
|
||||
@@ -328,10 +428,27 @@ impl Insta360App {
|
||||
ui.add_space(24.0_f32);
|
||||
|
||||
// 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")
|
||||
.selected_text(¤t_dev_label)
|
||||
.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() {
|
||||
let dev = DeviceSelection::Usb(cam);
|
||||
let label = dev.label();
|
||||
@@ -339,18 +456,20 @@ impl Insta360App {
|
||||
self.switch_camera(dev);
|
||||
}
|
||||
}
|
||||
if !self.connected_cameras.is_empty() {
|
||||
ui.separator();
|
||||
}
|
||||
for model in [
|
||||
CameraModel::Link2,
|
||||
CameraModel::Link2C,
|
||||
CameraModel::Link,
|
||||
] {
|
||||
let dev = DeviceSelection::Simulate(model);
|
||||
let label = dev.label();
|
||||
if ui.selectable_label(self.selected_device == dev, label).clicked() {
|
||||
self.switch_camera(dev);
|
||||
if show_simulators {
|
||||
if !self.connected_cameras.is_empty() {
|
||||
ui.separator();
|
||||
}
|
||||
for model in [
|
||||
CameraModel::Link2,
|
||||
CameraModel::Link2C,
|
||||
CameraModel::Link,
|
||||
] {
|
||||
let dev = DeviceSelection::Simulate(model);
|
||||
let label = dev.label();
|
||||
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() {
|
||||
self.connected_cameras = list_connected_cameras().unwrap_or_default();
|
||||
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);
|
||||
}
|
||||
|
||||
ui.add_space(10.0_f32);
|
||||
if show_simulators {
|
||||
ui.add_space(10.0_f32);
|
||||
|
||||
// Video Feed Source Selector
|
||||
let active_feed_label = if self.video_engine.is_using_physical_capture() {
|
||||
self.video_engine
|
||||
.active_capture_device()
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| "Physical Webcam".to_string())
|
||||
} else {
|
||||
"Studio Camera Feed (Live)".to_string()
|
||||
};
|
||||
// Video Feed Source Selector (debug / simulator mode only)
|
||||
let active_feed_label = if self.video_engine.is_using_physical_capture() {
|
||||
self.video_engine
|
||||
.active_capture_device()
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| "Physical Webcam".to_string())
|
||||
} else {
|
||||
"Studio Camera Feed (Live)".to_string()
|
||||
};
|
||||
|
||||
egui::ComboBox::from_id_salt("top_video_feed_selector")
|
||||
.selected_text(&active_feed_label)
|
||||
.show_ui(ui, |ui| {
|
||||
if ui
|
||||
.selectable_label(
|
||||
!self.video_engine.is_using_physical_capture(),
|
||||
"Studio Camera Feed (Live)",
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
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);
|
||||
egui::ComboBox::from_id_salt("top_video_feed_selector")
|
||||
.selected_text(&active_feed_label)
|
||||
.show_ui(ui, |ui| {
|
||||
if ui
|
||||
.selectable_label(
|
||||
!self.video_engine.is_using_physical_capture(),
|
||||
"Studio Camera Feed (Live)",
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Right group
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
@@ -484,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
|
||||
@@ -720,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
|
||||
@@ -766,7 +898,12 @@ impl Insta360App {
|
||||
}
|
||||
|
||||
// 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();
|
||||
if drag_delta.length_sq() > 0.25_f32 {
|
||||
self.state.pan_deg =
|
||||
@@ -801,7 +938,7 @@ impl Insta360App {
|
||||
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();
|
||||
self.state.last_ptz_cmd = (0, 0, 0, 0);
|
||||
}
|
||||
@@ -1960,23 +2097,43 @@ 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;
|
||||
let had_saved_settings = self.state.last_saved_settings.is_some();
|
||||
self.sync_device_info();
|
||||
if had_saved_settings && !self.self_test_enabled {
|
||||
self.apply_saved_settings_to_hardware();
|
||||
} else {
|
||||
self.state.last_saved_settings = Some(self.state.to_persisted());
|
||||
}
|
||||
// If the camera was left in Overhead or DeskView mode (which flips the ISP 180 degrees),
|
||||
// restore Normal mode so the live video stream is right-side up.
|
||||
if self.state.video_mode == VideoMode::OverheadShot
|
||||
@@ -1987,11 +2144,14 @@ impl eframe::App for Insta360App {
|
||||
.set_video_mode_raw(VideoMode::Normal, &VideoModeAuxData::default());
|
||||
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);
|
||||
self.state.pan_deg = 0.0_f32;
|
||||
self.state.tilt_deg = 0.0_f32;
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2034,7 +2194,14 @@ impl eframe::App for Insta360App {
|
||||
.clamp(0.05_f32, 0.95_f32);
|
||||
let cy = (((r.y as f32) + (r.height as f32) * 0.5_f32) / 10000.0_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);
|
||||
std::thread::sleep(Duration::from_millis(30));
|
||||
self.state.ready_auto_track = false;
|
||||
self.state.tracked_targets.clear();
|
||||
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 {
|
||||
let start = *self.self_test_start.get_or_insert_with(Instant::now);
|
||||
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.state.pan_deg = 70.0;
|
||||
self.state.tilt_deg = 0.0;
|
||||
let _ = self.controller.set_pantilt_absolute(252_000, 0);
|
||||
if self.state.video_mode != VideoMode::Normal {
|
||||
self.set_video_mode(VideoMode::Normal);
|
||||
}
|
||||
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!(
|
||||
"[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.firmware_version,
|
||||
self.state.video_mode
|
||||
self.state.video_mode,
|
||||
self.state.pan_deg,
|
||||
self.state.tilt_deg
|
||||
);
|
||||
|
||||
// Start 1080p video recording test
|
||||
@@ -2111,7 +2284,7 @@ impl eframe::App for Insta360App {
|
||||
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;
|
||||
let out_dir = std::env::temp_dir();
|
||||
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 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 nr_off = self.controller.get_audio_noise_reduction().ok();
|
||||
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();
|
||||
|
||||
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!(
|
||||
"[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
|
||||
);
|
||||
let _ = self.controller.set_pantilt_relative_ext(4, 1, 0, 0);
|
||||
self.state.last_ptz_cmd = (4, 1, 0, 0);
|
||||
} else if self.self_test_step == 2 && elapsed >= 5.2 {
|
||||
let _ = self.controller.set_pantilt_relative_ext(3, 1, 0, 0);
|
||||
self.state.last_ptz_cmd = (3, 1, 0, 0);
|
||||
} else if self.self_test_step == 2 && elapsed >= 5.5 {
|
||||
self.self_test_step = 3;
|
||||
let _ = self.controller.stop_pantilt_relative();
|
||||
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.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!(
|
||||
"[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.ui_mode = UiMode::Toolbar;
|
||||
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;
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
self.state.ui_mode = UiMode::MainWindow;
|
||||
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!(
|
||||
"[GUI-SELF-TEST] Step 4: AI Tracking active (ui_mode={:?}, hw_mode={:?}); switching to DeskView",
|
||||
self.state.video_mode, hw_mode
|
||||
);
|
||||
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;
|
||||
let hw_mode = self.controller.get_video_mode().ok().map(|m| m.0);
|
||||
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
|
||||
);
|
||||
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;
|
||||
if let Ok((p, t)) = self.controller.get_pantilt_absolute() {
|
||||
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<()> {
|
||||
tracing_subscriber::fmt::init();
|
||||
|
||||
// Matching webcam.qml: width: 1440, height: 714, minimumWidth: 1120, minimumHeight: 536
|
||||
let native_options = eframe::NativeOptions {
|
||||
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_min_inner_size([1120.0_f32, 536.0_f32])
|
||||
.with_title("Insta360 Link Controller"),
|
||||
|
||||
@@ -883,15 +883,15 @@ fn render_more_tab<T: UvcControlTransport>(
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
use egui::{ColorImage, Pos2, TextureHandle};
|
||||
pub use insta360_link_protocol::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
use std::time::Instant;
|
||||
|
||||
@@ -52,7 +53,7 @@ pub enum BackgroundEffectMode {
|
||||
}
|
||||
|
||||
/// Resolution & frame rate preset (`DefFormatCombo.qml`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum VideoFormatPreset {
|
||||
Uhd4k30,
|
||||
Fhd1080p60,
|
||||
@@ -281,6 +282,81 @@ pub struct CameraAppState {
|
||||
pub ready_auto_track: bool,
|
||||
pub last_track_poll: Option<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 {
|
||||
@@ -328,7 +404,7 @@ impl Default for CameraAppState {
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|_| PathBuf::from("."));
|
||||
|
||||
Self {
|
||||
let mut s = Self {
|
||||
is_light_mode: false,
|
||||
ui_mode: UiMode::MainWindow,
|
||||
right_tab: RightSidebarTab::View,
|
||||
@@ -489,7 +565,13 @@ impl Default for CameraAppState {
|
||||
ready_auto_track: false,
|
||||
last_track_poll: None,
|
||||
last_hw_sync: None,
|
||||
last_saved_settings: None,
|
||||
};
|
||||
if let Some(saved) = PersistedAppSettings::load() {
|
||||
s.apply_persisted(&saved);
|
||||
s.last_saved_settings = Some(saved);
|
||||
}
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
@@ -497,4 +579,81 @@ impl CameraAppState {
|
||||
pub fn show_toast(&mut self, msg: impl Into<String>, is_error: bool) {
|
||||
self.toast_message = Some((msg.into(), Instant::now(), is_error));
|
||||
}
|
||||
|
||||
pub fn to_persisted(&self) -> PersistedAppSettings {
|
||||
PersistedAppSettings {
|
||||
format_preset: self.format_preset,
|
||||
auto_exposure: self.auto_exposure,
|
||||
ev_compensation: (self.ev_compensation * 10.0).round() / 10.0,
|
||||
iso_index: self.iso_index,
|
||||
shutter_index: self.shutter_index,
|
||||
auto_focus: self.auto_focus,
|
||||
manual_focus: self.manual_focus,
|
||||
auto_white_balance: self.auto_white_balance,
|
||||
wb_kelvin: self.wb_kelvin,
|
||||
brightness: self.brightness,
|
||||
contrast: self.contrast,
|
||||
saturation: self.saturation,
|
||||
sharpness: self.sharpness,
|
||||
hdr_enabled: self.hdr_enabled,
|
||||
anti_flicker: self.anti_flicker,
|
||||
startup_params_enabled: self.startup_params_enabled,
|
||||
filter_mode: self.filter_mode,
|
||||
makeup_enabled: self.makeup_enabled,
|
||||
makeup_intensity: self.makeup_intensity,
|
||||
track_speed: self.track_speed,
|
||||
enable_single_tap_tracking: self.enable_single_tap_tracking,
|
||||
enable_all_gestures: self.enable_all_gestures,
|
||||
use_gesture_palm: self.use_gesture_palm,
|
||||
use_gesture_v: self.use_gesture_v,
|
||||
use_gesture_l: self.use_gesture_l,
|
||||
audio_denoise: self.audio_denoise,
|
||||
horizontal_mirror: self.horizontal_mirror,
|
||||
vertical_mirror: self.vertical_mirror,
|
||||
smart_adjustment: self.smart_adjustment,
|
||||
horizontal_correction: self.horizontal_correction,
|
||||
roll_deg: (self.roll_deg * 10.0).round() / 10.0,
|
||||
vertical_resolution_mode: self.vertical_resolution_mode,
|
||||
forced_vertical_lock: self.forced_vertical_lock,
|
||||
low_resolution_mode: self.low_resolution_mode,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn apply_persisted(&mut self, p: &PersistedAppSettings) {
|
||||
self.format_preset = p.format_preset;
|
||||
self.auto_exposure = p.auto_exposure;
|
||||
self.ev_compensation = p.ev_compensation;
|
||||
self.iso_index = p.iso_index.min(ISO_LIST.len() - 1);
|
||||
self.shutter_index = p.shutter_index.min(SHUTTER_DENOM_LIST.len() - 1);
|
||||
self.auto_focus = p.auto_focus;
|
||||
self.manual_focus = p.manual_focus.min(100);
|
||||
self.auto_white_balance = p.auto_white_balance;
|
||||
self.wb_kelvin = p.wb_kelvin.clamp(2000, 10000);
|
||||
self.brightness = p.brightness.clamp(0, 100);
|
||||
self.contrast = p.contrast.min(100);
|
||||
self.saturation = p.saturation.min(100);
|
||||
self.sharpness = p.sharpness.min(100);
|
||||
self.hdr_enabled = p.hdr_enabled;
|
||||
self.anti_flicker = p.anti_flicker;
|
||||
self.startup_params_enabled = p.startup_params_enabled;
|
||||
self.filter_mode = p.filter_mode;
|
||||
self.makeup_enabled = p.makeup_enabled;
|
||||
self.makeup_intensity = p.makeup_intensity.min(100);
|
||||
self.track_speed = p.track_speed;
|
||||
self.enable_single_tap_tracking = p.enable_single_tap_tracking;
|
||||
self.enable_all_gestures = p.enable_all_gestures;
|
||||
self.use_gesture_palm = p.use_gesture_palm;
|
||||
self.use_gesture_v = p.use_gesture_v;
|
||||
self.use_gesture_l = p.use_gesture_l;
|
||||
self.audio_denoise = p.audio_denoise;
|
||||
self.horizontal_mirror = p.horizontal_mirror;
|
||||
self.vertical_mirror = p.vertical_mirror;
|
||||
self.smart_adjustment = p.smart_adjustment;
|
||||
self.horizontal_correction = p.horizontal_correction;
|
||||
self.roll_deg = p.roll_deg.clamp(-1.0, 1.0);
|
||||
self.vertical_resolution_mode =
|
||||
p.vertical_resolution_mode || 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;
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ use insta360_link_protocol::{TrackSpeed, UvcRect, VideoMode};
|
||||
use std::io::{Read, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Child, ChildStdin, Command, Stdio};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
@@ -60,6 +60,9 @@ pub struct VideoPreviewEngine {
|
||||
capture_stop: Arc<AtomicBool>,
|
||||
capture_child: Arc<Mutex<Option<Child>>>,
|
||||
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_child: Option<Child>,
|
||||
recording_path: Option<PathBuf>,
|
||||
@@ -97,6 +100,9 @@ impl VideoPreviewEngine {
|
||||
capture_stop: Arc::new(AtomicBool::new(false)),
|
||||
capture_child: Arc::new(Mutex::new(None)),
|
||||
capture_frame: Arc::new(Mutex::new(None)),
|
||||
frame_seq: Arc::new(AtomicU64::new(0)),
|
||||
last_rendered_seq: 0,
|
||||
last_effects_key: (FilterMode::None, false, 0),
|
||||
recording_stdin: Arc::new(Mutex::new(None)),
|
||||
recording_child: None,
|
||||
recording_path: None,
|
||||
@@ -133,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
|
||||
@@ -283,6 +294,7 @@ impl VideoPreviewEngine {
|
||||
|
||||
let stop_flag = self.capture_stop.clone();
|
||||
let frame_buf = self.capture_frame.clone();
|
||||
let frame_seq = self.frame_seq.clone();
|
||||
let child_store = self.capture_child.clone();
|
||||
let rec_stdin = self.recording_stdin.clone();
|
||||
let out_w = self.capture_width;
|
||||
@@ -294,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([
|
||||
@@ -320,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"]);
|
||||
@@ -344,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",
|
||||
@@ -438,6 +466,7 @@ impl VideoPreviewEngine {
|
||||
}
|
||||
if let Ok(mut lock) = frame_buf.lock() {
|
||||
*lock = Some((out_w, out_h, buffer.clone()));
|
||||
frame_seq.fetch_add(1, Ordering::Release);
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
@@ -466,28 +495,29 @@ impl VideoPreviewEngine {
|
||||
self.active_device = None;
|
||||
}
|
||||
|
||||
/// Renders a full frame (either physical or virtual studio), processes all real-time
|
||||
/// filters/effects, uploads to egui GPU texture, and returns the texture handle.
|
||||
/// Renders a full frame (either physical or virtual studio) at the selected capture resolution,
|
||||
/// processes real-time filters/effects, uploads to egui GPU texture, and returns the texture handle.
|
||||
pub fn render_frame(&mut self, ctx: &Context, state: &mut CameraAppState) -> TextureHandle {
|
||||
let now = Instant::now();
|
||||
let dt = (now - self.last_frame_time).as_secs_f32().clamp(0.001_f32, 0.1_f32);
|
||||
self.last_frame_time = now;
|
||||
let elapsed = self.start_time.elapsed().as_secs_f32();
|
||||
|
||||
// 1. Output dimensions
|
||||
let (out_w, out_h) = if state.low_resolution_mode {
|
||||
if state.vertical_resolution_mode {
|
||||
(270, 480)
|
||||
} else {
|
||||
(480, 270)
|
||||
let cur_seq = self.frame_seq.load(Ordering::Acquire);
|
||||
let effects_key = (state.filter_mode, state.makeup_enabled, state.makeup_intensity);
|
||||
|
||||
// Fast-path: if physical capture is active and neither the frame nor host effects changed,
|
||||
// return the existing GPU texture handle without re-converting or re-uploading.
|
||||
if self.is_using_physical_capture()
|
||||
&& cur_seq > 0
|
||||
&& cur_seq == self.last_rendered_seq
|
||||
&& effects_key == self.last_effects_key
|
||||
{
|
||||
if let Some(ref th) = self.texture_handle {
|
||||
return th.clone();
|
||||
}
|
||||
} else if state.vertical_resolution_mode {
|
||||
(360, 640)
|
||||
} else {
|
||||
(640, 360)
|
||||
};
|
||||
}
|
||||
|
||||
// 2. Obtain raw RGB frame
|
||||
let physical_opt = self.capture_frame.lock().ok().and_then(|guard| guard.clone());
|
||||
let is_physical_active = self.is_using_physical_capture() && physical_opt.is_some();
|
||||
let is_insta360_hw_stream = self
|
||||
@@ -496,37 +526,62 @@ impl VideoPreviewEngine {
|
||||
.map(|d| d.to_lowercase().contains("insta360"))
|
||||
.unwrap_or(false);
|
||||
|
||||
let mut frame_pixels = if is_physical_active {
|
||||
let (src_w, src_h, ref raw_rgb) = physical_opt.unwrap();
|
||||
self.crop_physical_frame(
|
||||
raw_rgb,
|
||||
src_w,
|
||||
src_h,
|
||||
out_w,
|
||||
out_h,
|
||||
state,
|
||||
is_insta360_hw_stream,
|
||||
)
|
||||
} else {
|
||||
self.render_virtual_studio_frame(out_w, out_h, elapsed, dt, state)
|
||||
};
|
||||
let color_image = if is_physical_active {
|
||||
let (src_w, src_h, raw_rgb) = physical_opt.unwrap();
|
||||
self.last_rendered_seq = cur_seq;
|
||||
self.last_effects_key = effects_key;
|
||||
|
||||
// 3. Real-Time Image & Color Pipeline
|
||||
if is_physical_active && is_insta360_hw_stream {
|
||||
// Physical Insta360 camera applies PTZ, zoom, exposure, WB, brightness, contrast,
|
||||
// saturation, sharpness, and mirror in hardware; apply host-side effects (filters, makeup).
|
||||
self.apply_host_effects_only(&mut frame_pixels, state);
|
||||
if is_insta360_hw_stream && !state.makeup_enabled && state.filter_mode == FilterMode::None {
|
||||
// Zero-downscale native resolution fast path (360p, 720p, 1080p, 4K)
|
||||
ColorImage::from_rgb([src_w, src_h], &raw_rgb)
|
||||
} else {
|
||||
let (out_w, out_h) = if src_w > 1920 {
|
||||
(1920, (src_h * 1920) / src_w.max(1))
|
||||
} else {
|
||||
(src_w, src_h)
|
||||
};
|
||||
let mut frame_pixels = self.crop_physical_frame(
|
||||
&raw_rgb,
|
||||
src_w,
|
||||
src_h,
|
||||
out_w,
|
||||
out_h,
|
||||
state,
|
||||
is_insta360_hw_stream,
|
||||
);
|
||||
if is_insta360_hw_stream {
|
||||
self.apply_host_effects_only(&mut frame_pixels, state);
|
||||
} else {
|
||||
self.apply_image_pipeline(&mut frame_pixels, out_w, out_h, state);
|
||||
}
|
||||
let mut pixels = Vec::with_capacity(out_w * out_h);
|
||||
for p in frame_pixels {
|
||||
pixels.push(p.to_color32());
|
||||
}
|
||||
ColorImage {
|
||||
size: [out_w, out_h],
|
||||
pixels,
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let 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);
|
||||
}
|
||||
|
||||
// 4. Convert to egui::ColorImage
|
||||
let mut pixels = Vec::with_capacity(out_w * out_h);
|
||||
for p in frame_pixels {
|
||||
pixels.push(p.to_color32());
|
||||
}
|
||||
|
||||
if !is_physical_active {
|
||||
let mut pixels = Vec::with_capacity(out_w * out_h);
|
||||
for p in frame_pixels {
|
||||
pixels.push(p.to_color32());
|
||||
}
|
||||
if let Ok(mut rec_lock) = self.recording_stdin.lock() {
|
||||
if let Some((ref mut stdin, rw, rh)) = *rec_lock {
|
||||
let mut rgb24 = Vec::with_capacity(rw * rh * 3);
|
||||
@@ -543,14 +598,13 @@ impl VideoPreviewEngine {
|
||||
let _ = stdin.write_all(&rgb24);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let color_image = ColorImage {
|
||||
size: [out_w, out_h],
|
||||
pixels,
|
||||
ColorImage {
|
||||
size: [out_w, out_h],
|
||||
pixels,
|
||||
}
|
||||
};
|
||||
|
||||
// 5. Upload to TextureHandle
|
||||
// Upload to TextureHandle
|
||||
let texture_options = TextureOptions::LINEAR;
|
||||
let handle = if let Some(ref mut th) = self.texture_handle {
|
||||
th.set(color_image.clone(), texture_options);
|
||||
@@ -1078,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();
|
||||
|
||||
|
||||
@@ -111,6 +111,11 @@ pub fn decode_get_video_mode(data: &[u8]) -> Result<(VideoMode, VideoModeAuxData
|
||||
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 mut aux = VideoModeAuxData {
|
||||
flags: data[1],
|
||||
|
||||
@@ -941,6 +941,9 @@ impl<T: UvcControlTransport> Insta360CameraController<T> {
|
||||
}
|
||||
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<T: UvcControlTransport> Insta360CameraController<T> {
|
||||
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
|
||||
|
||||
@@ -72,6 +72,12 @@ pub fn list_connected_cameras() -> Result<Vec<DiscoveredCamera>, TransportError>
|
||||
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 {
|
||||
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`
|
||||
/// (Linux `uvcvideo` `UVCIOC_CTRL_QUERY` + `usbfs`, Windows `WinUSB`, macOS `IOKit`).
|
||||
/// (Linux `uvcvideo` `UVCIOC_CTRL_QUERY` + V4L2 ioctls, Windows `WinUSB`, macOS `IOKit`).
|
||||
pub struct NusbTransport {
|
||||
info: DiscoveredCamera,
|
||||
device: nusb::Device,
|
||||
device: Option<nusb::Device>,
|
||||
interface: Option<nusb::Interface>,
|
||||
#[cfg(target_os = "linux")]
|
||||
v4l_file: Option<std::fs::File>,
|
||||
v4l_file: std::sync::Mutex<Option<std::fs::File>>,
|
||||
timeout: Duration,
|
||||
}
|
||||
|
||||
#[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 pid_hex = format!("{:04x}", pid);
|
||||
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);
|
||||
|
||||
let mut fallback: Option<std::fs::File> = None;
|
||||
let mut fallback: Option<(std::fs::File, UvcUnitIds)> = None;
|
||||
for (_, path, name) in candidates {
|
||||
let dev_link = path.join("device");
|
||||
let Ok(canon) = std::fs::canonicalize(&dev_link) else {
|
||||
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() {
|
||||
let v = std::fs::read_to_string(usb_dev_dir.join("idVendor"))
|
||||
.unwrap_or_default()
|
||||
@@ -132,6 +138,23 @@ fn open_linux_v4l2_node(vid: u16, pid: u16, xu1_id: u8) -> Option<std::fs::File>
|
||||
.trim()
|
||||
.to_lowercase();
|
||||
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);
|
||||
if let Ok(file) = std::fs::OpenOptions::new()
|
||||
.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];
|
||||
if linux_uvcioc_ctrl_query(
|
||||
&file,
|
||||
xu1_id,
|
||||
probe_xu1,
|
||||
0x03,
|
||||
UvcRequest::GetLen as u8,
|
||||
&mut len_buf,
|
||||
)
|
||||
.is_ok()
|
||||
{
|
||||
return Some(file);
|
||||
return Some((file, parsed_ids));
|
||||
}
|
||||
if fallback.is_none() {
|
||||
fallback = Some(file);
|
||||
fallback = Some((file, parsed_ids));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -352,35 +375,84 @@ impl NusbTransport {
|
||||
})
|
||||
.ok_or(TransportError::DeviceNotFound)?;
|
||||
|
||||
let device = dev_info.open().map_err(|e| TransportError::Usb(e.to_string()))?;
|
||||
if let Ok(cfg) = device.active_configuration() {
|
||||
let raw: &[u8] = cfg.descriptors().as_bytes();
|
||||
info.unit_ids = parse_uvc_vc_descriptors(raw);
|
||||
let device = dev_info.open().ok();
|
||||
if let Some(ref dev) = device {
|
||||
if let Ok(cfg) = dev.active_configuration() {
|
||||
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
|
||||
// driver (`uvcvideo` on Linux, `UVCAssistant` on macOS, `usbvideo.sys` on Windows) so that
|
||||
// live webcam video capture and UVC/XU control transfers operate simultaneously.
|
||||
let interface = device
|
||||
.claim_interface(info.unit_ids.control_interface)
|
||||
.ok();
|
||||
.as_ref()
|
||||
.and_then(|d| d.claim_interface(info.unit_ids.control_interface).ok());
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
let v4l_file = open_linux_v4l2_node(
|
||||
let v4l_file = match open_linux_v4l2_node(
|
||||
info.vendor_id,
|
||||
info.product_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 {
|
||||
info,
|
||||
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<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 {
|
||||
@@ -415,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(
|
||||
@@ -486,18 +558,15 @@ impl UvcControlTransport for NusbTransport {
|
||||
.map_err(|e| TransportError::Usb(e.to_string()))
|
||||
} else {
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
self.device
|
||||
if let Some(dev) = &self.device {
|
||||
return dev
|
||||
.control_out_blocking(control, payload, self.timeout)
|
||||
.map(|_| ())
|
||||
.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(),
|
||||
))
|
||||
.map_err(|e| TransportError::Usb(e.to_string()));
|
||||
}
|
||||
Err(TransportError::Usb(
|
||||
"UVC control transfer failed".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -523,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<i32, TransportError> = 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(
|
||||
@@ -590,17 +657,16 @@ impl UvcControlTransport for NusbTransport {
|
||||
.map_err(|e| TransportError::Usb(e.to_string()))?
|
||||
} else {
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
self.device
|
||||
if let Some(dev) = &self.device {
|
||||
let n = dev
|
||||
.control_in_blocking(control, &mut buf, self.timeout)
|
||||
.map_err(|e| TransportError::Usb(e.to_string()))?
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
return Err(TransportError::Usb(
|
||||
"UVC control interface could not be claimed on Windows".to_string(),
|
||||
));
|
||||
.map_err(|e| TransportError::Usb(e.to_string()))?;
|
||||
buf.truncate(n);
|
||||
return Ok(buf);
|
||||
}
|
||||
return Err(TransportError::Usb(
|
||||
"UVC control transfer failed".to_string(),
|
||||
));
|
||||
};
|
||||
buf.truncate(n);
|
||||
Ok(buf)
|
||||
|
||||
@@ -29,10 +29,6 @@ run = "cargo test --workspace"
|
||||
description = "Run the automated live hardware GUI verification sequence"
|
||||
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]
|
||||
description = "Build and package release binaries for a target (usage: mise run package-release [target] [version])"
|
||||
run = '''
|
||||
@@ -96,7 +92,7 @@ else
|
||||
cp "target/${TARGET}/release/insta360-link-ctl" "$STAGE_DIR/"
|
||||
cp "target/${TARGET}/release/insta360-link-server" "$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"
|
||||
echo "Packaged dist/${STAGE_NAME}.tar.gz"
|
||||
fi
|
||||
|
||||
@@ -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"
|
||||
Reference in new issue
Block a user