From df987ce1616c6a252a8048e7fbf44854732c5562 Mon Sep 17 00:00:00 2001 From: ruscher Date: Mon, 30 Mar 2026 17:17:17 -0300 Subject: [PATCH 1/2] v4.5.0: parallel detection, QUIC/WebTransport, adaptive streaming, encoder alignment - Parallel camera detection with ThreadPoolExecutor and incremental emission - Backend priority dedup (V4L2 > GPhoto2 > Libcamera > PipeWire) - USB camera filtering in Libcamera and PipeWire backends - Deferred virtual camera creation (after first frame renders) - QUIC/WebTransport server (aioquic) for phone camera streaming - Video: independent unidirectional streams (no HOL blocking) - Audio: unreliable QUIC datagrams (low latency) - Auto-fallback to WebSocket when unavailable - Adaptive JPEG quality based on WebSocket buffer pressure - H264/H265/VP9 encoder alignment with big-video-converter defaults - Incompatible camera detection (Sony DSLR-A300/A37 PTP-only warning) - Close dialog lists all active sources with bullet points - Duplicate toast notification fix (dedup by device name) - GPhoto2 detection sleep 1s -> 0.3s --- README.md | 55 ++-- default.nix | 2 +- usr/share/biglinux/bigcam/constants.py | 2 +- .../bigcam/core/backends/gphoto2_backend.py | 4 +- .../bigcam/core/backends/libcamera_backend.py | 3 + .../bigcam/core/backends/pipewire_backend.py | 8 +- .../biglinux/bigcam/core/camera_manager.py | 121 +++++--- .../biglinux/bigcam/core/phone_camera.py | 260 ++++++++++++++++-- .../biglinux/bigcam/core/stream_engine.py | 22 +- .../biglinux/bigcam/core/video_recorder.py | 40 ++- usr/share/biglinux/bigcam/ui/window.py | 49 +++- 11 files changed, 459 insertions(+), 107 deletions(-) diff --git a/README.md b/README.md index 09571e1..387cd70 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ BigCam

-

BigCam 4.4.4

+

BigCam 4.5.0

The universal webcam control center for Linux — use any camera, including your smartphone, as a professional webcam. No expensive apps needed. @@ -21,7 +21,7 @@

- Version 4.4.4 + Version 4.5.0 License: GPL v3 Platform: Linux GTK 4.0 @@ -72,7 +72,20 @@ - **Smile Capture removed**: Removed mediapipe-dependent smile detection feature entirely (code, README, translations). - **i18n verified**: All UI strings confirmed English and translation-ready across 29 languages. -**Version 4.4.4** (current) is the **virtual camera & UX refinement update**: +**Version 4.5.0** (current) is the **detection speed & streaming protocol update**: + +- **Parallel camera detection**: All backends (V4L2, GPhoto2, Libcamera, PipeWire) now detect cameras in parallel using `ThreadPoolExecutor`, with incremental result emission — cameras appear as they are found instead of waiting for all backends. +- **Backend priority**: Duplicate cameras are resolved by backend priority (V4L2 > GPhoto2 > Libcamera > PipeWire), keeping the best backend when the same device is detected by multiple backends. +- **USB camera filtering**: Libcamera and PipeWire backends now filter for USB/UVC cameras only, avoiding false positives. +- **Deferred virtual camera**: Virtual camera device creation is deferred until the first video frame renders, reducing startup latency. +- **QUIC/WebTransport** (optional): Phone camera streaming can now use HTTP/3 (QUIC/UDP) via WebTransport when `python-aioquic` is installed. Each video frame travels as an independent QUIC stream (no head-of-line blocking), and audio uses unreliable QUIC datagrams for minimum latency. Falls back to WebSocket (TCP) when unavailable. +- **Adaptive Wi-Fi streaming**: Phone camera auto-adjusts JPEG quality based on WebSocket buffer pressure — reduces quality when congested, restores when clear. Frames are dropped entirely when the buffer exceeds 128 KB. +- **H264/H265/VP9 encoder alignment**: Recording encoders now match big-video-converter defaults — NVENC → VA-API (new) → VA-API (legacy) → Software priority, CQP/CRF rate control, profile high. +- **Incompatible camera detection**: Sony DSLR-A300/A37 and other PTP-only cameras are detected and shown with a warning icon instead of failing silently. +- **Close dialog improvements**: The close confirmation dialog now lists ALL active sources (playing camera, background virtual cameras, phone server, scrcpy, AirPlay) with bullet points. +- **Duplicate toast fix**: Camera notifications are deduplicated by name, preventing repeated toasts when the same physical device is detected by multiple backends. + +**Version 4.4.4** is the **virtual camera & UX refinement update**: - **Label-aware virtual camera allocation**: `allocate_device()` now verifies device labels match the current name template before reusing a v4l2loopback device. Static devices with mismatched labels are skipped — dynamic devices with the correct name are created instead. - **Stale device cleanup**: `cleanup_dynamic_devices()` now also finds and removes orphaned v4l2loopback devices from previous sessions that weren't tracked. Runs at app startup and during name changes. @@ -97,27 +110,35 @@ We are grateful to Rafael and Barnabé for starting this journey. --- -## What's New in 4.4.4 +## What's New in 4.5.0 -### Virtual Camera +### Detection Speed -- **Label-aware allocation**: Devices are only reused when their card label matches the current name template. Mismatched static devices (from modprobe) are skipped — new dynamic devices are created with the correct name. -- **Stale device cleanup**: Orphaned v4l2loopback devices from previous sessions are automatically cleaned up at startup and during name changes. No more device accumulation across restarts. -- **Duplicate name prevention**: Virtual camera numbering syncs with existing device labels before creating new devices, preventing duplicate "BigCam Virtual 1" names. -- **Background vcam lifecycle**: Toggling or renaming virtual cameras now stops all background pipelines, cleans up devices, and recreates everything with the correct configuration. +- **Parallel backend scanning**: Camera detection is now parallelized across all backends (`ThreadPoolExecutor`), with cameras emitted incrementally as they're found. Typical startup goes from ~4s to ~1s. +- **Backend priority dedup**: When the same camera is detected by multiple backends, the highest-priority one wins (V4L2 > GPhoto2 > Libcamera > PipeWire). +- **USB camera filtering**: Libcamera and PipeWire backends filter for USB/UVC devices, eliminating false positives from virtual or non-camera devices. +- **Deferred virtual camera**: The v4l2loopback virtual camera device is only created after the first video frame renders, cutting perceived startup time. -### Settings +### Phone Wi-Fi Streaming + +- **QUIC/WebTransport** (optional): When `python-aioquic` is installed, the phone browser streams via HTTP/3 (QUIC/UDP). Each video frame is an independent QUIC unidirectional stream — no head-of-line blocking between frames. Audio uses QUIC datagrams for minimal latency. Auto-falls back to WebSocket when unavailable. +- **Adaptive quality**: JPEG quality auto-adjusts based on WebSocket buffer pressure (reduces when congested, restores when clear). Frames are dropped entirely when buffer exceeds 128 KB. +- **Protocol indicator**: Stats display now shows "QUIC" or "WS" to indicate active transport. + +### Recording + +- **Encoder alignment**: H264/H265/VP9 encoders now match big-video-converter defaults — NVENC → VA-API (new) → VA-API (legacy) → Software, CQP/CRF rate control, profile high, proper presets. + +### Camera Compatibility -- **Device name apply button**: Changes to the virtual camera name only apply when pressing Enter or clicking the ✓ button — no more device recreation on every keystroke. -- **Keyboard shortcut safety**: Removed `Space` as a capture shortcut (Ctrl+P remains). Single-key shortcuts (Tab, 1/2/3) are suppressed when editing text. +- **Incompatible camera warning**: Sony DSLR-A300/A37 and other PTP-only cameras are detected and shown with a yellow exclamation icon and explanatory message instead of failing silently. +- **Duplicate toast prevention**: Camera discovery notifications are deduplicated by device name, preventing repeated toasts from multi-backend detection. -### Welcome Dialog +### Close Dialog -- **8th feature item**: Added "Advanced Controls" (fine-tune exposure, white balance, per-camera profiles). -- **Grid alignment**: Features use `Gtk.Grid` for consistent row alignment across columns. -- **Window dragging**: Dialog wrapped in `Gtk.WindowHandle` — drag from any empty area. +- **All active sources listed**: The close confirmation now shows ALL active sources (playing camera, background vcams, phone server, scrcpy, AirPlay) with bullet-point names. -### Previous (4.4.1) +### Previous (4.4.4) ### Phone Camera Notifications diff --git a/default.nix b/default.nix index d1ffddf..7520377 100644 --- a/default.nix +++ b/default.nix @@ -31,7 +31,7 @@ let in stdenv.mkDerivation { pname = "bigcam"; - version = "4.4.4"; + version = "4.5.0"; src = ./.; diff --git a/usr/share/biglinux/bigcam/constants.py b/usr/share/biglinux/bigcam/constants.py index d2f7366..6154252 100644 --- a/usr/share/biglinux/bigcam/constants.py +++ b/usr/share/biglinux/bigcam/constants.py @@ -5,7 +5,7 @@ APP_ID = "br.com.biglinux.bigcam" APP_NAME = "BigCam" -APP_VERSION = "4.4.4" +APP_VERSION = "4.5.0" APP_ICON = "bigcam" APP_WEBSITE = "https://github.com/biglinux/bigcam" APP_ISSUE_URL = "https://github.com/biglinux/bigcam/issues" diff --git a/usr/share/biglinux/bigcam/core/backends/gphoto2_backend.py b/usr/share/biglinux/bigcam/core/backends/gphoto2_backend.py index 4a34280..65fa5d4 100644 --- a/usr/share/biglinux/bigcam/core/backends/gphoto2_backend.py +++ b/usr/share/biglinux/bigcam/core/backends/gphoto2_backend.py @@ -264,7 +264,7 @@ def detect_cameras(self) -> list[CameraInfo]: capture_output=True, timeout=5, ) - time.sleep(1) + time.sleep(0.3) # Retry up to 2 times in case GVFS hasn't released the device yet max_attempts = 1 if self._streaming_active else 2 @@ -300,7 +300,7 @@ def detect_cameras(self) -> list[CameraInfo]: if cameras: break if not self._streaming_active: - time.sleep(1) + time.sleep(0.3) except Exception: pass if cameras: diff --git a/usr/share/biglinux/bigcam/core/backends/libcamera_backend.py b/usr/share/biglinux/bigcam/core/backends/libcamera_backend.py index 79796f1..7477d3c 100644 --- a/usr/share/biglinux/bigcam/core/backends/libcamera_backend.py +++ b/usr/share/biglinux/bigcam/core/backends/libcamera_backend.py @@ -51,6 +51,9 @@ def detect_cameras(self) -> list[CameraInfo]: idx = m.group(1) name = m.group(2).strip() path = m.group(3).strip() + # Skip USB/UVC cameras — V4L2 backend handles those + if "usb" in path.lower() or "uvc" in path.lower(): + continue cameras.append( CameraInfo( id=f"libcamera:{idx}", diff --git a/usr/share/biglinux/bigcam/core/backends/pipewire_backend.py b/usr/share/biglinux/bigcam/core/backends/pipewire_backend.py index 5d4858e..796006b 100644 --- a/usr/share/biglinux/bigcam/core/backends/pipewire_backend.py +++ b/usr/share/biglinux/bigcam/core/backends/pipewire_backend.py @@ -88,7 +88,13 @@ def _parse_pw_objects(self, output: str) -> list[CameraInfo]: @staticmethod def _is_video_source(props: dict[str, str]) -> bool: - return props.get("media.class", "") in ("Video/Source", "Video/Source/Virtual") + mc = props.get("media.class", "") + if mc not in ("Video/Source", "Video/Source/Virtual"): + return False + # Skip real V4L2 hardware cameras — the V4L2 backend handles those + if props.get("api.v4l2.path") or props.get("device.api") == "v4l2": + return False + return True @staticmethod def _make_camera(node_id: str, props: dict[str, str]) -> CameraInfo: diff --git a/usr/share/biglinux/bigcam/core/camera_manager.py b/usr/share/biglinux/bigcam/core/camera_manager.py index 497fed6..e92ae89 100644 --- a/usr/share/biglinux/bigcam/core/camera_manager.py +++ b/usr/share/biglinux/bigcam/core/camera_manager.py @@ -6,6 +6,7 @@ import re import subprocess import threading +from concurrent.futures import ThreadPoolExecutor, as_completed from typing import Any import glob @@ -95,6 +96,14 @@ def detect_cameras_async(self, force_emit: bool = False) -> None: self._detecting = True self._force_emit = force_emit + # Backend priority: lower number = higher priority for duplicate resolution + _BACKEND_PRIORITY = { + BackendType.V4L2: 0, + BackendType.GPHOTO2: 1, + BackendType.LIBCAMERA: 2, + BackendType.PIPEWIRE: 3, + } + def _normalize_name(name: str) -> str: """Strip non-alphanumeric chars for duplicate detection.""" return re.sub(r"[^a-z0-9 ]", "", name.lower()).strip() @@ -102,45 +111,83 @@ def _normalize_name(name: str) -> str: def _worker() -> None: all_cameras: list[CameraInfo] = [] seen_ids: set[str] = set() - seen_norm: list[str] = [] + seen_norm: list[tuple[str, int]] = [] # (norm_name, index_in_all_cameras) + merge_lock = threading.Lock() + + backends_to_scan = [ + b for b in self._backends + if b.get_backend_type() != BackendType.IP + ] + + def _detect_one(b: CameraBackend) -> list[CameraInfo]: + try: + found = b.detect_cameras() + if ( + not found + and hasattr(b, "_streaming_active") + and b._streaming_active + ): + found = [ + c + for c in self._cameras + if c.backend == b.get_backend_type() + ] + return found + except Exception as exc: + GLib.idle_add(self.emit, "camera-error", str(exc)) + return [] + + completed = 0 + total = len(backends_to_scan) + try: - for b in self._backends: - if b.get_backend_type() == BackendType.IP: - continue # IP cameras are added manually - try: - found = b.detect_cameras() - if ( - not found - and hasattr(b, "_streaming_active") - and b._streaming_active - ): - # Keep existing cameras for this backend during streaming - found = [ - c - for c in self._cameras - if c.backend == b.get_backend_type() - ] - for cam in found: - if cam.id in seen_ids: - continue - # Skip if another backend already detected the - # same physical camera (normalized substring match). - norm = _normalize_name(cam.name) - dup = False - for sn in seen_norm: - if sn in norm or norm in sn: - dup = True - break - if dup: - continue - seen_ids.add(cam.id) - seen_norm.append(norm) - all_cameras.append(cam) - except Exception as exc: - GLib.idle_add(self.emit, "camera-error", str(exc)) - finally: + with ThreadPoolExecutor(max_workers=total) as pool: + futures = { + pool.submit(_detect_one, b): b for b in backends_to_scan + } + for future in as_completed(futures): + found = future.result() + with merge_lock: + for cam in found: + if cam.id in seen_ids: + continue + norm = _normalize_name(cam.name) + cam_prio = _BACKEND_PRIORITY.get(cam.backend, 99) + dup_idx = -1 + for sn, idx in seen_norm: + if sn in norm or norm in sn: + dup_idx = idx + break + if dup_idx >= 0: + # Duplicate found — replace if new camera has higher priority + existing = all_cameras[dup_idx] + existing_prio = _BACKEND_PRIORITY.get(existing.backend, 99) + if cam_prio < existing_prio: + seen_ids.discard(existing.id) + seen_ids.add(cam.id) + all_cameras[dup_idx] = cam + # Update norm entry + for i, (sn, sidx) in enumerate(seen_norm): + if sidx == dup_idx: + seen_norm[i] = (norm, dup_idx) + break + continue + seen_ids.add(cam.id) + seen_norm.append((norm, len(all_cameras))) + all_cameras.append(cam) + completed += 1 + snapshot = list(all_cameras) + is_last = completed == total + + # Emit partial results so fast backends show up immediately + if is_last: + self._detecting = False + GLib.idle_add(self._on_detection_done, snapshot) + elif snapshot: + # Only emit partial results when there are cameras to show + GLib.idle_add(self._on_detection_done, snapshot) + except Exception: self._detecting = False - GLib.idle_add(self._on_detection_done, all_cameras) threading.Thread(target=_worker, daemon=True).start() diff --git a/usr/share/biglinux/bigcam/core/phone_camera.py b/usr/share/biglinux/bigcam/core/phone_camera.py index b6249e8..c7f08e3 100644 --- a/usr/share/biglinux/bigcam/core/phone_camera.py +++ b/usr/share/biglinux/bigcam/core/phone_camera.py @@ -31,6 +31,23 @@ except ImportError: _HAS_AIOHTTP = False +try: + from aioquic.asyncio import serve as quic_serve + from aioquic.asyncio.protocol import QuicConnectionProtocol + from aioquic.h3.connection import H3_ALPN, H3Connection + from aioquic.h3.events import ( + DatagramReceived, + H3Event, + HeadersReceived, + WebTransportStreamDataReceived, + ) + from aioquic.quic.configuration import QuicConfiguration + from aioquic.quic.events import ProtocolNegotiated, QuicEvent + + _HAS_QUIC = True +except ImportError: + _HAS_QUIC = False + _CERT_DIR = os.path.join(GLib.get_user_cache_dir(), "bigcam") _CERT_FILE = os.path.join(_CERT_DIR, "cert.pem") _KEY_FILE = os.path.join(_CERT_DIR, "key.pem") @@ -204,8 +221,10 @@

Accept the security warning to allow camera access.