From df987ce1616c6a252a8048e7fbf44854732c5562 Mon Sep 17 00:00:00 2001
From: ruscher
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 @@
-
+
@@ -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 @@