swap-cli 0.1.1__tar.gz → 0.1.2__tar.gz

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.
Files changed (53) hide show
  1. {swap_cli-0.1.1 → swap_cli-0.1.2}/.gitignore +3 -0
  2. {swap_cli-0.1.1 → swap_cli-0.1.2}/PKG-INFO +1 -1
  3. {swap_cli-0.1.1 → swap_cli-0.1.2}/pyproject.toml +4 -1
  4. swap_cli-0.1.2/src/swap_cli/assets/watermark_default.png +0 -0
  5. {swap_cli-0.1.1 → swap_cli-0.1.2}/src/swap_cli/cli.py +160 -2
  6. {swap_cli-0.1.1 → swap_cli-0.1.2}/src/swap_cli/config.py +54 -0
  7. swap_cli-0.1.2/src/swap_cli/display.py +350 -0
  8. {swap_cli-0.1.1 → swap_cli-0.1.2}/src/swap_cli/gui.py +242 -13
  9. {swap_cli-0.1.1 → swap_cli-0.1.2}/src/swap_cli/runtime.py +150 -9
  10. swap_cli-0.1.2/src/swap_cli/version.py +1 -0
  11. swap_cli-0.1.2/src/swap_cli/watermark.py +786 -0
  12. {swap_cli-0.1.1 → swap_cli-0.1.2}/tests/test_config.py +36 -0
  13. {swap_cli-0.1.1 → swap_cli-0.1.2}/tests/test_runtime_timeout.py +33 -3
  14. {swap_cli-0.1.1 → swap_cli-0.1.2}/tests/test_virtual_camera.py +50 -0
  15. swap_cli-0.1.2/tests/test_watermark.py +918 -0
  16. swap_cli-0.1.1/src/swap_cli/display.py +0 -177
  17. swap_cli-0.1.1/src/swap_cli/version.py +0 -1
  18. {swap_cli-0.1.1 → swap_cli-0.1.2}/.github/workflows/ci.yml +0 -0
  19. {swap_cli-0.1.1 → swap_cli-0.1.2}/.github/workflows/release.yml +0 -0
  20. {swap_cli-0.1.1 → swap_cli-0.1.2}/CHANGELOG.md +0 -0
  21. {swap_cli-0.1.1 → swap_cli-0.1.2}/LICENSE.md +0 -0
  22. {swap_cli-0.1.1 → swap_cli-0.1.2}/README.md +0 -0
  23. {swap_cli-0.1.1 → swap_cli-0.1.2}/docs/RELEASING.md +0 -0
  24. {swap_cli-0.1.1 → swap_cli-0.1.2}/scripts/mirror_voices.py +0 -0
  25. {swap_cli-0.1.1 → swap_cli-0.1.2}/src/swap_cli/__init__.py +0 -0
  26. {swap_cli-0.1.1 → swap_cli-0.1.2}/src/swap_cli/__main__.py +0 -0
  27. {swap_cli-0.1.1 → swap_cli-0.1.2}/src/swap_cli/camera.py +0 -0
  28. {swap_cli-0.1.1 → swap_cli-0.1.2}/src/swap_cli/devices.py +0 -0
  29. {swap_cli-0.1.1 → swap_cli-0.1.2}/src/swap_cli/license.py +0 -0
  30. {swap_cli-0.1.1 → swap_cli-0.1.2}/src/swap_cli/rvc_catalog.py +0 -0
  31. {swap_cli-0.1.1 → swap_cli-0.1.2}/src/swap_cli/voice_engines/__init__.py +0 -0
  32. {swap_cli-0.1.1 → swap_cli-0.1.2}/src/swap_cli/voice_engines/rvc_converter.py +0 -0
  33. {swap_cli-0.1.1 → swap_cli-0.1.2}/src/swap_cli/voice_engines/rvc_engine.py +0 -0
  34. {swap_cli-0.1.1 → swap_cli-0.1.2}/src/swap_cli/voice_library.py +0 -0
  35. {swap_cli-0.1.1 → swap_cli-0.1.2}/src/swap_cli/voice_ops.py +0 -0
  36. {swap_cli-0.1.1 → swap_cli-0.1.2}/src/swap_cli/voice_prereq.py +0 -0
  37. {swap_cli-0.1.1 → swap_cli-0.1.2}/src/swap_cli/voice_router.py +0 -0
  38. {swap_cli-0.1.1 → swap_cli-0.1.2}/src/swap_cli/voice_track.py +0 -0
  39. {swap_cli-0.1.1 → swap_cli-0.1.2}/src/swap_cli/voices/__init__.py +0 -0
  40. {swap_cli-0.1.1 → swap_cli-0.1.2}/tests/test_cuda_torch.py +0 -0
  41. {swap_cli-0.1.1 → swap_cli-0.1.2}/tests/test_devices.py +0 -0
  42. {swap_cli-0.1.1 → swap_cli-0.1.2}/tests/test_engine_wiring.py +0 -0
  43. {swap_cli-0.1.1 → swap_cli-0.1.2}/tests/test_engines.py +0 -0
  44. {swap_cli-0.1.1 → swap_cli-0.1.2}/tests/test_fairseq_patch.py +0 -0
  45. {swap_cli-0.1.1 → swap_cli-0.1.2}/tests/test_rvc.py +0 -0
  46. {swap_cli-0.1.1 → swap_cli-0.1.2}/tests/test_rvc_catalog.py +0 -0
  47. {swap_cli-0.1.1 → swap_cli-0.1.2}/tests/test_settings_modal.py +0 -0
  48. {swap_cli-0.1.1 → swap_cli-0.1.2}/tests/test_silent_threshold.py +0 -0
  49. {swap_cli-0.1.1 → swap_cli-0.1.2}/tests/test_sola.py +0 -0
  50. {swap_cli-0.1.1 → swap_cli-0.1.2}/tests/test_voice_prereq.py +0 -0
  51. {swap_cli-0.1.1 → swap_cli-0.1.2}/tests/test_voice_router.py +0 -0
  52. {swap_cli-0.1.1 → swap_cli-0.1.2}/tools/build_library.py +0 -0
  53. {swap_cli-0.1.1 → swap_cli-0.1.2}/tools/personas.yaml +0 -0
@@ -57,3 +57,6 @@ swap-config.toml
57
57
  # Recordings
58
58
  recordings/
59
59
  *.mp4
60
+
61
+ # Watermark capture source (throwaway; cropped template lives in assets/)
62
+ wm_source.*
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: swap-cli
3
- Version: 0.1.1
3
+ Version: 0.1.2
4
4
  Summary: Real-time deepfake on your desktop. Bring your own Decart API key.
5
5
  Project-URL: Homepage, https://github.com/BlAcQW/swap-cli
6
6
  Project-URL: Repository, https://github.com/BlAcQW/swap-cli
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "swap-cli"
3
- version = "0.1.1"
3
+ version = "0.1.2"
4
4
  description = "Real-time deepfake on your desktop. Bring your own Decart API key."
5
5
  authors = [{ name = "BlAcQW", email = "enochhenyo@gmail.com" }]
6
6
  readme = "README.md"
@@ -106,6 +106,9 @@ allow-direct-references = true
106
106
 
107
107
  [tool.hatch.build.targets.wheel]
108
108
  packages = ["src/swap_cli"]
109
+ # Ship the bundled watermark template (and any future data assets) even
110
+ # though *.png would otherwise be excluded by VCS-based file selection.
111
+ artifacts = ["src/swap_cli/assets/*.png"]
109
112
 
110
113
  [tool.ruff]
111
114
  line-length = 100
@@ -194,6 +194,36 @@ def run(
194
194
  ),
195
195
  ),
196
196
  ] = True,
197
+ remove_watermark: Annotated[
198
+ bool | None,
199
+ typer.Option(
200
+ "--remove-watermark/--no-remove-watermark",
201
+ help=(
202
+ "Remove the Decart 'AI Generated' watermark from each frame "
203
+ "via template-match + inpaint. A default template ships with "
204
+ "the app; capture your own with `swap capture-watermark` or "
205
+ "the W key. Omit to use your saved preference."
206
+ ),
207
+ ),
208
+ ] = None,
209
+ watermark_template: Annotated[
210
+ Path | None,
211
+ typer.Option(
212
+ "--watermark-template",
213
+ help="Path to the watermark template PNG (overrides saved config).",
214
+ ),
215
+ ] = None,
216
+ watermark_removal: Annotated[
217
+ str | None,
218
+ typer.Option(
219
+ "--watermark-removal",
220
+ help=(
221
+ "How to hide the badge: 'reconstruct' (rebuild the background, "
222
+ "invisible) or 'blur' (smear it into a soft patch — always works). "
223
+ "Omit to use your saved preference."
224
+ ),
225
+ ),
226
+ ] = None,
197
227
  ) -> None:
198
228
  """Open a realtime Decart session and stream until you press Q."""
199
229
  cfg = config.load()
@@ -219,6 +249,17 @@ def run(
219
249
  if status.cached:
220
250
  console.print(f"[dim]license: cached ({status.reason})[/dim]")
221
251
 
252
+ # Tri-state precedence: an explicit --remove-watermark / --no-remove-
253
+ # watermark wins; when omitted (None) fall back to saved config. This
254
+ # keeps --no-remove-watermark working even when config defaults it on.
255
+ wm_enabled = (
256
+ remove_watermark if remove_watermark is not None else cfg.remove_watermark
257
+ )
258
+ wm_template = (
259
+ str(watermark_template) if watermark_template else cfg.watermark_template
260
+ )
261
+ wm_removal = watermark_removal if watermark_removal is not None else cfg.watermark_removal
262
+
222
263
  opts = RunOptions(
223
264
  decart_api_key=cfg.decart_api_key,
224
265
  reference=reference,
@@ -227,15 +268,24 @@ def run(
227
268
  camera_device=device,
228
269
  record=record,
229
270
  virtual_camera=vcam,
271
+ remove_watermark=wm_enabled,
272
+ watermark_template=wm_template,
273
+ watermark_method=cfg.watermark_method,
274
+ watermark_removal=wm_removal,
275
+ watermark_threshold=cfg.watermark_threshold,
276
+ watermark_inpaint_radius=cfg.watermark_inpaint_radius,
277
+ watermark_template_width=cfg.watermark_template_width,
230
278
  )
231
279
 
280
+ _wm_status = "[green]on[/green]" if opts.remove_watermark else "[dim]off[/dim]"
232
281
  console.print(
233
282
  Panel.fit(
234
283
  f"model: [bold]{opts.model_name}[/bold]\n"
235
284
  f"reference: {opts.reference or '[dim]none[/dim]'}\n"
236
285
  f"camera device: {opts.camera_device}\n"
237
- f"record: {opts.record or '[dim]off[/dim]'}\n\n"
238
- "[dim]Press [bold]Q[/bold] in the preview window to quit.[/dim]",
286
+ f"record: {opts.record or '[dim]off[/dim]'}\n"
287
+ f"watermark removal: {_wm_status}\n\n"
288
+ "[dim]Press [bold]Q[/bold] to quit · [bold]W[/bold] to capture the watermark.[/dim]",
239
289
  title="▶ swap · live",
240
290
  border_style="cyan",
241
291
  )
@@ -250,6 +300,78 @@ def run(
250
300
  raise typer.Exit(1) from err
251
301
 
252
302
 
303
+ @app.command(name="capture-watermark")
304
+ def capture_watermark(
305
+ snapshot: Annotated[
306
+ Path,
307
+ typer.Argument(
308
+ help="Snapshot PNG/JPG containing the watermark to crop out.",
309
+ ),
310
+ ],
311
+ roi: Annotated[
312
+ str | None,
313
+ typer.Option(
314
+ "--roi",
315
+ help="Crop region as x,y,w,h in pixels. Omit to crop interactively.",
316
+ ),
317
+ ] = None,
318
+ ) -> None:
319
+ """Crop a watermark template from a snapshot for `--remove-watermark`.
320
+
321
+ Take a normal snapshot of a live frame that shows the badge, then run
322
+ this to save the watermark template. Pass --roi x,y,w,h for a headless
323
+ crop, or omit it to drag-select in a window.
324
+ """
325
+ import cv2 # local import: cv2 isn't needed for `swap version` etc.
326
+
327
+ from .display import default_watermark_template_path
328
+
329
+ if not snapshot.exists():
330
+ err_console.print(f"[red]snapshot not found: {snapshot}[/red]")
331
+ raise typer.Exit(2)
332
+ img = cv2.imread(str(snapshot))
333
+ if img is None:
334
+ err_console.print(f"[red]could not read image: {snapshot}[/red]")
335
+ raise typer.Exit(2)
336
+
337
+ if roi:
338
+ try:
339
+ x, y, w, h = (int(v.strip()) for v in roi.split(","))
340
+ except ValueError as err:
341
+ err_console.print("[red]--roi must be 'x,y,w,h' integers[/red]")
342
+ raise typer.Exit(2) from err
343
+ else:
344
+ try:
345
+ x, y, w, h = (int(v) for v in cv2.selectROI("select watermark", img))
346
+ cv2.destroyAllWindows()
347
+ except Exception as err: # noqa: BLE001 — headless without a display
348
+ err_console.print(
349
+ f"[red]interactive crop unavailable ({err}). Pass --roi x,y,w,h.[/red]"
350
+ )
351
+ raise typer.Exit(2) from err
352
+
353
+ if w <= 0 or h <= 0:
354
+ err_console.print("[red]empty selection — nothing saved.[/red]")
355
+ raise typer.Exit(2)
356
+
357
+ crop = img[y : y + h, x : x + w]
358
+ dest = default_watermark_template_path()
359
+ dest.parent.mkdir(parents=True, exist_ok=True)
360
+ if not cv2.imwrite(str(dest), crop):
361
+ err_console.print(f"[red]failed to write template → {dest}[/red]")
362
+ raise typer.Exit(1)
363
+ config.update(watermark_template=str(dest))
364
+ console.print(
365
+ Panel.fit(
366
+ f"saved: [bold]{dest}[/bold]\n"
367
+ f"region: {x},{y} {w}×{h}\n\n"
368
+ "[dim]Run [bold]swap run --remove-watermark[/bold] to use it.[/dim]",
369
+ title="✂ watermark template",
370
+ border_style="green",
371
+ )
372
+ )
373
+
374
+
253
375
  @app.command()
254
376
  def voice(
255
377
  voice: Annotated[
@@ -1088,6 +1210,11 @@ async def _doctor() -> None:
1088
1210
  f"[yellow]⚠ {vcam_check.label} — {vcam_check.hint}[/yellow]",
1089
1211
  )
1090
1212
 
1213
+ # Watermark removal template (Sprint 15) — show whether a captured
1214
+ # template exists and is loadable, so users know if --remove-watermark
1215
+ # will actually do anything.
1216
+ table.add_row("watermark template", _watermark_template_label())
1217
+
1091
1218
  # macOS-only: customtkinter needs Tcl/Tk >= 8.6.9. The system Python
1092
1219
  # ships 8.5.9 which fails silently or renders broken windows. Surface
1093
1220
  # this here so users know to switch to python.org Python or
@@ -1106,6 +1233,37 @@ async def _doctor() -> None:
1106
1233
  sys.exit(1)
1107
1234
 
1108
1235
 
1236
+ def _watermark_template_label() -> str:
1237
+ cfg = config.load()
1238
+ source = "custom"
1239
+ template = cfg.watermark_template
1240
+ if not template:
1241
+ from .watermark import bundled_template_path
1242
+
1243
+ bundled = bundled_template_path()
1244
+ if bundled is None:
1245
+ return "[dim]none — run `swap capture-watermark`[/dim]"
1246
+ template = str(bundled)
1247
+ source = "bundled default"
1248
+ path = Path(template)
1249
+ if not path.exists():
1250
+ return f"[yellow]⚠ missing: {path}[/yellow]"
1251
+ try:
1252
+ import cv2
1253
+
1254
+ img = cv2.imread(str(path))
1255
+ if img is None or img.size == 0:
1256
+ return f"[yellow]⚠ unreadable: {path}[/yellow]"
1257
+ h, w = img.shape[:2]
1258
+ ref = cfg.watermark_template_width or 1280
1259
+ return (
1260
+ f"[green]✓ {w}×{h} {path.name} ({source})[/green] "
1261
+ f"[dim]· multi-scale match (ref {ref}px)[/dim]"
1262
+ )
1263
+ except Exception: # noqa: BLE001 — doctor must never crash
1264
+ return f"[green]✓ {path.name} ({source})[/green]"
1265
+
1266
+
1109
1267
  def _camera_probe_label() -> str:
1110
1268
  try:
1111
1269
  import cv2
@@ -38,6 +38,22 @@ class Config:
38
38
  # (skip Faiss retrieval). Trades timbre quality for big speedup —
39
39
  # essential on weak GPUs or when using voices with huge .index files.
40
40
  voice_fast: bool = False
41
+ # Sprint 15: per-frame watermark removal. The Decart "AI Generated"
42
+ # badge roams the frame, so removal runs template-match + inpaint on
43
+ # every frame. All optional — older config files load fine. Off by
44
+ # default so existing users aren't surprised by the latency cost.
45
+ remove_watermark: bool = False
46
+ watermark_template: str | None = None # path to captured watermark PNG
47
+ watermark_method: str = "template" # "template" | "threshold"
48
+ # How a located badge is hidden: "reconstruct" (invisible rebuild) or
49
+ # "blur" (smear into an unreadable soft patch). Detection is separate.
50
+ watermark_removal: str = "reconstruct" # "reconstruct" | "blur"
51
+ watermark_threshold: float = 0.50 # matchTemplate confidence gate (0..1)
52
+ watermark_inpaint_radius: int = 3
53
+ # Frame width the captured template was grabbed at. Decart's output
54
+ # resolution varies, so this centers the multi-scale match exactly for a
55
+ # user-captured template. None → the 1280px bundled-default assumption.
56
+ watermark_template_width: int | None = None
41
57
 
42
58
  @property
43
59
  def is_complete(self) -> bool:
@@ -69,6 +85,13 @@ def load() -> Config:
69
85
  last_voice_output=_int_or_none(data.get("last_voice_output")),
70
86
  voice_engine=_clean(data.get("voice_engine")) or "rvc",
71
87
  voice_fast=bool(data.get("voice_fast", False)),
88
+ remove_watermark=bool(data.get("remove_watermark", False)),
89
+ watermark_template=_clean(data.get("watermark_template")),
90
+ watermark_method=_clean(data.get("watermark_method")) or "template",
91
+ watermark_removal=_clean(data.get("watermark_removal")) or "reconstruct",
92
+ watermark_threshold=_float_or_none(data.get("watermark_threshold")) or 0.50,
93
+ watermark_inpaint_radius=_int_or_none(data.get("watermark_inpaint_radius")) or 3,
94
+ watermark_template_width=_int_or_none(data.get("watermark_template_width")),
72
95
  )
73
96
 
74
97
 
@@ -98,6 +121,20 @@ def save(cfg: Config) -> Path:
98
121
  body.append(f'voice_engine = "{_escape(cfg.voice_engine)}"')
99
122
  if cfg.voice_fast:
100
123
  body.append("voice_fast = true")
124
+ if cfg.remove_watermark:
125
+ body.append("remove_watermark = true")
126
+ if cfg.watermark_template:
127
+ body.append(f'watermark_template = "{_escape(cfg.watermark_template)}"')
128
+ if cfg.watermark_method and cfg.watermark_method != "template":
129
+ body.append(f'watermark_method = "{_escape(cfg.watermark_method)}"')
130
+ if cfg.watermark_removal and cfg.watermark_removal != "reconstruct":
131
+ body.append(f'watermark_removal = "{_escape(cfg.watermark_removal)}"')
132
+ if cfg.watermark_threshold != 0.50:
133
+ body.append(f"watermark_threshold = {cfg.watermark_threshold}")
134
+ if cfg.watermark_inpaint_radius != 3:
135
+ body.append(f"watermark_inpaint_radius = {cfg.watermark_inpaint_radius}")
136
+ if cfg.watermark_template_width is not None:
137
+ body.append(f"watermark_template_width = {cfg.watermark_template_width}")
101
138
 
102
139
  text = "\n".join(body) + "\n"
103
140
  tmp = path.with_suffix(path.suffix + ".tmp")
@@ -124,6 +161,17 @@ def update(**kwargs: Any) -> Config:
124
161
  last_voice_output=kwargs.get("last_voice_output", current.last_voice_output),
125
162
  voice_engine=kwargs.get("voice_engine", current.voice_engine),
126
163
  voice_fast=kwargs.get("voice_fast", current.voice_fast),
164
+ remove_watermark=kwargs.get("remove_watermark", current.remove_watermark),
165
+ watermark_template=kwargs.get("watermark_template", current.watermark_template),
166
+ watermark_method=kwargs.get("watermark_method", current.watermark_method),
167
+ watermark_removal=kwargs.get("watermark_removal", current.watermark_removal),
168
+ watermark_threshold=kwargs.get("watermark_threshold", current.watermark_threshold),
169
+ watermark_inpaint_radius=kwargs.get(
170
+ "watermark_inpaint_radius", current.watermark_inpaint_radius
171
+ ),
172
+ watermark_template_width=kwargs.get(
173
+ "watermark_template_width", current.watermark_template_width
174
+ ),
127
175
  )
128
176
  save(merged)
129
177
  return merged
@@ -148,5 +196,11 @@ def _int_or_none(value: Any) -> int | None:
148
196
  return None
149
197
 
150
198
 
199
+ def _float_or_none(value: Any) -> float | None:
200
+ if isinstance(value, (int, float)) and not isinstance(value, bool):
201
+ return float(value)
202
+ return None
203
+
204
+
151
205
  def _escape(value: str) -> str:
152
206
  return value.replace("\\", "\\\\").replace('"', '\\"')
@@ -0,0 +1,350 @@
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
+ from .watermark import WatermarkRemover
23
+
24
+
25
+ WINDOW_TITLE = "swap — Lucy 2 live"
26
+
27
+
28
+ class Display:
29
+ """Pulls frames from a remote MediaStreamTrack and renders them.
30
+
31
+ Press Q in the window or call `stop()` to terminate the loop.
32
+ """
33
+
34
+ def __init__(
35
+ self,
36
+ track: MediaStreamTrack,
37
+ *,
38
+ record_path: Path | None = None,
39
+ on_quit: callable = lambda: None, # type: ignore[assignment]
40
+ virtual_camera: bool = False,
41
+ watermark: WatermarkRemover | None = None,
42
+ show_window: bool = True,
43
+ ) -> None:
44
+ self._track = track
45
+ self._record_path = record_path
46
+ self._on_quit = on_quit
47
+ self._virtual_camera = virtual_camera
48
+ # Show the cv2 preview window. False in GUI mode: the session runs on a
49
+ # worker thread, and macOS forbids OpenCV HighGUI off the main thread, so
50
+ # the GUI renders frames itself (tkinter, main thread) via latest_frame().
51
+ self._show_window = show_window
52
+ # Sprint 15: optional per-frame watermark remover. None = no-op.
53
+ # Injected as a configured instance so _loop stays thin and the
54
+ # remover is unit-testable in isolation.
55
+ self._watermark = watermark
56
+ self._writer: cv2.VideoWriter | None = None
57
+ # pyvirtualcam.Camera — lazy-init on first frame so we know the
58
+ # actual width/height from Decart's stream rather than guessing.
59
+ self._vcam: Any = None
60
+ self._task: asyncio.Task[None] | None = None
61
+ self._stopped = asyncio.Event()
62
+ self._latest_bgr: np.ndarray | None = None
63
+ # Raw (pre-removal) frame, kept so the W-key capture grabs the badge
64
+ # even while watermark removal is on.
65
+ self._latest_raw_bgr: np.ndarray | None = None
66
+
67
+ def start(self) -> None:
68
+ self._task = asyncio.create_task(self._loop())
69
+
70
+ async def stop(self) -> None:
71
+ self._stopped.set()
72
+ if self._task:
73
+ self._task.cancel()
74
+ with suppress(asyncio.CancelledError):
75
+ await self._task
76
+ if self._writer is not None:
77
+ self._writer.release()
78
+ if self._vcam is not None:
79
+ with suppress(Exception):
80
+ self._vcam.close()
81
+ self._vcam = None
82
+ if self._show_window:
83
+ cv2.destroyAllWindows()
84
+
85
+ def snapshot(self, dest: Path) -> bool:
86
+ """Save the most recent rendered frame as JPEG. Returns success."""
87
+ if self._latest_bgr is None:
88
+ return False
89
+ dest.parent.mkdir(parents=True, exist_ok=True)
90
+ return cv2.imwrite(str(dest), self._latest_bgr)
91
+
92
+ def latest_frame(self) -> np.ndarray | None:
93
+ """Most recent processed (cleaned) frame — for the in-app GUI preview.
94
+ The render loop rebinds this each frame, so the read is atomic."""
95
+ return self._latest_bgr
96
+
97
+ def latest_raw_frame(self) -> np.ndarray | None:
98
+ """Most recent RAW (pre-removal) frame — for GUI watermark capture."""
99
+ return self._latest_raw_bgr
100
+
101
+ async def _loop(self) -> None:
102
+ if self._show_window:
103
+ cv2.namedWindow(WINDOW_TITLE, cv2.WINDOW_NORMAL)
104
+ cv2.resizeWindow(WINDOW_TITLE, 960, 540)
105
+ first_frame = True
106
+ try:
107
+ while not self._stopped.is_set():
108
+ frame = await self._track.recv()
109
+ bgr = frame.to_ndarray(format="bgr24")
110
+ self._latest_raw_bgr = bgr # keep the un-cleaned frame for W capture
111
+ # Sprint 15: strip the Decart watermark before anything
112
+ # downstream sees the frame. process() never raises — it
113
+ # returns the frame unchanged on any failure.
114
+ if self._watermark is not None:
115
+ bgr = self._watermark.process(bgr)
116
+ self._latest_bgr = bgr
117
+ self._maybe_init_writer(bgr.shape, fps_guess=20)
118
+ if self._writer is not None:
119
+ self._writer.write(bgr)
120
+ # Sprint 14k: also push the frame to the OBS Virtual Camera
121
+ # driver so Zoom/Meet/Discord pick it up as a real camera.
122
+ # pyvirtualcam expects RGB.
123
+ if self._virtual_camera:
124
+ self._maybe_init_vcam(bgr.shape, fps_guess=20)
125
+ if self._vcam is not None:
126
+ try:
127
+ rgb = cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB)
128
+ self._vcam.send(rgb)
129
+ self._vcam.sleep_until_next_frame()
130
+ except Exception as err: # noqa: BLE001
131
+ print(f"[display] vcam send error: {err}", flush=True)
132
+ # Don't tear the driver down on a single bad frame.
133
+ if not self._show_window:
134
+ # GUI renders the preview itself (tkinter, main thread) and
135
+ # drives capture/stop — no cv2 window/keys here. Yield so a
136
+ # non-vcam session doesn't spin the loop hot.
137
+ if not self._virtual_camera:
138
+ await asyncio.sleep(0)
139
+ first_frame = False
140
+ continue
141
+ cv2.imshow(WINDOW_TITLE, bgr)
142
+ if first_frame:
143
+ # Flash topmost so the cv2 window pops above the tk GUI on
144
+ # Windows. We don't want it pinned forever — just one beat.
145
+ with suppress(Exception):
146
+ cv2.setWindowProperty(WINDOW_TITLE, cv2.WND_PROP_TOPMOST, 1)
147
+ cv2.waitKey(1)
148
+ cv2.setWindowProperty(WINDOW_TITLE, cv2.WND_PROP_TOPMOST, 0)
149
+ first_frame = False
150
+ key = cv2.waitKey(1) & 0xFF
151
+ if key in (ord("q"), ord("Q"), 27): # q or ESC
152
+ self._on_quit()
153
+ self._stopped.set()
154
+ break
155
+ if key in (ord("w"), ord("W")): # Sprint 15: capture watermark
156
+ self._capture_watermark_template()
157
+ except asyncio.CancelledError:
158
+ raise
159
+ except Exception as err: # noqa: BLE001 — show + exit cleanly
160
+ # The Decart remote track raises (often with an empty message) when
161
+ # the connection drops. End the session cleanly so it doesn't hang
162
+ # in "reconnecting" — the user can click Live again.
163
+ detail = str(err) or err.__class__.__name__
164
+ print(f"[display] stream ended: {detail} — stopping session.", flush=True)
165
+ with suppress(Exception):
166
+ self._on_quit()
167
+ self._stopped.set()
168
+ finally:
169
+ if self._show_window:
170
+ cv2.destroyAllWindows()
171
+
172
+ def capture_watermark(self, roi: tuple[int, int, int, int]) -> None:
173
+ """Public entry for the GUI: capture the badge template from a caller-
174
+ supplied ROI (the GUI's tk drag-selector), instead of cv2.selectROI."""
175
+ self._capture_watermark_template(roi=roi)
176
+
177
+ def _capture_watermark_template(
178
+ self, roi: tuple[int, int, int, int] | None = None
179
+ ) -> None:
180
+ """Select the watermark on the current frame and save it as the template
181
+ PNG (Sprint 15). `roi` is supplied by the GUI's tk selector; when None
182
+ (CLI / cv2-window mode) we fall back to cv2.selectROI on the W key.
183
+
184
+ Captures from the RAW (pre-removal) frame so it works even while
185
+ removal is on but not matching. Persists the path AND the frame width
186
+ so the multi-scale match centers exactly, then HOT-RELOADS the live
187
+ remover so removal starts immediately — no restart needed.
188
+ """
189
+ # Prefer the raw frame; fall back to the displayed one.
190
+ source = self._latest_raw_bgr
191
+ if source is None:
192
+ source = self._latest_bgr
193
+ if source is None:
194
+ print("[display] no frame yet — can't capture watermark.", flush=True)
195
+ return
196
+ try:
197
+ from . import config as _config
198
+
199
+ if roi is None:
200
+ roi = cv2.selectROI(
201
+ "select watermark — ENTER to save, C to cancel",
202
+ source,
203
+ showCrosshair=False,
204
+ )
205
+ cv2.destroyWindow("select watermark — ENTER to save, C to cancel")
206
+ x, y, w, h = (int(v) for v in roi)
207
+ if w <= 0 or h <= 0:
208
+ print("[display] watermark capture cancelled.", flush=True)
209
+ return
210
+ # Guard against a stray click saving a junk template that then
211
+ # matches nothing — the badge is ~150–260px wide.
212
+ if w < 20 or h < 10:
213
+ print(
214
+ f"[display] selection too small ({w}x{h}) — press W again "
215
+ "and drag a box around the whole badge.",
216
+ flush=True,
217
+ )
218
+ return
219
+ crop = source[y : y + h, x : x + w]
220
+ crop = _tighten_to_badge(crop) # shrink a loose box to the strokes
221
+ frame_width = int(source.shape[1])
222
+ dest = default_watermark_template_path()
223
+ dest.parent.mkdir(parents=True, exist_ok=True)
224
+ if not cv2.imwrite(str(dest), crop):
225
+ print(f"[display] failed to write template → {dest}", flush=True)
226
+ return
227
+ _config.update(
228
+ watermark_template=str(dest),
229
+ watermark_template_width=frame_width,
230
+ )
231
+ print(
232
+ f"[display] watermark template saved → {dest} "
233
+ f"(from {frame_width}px-wide frame)",
234
+ flush=True,
235
+ )
236
+ # Hot-reload: rebuild the live remover from the just-saved config so
237
+ # removal starts THIS frame — no restart. Pressing W is a clear
238
+ # intent to remove, so we enable even if the toggle was off.
239
+ from .watermark import WatermarkRemover
240
+
241
+ new = WatermarkRemover.from_config(_config.load(), enabled=True)
242
+ if new is not None:
243
+ self._watermark = new
244
+ print(
245
+ "[display] watermark removal now using the captured template "
246
+ "(live) — if the badge is still visible, press W again and "
247
+ "box it tighter.",
248
+ flush=True,
249
+ )
250
+ except Exception as err: # noqa: BLE001 — capture is best-effort
251
+ print(f"[display] watermark capture error: {err}", flush=True)
252
+
253
+ def _maybe_init_writer(self, shape: tuple[int, ...], fps_guess: int) -> None:
254
+ if self._record_path is None or self._writer is not None:
255
+ return
256
+ h, w = shape[:2]
257
+ fourcc = cv2.VideoWriter_fourcc(*"mp4v") # type: ignore[attr-defined]
258
+ self._record_path.parent.mkdir(parents=True, exist_ok=True)
259
+ self._writer = cv2.VideoWriter(str(self._record_path), fourcc, fps_guess, (w, h))
260
+
261
+ def _maybe_init_vcam(self, shape: tuple[int, ...], fps_guess: int) -> None:
262
+ """Open pyvirtualcam.Camera on the first frame so we use the
263
+ actual stream resolution. Silently no-ops if pyvirtualcam isn't
264
+ installed or no virtual camera driver is registered — preview
265
+ window keeps working in that case."""
266
+ if self._vcam is not None:
267
+ return
268
+ try:
269
+ import pyvirtualcam # type: ignore[import-not-found]
270
+ except ImportError:
271
+ # Pure-Python wrapper missing; ship hint via the doctor row.
272
+ self._virtual_camera = False
273
+ print(
274
+ "[display] vcam: pyvirtualcam not installed — `pip install pyvirtualcam`",
275
+ flush=True,
276
+ )
277
+ return
278
+ h, w = shape[:2]
279
+ try:
280
+ # backend=None lets pyvirtualcam pick the right one per OS:
281
+ # Windows → OBS Virtual Camera, macOS → OBS, Linux → v4l2loopback.
282
+ self._vcam = pyvirtualcam.Camera(width=w, height=h, fps=fps_guess)
283
+ print(
284
+ f"[display] vcam ready: '{self._vcam.device}' "
285
+ f"{w}x{h}@{fps_guess}fps — Zoom/Meet/Discord can pick it now.",
286
+ flush=True,
287
+ )
288
+ except Exception as err: # noqa: BLE001
289
+ # Driver not installed, busy, or fps unsupported. Disable vcam
290
+ # for this session and keep the preview window healthy.
291
+ self._virtual_camera = False
292
+ self._vcam = None
293
+ print(
294
+ f"[display] vcam unavailable: {err}\n"
295
+ "[display] Install OBS Studio for the OBS Virtual Camera driver: "
296
+ "https://obsproject.com/download",
297
+ flush=True,
298
+ )
299
+
300
+
301
+ def _tighten_to_badge(crop: np.ndarray) -> np.ndarray:
302
+ """Shrink a (possibly loose) selection to the badge's bright strokes, so a
303
+ sloppy drag still yields a tight, background-free template that matches
304
+ with high confidence. Uses the same white top-hat as the matcher; returns
305
+ the original crop if no clear strokes are found."""
306
+ try:
307
+ if crop.size == 0 or crop.shape[0] < 8 or crop.shape[1] < 8:
308
+ return crop
309
+ gray = cv2.cvtColor(crop, cv2.COLOR_BGR2GRAY)
310
+ kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (21, 21))
311
+ top = cv2.morphologyEx(gray, cv2.MORPH_TOPHAT, kernel)
312
+ _ret, strokes = cv2.threshold(top, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
313
+ ys, xs = np.where(strokes > 0)
314
+ if xs.size < 20: # not enough signal — keep the user's box
315
+ return crop
316
+ m = 6 # small margin around the strokes
317
+ x0 = max(0, int(xs.min()) - m)
318
+ y0 = max(0, int(ys.min()) - m)
319
+ x1 = min(crop.shape[1], int(xs.max()) + m + 1)
320
+ y1 = min(crop.shape[0], int(ys.max()) + m + 1)
321
+ tight = crop[y0:y1, x0:x1]
322
+ if tight.shape[0] < 8 or tight.shape[1] < 20:
323
+ return crop
324
+ print(
325
+ f"[display] tightened selection {crop.shape[1]}x{crop.shape[0]} "
326
+ f"-> {tight.shape[1]}x{tight.shape[0]} (badge strokes)",
327
+ flush=True,
328
+ )
329
+ return tight
330
+ except Exception: # noqa: BLE001 — tightening is best-effort
331
+ return crop
332
+
333
+
334
+ def default_watermark_template_path() -> Path:
335
+ """Canonical location for the captured watermark template PNG."""
336
+ from platformdirs import user_config_dir
337
+
338
+ from .config import APP_NAME
339
+
340
+ return Path(user_config_dir(APP_NAME)) / "watermarks" / "watermark.png"
341
+
342
+
343
+ def default_snapshot_path() -> Path:
344
+ ts = time.strftime("%Y%m%d-%H%M%S")
345
+ return Path.cwd() / "snapshots" / f"swap-{ts}.jpg"
346
+
347
+
348
+ def default_recording_path() -> Path:
349
+ ts = time.strftime("%Y%m%d-%H%M%S")
350
+ return Path.cwd() / "recordings" / f"swap-{ts}.mp4"