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
|
- 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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 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 {
|
||||||
|
|||||||
@@ -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(¤t_dev_label)
|
.selected_text(¤t_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"),
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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