swap-cli 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- swap_cli/__init__.py +5 -0
- swap_cli/__main__.py +6 -0
- swap_cli/camera.py +83 -0
- swap_cli/cli.py +1183 -0
- swap_cli/config.py +152 -0
- swap_cli/devices.py +216 -0
- swap_cli/display.py +177 -0
- swap_cli/gui.py +1695 -0
- swap_cli/license.py +93 -0
- swap_cli/runtime.py +273 -0
- swap_cli/rvc_catalog.py +105 -0
- swap_cli/version.py +1 -0
- swap_cli/voice_engines/__init__.py +148 -0
- swap_cli/voice_engines/rvc_converter.py +220 -0
- swap_cli/voice_engines/rvc_engine.py +108 -0
- swap_cli/voice_library.py +141 -0
- swap_cli/voice_ops.py +594 -0
- swap_cli/voice_prereq.py +360 -0
- swap_cli/voice_router.py +153 -0
- swap_cli/voice_track.py +545 -0
- swap_cli/voices/__init__.py +1 -0
- swap_cli-0.1.1.dist-info/METADATA +504 -0
- swap_cli-0.1.1.dist-info/RECORD +26 -0
- swap_cli-0.1.1.dist-info/WHEEL +4 -0
- swap_cli-0.1.1.dist-info/entry_points.txt +2 -0
- swap_cli-0.1.1.dist-info/licenses/LICENSE.md +75 -0
swap_cli/config.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Local config: Decart API key + license key, stored in user config dir."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import os
|
|
7
|
+
import platform
|
|
8
|
+
import tomllib
|
|
9
|
+
import uuid
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from platformdirs import user_config_dir
|
|
15
|
+
|
|
16
|
+
APP_NAME = "swap-cli"
|
|
17
|
+
CONFIG_FILENAME = "config.toml"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class Config:
|
|
22
|
+
"""User configuration. Read-only at runtime."""
|
|
23
|
+
|
|
24
|
+
license_key: str | None
|
|
25
|
+
decart_api_key: str | None
|
|
26
|
+
license_cached_at: int | None # unix seconds; for offline grace
|
|
27
|
+
license_cached_valid_until: int | None # unix seconds
|
|
28
|
+
# Voice cloning preferences (sprint 13). All optional — older config files
|
|
29
|
+
# without these fields load fine. voice_enabled is the sticky toggle state.
|
|
30
|
+
voice_enabled: bool = False
|
|
31
|
+
last_voice_id: str | None = None
|
|
32
|
+
last_microphone: int | None = None
|
|
33
|
+
last_voice_output: int | None = None
|
|
34
|
+
# Sprint 14e: voice path is RVC-only. Field kept for forward
|
|
35
|
+
# compatibility (future engines: Applio, GPT-SoVITS).
|
|
36
|
+
voice_engine: str = "rvc"
|
|
37
|
+
# Sprint 14i: when True, the streaming engine sets index_rate=0
|
|
38
|
+
# (skip Faiss retrieval). Trades timbre quality for big speedup —
|
|
39
|
+
# essential on weak GPUs or when using voices with huge .index files.
|
|
40
|
+
voice_fast: bool = False
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def is_complete(self) -> bool:
|
|
44
|
+
return bool(self.license_key) and bool(self.decart_api_key)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def config_path() -> Path:
|
|
48
|
+
return Path(user_config_dir(APP_NAME)) / CONFIG_FILENAME
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def load() -> Config:
|
|
52
|
+
"""Load config from disk. Returns an empty config if file is missing."""
|
|
53
|
+
path = config_path()
|
|
54
|
+
if not path.exists():
|
|
55
|
+
return Config(None, None, None, None)
|
|
56
|
+
try:
|
|
57
|
+
data = tomllib.loads(path.read_text("utf-8"))
|
|
58
|
+
except (tomllib.TOMLDecodeError, OSError):
|
|
59
|
+
return Config(None, None, None, None)
|
|
60
|
+
|
|
61
|
+
return Config(
|
|
62
|
+
license_key=_clean(data.get("license_key")),
|
|
63
|
+
decart_api_key=_clean(data.get("decart_api_key")),
|
|
64
|
+
license_cached_at=_int_or_none(data.get("license_cached_at")),
|
|
65
|
+
license_cached_valid_until=_int_or_none(data.get("license_cached_valid_until")),
|
|
66
|
+
voice_enabled=bool(data.get("voice_enabled", False)),
|
|
67
|
+
last_voice_id=_clean(data.get("last_voice_id")),
|
|
68
|
+
last_microphone=_int_or_none(data.get("last_microphone")),
|
|
69
|
+
last_voice_output=_int_or_none(data.get("last_voice_output")),
|
|
70
|
+
voice_engine=_clean(data.get("voice_engine")) or "rvc",
|
|
71
|
+
voice_fast=bool(data.get("voice_fast", False)),
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def save(cfg: Config) -> Path:
|
|
76
|
+
"""Write config atomically with restrictive perms."""
|
|
77
|
+
path = config_path()
|
|
78
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
79
|
+
|
|
80
|
+
body: list[str] = []
|
|
81
|
+
if cfg.license_key:
|
|
82
|
+
body.append(f'license_key = "{_escape(cfg.license_key)}"')
|
|
83
|
+
if cfg.decart_api_key:
|
|
84
|
+
body.append(f'decart_api_key = "{_escape(cfg.decart_api_key)}"')
|
|
85
|
+
if cfg.license_cached_at is not None:
|
|
86
|
+
body.append(f"license_cached_at = {cfg.license_cached_at}")
|
|
87
|
+
if cfg.license_cached_valid_until is not None:
|
|
88
|
+
body.append(f"license_cached_valid_until = {cfg.license_cached_valid_until}")
|
|
89
|
+
if cfg.voice_enabled:
|
|
90
|
+
body.append("voice_enabled = true")
|
|
91
|
+
if cfg.last_voice_id:
|
|
92
|
+
body.append(f'last_voice_id = "{_escape(cfg.last_voice_id)}"')
|
|
93
|
+
if cfg.last_microphone is not None:
|
|
94
|
+
body.append(f"last_microphone = {cfg.last_microphone}")
|
|
95
|
+
if cfg.last_voice_output is not None:
|
|
96
|
+
body.append(f"last_voice_output = {cfg.last_voice_output}")
|
|
97
|
+
if cfg.voice_engine and cfg.voice_engine != "rvc":
|
|
98
|
+
body.append(f'voice_engine = "{_escape(cfg.voice_engine)}"')
|
|
99
|
+
if cfg.voice_fast:
|
|
100
|
+
body.append("voice_fast = true")
|
|
101
|
+
|
|
102
|
+
text = "\n".join(body) + "\n"
|
|
103
|
+
tmp = path.with_suffix(path.suffix + ".tmp")
|
|
104
|
+
tmp.write_text(text, encoding="utf-8")
|
|
105
|
+
if os.name != "nt":
|
|
106
|
+
os.chmod(tmp, 0o600)
|
|
107
|
+
tmp.replace(path)
|
|
108
|
+
return path
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def update(**kwargs: Any) -> Config:
|
|
112
|
+
"""Patch the on-disk config with new values."""
|
|
113
|
+
current = load()
|
|
114
|
+
merged = Config(
|
|
115
|
+
license_key=kwargs.get("license_key", current.license_key),
|
|
116
|
+
decart_api_key=kwargs.get("decart_api_key", current.decart_api_key),
|
|
117
|
+
license_cached_at=kwargs.get("license_cached_at", current.license_cached_at),
|
|
118
|
+
license_cached_valid_until=kwargs.get(
|
|
119
|
+
"license_cached_valid_until", current.license_cached_valid_until
|
|
120
|
+
),
|
|
121
|
+
voice_enabled=kwargs.get("voice_enabled", current.voice_enabled),
|
|
122
|
+
last_voice_id=kwargs.get("last_voice_id", current.last_voice_id),
|
|
123
|
+
last_microphone=kwargs.get("last_microphone", current.last_microphone),
|
|
124
|
+
last_voice_output=kwargs.get("last_voice_output", current.last_voice_output),
|
|
125
|
+
voice_engine=kwargs.get("voice_engine", current.voice_engine),
|
|
126
|
+
voice_fast=kwargs.get("voice_fast", current.voice_fast),
|
|
127
|
+
)
|
|
128
|
+
save(merged)
|
|
129
|
+
return merged
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def machine_id() -> str:
|
|
133
|
+
"""Stable, hashed device identifier. Never sends raw MAC/serial off-device."""
|
|
134
|
+
raw = f"{uuid.getnode()}|{platform.node()}|{platform.machine()}|{platform.system()}"
|
|
135
|
+
return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:32]
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _clean(value: Any) -> str | None:
|
|
139
|
+
if not isinstance(value, str):
|
|
140
|
+
return None
|
|
141
|
+
stripped = value.strip()
|
|
142
|
+
return stripped or None
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _int_or_none(value: Any) -> int | None:
|
|
146
|
+
if isinstance(value, int):
|
|
147
|
+
return value
|
|
148
|
+
return None
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _escape(value: str) -> str:
|
|
152
|
+
return value.replace("\\", "\\\\").replace('"', '\\"')
|
swap_cli/devices.py
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""Cross-platform camera enumeration.
|
|
2
|
+
|
|
3
|
+
We probe up to MAX_CAMERAS by opening each index in a SUBPROCESS so a
|
|
4
|
+
native crash (common on Alienware/IR cameras with OpenCV) only kills
|
|
5
|
+
the probe — not the GUI.
|
|
6
|
+
|
|
7
|
+
Friendly names:
|
|
8
|
+
- Windows: pygrabber's DirectShow filter list ("Logitech BRIO", etc.)
|
|
9
|
+
- Linux: /sys/class/video4linux/videoN/name
|
|
10
|
+
- macOS: AVFoundation gives us no easy name list, fall back to generic
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import platform
|
|
16
|
+
import subprocess
|
|
17
|
+
import sys
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
MAX_CAMERAS = 6
|
|
22
|
+
PROBE_TIMEOUT_S = 3.0
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class CameraDevice:
|
|
27
|
+
index: int
|
|
28
|
+
label: str
|
|
29
|
+
# Sprint 14o: True when the device's name matches a virtual-camera
|
|
30
|
+
# driver (OBS / Snap / ManyCam / DroidCam / XSplit / generic). The
|
|
31
|
+
# GUI deprioritises these in auto-pick to avoid the feedback loop:
|
|
32
|
+
# swap reading from OBS Virtual Camera while also writing to it.
|
|
33
|
+
virtual: bool = False
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# Virtual-camera deny list. Sprint 14o: user hit a feedback loop where
|
|
37
|
+
# swap auto-picked OBS Virtual Camera (because the real webcam probe
|
|
38
|
+
# timed out) and then also wrote its output to it — Lucy was consuming
|
|
39
|
+
# its own previous frames. Mirror voice_router's Sound Mapper pattern.
|
|
40
|
+
_VIRTUAL_CAMERA_NEEDLES = (
|
|
41
|
+
"obs virtual",
|
|
42
|
+
"virtual camera",
|
|
43
|
+
"virtual webcam",
|
|
44
|
+
"snap camera",
|
|
45
|
+
"manycam",
|
|
46
|
+
"xsplit",
|
|
47
|
+
"droidcam",
|
|
48
|
+
"e2esoft",
|
|
49
|
+
"iriun",
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def is_virtual_camera(name: str) -> bool:
|
|
54
|
+
"""True iff the device name matches a known virtual-camera driver."""
|
|
55
|
+
n = (name or "").lower()
|
|
56
|
+
return any(needle in n for needle in _VIRTUAL_CAMERA_NEEDLES)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# Inline probe script — runs in a child Python process so an OpenCV
|
|
60
|
+
# native crash (access violation on e.g. Alienware IR cameras) only kills
|
|
61
|
+
# the child, not the GUI.
|
|
62
|
+
_PROBE_SCRIPT = """
|
|
63
|
+
import sys
|
|
64
|
+
import cv2
|
|
65
|
+
idx = int(sys.argv[1])
|
|
66
|
+
backend = int(sys.argv[2])
|
|
67
|
+
cap = cv2.VideoCapture(idx, backend)
|
|
68
|
+
try:
|
|
69
|
+
if not cap.isOpened():
|
|
70
|
+
sys.exit(2)
|
|
71
|
+
ok, _ = cap.read()
|
|
72
|
+
sys.exit(0 if ok else 3)
|
|
73
|
+
finally:
|
|
74
|
+
cap.release()
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _backend_for_platform() -> int:
|
|
79
|
+
"""Pick the most stable cv2 capture backend for the current OS."""
|
|
80
|
+
import cv2
|
|
81
|
+
|
|
82
|
+
if sys.platform == "win32":
|
|
83
|
+
return cv2.CAP_DSHOW
|
|
84
|
+
if sys.platform == "darwin":
|
|
85
|
+
return cv2.CAP_AVFOUNDATION
|
|
86
|
+
return cv2.CAP_V4L2
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _probe_one(index: int, backend: int) -> tuple[bool, str]:
|
|
90
|
+
"""Probe a single camera index in an isolated subprocess.
|
|
91
|
+
|
|
92
|
+
Returns (ok, reason). reason is one of:
|
|
93
|
+
"" - probe succeeded
|
|
94
|
+
"timeout" - cv2.VideoCapture blocked > PROBE_TIMEOUT_S; most
|
|
95
|
+
common cause is another app holding the camera
|
|
96
|
+
(Zoom/Teams/Discord/browsers)
|
|
97
|
+
"not_present" - probe returned non-zero exit; the index doesn't
|
|
98
|
+
map to a real device
|
|
99
|
+
"error:<msg>" - subprocess raised something unexpected
|
|
100
|
+
"""
|
|
101
|
+
try:
|
|
102
|
+
proc = subprocess.run(
|
|
103
|
+
[sys.executable, "-c", _PROBE_SCRIPT, str(index), str(backend)],
|
|
104
|
+
capture_output=True,
|
|
105
|
+
timeout=PROBE_TIMEOUT_S,
|
|
106
|
+
)
|
|
107
|
+
if proc.returncode == 0:
|
|
108
|
+
return True, ""
|
|
109
|
+
return False, "not_present"
|
|
110
|
+
except subprocess.TimeoutExpired:
|
|
111
|
+
return False, "timeout"
|
|
112
|
+
except Exception as err: # noqa: BLE001
|
|
113
|
+
return False, f"error:{err}"
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _windows_friendly_names() -> dict[int, str]:
|
|
117
|
+
"""Return {index: friendly name} from DirectShow's filter list.
|
|
118
|
+
|
|
119
|
+
pygrabber wraps the same DirectShow API OBS / Discord / Zoom use, so
|
|
120
|
+
the names match what users see in those apps. Returns {} if the
|
|
121
|
+
library is missing or anything goes sideways.
|
|
122
|
+
"""
|
|
123
|
+
try:
|
|
124
|
+
from pygrabber.dshow_graph import FilterGraph # type: ignore[import-not-found]
|
|
125
|
+
|
|
126
|
+
graph = FilterGraph()
|
|
127
|
+
names = graph.get_input_devices()
|
|
128
|
+
return {i: name for i, name in enumerate(names)}
|
|
129
|
+
except Exception as err: # noqa: BLE001
|
|
130
|
+
print(f"[devices] friendly-name lookup unavailable: {err}", flush=True)
|
|
131
|
+
return {}
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _linux_friendly_names() -> dict[int, str]:
|
|
135
|
+
"""Read /sys/class/video4linux/videoN/name on Linux. Empty dict on failure."""
|
|
136
|
+
out: dict[int, str] = {}
|
|
137
|
+
base = Path("/sys/class/video4linux")
|
|
138
|
+
if not base.exists():
|
|
139
|
+
return out
|
|
140
|
+
try:
|
|
141
|
+
for entry in base.glob("video*"):
|
|
142
|
+
stem = entry.name.removeprefix("video")
|
|
143
|
+
if not stem.isdigit():
|
|
144
|
+
continue
|
|
145
|
+
idx = int(stem)
|
|
146
|
+
name_file = entry / "name"
|
|
147
|
+
if name_file.exists():
|
|
148
|
+
out[idx] = name_file.read_text(encoding="utf-8", errors="replace").strip()
|
|
149
|
+
except Exception as err: # noqa: BLE001
|
|
150
|
+
print(f"[devices] /sys readout failed: {err}", flush=True)
|
|
151
|
+
return out
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _friendly_names() -> dict[int, str]:
|
|
155
|
+
if sys.platform == "win32":
|
|
156
|
+
return _windows_friendly_names()
|
|
157
|
+
if sys.platform.startswith("linux"):
|
|
158
|
+
return _linux_friendly_names()
|
|
159
|
+
return {}
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def enumerate_cameras() -> list[CameraDevice]:
|
|
163
|
+
"""Return working cameras with their friendly names where possible.
|
|
164
|
+
|
|
165
|
+
Sprint 14o: each device now carries a `virtual` flag so the GUI can
|
|
166
|
+
deprioritise OBS/Snap/etc. when picking the default input. Probe
|
|
167
|
+
failures distinguish timeout (camera held by another app) from
|
|
168
|
+
not_present (no device at that index).
|
|
169
|
+
"""
|
|
170
|
+
backend = _backend_for_platform()
|
|
171
|
+
names = _friendly_names()
|
|
172
|
+
if names:
|
|
173
|
+
print(f"[devices] friendly names: {names}", flush=True)
|
|
174
|
+
|
|
175
|
+
devices: list[CameraDevice] = []
|
|
176
|
+
for i in range(MAX_CAMERAS):
|
|
177
|
+
print(f"[devices] probing index {i} (subprocess, backend={backend})", flush=True)
|
|
178
|
+
ok, reason = _probe_one(i, backend)
|
|
179
|
+
if ok:
|
|
180
|
+
friendly = names.get(i)
|
|
181
|
+
label = _label_for(i, friendly)
|
|
182
|
+
virtual = is_virtual_camera(friendly or "")
|
|
183
|
+
tag = " [virtual]" if virtual else ""
|
|
184
|
+
print(f"[devices] index {i} ok — {label}{tag}", flush=True)
|
|
185
|
+
devices.append(CameraDevice(index=i, label=label, virtual=virtual))
|
|
186
|
+
elif reason == "timeout":
|
|
187
|
+
print(
|
|
188
|
+
f"[devices] index {i} probe timed out — another app may be holding "
|
|
189
|
+
"this camera (Zoom/Teams/Discord/browsers). Close it and re-launch.",
|
|
190
|
+
flush=True,
|
|
191
|
+
)
|
|
192
|
+
elif reason.startswith("error:"):
|
|
193
|
+
print(f"[devices] index {i} probe error: {reason[6:]}", flush=True)
|
|
194
|
+
else:
|
|
195
|
+
print(f"[devices] index {i} unavailable", flush=True)
|
|
196
|
+
real = sum(1 for d in devices if not d.virtual)
|
|
197
|
+
virtual = sum(1 for d in devices if d.virtual)
|
|
198
|
+
print(
|
|
199
|
+
f"[devices] {len(devices)} camera(s) found ({real} real, {virtual} virtual)",
|
|
200
|
+
flush=True,
|
|
201
|
+
)
|
|
202
|
+
return devices
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _label_for(index: int, friendly: str | None = None) -> str:
|
|
206
|
+
if friendly:
|
|
207
|
+
return f"{friendly} (#{index})"
|
|
208
|
+
system = platform.system()
|
|
209
|
+
suffix = {
|
|
210
|
+
"Darwin": "FaceTime / iSight",
|
|
211
|
+
"Windows": "DirectShow",
|
|
212
|
+
"Linux": "/dev/video",
|
|
213
|
+
}.get(system, "")
|
|
214
|
+
if index == 0:
|
|
215
|
+
return f"Camera 0 (default){' · ' + suffix if suffix else ''}"
|
|
216
|
+
return f"Camera {index}"
|
swap_cli/display.py
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""Render an aiortc remote video track in a cv2.imshow window.
|
|
2
|
+
|
|
3
|
+
Also handles snapshot-on-keypress, optional MP4 recording, and
|
|
4
|
+
(Sprint 14k) optional output to a virtual camera device so apps like
|
|
5
|
+
Zoom/Meet/Discord see the deepfake stream directly.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import time
|
|
12
|
+
from contextlib import suppress
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import TYPE_CHECKING, Any
|
|
15
|
+
|
|
16
|
+
import cv2
|
|
17
|
+
import numpy as np
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from aiortc.mediastreams import MediaStreamTrack
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
WINDOW_TITLE = "swap — Lucy 2 live"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Display:
|
|
27
|
+
"""Pulls frames from a remote MediaStreamTrack and renders them.
|
|
28
|
+
|
|
29
|
+
Press Q in the window or call `stop()` to terminate the loop.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
track: MediaStreamTrack,
|
|
35
|
+
*,
|
|
36
|
+
record_path: Path | None = None,
|
|
37
|
+
on_quit: callable = lambda: None, # type: ignore[assignment]
|
|
38
|
+
virtual_camera: bool = False,
|
|
39
|
+
) -> None:
|
|
40
|
+
self._track = track
|
|
41
|
+
self._record_path = record_path
|
|
42
|
+
self._on_quit = on_quit
|
|
43
|
+
self._virtual_camera = virtual_camera
|
|
44
|
+
self._writer: cv2.VideoWriter | None = None
|
|
45
|
+
# pyvirtualcam.Camera — lazy-init on first frame so we know the
|
|
46
|
+
# actual width/height from Decart's stream rather than guessing.
|
|
47
|
+
self._vcam: Any = None
|
|
48
|
+
self._task: asyncio.Task[None] | None = None
|
|
49
|
+
self._stopped = asyncio.Event()
|
|
50
|
+
self._latest_bgr: np.ndarray | None = None
|
|
51
|
+
|
|
52
|
+
def start(self) -> None:
|
|
53
|
+
self._task = asyncio.create_task(self._loop())
|
|
54
|
+
|
|
55
|
+
async def stop(self) -> None:
|
|
56
|
+
self._stopped.set()
|
|
57
|
+
if self._task:
|
|
58
|
+
self._task.cancel()
|
|
59
|
+
with suppress(asyncio.CancelledError):
|
|
60
|
+
await self._task
|
|
61
|
+
if self._writer is not None:
|
|
62
|
+
self._writer.release()
|
|
63
|
+
if self._vcam is not None:
|
|
64
|
+
with suppress(Exception):
|
|
65
|
+
self._vcam.close()
|
|
66
|
+
self._vcam = None
|
|
67
|
+
cv2.destroyAllWindows()
|
|
68
|
+
|
|
69
|
+
def snapshot(self, dest: Path) -> bool:
|
|
70
|
+
"""Save the most recent rendered frame as JPEG. Returns success."""
|
|
71
|
+
if self._latest_bgr is None:
|
|
72
|
+
return False
|
|
73
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
74
|
+
return cv2.imwrite(str(dest), self._latest_bgr)
|
|
75
|
+
|
|
76
|
+
async def _loop(self) -> None:
|
|
77
|
+
cv2.namedWindow(WINDOW_TITLE, cv2.WINDOW_NORMAL)
|
|
78
|
+
cv2.resizeWindow(WINDOW_TITLE, 960, 540)
|
|
79
|
+
first_frame = True
|
|
80
|
+
try:
|
|
81
|
+
while not self._stopped.is_set():
|
|
82
|
+
frame = await self._track.recv()
|
|
83
|
+
bgr = frame.to_ndarray(format="bgr24")
|
|
84
|
+
self._latest_bgr = bgr
|
|
85
|
+
self._maybe_init_writer(bgr.shape, fps_guess=20)
|
|
86
|
+
if self._writer is not None:
|
|
87
|
+
self._writer.write(bgr)
|
|
88
|
+
cv2.imshow(WINDOW_TITLE, bgr)
|
|
89
|
+
# Sprint 14k: also push the frame to the OBS Virtual Camera
|
|
90
|
+
# driver so Zoom/Meet/Discord pick it up as a real camera.
|
|
91
|
+
# pyvirtualcam expects RGB.
|
|
92
|
+
if self._virtual_camera:
|
|
93
|
+
self._maybe_init_vcam(bgr.shape, fps_guess=20)
|
|
94
|
+
if self._vcam is not None:
|
|
95
|
+
try:
|
|
96
|
+
rgb = cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB)
|
|
97
|
+
self._vcam.send(rgb)
|
|
98
|
+
self._vcam.sleep_until_next_frame()
|
|
99
|
+
except Exception as err: # noqa: BLE001
|
|
100
|
+
print(f"[display] vcam send error: {err}", flush=True)
|
|
101
|
+
# Don't tear the driver down on a single bad frame.
|
|
102
|
+
if first_frame:
|
|
103
|
+
# Flash topmost so the cv2 window pops above the tk GUI on
|
|
104
|
+
# Windows. We don't want it pinned forever — just one beat.
|
|
105
|
+
with suppress(Exception):
|
|
106
|
+
cv2.setWindowProperty(WINDOW_TITLE, cv2.WND_PROP_TOPMOST, 1)
|
|
107
|
+
cv2.waitKey(1)
|
|
108
|
+
cv2.setWindowProperty(WINDOW_TITLE, cv2.WND_PROP_TOPMOST, 0)
|
|
109
|
+
first_frame = False
|
|
110
|
+
key = cv2.waitKey(1) & 0xFF
|
|
111
|
+
if key in (ord("q"), ord("Q"), 27): # q or ESC
|
|
112
|
+
self._on_quit()
|
|
113
|
+
self._stopped.set()
|
|
114
|
+
break
|
|
115
|
+
except asyncio.CancelledError:
|
|
116
|
+
raise
|
|
117
|
+
except Exception as err: # noqa: BLE001 — show + exit cleanly
|
|
118
|
+
print(f"[display] error: {err}")
|
|
119
|
+
finally:
|
|
120
|
+
cv2.destroyAllWindows()
|
|
121
|
+
|
|
122
|
+
def _maybe_init_writer(self, shape: tuple[int, ...], fps_guess: int) -> None:
|
|
123
|
+
if self._record_path is None or self._writer is not None:
|
|
124
|
+
return
|
|
125
|
+
h, w = shape[:2]
|
|
126
|
+
fourcc = cv2.VideoWriter_fourcc(*"mp4v") # type: ignore[attr-defined]
|
|
127
|
+
self._record_path.parent.mkdir(parents=True, exist_ok=True)
|
|
128
|
+
self._writer = cv2.VideoWriter(str(self._record_path), fourcc, fps_guess, (w, h))
|
|
129
|
+
|
|
130
|
+
def _maybe_init_vcam(self, shape: tuple[int, ...], fps_guess: int) -> None:
|
|
131
|
+
"""Open pyvirtualcam.Camera on the first frame so we use the
|
|
132
|
+
actual stream resolution. Silently no-ops if pyvirtualcam isn't
|
|
133
|
+
installed or no virtual camera driver is registered — preview
|
|
134
|
+
window keeps working in that case."""
|
|
135
|
+
if self._vcam is not None:
|
|
136
|
+
return
|
|
137
|
+
try:
|
|
138
|
+
import pyvirtualcam # type: ignore[import-not-found]
|
|
139
|
+
except ImportError:
|
|
140
|
+
# Pure-Python wrapper missing; ship hint via the doctor row.
|
|
141
|
+
self._virtual_camera = False
|
|
142
|
+
print(
|
|
143
|
+
"[display] vcam: pyvirtualcam not installed — `pip install pyvirtualcam`",
|
|
144
|
+
flush=True,
|
|
145
|
+
)
|
|
146
|
+
return
|
|
147
|
+
h, w = shape[:2]
|
|
148
|
+
try:
|
|
149
|
+
# backend=None lets pyvirtualcam pick the right one per OS:
|
|
150
|
+
# Windows → OBS Virtual Camera, macOS → OBS, Linux → v4l2loopback.
|
|
151
|
+
self._vcam = pyvirtualcam.Camera(width=w, height=h, fps=fps_guess)
|
|
152
|
+
print(
|
|
153
|
+
f"[display] vcam ready: '{self._vcam.device}' "
|
|
154
|
+
f"{w}x{h}@{fps_guess}fps — Zoom/Meet/Discord can pick it now.",
|
|
155
|
+
flush=True,
|
|
156
|
+
)
|
|
157
|
+
except Exception as err: # noqa: BLE001
|
|
158
|
+
# Driver not installed, busy, or fps unsupported. Disable vcam
|
|
159
|
+
# for this session and keep the preview window healthy.
|
|
160
|
+
self._virtual_camera = False
|
|
161
|
+
self._vcam = None
|
|
162
|
+
print(
|
|
163
|
+
f"[display] vcam unavailable: {err}\n"
|
|
164
|
+
"[display] Install OBS Studio for the OBS Virtual Camera driver: "
|
|
165
|
+
"https://obsproject.com/download",
|
|
166
|
+
flush=True,
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def default_snapshot_path() -> Path:
|
|
171
|
+
ts = time.strftime("%Y%m%d-%H%M%S")
|
|
172
|
+
return Path.cwd() / "snapshots" / f"swap-{ts}.jpg"
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def default_recording_path() -> Path:
|
|
176
|
+
ts = time.strftime("%Y%m%d-%H%M%S")
|
|
177
|
+
return Path.cwd() / "recordings" / f"swap-{ts}.mp4"
|