Implement WGC multi-monitor simultaneous capture support
Some checks failed
Validate / validate (push) Failing after 9s

- Refactored WGC engine to maintain separate capture instances per monitor
- Each monitor gets dedicated instance, control, frame storage, and events
- Supports simultaneous capture from multiple monitors using same template
- Fixed template test endpoint to avoid redundant monitor 0 initialization
- Removed monitor_index from WGC template configuration (monitor-agnostic)

This enables using the same WGC template for multiple devices capturing
from different monitors without conflicts or unexpected borders.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-09 19:12:21 +03:00
parent c371e07e81
commit 5ce4dba925
2 changed files with 533 additions and 96 deletions

View File

@@ -40,57 +40,70 @@ class WGCEngine(CaptureEngine):
Args:
config: Engine configuration
- monitor_index (int): Monitor index (default: 0)
- capture_cursor (bool): Include cursor in capture (default: False)
- draw_border (bool): Draw border around capture (default: False)
Note: monitor_index is NOT in config - WGC maintains separate instances per monitor
to support simultaneous capture from multiple monitors.
"""
super().__init__(config)
self._wgc = None
self._capture_instance = None
self._capture_control = None
self._latest_frame = None
# Per-monitor capture instances: {monitor_index: (instance, control, frame, frame_event)}
self._monitor_captures = {}
self._frame_lock = threading.Lock()
self._frame_event = threading.Event()
self._closed_event = threading.Event() # Signals when capture session is closed
def initialize(self) -> None:
"""Initialize WGC capture.
def initialize(self, monitor_index: int = 0) -> None:
"""Initialize WGC capture for a specific monitor.
Maintains separate capture instances per monitor to support simultaneous
capture from multiple monitors.
Args:
monitor_index: Monitor index to capture (0-based)
Raises:
RuntimeError: If windows-capture not installed or initialization fails
"""
try:
import windows_capture
self._wgc = windows_capture
except ImportError:
raise RuntimeError(
"windows-capture not installed. Install with: pip install windows-capture"
)
# Import windows_capture if not already imported
if self._wgc is None:
try:
import windows_capture
self._wgc = windows_capture
except ImportError:
raise RuntimeError(
"windows-capture not installed. Install with: pip install windows-capture"
)
# Clear events for fresh initialization
self._frame_event.clear()
self._closed_event.clear()
# Skip if already initialized for this monitor
if monitor_index in self._monitor_captures:
logger.debug(f"WGC already initialized for monitor {monitor_index}")
return
try:
monitor_index = self.config.get("monitor_index", 0)
capture_cursor = self.config.get("capture_cursor", False)
# Note: draw_border is not supported by WGC API on most platforms
# WGC uses 1-based monitor indexing (1, 2, 3...) while we use 0-based (0, 1, 2...)
wgc_monitor_index = monitor_index + 1
# Create per-monitor events and storage
frame_event = threading.Event()
closed_event = threading.Event()
latest_frame = None
# Create capture instance
# Note: draw_border parameter not supported on all platforms
self._capture_instance = self._wgc.WindowsCapture(
capture_instance = self._wgc.WindowsCapture(
cursor_capture=capture_cursor,
monitor_index=wgc_monitor_index,
)
# Define event handlers as local functions first
# Define event handlers as local functions that capture monitor_index
def on_frame_arrived(frame, capture_control):
"""Called when a new frame is captured."""
nonlocal latest_frame
try:
logger.debug("WGC frame callback triggered")
logger.debug(f"WGC frame callback triggered for monitor {monitor_index}")
# Get frame buffer as numpy array
frame_buffer = frame.frame_buffer
@@ -104,97 +117,128 @@ class WGCEngine(CaptureEngine):
# Convert BGRA to RGB
frame_rgb = frame_array[:, :, [2, 1, 0]] # Take BGR channels
# Store the latest frame
# Store the latest frame for this monitor
with self._frame_lock:
self._latest_frame = frame_rgb.copy()
self._frame_event.set()
if monitor_index in self._monitor_captures:
self._monitor_captures[monitor_index]['latest_frame'] = frame_rgb.copy()
self._monitor_captures[monitor_index]['frame_event'].set()
except Exception as e:
logger.error(f"Error processing WGC frame: {e}", exc_info=True)
logger.error(f"Error processing WGC frame for monitor {monitor_index}: {e}", exc_info=True)
def on_closed():
"""Called when capture session is closed."""
logger.debug("WGC capture session closed callback triggered")
logger.debug(f"WGC capture session closed for monitor {monitor_index}")
# Signal that the capture session has fully closed and resources are released
self._closed_event.set()
with self._frame_lock:
if monitor_index in self._monitor_captures:
self._monitor_captures[monitor_index]['closed_event'].set()
# Set handlers directly as attributes
self._capture_instance.frame_handler = on_frame_arrived
self._capture_instance.closed_handler = on_closed
capture_instance.frame_handler = on_frame_arrived
capture_instance.closed_handler = on_closed
# Start capture using free-threaded mode (non-blocking)
# IMPORTANT: start_free_threaded() returns a CaptureControl object for cleanup
logger.debug("Starting WGC capture (free-threaded mode)...")
self._capture_control = self._capture_instance.start_free_threaded()
logger.debug(f"Starting WGC capture for monitor {monitor_index} (free-threaded mode)...")
capture_control = capture_instance.start_free_threaded()
# Store all per-monitor data
self._monitor_captures[monitor_index] = {
'instance': capture_instance,
'control': capture_control,
'latest_frame': None,
'frame_event': frame_event,
'closed_event': closed_event,
}
# Wait for first frame to arrive (with timeout)
logger.debug("Waiting for first WGC frame...")
frame_received = self._frame_event.wait(timeout=5.0)
logger.debug(f"Waiting for first WGC frame from monitor {monitor_index}...")
frame_received = frame_event.wait(timeout=5.0)
if not frame_received or self._latest_frame is None:
if not frame_received or self._monitor_captures[monitor_index]['latest_frame'] is None:
# Cleanup on failure
with self._frame_lock:
if monitor_index in self._monitor_captures:
del self._monitor_captures[monitor_index]
raise RuntimeError(
"WGC capture started but no frames received within 5 seconds. "
f"WGC capture started for monitor {monitor_index} but no frames received within 5 seconds. "
"This may indicate the capture session failed to start or "
"the display is not actively updating."
)
self._initialized = True
logger.info(
f"WGC engine initialized (monitor={monitor_index}, "
f"cursor={capture_cursor})"
)
except Exception as e:
logger.error(f"Failed to initialize WGC: {e}", exc_info=True)
raise RuntimeError(f"Failed to initialize WGC: {e}")
logger.error(f"Failed to initialize WGC for monitor {monitor_index}: {e}", exc_info=True)
raise RuntimeError(f"Failed to initialize WGC for monitor {monitor_index}: {e}")
def cleanup(self) -> None:
"""Cleanup WGC resources."""
"""Cleanup WGC resources for all monitors."""
# Proper cleanup for free-threaded captures:
# 1. Stop capture via CaptureControl.stop() (signals thread to stop)
# 2. Wait for thread to finish using CaptureControl.wait() (blocks until done)
# 3. Delete capture instance (releases COM objects)
# 4. Force garbage collection (ensures COM cleanup)
if self._capture_control:
try:
logger.debug("Stopping WGC capture thread...")
self._capture_control.stop()
with self._frame_lock:
monitors_to_cleanup = list(self._monitor_captures.keys())
logger.debug("Waiting for WGC capture thread to finish...")
# This will block until the capture thread actually finishes
# This is the CORRECT way to wait for cleanup (not a timeout!)
self._capture_control.wait()
logger.debug("WGC capture thread finished successfully")
except Exception as e:
logger.error(f"Error during WGC capture control cleanup: {e}", exc_info=True)
finally:
self._capture_control = None
for monitor_index in monitors_to_cleanup:
logger.debug(f"Cleaning up WGC resources for monitor {monitor_index}...")
# Now that the thread has stopped, delete the capture instance
if self._capture_instance:
try:
logger.debug("Deleting WGC capture instance...")
instance = self._capture_instance
self._capture_instance = None
del instance
logger.debug("WGC capture instance deleted")
except Exception as e:
logger.error(f"Error deleting WGC capture instance: {e}", exc_info=True)
self._capture_instance = None
with self._frame_lock:
if monitor_index not in self._monitor_captures:
continue
monitor_data = self._monitor_captures[monitor_index]
# Stop and wait for capture thread
capture_control = monitor_data.get('control')
if capture_control:
try:
logger.debug(f"Stopping WGC capture thread for monitor {monitor_index}...")
capture_control.stop()
logger.debug(f"Waiting for WGC capture thread to finish (monitor {monitor_index})...")
# This will block until the capture thread actually finishes
capture_control.wait()
logger.debug(f"WGC capture thread finished successfully for monitor {monitor_index}")
except Exception as e:
logger.error(f"Error during WGC capture control cleanup for monitor {monitor_index}: {e}", exc_info=True)
# Delete capture instance
capture_instance = monitor_data.get('instance')
if capture_instance:
try:
logger.debug(f"Deleting WGC capture instance for monitor {monitor_index}...")
del capture_instance
logger.debug(f"WGC capture instance deleted for monitor {monitor_index}")
except Exception as e:
logger.error(f"Error deleting WGC capture instance for monitor {monitor_index}: {e}", exc_info=True)
# Clear events
frame_event = monitor_data.get('frame_event')
if frame_event:
frame_event.clear()
closed_event = monitor_data.get('closed_event')
if closed_event:
closed_event.clear()
# Remove from dictionary
with self._frame_lock:
if monitor_index in self._monitor_captures:
del self._monitor_captures[monitor_index]
logger.info(f"WGC engine cleaned up for monitor {monitor_index}")
# Force garbage collection to release COM objects
logger.debug("Running garbage collection for COM cleanup...")
gc.collect()
logger.debug("Garbage collection completed")
with self._frame_lock:
self._latest_frame = None
self._frame_event.clear()
self._closed_event.clear()
self._initialized = False
logger.info("WGC engine cleaned up")
def get_available_displays(self) -> List[DisplayInfo]:
"""Get list of available displays using MSS.
@@ -237,6 +281,9 @@ class WGCEngine(CaptureEngine):
def capture_display(self, display_index: int) -> ScreenCapture:
"""Capture display using WGC.
WGC dynamically initializes for the requested display if needed.
Supports simultaneous capture from multiple monitors.
Args:
display_index: Index of display to capture (0-based)
@@ -244,32 +291,29 @@ class WGCEngine(CaptureEngine):
ScreenCapture object with image data
Raises:
RuntimeError: If not initialized
ValueError: If display_index doesn't match configured monitor
RuntimeError: If capture fails or no frame available
RuntimeError: If initialization or capture fails
"""
if not self._initialized:
raise RuntimeError("Engine not initialized")
# WGC is configured for a specific monitor
configured_monitor = self.config.get("monitor_index", 0)
if display_index != configured_monitor:
raise ValueError(
f"WGC engine is configured for monitor {configured_monitor}, "
f"cannot capture display {display_index}. Create a new template "
f"with monitor_index={display_index} to capture this display."
)
# Initialize for this monitor if not already initialized
self.initialize(display_index)
try:
# Get the latest frame
# Get the latest frame for this monitor
with self._frame_lock:
if self._latest_frame is None:
if display_index not in self._monitor_captures:
raise RuntimeError(
"No frame available yet. The capture may not have started or "
"the screen hasn't updated. Wait a moment and try again."
f"Monitor {display_index} not initialized. This should not happen."
)
frame = self._latest_frame.copy()
monitor_data = self._monitor_captures[display_index]
latest_frame = monitor_data.get('latest_frame')
if latest_frame is None:
raise RuntimeError(
f"No frame available yet for monitor {display_index}. "
"The capture may not have started or the screen hasn't updated. "
"Wait a moment and try again."
)
frame = latest_frame.copy()
logger.debug(
f"WGC captured display {display_index}: "
@@ -330,11 +374,13 @@ class WGCEngine(CaptureEngine):
def get_default_config(cls) -> Dict[str, Any]:
"""Get default WGC configuration.
Note: monitor_index is NOT in config - WGC dynamically initializes
for the requested monitor at capture time.
Returns:
Default config dict with WGC options
"""
return {
"monitor_index": 0, # Primary monitor
"capture_cursor": False, # Exclude cursor (hardware exclusion)
"draw_border": False, # Don't draw border around capture
}