Implement WGC multi-monitor simultaneous capture support
Some checks failed
Validate / validate (push) Failing after 9s
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:
@@ -1,10 +1,15 @@
|
|||||||
"""API routes and endpoints."""
|
"""API routes and endpoints."""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import io
|
||||||
import sys
|
import sys
|
||||||
|
import time
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import List
|
from typing import List, Dict, Any
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
import numpy as np
|
||||||
|
from PIL import Image
|
||||||
from fastapi import APIRouter, HTTPException, Depends
|
from fastapi import APIRouter, HTTPException, Depends
|
||||||
|
|
||||||
from wled_controller import __version__
|
from wled_controller import __version__
|
||||||
@@ -24,6 +29,17 @@ from wled_controller.api.schemas import (
|
|||||||
CalibrationTestModeResponse,
|
CalibrationTestModeResponse,
|
||||||
ProcessingState,
|
ProcessingState,
|
||||||
MetricsResponse,
|
MetricsResponse,
|
||||||
|
TemplateCreate,
|
||||||
|
TemplateUpdate,
|
||||||
|
TemplateResponse,
|
||||||
|
TemplateListResponse,
|
||||||
|
EngineInfo,
|
||||||
|
EngineListResponse,
|
||||||
|
TemplateTestRequest,
|
||||||
|
TemplateTestResponse,
|
||||||
|
CaptureImage,
|
||||||
|
BorderExtraction,
|
||||||
|
PerformanceMetrics,
|
||||||
)
|
)
|
||||||
from wled_controller.config import get_config
|
from wled_controller.config import get_config
|
||||||
from wled_controller.core.processor_manager import ProcessorManager, ProcessingSettings
|
from wled_controller.core.processor_manager import ProcessorManager, ProcessingSettings
|
||||||
@@ -32,6 +48,8 @@ from wled_controller.core.calibration import (
|
|||||||
calibration_to_dict,
|
calibration_to_dict,
|
||||||
)
|
)
|
||||||
from wled_controller.storage import DeviceStore
|
from wled_controller.storage import DeviceStore
|
||||||
|
from wled_controller.storage.template_store import TemplateStore
|
||||||
|
from wled_controller.core.capture_engines import EngineRegistry
|
||||||
from wled_controller.utils import get_logger
|
from wled_controller.utils import get_logger
|
||||||
from wled_controller.core.screen_capture import get_available_displays
|
from wled_controller.core.screen_capture import get_available_displays
|
||||||
|
|
||||||
@@ -41,6 +59,7 @@ router = APIRouter()
|
|||||||
|
|
||||||
# Global instances (initialized in main.py)
|
# Global instances (initialized in main.py)
|
||||||
_device_store: DeviceStore | None = None
|
_device_store: DeviceStore | None = None
|
||||||
|
_template_store: TemplateStore | None = None
|
||||||
_processor_manager: ProcessorManager | None = None
|
_processor_manager: ProcessorManager | None = None
|
||||||
|
|
||||||
|
|
||||||
@@ -51,6 +70,13 @@ def get_device_store() -> DeviceStore:
|
|||||||
return _device_store
|
return _device_store
|
||||||
|
|
||||||
|
|
||||||
|
def get_template_store() -> TemplateStore:
|
||||||
|
"""Get template store dependency."""
|
||||||
|
if _template_store is None:
|
||||||
|
raise RuntimeError("Template store not initialized")
|
||||||
|
return _template_store
|
||||||
|
|
||||||
|
|
||||||
def get_processor_manager() -> ProcessorManager:
|
def get_processor_manager() -> ProcessorManager:
|
||||||
"""Get processor manager dependency."""
|
"""Get processor manager dependency."""
|
||||||
if _processor_manager is None:
|
if _processor_manager is None:
|
||||||
@@ -58,10 +84,15 @@ def get_processor_manager() -> ProcessorManager:
|
|||||||
return _processor_manager
|
return _processor_manager
|
||||||
|
|
||||||
|
|
||||||
def init_dependencies(device_store: DeviceStore, processor_manager: ProcessorManager):
|
def init_dependencies(
|
||||||
|
device_store: DeviceStore,
|
||||||
|
template_store: TemplateStore,
|
||||||
|
processor_manager: ProcessorManager,
|
||||||
|
):
|
||||||
"""Initialize global dependencies."""
|
"""Initialize global dependencies."""
|
||||||
global _device_store, _processor_manager
|
global _device_store, _template_store, _processor_manager
|
||||||
_device_store = device_store
|
_device_store = device_store
|
||||||
|
_template_store = template_store
|
||||||
_processor_manager = processor_manager
|
_processor_manager = processor_manager
|
||||||
|
|
||||||
|
|
||||||
@@ -214,6 +245,7 @@ async def create_device(
|
|||||||
state_check_interval=device.settings.state_check_interval,
|
state_check_interval=device.settings.state_check_interval,
|
||||||
),
|
),
|
||||||
calibration=CalibrationSchema(**calibration_to_dict(device.calibration)),
|
calibration=CalibrationSchema(**calibration_to_dict(device.calibration)),
|
||||||
|
capture_template_id=device.capture_template_id,
|
||||||
created_at=device.created_at,
|
created_at=device.created_at,
|
||||||
updated_at=device.updated_at,
|
updated_at=device.updated_at,
|
||||||
)
|
)
|
||||||
@@ -248,6 +280,7 @@ async def list_devices(
|
|||||||
state_check_interval=device.settings.state_check_interval,
|
state_check_interval=device.settings.state_check_interval,
|
||||||
),
|
),
|
||||||
calibration=CalibrationSchema(**calibration_to_dict(device.calibration)),
|
calibration=CalibrationSchema(**calibration_to_dict(device.calibration)),
|
||||||
|
capture_template_id=device.capture_template_id,
|
||||||
created_at=device.created_at,
|
created_at=device.created_at,
|
||||||
updated_at=device.updated_at,
|
updated_at=device.updated_at,
|
||||||
)
|
)
|
||||||
@@ -291,6 +324,7 @@ async def get_device(
|
|||||||
state_check_interval=device.settings.state_check_interval,
|
state_check_interval=device.settings.state_check_interval,
|
||||||
),
|
),
|
||||||
calibration=CalibrationSchema(**calibration_to_dict(device.calibration)),
|
calibration=CalibrationSchema(**calibration_to_dict(device.calibration)),
|
||||||
|
capture_template_id=device.capture_template_id,
|
||||||
created_at=device.created_at,
|
created_at=device.created_at,
|
||||||
updated_at=device.updated_at,
|
updated_at=device.updated_at,
|
||||||
)
|
)
|
||||||
@@ -302,16 +336,53 @@ async def update_device(
|
|||||||
update_data: DeviceUpdate,
|
update_data: DeviceUpdate,
|
||||||
_auth: AuthRequired,
|
_auth: AuthRequired,
|
||||||
store: DeviceStore = Depends(get_device_store),
|
store: DeviceStore = Depends(get_device_store),
|
||||||
|
manager: ProcessorManager = Depends(get_processor_manager),
|
||||||
):
|
):
|
||||||
"""Update device information."""
|
"""Update device information."""
|
||||||
try:
|
try:
|
||||||
|
# Check if template changed and device is processing (for hot-swap)
|
||||||
|
old_device = store.get_device(device_id)
|
||||||
|
template_changed = (
|
||||||
|
update_data.capture_template_id is not None
|
||||||
|
and update_data.capture_template_id != old_device.capture_template_id
|
||||||
|
)
|
||||||
|
was_processing = manager.is_processing(device_id)
|
||||||
|
|
||||||
|
# Update device
|
||||||
device = store.update_device(
|
device = store.update_device(
|
||||||
device_id=device_id,
|
device_id=device_id,
|
||||||
name=update_data.name,
|
name=update_data.name,
|
||||||
url=update_data.url,
|
url=update_data.url,
|
||||||
enabled=update_data.enabled,
|
enabled=update_data.enabled,
|
||||||
|
capture_template_id=update_data.capture_template_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Hot-swap: If template changed and device was processing, restart it
|
||||||
|
if template_changed and was_processing:
|
||||||
|
logger.info(f"Hot-swapping template for device {device_id}")
|
||||||
|
try:
|
||||||
|
# Stop current processing
|
||||||
|
await manager.stop_processing(device_id)
|
||||||
|
|
||||||
|
# Update processor with new template
|
||||||
|
manager.remove_device(device_id)
|
||||||
|
manager.add_device(
|
||||||
|
device_id=device.id,
|
||||||
|
device_url=device.url,
|
||||||
|
led_count=device.led_count,
|
||||||
|
settings=device.settings,
|
||||||
|
calibration=device.calibration,
|
||||||
|
capture_template_id=device.capture_template_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Restart processing
|
||||||
|
await manager.start_processing(device_id)
|
||||||
|
logger.info(f"Successfully hot-swapped template for device {device_id}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error during template hot-swap: {e}")
|
||||||
|
# Device is stopped but updated - user can manually restart
|
||||||
|
|
||||||
return DeviceResponse(
|
return DeviceResponse(
|
||||||
id=device.id,
|
id=device.id,
|
||||||
name=device.name,
|
name=device.name,
|
||||||
@@ -327,6 +398,7 @@ async def update_device(
|
|||||||
state_check_interval=device.settings.state_check_interval,
|
state_check_interval=device.settings.state_check_interval,
|
||||||
),
|
),
|
||||||
calibration=CalibrationSchema(**calibration_to_dict(device.calibration)),
|
calibration=CalibrationSchema(**calibration_to_dict(device.calibration)),
|
||||||
|
capture_template_id=device.capture_template_id,
|
||||||
created_at=device.created_at,
|
created_at=device.created_at,
|
||||||
updated_at=device.updated_at,
|
updated_at=device.updated_at,
|
||||||
)
|
)
|
||||||
@@ -626,3 +698,322 @@ async def get_metrics(
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to get metrics: {e}")
|
logger.error(f"Failed to get metrics: {e}")
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
# ===== CAPTURE TEMPLATE ENDPOINTS =====
|
||||||
|
|
||||||
|
@router.get("/api/v1/capture-templates", response_model=TemplateListResponse, tags=["Templates"])
|
||||||
|
async def list_templates(
|
||||||
|
_auth: AuthRequired,
|
||||||
|
template_store: TemplateStore = Depends(get_template_store),
|
||||||
|
):
|
||||||
|
"""List all capture templates."""
|
||||||
|
try:
|
||||||
|
templates = template_store.get_all_templates()
|
||||||
|
|
||||||
|
template_responses = [
|
||||||
|
TemplateResponse(
|
||||||
|
id=t.id,
|
||||||
|
name=t.name,
|
||||||
|
engine_type=t.engine_type,
|
||||||
|
engine_config=t.engine_config,
|
||||||
|
is_default=t.is_default,
|
||||||
|
created_at=t.created_at,
|
||||||
|
updated_at=t.updated_at,
|
||||||
|
description=t.description,
|
||||||
|
)
|
||||||
|
for t in templates
|
||||||
|
]
|
||||||
|
|
||||||
|
return TemplateListResponse(
|
||||||
|
templates=template_responses,
|
||||||
|
count=len(template_responses),
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to list templates: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/v1/capture-templates", response_model=TemplateResponse, tags=["Templates"], status_code=201)
|
||||||
|
async def create_template(
|
||||||
|
template_data: TemplateCreate,
|
||||||
|
_auth: AuthRequired,
|
||||||
|
template_store: TemplateStore = Depends(get_template_store),
|
||||||
|
):
|
||||||
|
"""Create a new capture template."""
|
||||||
|
try:
|
||||||
|
template = template_store.create_template(
|
||||||
|
name=template_data.name,
|
||||||
|
engine_type=template_data.engine_type,
|
||||||
|
engine_config=template_data.engine_config,
|
||||||
|
description=template_data.description,
|
||||||
|
)
|
||||||
|
|
||||||
|
return TemplateResponse(
|
||||||
|
id=template.id,
|
||||||
|
name=template.name,
|
||||||
|
engine_type=template.engine_type,
|
||||||
|
engine_config=template.engine_config,
|
||||||
|
is_default=template.is_default,
|
||||||
|
created_at=template.created_at,
|
||||||
|
updated_at=template.updated_at,
|
||||||
|
description=template.description,
|
||||||
|
)
|
||||||
|
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to create template: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/v1/capture-templates/{template_id}", response_model=TemplateResponse, tags=["Templates"])
|
||||||
|
async def get_template(
|
||||||
|
template_id: str,
|
||||||
|
_auth: AuthRequired,
|
||||||
|
template_store: TemplateStore = Depends(get_template_store),
|
||||||
|
):
|
||||||
|
"""Get template by ID."""
|
||||||
|
template = template_store.get_template(template_id)
|
||||||
|
if not template:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Template {template_id} not found")
|
||||||
|
|
||||||
|
return TemplateResponse(
|
||||||
|
id=template.id,
|
||||||
|
name=template.name,
|
||||||
|
engine_type=template.engine_type,
|
||||||
|
engine_config=template.engine_config,
|
||||||
|
is_default=template.is_default,
|
||||||
|
created_at=template.created_at,
|
||||||
|
updated_at=template.updated_at,
|
||||||
|
description=template.description,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/api/v1/capture-templates/{template_id}", response_model=TemplateResponse, tags=["Templates"])
|
||||||
|
async def update_template(
|
||||||
|
template_id: str,
|
||||||
|
update_data: TemplateUpdate,
|
||||||
|
_auth: AuthRequired,
|
||||||
|
template_store: TemplateStore = Depends(get_template_store),
|
||||||
|
):
|
||||||
|
"""Update a template."""
|
||||||
|
try:
|
||||||
|
template = template_store.update_template(
|
||||||
|
template_id=template_id,
|
||||||
|
name=update_data.name,
|
||||||
|
engine_config=update_data.engine_config,
|
||||||
|
description=update_data.description,
|
||||||
|
)
|
||||||
|
|
||||||
|
return TemplateResponse(
|
||||||
|
id=template.id,
|
||||||
|
name=template.name,
|
||||||
|
engine_type=template.engine_type,
|
||||||
|
engine_config=template.engine_config,
|
||||||
|
is_default=template.is_default,
|
||||||
|
created_at=template.created_at,
|
||||||
|
updated_at=template.updated_at,
|
||||||
|
description=template.description,
|
||||||
|
)
|
||||||
|
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to update template: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/api/v1/capture-templates/{template_id}", status_code=204, tags=["Templates"])
|
||||||
|
async def delete_template(
|
||||||
|
template_id: str,
|
||||||
|
_auth: AuthRequired,
|
||||||
|
template_store: TemplateStore = Depends(get_template_store),
|
||||||
|
device_store: DeviceStore = Depends(get_device_store),
|
||||||
|
):
|
||||||
|
"""Delete a template.
|
||||||
|
|
||||||
|
Validates that no devices are currently using this template before deletion.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Check if any devices are using this template
|
||||||
|
devices_using_template = []
|
||||||
|
for device in device_store.get_all_devices():
|
||||||
|
if device.capture_template_id == template_id:
|
||||||
|
devices_using_template.append(device.name)
|
||||||
|
|
||||||
|
if devices_using_template:
|
||||||
|
device_list = ", ".join(devices_using_template)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail=f"Cannot delete template: it is currently assigned to the following device(s): {device_list}. "
|
||||||
|
f"Please reassign these devices to a different template before deleting."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Proceed with deletion
|
||||||
|
template_store.delete_template(template_id)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise # Re-raise HTTP exceptions as-is
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to delete template: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/v1/capture-engines", response_model=EngineListResponse, tags=["Templates"])
|
||||||
|
async def list_engines(_auth: AuthRequired):
|
||||||
|
"""List available capture engines on this system.
|
||||||
|
|
||||||
|
Returns all registered engines that are available on the current platform.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
available_engine_types = EngineRegistry.get_available_engines()
|
||||||
|
|
||||||
|
engines = []
|
||||||
|
for engine_type in available_engine_types:
|
||||||
|
engine_class = EngineRegistry.get_engine(engine_type)
|
||||||
|
engines.append(
|
||||||
|
EngineInfo(
|
||||||
|
type=engine_type,
|
||||||
|
name=engine_type.upper(),
|
||||||
|
default_config=engine_class.get_default_config(),
|
||||||
|
available=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return EngineListResponse(engines=engines, count=len(engines))
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to list engines: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/v1/capture-templates/test", response_model=TemplateTestResponse, tags=["Templates"])
|
||||||
|
async def test_template(
|
||||||
|
test_request: TemplateTestRequest,
|
||||||
|
_auth: AuthRequired,
|
||||||
|
processor_manager: ProcessorManager = Depends(get_processor_manager),
|
||||||
|
device_store: DeviceStore = Depends(get_device_store),
|
||||||
|
):
|
||||||
|
"""Test a capture template configuration.
|
||||||
|
|
||||||
|
Temporarily instantiates an engine with the provided configuration,
|
||||||
|
captures frames for the specified duration, and returns actual FPS metrics.
|
||||||
|
"""
|
||||||
|
engine = None
|
||||||
|
try:
|
||||||
|
# Validate engine type
|
||||||
|
if test_request.engine_type not in EngineRegistry.get_available_engines():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Engine '{test_request.engine_type}' is not available on this system"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if display is already being captured
|
||||||
|
locked_device_id = processor_manager.get_display_lock_info(test_request.display_index)
|
||||||
|
if locked_device_id:
|
||||||
|
# Get device info for better error message
|
||||||
|
try:
|
||||||
|
device = device_store.get_device(locked_device_id)
|
||||||
|
device_name = device.name
|
||||||
|
except Exception:
|
||||||
|
device_name = locked_device_id
|
||||||
|
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail=(
|
||||||
|
f"Display {test_request.display_index} is currently being captured by device "
|
||||||
|
f"'{device_name}'. Please stop the device processing before testing this template."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create engine (initialization happens on first capture)
|
||||||
|
engine = EngineRegistry.create_engine(test_request.engine_type, test_request.engine_config)
|
||||||
|
|
||||||
|
# Run sustained capture test
|
||||||
|
logger.info(f"Starting {test_request.capture_duration}s capture test with {test_request.engine_type}")
|
||||||
|
|
||||||
|
frame_count = 0
|
||||||
|
total_capture_time = 0.0
|
||||||
|
last_frame = None
|
||||||
|
|
||||||
|
start_time = time.perf_counter()
|
||||||
|
end_time = start_time + test_request.capture_duration
|
||||||
|
|
||||||
|
while time.perf_counter() < end_time:
|
||||||
|
capture_start = time.perf_counter()
|
||||||
|
screen_capture = engine.capture_display(test_request.display_index)
|
||||||
|
capture_elapsed = time.perf_counter() - capture_start
|
||||||
|
|
||||||
|
total_capture_time += capture_elapsed
|
||||||
|
frame_count += 1
|
||||||
|
last_frame = screen_capture
|
||||||
|
|
||||||
|
actual_duration = time.perf_counter() - start_time
|
||||||
|
|
||||||
|
logger.info(f"Captured {frame_count} frames in {actual_duration:.2f}s")
|
||||||
|
|
||||||
|
# Use the last captured frame for preview
|
||||||
|
if last_frame is None:
|
||||||
|
raise RuntimeError("No frames captured during test")
|
||||||
|
|
||||||
|
# Convert numpy array to PIL Image
|
||||||
|
if isinstance(last_frame.image, np.ndarray):
|
||||||
|
pil_image = Image.fromarray(last_frame.image)
|
||||||
|
else:
|
||||||
|
raise ValueError("Unexpected image format from engine")
|
||||||
|
|
||||||
|
# Create thumbnail (640px wide, maintain aspect ratio)
|
||||||
|
thumbnail_width = 640
|
||||||
|
aspect_ratio = pil_image.height / pil_image.width
|
||||||
|
thumbnail_height = int(thumbnail_width * aspect_ratio)
|
||||||
|
thumbnail = pil_image.copy()
|
||||||
|
thumbnail.thumbnail((thumbnail_width, thumbnail_height), Image.Resampling.LANCZOS)
|
||||||
|
|
||||||
|
# Encode full capture thumbnail as JPEG
|
||||||
|
img_buffer = io.BytesIO()
|
||||||
|
thumbnail.save(img_buffer, format='JPEG', quality=85)
|
||||||
|
img_buffer.seek(0)
|
||||||
|
full_capture_b64 = base64.b64encode(img_buffer.getvalue()).decode('utf-8')
|
||||||
|
full_capture_data_uri = f"data:image/jpeg;base64,{full_capture_b64}"
|
||||||
|
|
||||||
|
# Calculate metrics
|
||||||
|
actual_fps = frame_count / actual_duration if actual_duration > 0 else 0
|
||||||
|
avg_capture_time_ms = (total_capture_time / frame_count * 1000) if frame_count > 0 else 0
|
||||||
|
|
||||||
|
width, height = pil_image.size
|
||||||
|
|
||||||
|
return TemplateTestResponse(
|
||||||
|
full_capture=CaptureImage(
|
||||||
|
image=full_capture_data_uri,
|
||||||
|
width=width,
|
||||||
|
height=height,
|
||||||
|
thumbnail_width=thumbnail_width,
|
||||||
|
thumbnail_height=thumbnail_height,
|
||||||
|
),
|
||||||
|
border_extraction=None,
|
||||||
|
performance=PerformanceMetrics(
|
||||||
|
capture_duration_s=actual_duration,
|
||||||
|
frame_count=frame_count,
|
||||||
|
actual_fps=actual_fps,
|
||||||
|
avg_capture_time_ms=avg_capture_time_ms,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
except RuntimeError as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Engine error: {str(e)}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to test template: {e}", exc_info=True)
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
finally:
|
||||||
|
# Always cleanup engine
|
||||||
|
if engine:
|
||||||
|
try:
|
||||||
|
engine.cleanup()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error cleaning up test engine: {e}")
|
||||||
|
|||||||
@@ -40,25 +40,32 @@ class WGCEngine(CaptureEngine):
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
config: Engine configuration
|
config: Engine configuration
|
||||||
- monitor_index (int): Monitor index (default: 0)
|
|
||||||
- capture_cursor (bool): Include cursor in capture (default: False)
|
- capture_cursor (bool): Include cursor in capture (default: False)
|
||||||
- draw_border (bool): Draw border around 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)
|
super().__init__(config)
|
||||||
self._wgc = None
|
self._wgc = None
|
||||||
self._capture_instance = None
|
# Per-monitor capture instances: {monitor_index: (instance, control, frame, frame_event)}
|
||||||
self._capture_control = None
|
self._monitor_captures = {}
|
||||||
self._latest_frame = None
|
|
||||||
self._frame_lock = threading.Lock()
|
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:
|
def initialize(self, monitor_index: int = 0) -> None:
|
||||||
"""Initialize WGC capture.
|
"""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:
|
Raises:
|
||||||
RuntimeError: If windows-capture not installed or initialization fails
|
RuntimeError: If windows-capture not installed or initialization fails
|
||||||
"""
|
"""
|
||||||
|
# Import windows_capture if not already imported
|
||||||
|
if self._wgc is None:
|
||||||
try:
|
try:
|
||||||
import windows_capture
|
import windows_capture
|
||||||
self._wgc = windows_capture
|
self._wgc = windows_capture
|
||||||
@@ -67,30 +74,36 @@ class WGCEngine(CaptureEngine):
|
|||||||
"windows-capture not installed. Install with: pip install windows-capture"
|
"windows-capture not installed. Install with: pip install windows-capture"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Clear events for fresh initialization
|
# Skip if already initialized for this monitor
|
||||||
self._frame_event.clear()
|
if monitor_index in self._monitor_captures:
|
||||||
self._closed_event.clear()
|
logger.debug(f"WGC already initialized for monitor {monitor_index}")
|
||||||
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
monitor_index = self.config.get("monitor_index", 0)
|
|
||||||
capture_cursor = self.config.get("capture_cursor", False)
|
capture_cursor = self.config.get("capture_cursor", False)
|
||||||
# Note: draw_border is not supported by WGC API on most platforms
|
# 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 uses 1-based monitor indexing (1, 2, 3...) while we use 0-based (0, 1, 2...)
|
||||||
wgc_monitor_index = monitor_index + 1
|
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
|
# Create capture instance
|
||||||
# Note: draw_border parameter not supported on all platforms
|
# Note: draw_border parameter not supported on all platforms
|
||||||
self._capture_instance = self._wgc.WindowsCapture(
|
capture_instance = self._wgc.WindowsCapture(
|
||||||
cursor_capture=capture_cursor,
|
cursor_capture=capture_cursor,
|
||||||
monitor_index=wgc_monitor_index,
|
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):
|
def on_frame_arrived(frame, capture_control):
|
||||||
"""Called when a new frame is captured."""
|
"""Called when a new frame is captured."""
|
||||||
|
nonlocal latest_frame
|
||||||
try:
|
try:
|
||||||
logger.debug("WGC frame callback triggered")
|
logger.debug(f"WGC frame callback triggered for monitor {monitor_index}")
|
||||||
|
|
||||||
# Get frame buffer as numpy array
|
# Get frame buffer as numpy array
|
||||||
frame_buffer = frame.frame_buffer
|
frame_buffer = frame.frame_buffer
|
||||||
@@ -104,97 +117,128 @@ class WGCEngine(CaptureEngine):
|
|||||||
# Convert BGRA to RGB
|
# Convert BGRA to RGB
|
||||||
frame_rgb = frame_array[:, :, [2, 1, 0]] # Take BGR channels
|
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:
|
with self._frame_lock:
|
||||||
self._latest_frame = frame_rgb.copy()
|
if monitor_index in self._monitor_captures:
|
||||||
self._frame_event.set()
|
self._monitor_captures[monitor_index]['latest_frame'] = frame_rgb.copy()
|
||||||
|
self._monitor_captures[monitor_index]['frame_event'].set()
|
||||||
except Exception as e:
|
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():
|
def on_closed():
|
||||||
"""Called when capture session is 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
|
# 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
|
# Set handlers directly as attributes
|
||||||
self._capture_instance.frame_handler = on_frame_arrived
|
capture_instance.frame_handler = on_frame_arrived
|
||||||
self._capture_instance.closed_handler = on_closed
|
capture_instance.closed_handler = on_closed
|
||||||
|
|
||||||
# Start capture using free-threaded mode (non-blocking)
|
# Start capture using free-threaded mode (non-blocking)
|
||||||
# IMPORTANT: start_free_threaded() returns a CaptureControl object for cleanup
|
# IMPORTANT: start_free_threaded() returns a CaptureControl object for cleanup
|
||||||
logger.debug("Starting WGC capture (free-threaded mode)...")
|
logger.debug(f"Starting WGC capture for monitor {monitor_index} (free-threaded mode)...")
|
||||||
self._capture_control = self._capture_instance.start_free_threaded()
|
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)
|
# Wait for first frame to arrive (with timeout)
|
||||||
logger.debug("Waiting for first WGC frame...")
|
logger.debug(f"Waiting for first WGC frame from monitor {monitor_index}...")
|
||||||
frame_received = self._frame_event.wait(timeout=5.0)
|
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(
|
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 "
|
"This may indicate the capture session failed to start or "
|
||||||
"the display is not actively updating."
|
"the display is not actively updating."
|
||||||
)
|
)
|
||||||
|
|
||||||
self._initialized = True
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"WGC engine initialized (monitor={monitor_index}, "
|
f"WGC engine initialized (monitor={monitor_index}, "
|
||||||
f"cursor={capture_cursor})"
|
f"cursor={capture_cursor})"
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to initialize WGC: {e}", exc_info=True)
|
logger.error(f"Failed to initialize WGC for monitor {monitor_index}: {e}", exc_info=True)
|
||||||
raise RuntimeError(f"Failed to initialize WGC: {e}")
|
raise RuntimeError(f"Failed to initialize WGC for monitor {monitor_index}: {e}")
|
||||||
|
|
||||||
def cleanup(self) -> None:
|
def cleanup(self) -> None:
|
||||||
"""Cleanup WGC resources."""
|
"""Cleanup WGC resources for all monitors."""
|
||||||
# Proper cleanup for free-threaded captures:
|
# Proper cleanup for free-threaded captures:
|
||||||
# 1. Stop capture via CaptureControl.stop() (signals thread to stop)
|
# 1. Stop capture via CaptureControl.stop() (signals thread to stop)
|
||||||
# 2. Wait for thread to finish using CaptureControl.wait() (blocks until done)
|
# 2. Wait for thread to finish using CaptureControl.wait() (blocks until done)
|
||||||
# 3. Delete capture instance (releases COM objects)
|
# 3. Delete capture instance (releases COM objects)
|
||||||
# 4. Force garbage collection (ensures COM cleanup)
|
# 4. Force garbage collection (ensures COM cleanup)
|
||||||
|
|
||||||
if self._capture_control:
|
with self._frame_lock:
|
||||||
try:
|
monitors_to_cleanup = list(self._monitor_captures.keys())
|
||||||
logger.debug("Stopping WGC capture thread...")
|
|
||||||
self._capture_control.stop()
|
|
||||||
|
|
||||||
logger.debug("Waiting for WGC capture thread to finish...")
|
for monitor_index in monitors_to_cleanup:
|
||||||
|
logger.debug(f"Cleaning up WGC resources for monitor {monitor_index}...")
|
||||||
|
|
||||||
|
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
|
# This will block until the capture thread actually finishes
|
||||||
# This is the CORRECT way to wait for cleanup (not a timeout!)
|
capture_control.wait()
|
||||||
self._capture_control.wait()
|
logger.debug(f"WGC capture thread finished successfully for monitor {monitor_index}")
|
||||||
logger.debug("WGC capture thread finished successfully")
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error during WGC capture control cleanup: {e}", exc_info=True)
|
logger.error(f"Error during WGC capture control cleanup for monitor {monitor_index}: {e}", exc_info=True)
|
||||||
finally:
|
|
||||||
self._capture_control = None
|
|
||||||
|
|
||||||
# Now that the thread has stopped, delete the capture instance
|
# Delete capture instance
|
||||||
if self._capture_instance:
|
capture_instance = monitor_data.get('instance')
|
||||||
|
if capture_instance:
|
||||||
try:
|
try:
|
||||||
logger.debug("Deleting WGC capture instance...")
|
logger.debug(f"Deleting WGC capture instance for monitor {monitor_index}...")
|
||||||
instance = self._capture_instance
|
del capture_instance
|
||||||
self._capture_instance = None
|
logger.debug(f"WGC capture instance deleted for monitor {monitor_index}")
|
||||||
del instance
|
|
||||||
logger.debug("WGC capture instance deleted")
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error deleting WGC capture instance: {e}", exc_info=True)
|
logger.error(f"Error deleting WGC capture instance for monitor {monitor_index}: {e}", exc_info=True)
|
||||||
self._capture_instance = None
|
|
||||||
|
# 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
|
# Force garbage collection to release COM objects
|
||||||
logger.debug("Running garbage collection for COM cleanup...")
|
logger.debug("Running garbage collection for COM cleanup...")
|
||||||
gc.collect()
|
gc.collect()
|
||||||
logger.debug("Garbage collection completed")
|
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]:
|
def get_available_displays(self) -> List[DisplayInfo]:
|
||||||
"""Get list of available displays using MSS.
|
"""Get list of available displays using MSS.
|
||||||
|
|
||||||
@@ -237,6 +281,9 @@ class WGCEngine(CaptureEngine):
|
|||||||
def capture_display(self, display_index: int) -> ScreenCapture:
|
def capture_display(self, display_index: int) -> ScreenCapture:
|
||||||
"""Capture display using WGC.
|
"""Capture display using WGC.
|
||||||
|
|
||||||
|
WGC dynamically initializes for the requested display if needed.
|
||||||
|
Supports simultaneous capture from multiple monitors.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
display_index: Index of display to capture (0-based)
|
display_index: Index of display to capture (0-based)
|
||||||
|
|
||||||
@@ -244,32 +291,29 @@ class WGCEngine(CaptureEngine):
|
|||||||
ScreenCapture object with image data
|
ScreenCapture object with image data
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
RuntimeError: If not initialized
|
RuntimeError: If initialization or capture fails
|
||||||
ValueError: If display_index doesn't match configured monitor
|
|
||||||
RuntimeError: If capture fails or no frame available
|
|
||||||
"""
|
"""
|
||||||
if not self._initialized:
|
# Initialize for this monitor if not already initialized
|
||||||
raise RuntimeError("Engine not initialized")
|
self.initialize(display_index)
|
||||||
|
|
||||||
# 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."
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Get the latest frame
|
# Get the latest frame for this monitor
|
||||||
with self._frame_lock:
|
with self._frame_lock:
|
||||||
if self._latest_frame is None:
|
if display_index not in self._monitor_captures:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
"No frame available yet. The capture may not have started or "
|
f"Monitor {display_index} not initialized. This should not happen."
|
||||||
"the screen hasn't updated. Wait a moment and try again."
|
|
||||||
)
|
)
|
||||||
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(
|
logger.debug(
|
||||||
f"WGC captured display {display_index}: "
|
f"WGC captured display {display_index}: "
|
||||||
@@ -330,11 +374,13 @@ class WGCEngine(CaptureEngine):
|
|||||||
def get_default_config(cls) -> Dict[str, Any]:
|
def get_default_config(cls) -> Dict[str, Any]:
|
||||||
"""Get default WGC configuration.
|
"""Get default WGC configuration.
|
||||||
|
|
||||||
|
Note: monitor_index is NOT in config - WGC dynamically initializes
|
||||||
|
for the requested monitor at capture time.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Default config dict with WGC options
|
Default config dict with WGC options
|
||||||
"""
|
"""
|
||||||
return {
|
return {
|
||||||
"monitor_index": 0, # Primary monitor
|
|
||||||
"capture_cursor": False, # Exclude cursor (hardware exclusion)
|
"capture_cursor": False, # Exclude cursor (hardware exclusion)
|
||||||
"draw_border": False, # Don't draw border around capture
|
"draw_border": False, # Don't draw border around capture
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user