Add live preview streaming for capture tests via WebSocket
Replace blocking REST-based capture tests with WebSocket endpoints that stream intermediate frame thumbnails at ~100ms intervals, giving real-time visual feedback during capture. Preview resolution adapts dynamically to the client viewport size and device pixel ratio. - New shared helper (_test_helpers.py) with engine_factory pattern to avoid MSS thread-affinity issues - WS endpoints for stream, capture template, and PP template tests - Enhanced overlay spinner with live preview image and stats - Frontend _runTestViaWS shared helper replaces three REST test runners Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
215
server/src/wled_controller/api/routes/_test_helpers.py
Normal file
215
server/src/wled_controller/api/routes/_test_helpers.py
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
"""Shared helpers for WebSocket-based capture test endpoints."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import base64
|
||||||
|
import io
|
||||||
|
import secrets
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from typing import Callable, List, Optional
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from PIL import Image
|
||||||
|
from starlette.websockets import WebSocket
|
||||||
|
|
||||||
|
from wled_controller.config import get_config
|
||||||
|
from wled_controller.core.filters import FilterRegistry, ImagePool
|
||||||
|
from wled_controller.utils import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
PREVIEW_INTERVAL = 0.1 # seconds between intermediate thumbnail sends
|
||||||
|
PREVIEW_MAX_WIDTH = 640 # px for intermediate thumbnails
|
||||||
|
FINAL_THUMBNAIL_WIDTH = 640 # px for the final thumbnail
|
||||||
|
FINAL_JPEG_QUALITY = 90
|
||||||
|
PREVIEW_JPEG_QUALITY = 70
|
||||||
|
|
||||||
|
|
||||||
|
def authenticate_ws_token(token: str) -> bool:
|
||||||
|
"""Check a WebSocket query-param token against configured API keys."""
|
||||||
|
cfg = get_config()
|
||||||
|
if token and cfg.auth.api_keys:
|
||||||
|
for _label, api_key in cfg.auth.api_keys.items():
|
||||||
|
if secrets.compare_digest(token, api_key):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _encode_jpeg(pil_image: Image.Image, quality: int = 85) -> str:
|
||||||
|
"""Encode a PIL image as a JPEG base64 data URI."""
|
||||||
|
buf = io.BytesIO()
|
||||||
|
pil_image.save(buf, format="JPEG", quality=quality)
|
||||||
|
buf.seek(0)
|
||||||
|
b64 = base64.b64encode(buf.getvalue()).decode("utf-8")
|
||||||
|
return f"data:image/jpeg;base64,{b64}"
|
||||||
|
|
||||||
|
|
||||||
|
def _make_thumbnail(pil_image: Image.Image, max_width: int) -> Image.Image:
|
||||||
|
"""Create a thumbnail copy of the image, preserving aspect ratio."""
|
||||||
|
thumb = pil_image.copy()
|
||||||
|
aspect = pil_image.height / pil_image.width
|
||||||
|
thumb.thumbnail((max_width, int(max_width * aspect)), Image.Resampling.LANCZOS)
|
||||||
|
return thumb
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_pp_filters(pil_image: Image.Image, flat_filters: list) -> Image.Image:
|
||||||
|
"""Apply postprocessing filter instances to a PIL image."""
|
||||||
|
if not flat_filters:
|
||||||
|
return pil_image
|
||||||
|
pool = ImagePool()
|
||||||
|
arr = np.array(pil_image)
|
||||||
|
for fi in flat_filters:
|
||||||
|
f = FilterRegistry.create_instance(fi.filter_id, fi.options)
|
||||||
|
result = f.process_image(arr, pool)
|
||||||
|
if result is not None:
|
||||||
|
arr = result
|
||||||
|
return Image.fromarray(arr)
|
||||||
|
|
||||||
|
|
||||||
|
async def stream_capture_test(
|
||||||
|
websocket: WebSocket,
|
||||||
|
engine_factory: Callable,
|
||||||
|
duration: float,
|
||||||
|
pp_filters: Optional[list] = None,
|
||||||
|
preview_width: Optional[int] = None,
|
||||||
|
) -> None:
|
||||||
|
"""Run a capture test, streaming intermediate thumbnails and a final full-res frame.
|
||||||
|
|
||||||
|
The engine is created and used entirely within a background thread to avoid
|
||||||
|
thread-affinity issues (e.g. MSS uses thread-local state).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
websocket: Accepted WebSocket connection.
|
||||||
|
engine_factory: Zero-arg callable that returns an initialized engine stream
|
||||||
|
(with .capture_frame() and .cleanup() methods). Called inside the
|
||||||
|
capture thread so thread-local resources work correctly.
|
||||||
|
duration: Test duration in seconds.
|
||||||
|
pp_filters: Optional list of resolved filter instances to apply to frames.
|
||||||
|
"""
|
||||||
|
thumb_width = preview_width or PREVIEW_MAX_WIDTH
|
||||||
|
|
||||||
|
# Shared state between capture thread and async loop
|
||||||
|
latest_frame = None # PIL Image (converted from numpy)
|
||||||
|
frame_count = 0
|
||||||
|
total_capture_time = 0.0
|
||||||
|
stop_event = threading.Event()
|
||||||
|
done_event = threading.Event()
|
||||||
|
init_error = None # set if engine_factory fails
|
||||||
|
|
||||||
|
def _capture_loop():
|
||||||
|
nonlocal latest_frame, frame_count, total_capture_time, init_error
|
||||||
|
stream = None
|
||||||
|
try:
|
||||||
|
stream = engine_factory()
|
||||||
|
start = time.perf_counter()
|
||||||
|
end = start + duration
|
||||||
|
while time.perf_counter() < end and not stop_event.is_set():
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
capture = stream.capture_frame()
|
||||||
|
t1 = time.perf_counter()
|
||||||
|
if capture is None:
|
||||||
|
time.sleep(0.005)
|
||||||
|
continue
|
||||||
|
total_capture_time += t1 - t0
|
||||||
|
frame_count += 1
|
||||||
|
# Convert numpy → PIL once in the capture thread
|
||||||
|
if isinstance(capture.image, np.ndarray):
|
||||||
|
latest_frame = Image.fromarray(capture.image)
|
||||||
|
else:
|
||||||
|
latest_frame = capture.image
|
||||||
|
except Exception as e:
|
||||||
|
init_error = str(e)
|
||||||
|
logger.error(f"Capture thread error: {e}")
|
||||||
|
finally:
|
||||||
|
if stream:
|
||||||
|
try:
|
||||||
|
stream.cleanup()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
done_event.set()
|
||||||
|
|
||||||
|
# Start capture in background thread
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
capture_future = loop.run_in_executor(None, _capture_loop)
|
||||||
|
|
||||||
|
start_time = time.perf_counter()
|
||||||
|
last_sent_frame = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Stream intermediate previews
|
||||||
|
while not done_event.is_set():
|
||||||
|
await asyncio.sleep(PREVIEW_INTERVAL)
|
||||||
|
|
||||||
|
# Check for init error
|
||||||
|
if init_error:
|
||||||
|
await websocket.send_json({"type": "error", "detail": init_error})
|
||||||
|
return
|
||||||
|
|
||||||
|
frame = latest_frame
|
||||||
|
if frame is not None and frame is not last_sent_frame:
|
||||||
|
last_sent_frame = frame
|
||||||
|
elapsed = time.perf_counter() - start_time
|
||||||
|
fc = frame_count
|
||||||
|
tc = total_capture_time
|
||||||
|
# Encode preview thumbnail (small + fast)
|
||||||
|
thumb = _make_thumbnail(frame, thumb_width)
|
||||||
|
if pp_filters:
|
||||||
|
thumb = _apply_pp_filters(thumb, pp_filters)
|
||||||
|
thumb_uri = _encode_jpeg(thumb, PREVIEW_JPEG_QUALITY)
|
||||||
|
fps = fc / elapsed if elapsed > 0 else 0
|
||||||
|
avg_ms = (tc / fc * 1000) if fc > 0 else 0
|
||||||
|
await websocket.send_json({
|
||||||
|
"type": "frame",
|
||||||
|
"thumbnail": thumb_uri,
|
||||||
|
"frame_count": fc,
|
||||||
|
"elapsed_s": round(elapsed, 2),
|
||||||
|
"fps": round(fps, 1),
|
||||||
|
"avg_capture_ms": round(avg_ms, 1),
|
||||||
|
})
|
||||||
|
|
||||||
|
# Wait for capture thread to fully finish
|
||||||
|
await capture_future
|
||||||
|
|
||||||
|
# Check for errors
|
||||||
|
if init_error:
|
||||||
|
await websocket.send_json({"type": "error", "detail": init_error})
|
||||||
|
return
|
||||||
|
|
||||||
|
# Send final result
|
||||||
|
final_frame = latest_frame
|
||||||
|
if final_frame is None:
|
||||||
|
await websocket.send_json({"type": "error", "detail": "No frames captured"})
|
||||||
|
return
|
||||||
|
|
||||||
|
elapsed = time.perf_counter() - start_time
|
||||||
|
fc = frame_count
|
||||||
|
tc = total_capture_time
|
||||||
|
fps = fc / elapsed if elapsed > 0 else 0
|
||||||
|
avg_ms = (tc / fc * 1000) if fc > 0 else 0
|
||||||
|
w, h = final_frame.size
|
||||||
|
|
||||||
|
# Apply PP filters to final images
|
||||||
|
if pp_filters:
|
||||||
|
final_frame = _apply_pp_filters(final_frame, pp_filters)
|
||||||
|
|
||||||
|
full_uri = _encode_jpeg(final_frame, FINAL_JPEG_QUALITY)
|
||||||
|
thumb = _make_thumbnail(final_frame, FINAL_THUMBNAIL_WIDTH)
|
||||||
|
thumb_uri = _encode_jpeg(thumb, 85)
|
||||||
|
|
||||||
|
await websocket.send_json({
|
||||||
|
"type": "result",
|
||||||
|
"full_image": full_uri,
|
||||||
|
"thumbnail": thumb_uri,
|
||||||
|
"width": w,
|
||||||
|
"height": h,
|
||||||
|
"frame_count": fc,
|
||||||
|
"elapsed_s": round(elapsed, 2),
|
||||||
|
"fps": round(fps, 1),
|
||||||
|
"avg_capture_ms": round(avg_ms, 1),
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
# WebSocket disconnect or send error — signal capture thread to stop
|
||||||
|
stop_event.set()
|
||||||
|
await capture_future
|
||||||
|
raise
|
||||||
@@ -7,7 +7,7 @@ import time
|
|||||||
import httpx
|
import httpx
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
from fastapi import APIRouter, HTTPException, Depends, Query
|
from fastapi import APIRouter, HTTPException, Depends, Query, WebSocket, WebSocketDisconnect
|
||||||
from fastapi.responses import Response
|
from fastapi.responses import Response
|
||||||
|
|
||||||
from wled_controller.api.auth import AuthRequired
|
from wled_controller.api.auth import AuthRequired
|
||||||
@@ -467,3 +467,100 @@ async def test_picture_source(
|
|||||||
stream.cleanup()
|
stream.cleanup()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error cleaning up test stream: {e}")
|
logger.error(f"Error cleaning up test stream: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
# ===== REAL-TIME PICTURE SOURCE TEST WEBSOCKET =====
|
||||||
|
|
||||||
|
|
||||||
|
@router.websocket("/api/v1/picture-sources/{stream_id}/test/ws")
|
||||||
|
async def test_picture_source_ws(
|
||||||
|
websocket: WebSocket,
|
||||||
|
stream_id: str,
|
||||||
|
token: str = Query(""),
|
||||||
|
duration: float = Query(5.0),
|
||||||
|
preview_width: int = Query(0),
|
||||||
|
):
|
||||||
|
"""WebSocket for picture source test with intermediate frame previews."""
|
||||||
|
from wled_controller.api.routes._test_helpers import (
|
||||||
|
authenticate_ws_token,
|
||||||
|
stream_capture_test,
|
||||||
|
)
|
||||||
|
from wled_controller.api.dependencies import (
|
||||||
|
get_picture_source_store as _get_ps_store,
|
||||||
|
get_template_store as _get_t_store,
|
||||||
|
get_pp_template_store as _get_pp_store,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not authenticate_ws_token(token):
|
||||||
|
await websocket.close(code=4001, reason="Unauthorized")
|
||||||
|
return
|
||||||
|
|
||||||
|
store = _get_ps_store()
|
||||||
|
template_store = _get_t_store()
|
||||||
|
pp_store = _get_pp_store()
|
||||||
|
|
||||||
|
# Resolve stream chain
|
||||||
|
try:
|
||||||
|
chain = store.resolve_stream_chain(stream_id)
|
||||||
|
except ValueError as e:
|
||||||
|
await websocket.close(code=4004, reason=str(e))
|
||||||
|
return
|
||||||
|
|
||||||
|
raw_stream = chain["raw_stream"]
|
||||||
|
|
||||||
|
# Static images don't benefit from streaming — reject gracefully
|
||||||
|
if isinstance(raw_stream, StaticImagePictureSource):
|
||||||
|
await websocket.close(code=4003, reason="Static image streams don't support live test")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not isinstance(raw_stream, ScreenCapturePictureSource):
|
||||||
|
await websocket.close(code=4003, reason="Unsupported stream type for live test")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Create capture engine
|
||||||
|
try:
|
||||||
|
capture_template = template_store.get_template(raw_stream.capture_template_id)
|
||||||
|
except ValueError as e:
|
||||||
|
await websocket.close(code=4004, reason=str(e))
|
||||||
|
return
|
||||||
|
|
||||||
|
if capture_template.engine_type not in EngineRegistry.get_available_engines():
|
||||||
|
await websocket.close(code=4003, reason=f"Engine '{capture_template.engine_type}' not available")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Resolve postprocessing filters (if any)
|
||||||
|
pp_filters = None
|
||||||
|
pp_template_ids = chain.get("postprocessing_template_ids", [])
|
||||||
|
if pp_template_ids:
|
||||||
|
try:
|
||||||
|
pp_template = pp_store.get_template(pp_template_ids[0])
|
||||||
|
pp_filters = pp_store.resolve_filter_instances(pp_template.filters) or None
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Engine factory — creates + initializes engine inside the capture thread
|
||||||
|
# to avoid thread-affinity issues (e.g. MSS uses thread-local state)
|
||||||
|
_engine_type = capture_template.engine_type
|
||||||
|
_display_index = raw_stream.display_index
|
||||||
|
_engine_config = capture_template.engine_config
|
||||||
|
|
||||||
|
def engine_factory():
|
||||||
|
s = EngineRegistry.create_stream(_engine_type, _display_index, _engine_config)
|
||||||
|
s.initialize()
|
||||||
|
return s
|
||||||
|
|
||||||
|
await websocket.accept()
|
||||||
|
logger.info(f"Picture source test WS connected for {stream_id} ({duration}s)")
|
||||||
|
|
||||||
|
try:
|
||||||
|
await stream_capture_test(
|
||||||
|
websocket, engine_factory, duration,
|
||||||
|
pp_filters=pp_filters,
|
||||||
|
preview_width=preview_width or None,
|
||||||
|
)
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Picture source test WS error for {stream_id}: {e}")
|
||||||
|
finally:
|
||||||
|
logger.info(f"Picture source test WS disconnected for {stream_id}")
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import time
|
|||||||
import httpx
|
import httpx
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
from fastapi import APIRouter, HTTPException, Depends
|
from fastapi import APIRouter, HTTPException, Depends, Query, WebSocket, WebSocketDisconnect
|
||||||
|
|
||||||
from wled_controller.api.auth import AuthRequired
|
from wled_controller.api.auth import AuthRequired
|
||||||
from wled_controller.api.dependencies import (
|
from wled_controller.api.dependencies import (
|
||||||
@@ -330,3 +330,104 @@ async def test_pp_template(
|
|||||||
stream.cleanup()
|
stream.cleanup()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# ===== REAL-TIME PP TEMPLATE TEST WEBSOCKET =====
|
||||||
|
|
||||||
|
|
||||||
|
@router.websocket("/api/v1/postprocessing-templates/{template_id}/test/ws")
|
||||||
|
async def test_pp_template_ws(
|
||||||
|
websocket: WebSocket,
|
||||||
|
template_id: str,
|
||||||
|
token: str = Query(""),
|
||||||
|
duration: float = Query(5.0),
|
||||||
|
source_stream_id: str = Query(""),
|
||||||
|
preview_width: int = Query(0),
|
||||||
|
):
|
||||||
|
"""WebSocket for PP template test with intermediate frame previews."""
|
||||||
|
from wled_controller.api.routes._test_helpers import (
|
||||||
|
authenticate_ws_token,
|
||||||
|
stream_capture_test,
|
||||||
|
)
|
||||||
|
from wled_controller.api.dependencies import (
|
||||||
|
get_picture_source_store as _get_ps_store,
|
||||||
|
get_template_store as _get_t_store,
|
||||||
|
get_pp_template_store as _get_pp_store,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not authenticate_ws_token(token):
|
||||||
|
await websocket.close(code=4001, reason="Unauthorized")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not source_stream_id:
|
||||||
|
await websocket.close(code=4003, reason="source_stream_id is required")
|
||||||
|
return
|
||||||
|
|
||||||
|
pp_store = _get_pp_store()
|
||||||
|
stream_store = _get_ps_store()
|
||||||
|
template_store = _get_t_store()
|
||||||
|
|
||||||
|
# Get PP template
|
||||||
|
try:
|
||||||
|
pp_template = pp_store.get_template(template_id)
|
||||||
|
except ValueError as e:
|
||||||
|
await websocket.close(code=4004, reason=str(e))
|
||||||
|
return
|
||||||
|
|
||||||
|
# Resolve source stream chain
|
||||||
|
try:
|
||||||
|
chain = stream_store.resolve_stream_chain(source_stream_id)
|
||||||
|
except ValueError as e:
|
||||||
|
await websocket.close(code=4004, reason=str(e))
|
||||||
|
return
|
||||||
|
|
||||||
|
raw_stream = chain["raw_stream"]
|
||||||
|
|
||||||
|
if isinstance(raw_stream, StaticImagePictureSource):
|
||||||
|
await websocket.close(code=4003, reason="Static image streams don't support live test")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not isinstance(raw_stream, ScreenCapturePictureSource):
|
||||||
|
await websocket.close(code=4003, reason="Unsupported stream type for live test")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Create capture engine
|
||||||
|
try:
|
||||||
|
capture_template = template_store.get_template(raw_stream.capture_template_id)
|
||||||
|
except ValueError as e:
|
||||||
|
await websocket.close(code=4004, reason=str(e))
|
||||||
|
return
|
||||||
|
|
||||||
|
if capture_template.engine_type not in EngineRegistry.get_available_engines():
|
||||||
|
await websocket.close(code=4003, reason=f"Engine '{capture_template.engine_type}' not available")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Resolve PP filters
|
||||||
|
pp_filters = pp_store.resolve_filter_instances(pp_template.filters) or None
|
||||||
|
|
||||||
|
# Engine factory — creates + initializes engine inside the capture thread
|
||||||
|
# to avoid thread-affinity issues (e.g. MSS uses thread-local state)
|
||||||
|
_engine_type = capture_template.engine_type
|
||||||
|
_display_index = raw_stream.display_index
|
||||||
|
_engine_config = capture_template.engine_config
|
||||||
|
|
||||||
|
def engine_factory():
|
||||||
|
s = EngineRegistry.create_stream(_engine_type, _display_index, _engine_config)
|
||||||
|
s.initialize()
|
||||||
|
return s
|
||||||
|
|
||||||
|
await websocket.accept()
|
||||||
|
logger.info(f"PP template test WS connected for {template_id} ({duration}s)")
|
||||||
|
|
||||||
|
try:
|
||||||
|
await stream_capture_test(
|
||||||
|
websocket, engine_factory, duration,
|
||||||
|
pp_filters=pp_filters,
|
||||||
|
preview_width=preview_width or None,
|
||||||
|
)
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"PP template test WS error for {template_id}: {e}")
|
||||||
|
finally:
|
||||||
|
logger.info(f"PP template test WS disconnected for {template_id}")
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import time
|
|||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
from fastapi import APIRouter, HTTPException, Depends
|
from fastapi import APIRouter, HTTPException, Depends, Query, WebSocket, WebSocketDisconnect
|
||||||
|
|
||||||
from wled_controller.api.auth import AuthRequired
|
from wled_controller.api.auth import AuthRequired
|
||||||
from wled_controller.api.dependencies import (
|
from wled_controller.api.dependencies import (
|
||||||
@@ -365,6 +365,68 @@ def test_template(
|
|||||||
logger.error(f"Error cleaning up test stream: {e}")
|
logger.error(f"Error cleaning up test stream: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
# ===== REAL-TIME CAPTURE TEMPLATE TEST WEBSOCKET =====
|
||||||
|
|
||||||
|
|
||||||
|
@router.websocket("/api/v1/capture-templates/test/ws")
|
||||||
|
async def test_template_ws(
|
||||||
|
websocket: WebSocket,
|
||||||
|
token: str = Query(""),
|
||||||
|
):
|
||||||
|
"""WebSocket for capture template test with intermediate frame previews.
|
||||||
|
|
||||||
|
Config is sent as the first client message (JSON with engine_type,
|
||||||
|
engine_config, display_index, capture_duration).
|
||||||
|
"""
|
||||||
|
from wled_controller.api.routes._test_helpers import (
|
||||||
|
authenticate_ws_token,
|
||||||
|
stream_capture_test,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not authenticate_ws_token(token):
|
||||||
|
await websocket.close(code=4001, reason="Unauthorized")
|
||||||
|
return
|
||||||
|
|
||||||
|
await websocket.accept()
|
||||||
|
|
||||||
|
# Read config from first client message
|
||||||
|
try:
|
||||||
|
config = await websocket.receive_json()
|
||||||
|
except Exception as e:
|
||||||
|
await websocket.send_json({"type": "error", "detail": f"Expected JSON config: {e}"})
|
||||||
|
await websocket.close(code=4003)
|
||||||
|
return
|
||||||
|
|
||||||
|
engine_type = config.get("engine_type", "")
|
||||||
|
engine_config = config.get("engine_config", {})
|
||||||
|
display_index = config.get("display_index", 0)
|
||||||
|
duration = float(config.get("capture_duration", 5.0))
|
||||||
|
pw = int(config.get("preview_width", 0)) or None
|
||||||
|
|
||||||
|
if engine_type not in EngineRegistry.get_available_engines():
|
||||||
|
await websocket.send_json({"type": "error", "detail": f"Engine '{engine_type}' not available"})
|
||||||
|
await websocket.close(code=4003)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Engine factory — creates + initializes engine inside the capture thread
|
||||||
|
# to avoid thread-affinity issues (e.g. MSS uses thread-local state)
|
||||||
|
def engine_factory():
|
||||||
|
s = EngineRegistry.create_stream(engine_type, display_index, engine_config)
|
||||||
|
s.initialize()
|
||||||
|
return s
|
||||||
|
|
||||||
|
logger.info(f"Capture template test WS connected ({engine_type}, display {display_index}, {duration}s)")
|
||||||
|
|
||||||
|
try:
|
||||||
|
await stream_capture_test(websocket, engine_factory, duration, preview_width=pw)
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Capture template test WS error: {e}")
|
||||||
|
finally:
|
||||||
|
logger.info("Capture template test WS disconnected")
|
||||||
|
|
||||||
|
|
||||||
# ===== FILTER TYPE ENDPOINTS =====
|
# ===== FILTER TYPE ENDPOINTS =====
|
||||||
|
|
||||||
@router.get("/api/v1/filters", response_model=FilterTypeListResponse, tags=["Filters"])
|
@router.get("/api/v1/filters", response_model=FilterTypeListResponse, tags=["Filters"])
|
||||||
|
|||||||
@@ -313,6 +313,21 @@ input:-webkit-autofill:focus {
|
|||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.overlay-preview-img {
|
||||||
|
max-width: 80vw;
|
||||||
|
max-height: 50vh;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-top: 16px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-preview-stats {
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin-top: 8px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
.toast {
|
.toast {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 40px;
|
bottom: 40px;
|
||||||
|
|||||||
@@ -255,8 +255,22 @@ export function showOverlaySpinner(text, duration = 0) {
|
|||||||
spinnerText.className = 'spinner-text';
|
spinnerText.className = 'spinner-text';
|
||||||
spinnerText.textContent = text;
|
spinnerText.textContent = text;
|
||||||
|
|
||||||
|
// Preview image (hidden until updateOverlayPreview is called)
|
||||||
|
const previewImg = document.createElement('img');
|
||||||
|
previewImg.id = 'overlay-preview-img';
|
||||||
|
previewImg.className = 'overlay-preview-img';
|
||||||
|
previewImg.style.display = 'none';
|
||||||
|
|
||||||
|
// Preview stats (hidden until updateOverlayPreview is called)
|
||||||
|
const previewStats = document.createElement('div');
|
||||||
|
previewStats.id = 'overlay-preview-stats';
|
||||||
|
previewStats.className = 'overlay-preview-stats';
|
||||||
|
previewStats.style.display = 'none';
|
||||||
|
|
||||||
overlay.appendChild(progressContainer);
|
overlay.appendChild(progressContainer);
|
||||||
overlay.appendChild(spinnerText);
|
overlay.appendChild(spinnerText);
|
||||||
|
overlay.appendChild(previewImg);
|
||||||
|
overlay.appendChild(previewStats);
|
||||||
document.body.appendChild(overlay);
|
document.body.appendChild(overlay);
|
||||||
|
|
||||||
if (duration > 0) {
|
if (duration > 0) {
|
||||||
@@ -293,6 +307,24 @@ export function hideOverlaySpinner() {
|
|||||||
if (overlay) overlay.remove();
|
if (overlay) overlay.remove();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the overlay spinner with a live preview thumbnail and stats.
|
||||||
|
* Call this while the spinner is open to show intermediate test frames.
|
||||||
|
*/
|
||||||
|
export function updateOverlayPreview(thumbnailSrc, stats) {
|
||||||
|
const img = document.getElementById('overlay-preview-img');
|
||||||
|
const statsEl = document.getElementById('overlay-preview-stats');
|
||||||
|
if (!img || !statsEl) return;
|
||||||
|
if (thumbnailSrc) {
|
||||||
|
img.src = thumbnailSrc;
|
||||||
|
img.style.display = '';
|
||||||
|
}
|
||||||
|
if (stats) {
|
||||||
|
statsEl.textContent = `${t('test.frames')}: ${stats.frame_count} | ${t('test.fps')}: ${stats.fps} | ${t('test.avg_capture')}: ${stats.avg_capture_ms}ms`;
|
||||||
|
statsEl.style.display = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Toggle the thin loading bar on a tab panel during data refresh.
|
/** Toggle the thin loading bar on a tab panel during data refresh.
|
||||||
* Delays showing the bar by 400ms so quick loads never flash it. */
|
* Delays showing the bar by 400ms so quick loads never flash it. */
|
||||||
const _refreshTimers = {};
|
const _refreshTimers = {};
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ import {
|
|||||||
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.js';
|
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.js';
|
||||||
import { t } from '../core/i18n.js';
|
import { t } from '../core/i18n.js';
|
||||||
import { Modal } from '../core/modal.js';
|
import { Modal } from '../core/modal.js';
|
||||||
import { showToast, showConfirm, openLightbox, openFullImageLightbox, showOverlaySpinner, hideOverlaySpinner, setTabRefreshing } from '../core/ui.js';
|
import { showToast, showConfirm, openLightbox, openFullImageLightbox, showOverlaySpinner, hideOverlaySpinner, updateOverlayPreview, setTabRefreshing } from '../core/ui.js';
|
||||||
import { openDisplayPicker, formatDisplayLabel } from './displays.js';
|
import { openDisplayPicker, formatDisplayLabel } from './displays.js';
|
||||||
import { CardSection } from '../core/card-sections.js';
|
import { CardSection } from '../core/card-sections.js';
|
||||||
import { updateSubTabHash } from './tabs.js';
|
import { updateSubTabHash } from './tabs.js';
|
||||||
@@ -415,7 +415,7 @@ async function loadDisplaysForTest() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runTemplateTest() {
|
export function runTemplateTest() {
|
||||||
if (!window.currentTestingTemplate) {
|
if (!window.currentTestingTemplate) {
|
||||||
showToast(t('templates.test.error.no_engine'), 'error');
|
showToast(t('templates.test.error.no_engine'), 'error');
|
||||||
return;
|
return;
|
||||||
@@ -430,57 +430,137 @@ export async function runTemplateTest() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const template = window.currentTestingTemplate;
|
const template = window.currentTestingTemplate;
|
||||||
showOverlaySpinner(t('templates.test.running'), captureDuration);
|
localStorage.setItem('lastTestDisplayIndex', displayIndex);
|
||||||
const signal = window._overlayAbortController?.signal;
|
|
||||||
|
|
||||||
try {
|
const previewWidth = Math.round(Math.min(window.innerWidth * 0.8, 1920) * Math.min(window.devicePixelRatio || 1, 2));
|
||||||
const response = await fetchWithAuth('/capture-templates/test', {
|
_runTestViaWS(
|
||||||
method: 'POST',
|
'/capture-templates/test/ws',
|
||||||
body: JSON.stringify({
|
{},
|
||||||
|
{
|
||||||
engine_type: template.engine_type,
|
engine_type: template.engine_type,
|
||||||
engine_config: template.engine_config,
|
engine_config: template.engine_config,
|
||||||
display_index: parseInt(displayIndex),
|
display_index: parseInt(displayIndex),
|
||||||
capture_duration: captureDuration
|
capture_duration: captureDuration,
|
||||||
}),
|
preview_width: previewWidth,
|
||||||
signal
|
},
|
||||||
});
|
captureDuration,
|
||||||
|
);
|
||||||
if (!response.ok) {
|
|
||||||
const error = await response.json();
|
|
||||||
throw new Error(error.detail || error.message || 'Test failed');
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await response.json();
|
|
||||||
localStorage.setItem('lastTestDisplayIndex', displayIndex);
|
|
||||||
displayTestResults(result);
|
|
||||||
} catch (error) {
|
|
||||||
if (error.name === 'AbortError') return;
|
|
||||||
console.error('Error running test:', error);
|
|
||||||
hideOverlaySpinner();
|
|
||||||
showToast(t('templates.test.error.failed'), 'error');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildTestStatsHtml(result) {
|
function buildTestStatsHtml(result) {
|
||||||
const p = result.performance;
|
// Support both REST format (nested) and WS format (flat)
|
||||||
const res = `${result.full_capture.width}x${result.full_capture.height}`;
|
const p = result.performance || result;
|
||||||
|
const duration = p.capture_duration_s ?? p.elapsed_s ?? 0;
|
||||||
|
const frameCount = p.frame_count ?? 0;
|
||||||
|
const fps = p.actual_fps ?? p.fps ?? 0;
|
||||||
|
const avgMs = p.avg_capture_time_ms ?? p.avg_capture_ms ?? 0;
|
||||||
|
const w = result.full_capture?.width ?? result.width ?? 0;
|
||||||
|
const h = result.full_capture?.height ?? result.height ?? 0;
|
||||||
|
const res = `${w}x${h}`;
|
||||||
|
|
||||||
let html = `
|
let html = `
|
||||||
<div class="stat-item"><span>${t('templates.test.results.duration')}:</span> <strong>${p.capture_duration_s.toFixed(2)}s</strong></div>
|
<div class="stat-item"><span>${t('templates.test.results.duration')}:</span> <strong>${Number(duration).toFixed(2)}s</strong></div>
|
||||||
<div class="stat-item"><span>${t('templates.test.results.frame_count')}:</span> <strong>${p.frame_count}</strong></div>`;
|
<div class="stat-item"><span>${t('templates.test.results.frame_count')}:</span> <strong>${frameCount}</strong></div>`;
|
||||||
if (p.frame_count > 1) {
|
if (frameCount > 1) {
|
||||||
html += `
|
html += `
|
||||||
<div class="stat-item"><span>${t('templates.test.results.actual_fps')}:</span> <strong>${p.actual_fps.toFixed(1)}</strong></div>
|
<div class="stat-item"><span>${t('templates.test.results.actual_fps')}:</span> <strong>${Number(fps).toFixed(1)}</strong></div>
|
||||||
<div class="stat-item"><span>${t('templates.test.results.avg_capture_time')}:</span> <strong>${p.avg_capture_time_ms.toFixed(1)}ms</strong></div>`;
|
<div class="stat-item"><span>${t('templates.test.results.avg_capture_time')}:</span> <strong>${Number(avgMs).toFixed(1)}ms</strong></div>`;
|
||||||
}
|
}
|
||||||
html += `
|
html += `
|
||||||
<div class="stat-item"><span>Resolution:</span> <strong>${res}</strong></div>`;
|
<div class="stat-item"><span>Resolution:</span> <strong>${res}</strong></div>`;
|
||||||
return html;
|
return html;
|
||||||
}
|
}
|
||||||
|
|
||||||
function displayTestResults(result) {
|
// ===== Shared WebSocket test helper =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run a capture test via WebSocket, streaming intermediate previews into
|
||||||
|
* the overlay spinner and opening the lightbox with the final result.
|
||||||
|
*
|
||||||
|
* @param {string} wsPath Relative WS path (e.g. '/picture-sources/{id}/test/ws')
|
||||||
|
* @param {Object} queryParams Extra query params (duration, source_stream_id, etc.)
|
||||||
|
* @param {Object|null} firstMessage If non-null, sent as JSON after WS opens (for template test)
|
||||||
|
* @param {number} duration Test duration for overlay progress ring
|
||||||
|
*/
|
||||||
|
function _runTestViaWS(wsPath, queryParams = {}, firstMessage = null, duration = 5) {
|
||||||
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
// Dynamic preview resolution: 80% of viewport width, scaled by DPR, capped at 1920px
|
||||||
|
const previewWidth = Math.round(Math.min(window.innerWidth * 0.8, 1920) * Math.min(window.devicePixelRatio || 1, 2));
|
||||||
|
const params = new URLSearchParams({ token: apiKey, preview_width: previewWidth, ...queryParams });
|
||||||
|
const wsUrl = `${protocol}//${window.location.host}${API_BASE}${wsPath}?${params}`;
|
||||||
|
|
||||||
|
showOverlaySpinner(t('streams.test.running'), duration);
|
||||||
|
|
||||||
|
let gotResult = false;
|
||||||
|
let ws;
|
||||||
|
|
||||||
|
try {
|
||||||
|
ws = new WebSocket(wsUrl);
|
||||||
|
} catch (e) {
|
||||||
hideOverlaySpinner();
|
hideOverlaySpinner();
|
||||||
const fullImageSrc = result.full_capture.full_image || result.full_capture.image;
|
showToast(t('streams.test.error.failed') + ': ' + e.message, 'error');
|
||||||
openLightbox(fullImageSrc, buildTestStatsHtml(result));
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close WS when user cancels overlay
|
||||||
|
const patchCloseBtn = () => {
|
||||||
|
const closeBtn = document.querySelector('.overlay-spinner-close');
|
||||||
|
if (closeBtn) {
|
||||||
|
const origHandler = closeBtn.onclick;
|
||||||
|
closeBtn.onclick = () => {
|
||||||
|
if (ws.readyState <= WebSocket.OPEN) ws.close();
|
||||||
|
if (origHandler) origHandler();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
patchCloseBtn();
|
||||||
|
|
||||||
|
// Also close on ESC (overlay ESC handler calls hideOverlaySpinner which aborts)
|
||||||
|
const origAbort = window._overlayAbortController;
|
||||||
|
if (origAbort) {
|
||||||
|
origAbort.signal.addEventListener('abort', () => {
|
||||||
|
if (ws.readyState <= WebSocket.OPEN) ws.close();
|
||||||
|
}, { once: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
if (firstMessage) {
|
||||||
|
ws.send(JSON.stringify(firstMessage));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(event.data);
|
||||||
|
if (msg.type === 'frame') {
|
||||||
|
updateOverlayPreview(msg.thumbnail, msg);
|
||||||
|
} else if (msg.type === 'result') {
|
||||||
|
gotResult = true;
|
||||||
|
hideOverlaySpinner();
|
||||||
|
openLightbox(msg.full_image, buildTestStatsHtml(msg));
|
||||||
|
ws.close();
|
||||||
|
} else if (msg.type === 'error') {
|
||||||
|
hideOverlaySpinner();
|
||||||
|
showToast(msg.detail || 'Test failed', 'error');
|
||||||
|
ws.close();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error parsing test WS message:', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = () => {
|
||||||
|
if (!gotResult) {
|
||||||
|
hideOverlaySpinner();
|
||||||
|
showToast(t('streams.test.error.failed'), 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
if (!gotResult) {
|
||||||
|
hideOverlaySpinner();
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveTemplate() {
|
export async function saveTemplate() {
|
||||||
@@ -1728,32 +1808,16 @@ function restoreStreamTestDuration() {
|
|||||||
document.getElementById('test-stream-duration-value').textContent = saved;
|
document.getElementById('test-stream-duration-value').textContent = saved;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runStreamTest() {
|
export function runStreamTest() {
|
||||||
if (!_currentTestStreamId) return;
|
if (!_currentTestStreamId) return;
|
||||||
const captureDuration = parseFloat(document.getElementById('test-stream-duration').value);
|
const captureDuration = parseFloat(document.getElementById('test-stream-duration').value);
|
||||||
showOverlaySpinner(t('streams.test.running'), captureDuration);
|
|
||||||
const signal = window._overlayAbortController?.signal;
|
|
||||||
|
|
||||||
try {
|
_runTestViaWS(
|
||||||
const response = await fetchWithAuth(`/picture-sources/${_currentTestStreamId}/test`, {
|
`/picture-sources/${_currentTestStreamId}/test/ws`,
|
||||||
method: 'POST',
|
{ duration: captureDuration },
|
||||||
body: JSON.stringify({ capture_duration: captureDuration }),
|
null,
|
||||||
signal
|
captureDuration,
|
||||||
});
|
);
|
||||||
if (!response.ok) {
|
|
||||||
const error = await response.json();
|
|
||||||
throw new Error(error.detail || error.message || 'Test failed');
|
|
||||||
}
|
|
||||||
const result = await response.json();
|
|
||||||
hideOverlaySpinner();
|
|
||||||
const fullImageSrc = result.full_capture.full_image || result.full_capture.image;
|
|
||||||
openLightbox(fullImageSrc, buildTestStatsHtml(result));
|
|
||||||
} catch (error) {
|
|
||||||
if (error.name === 'AbortError') return;
|
|
||||||
console.error('Error running stream test:', error);
|
|
||||||
hideOverlaySpinner();
|
|
||||||
showToast(t('streams.test.error.failed') + ': ' + error.message, 'error');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== PP Template Test =====
|
// ===== PP Template Test =====
|
||||||
@@ -1800,36 +1864,20 @@ function restorePPTestDuration() {
|
|||||||
document.getElementById('test-pp-duration-value').textContent = saved;
|
document.getElementById('test-pp-duration-value').textContent = saved;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runPPTemplateTest() {
|
export function runPPTemplateTest() {
|
||||||
if (!_currentTestPPTemplateId) return;
|
if (!_currentTestPPTemplateId) return;
|
||||||
const sourceStreamId = document.getElementById('test-pp-source-stream').value;
|
const sourceStreamId = document.getElementById('test-pp-source-stream').value;
|
||||||
if (!sourceStreamId) { showToast(t('postprocessing.test.error.no_stream'), 'error'); return; }
|
if (!sourceStreamId) { showToast(t('postprocessing.test.error.no_stream'), 'error'); return; }
|
||||||
localStorage.setItem('lastPPTestStreamId', sourceStreamId);
|
localStorage.setItem('lastPPTestStreamId', sourceStreamId);
|
||||||
|
|
||||||
const captureDuration = parseFloat(document.getElementById('test-pp-duration').value);
|
const captureDuration = parseFloat(document.getElementById('test-pp-duration').value);
|
||||||
showOverlaySpinner(t('postprocessing.test.running'), captureDuration);
|
|
||||||
const signal = window._overlayAbortController?.signal;
|
|
||||||
|
|
||||||
try {
|
_runTestViaWS(
|
||||||
const response = await fetchWithAuth(`/postprocessing-templates/${_currentTestPPTemplateId}/test`, {
|
`/postprocessing-templates/${_currentTestPPTemplateId}/test/ws`,
|
||||||
method: 'POST',
|
{ duration: captureDuration, source_stream_id: sourceStreamId },
|
||||||
body: JSON.stringify({ source_stream_id: sourceStreamId, capture_duration: captureDuration }),
|
null,
|
||||||
signal
|
captureDuration,
|
||||||
});
|
);
|
||||||
if (!response.ok) {
|
|
||||||
const error = await response.json();
|
|
||||||
throw new Error(error.detail || error.message || 'Test failed');
|
|
||||||
}
|
|
||||||
const result = await response.json();
|
|
||||||
hideOverlaySpinner();
|
|
||||||
const fullImageSrc = result.full_capture.full_image || result.full_capture.image;
|
|
||||||
openLightbox(fullImageSrc, buildTestStatsHtml(result));
|
|
||||||
} catch (error) {
|
|
||||||
if (error.name === 'AbortError') return;
|
|
||||||
console.error('Error running PP template test:', error);
|
|
||||||
hideOverlaySpinner();
|
|
||||||
showToast(t('postprocessing.test.error.failed') + ': ' + error.message, 'error');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== PP Templates =====
|
// ===== PP Templates =====
|
||||||
|
|||||||
@@ -903,6 +903,9 @@
|
|||||||
"value_source.test.current": "Current",
|
"value_source.test.current": "Current",
|
||||||
"value_source.test.min": "Min",
|
"value_source.test.min": "Min",
|
||||||
"value_source.test.max": "Max",
|
"value_source.test.max": "Max",
|
||||||
|
"test.frames": "Frames",
|
||||||
|
"test.fps": "FPS",
|
||||||
|
"test.avg_capture": "Avg",
|
||||||
"targets.brightness_vs": "Brightness Source:",
|
"targets.brightness_vs": "Brightness Source:",
|
||||||
"targets.brightness_vs.hint": "Optional value source that dynamically controls brightness each frame (overrides device brightness)",
|
"targets.brightness_vs.hint": "Optional value source that dynamically controls brightness each frame (overrides device brightness)",
|
||||||
"targets.brightness_vs.none": "None (device brightness)",
|
"targets.brightness_vs.none": "None (device brightness)",
|
||||||
|
|||||||
@@ -903,6 +903,9 @@
|
|||||||
"value_source.test.current": "Текущее",
|
"value_source.test.current": "Текущее",
|
||||||
"value_source.test.min": "Мин",
|
"value_source.test.min": "Мин",
|
||||||
"value_source.test.max": "Макс",
|
"value_source.test.max": "Макс",
|
||||||
|
"test.frames": "Кадры",
|
||||||
|
"test.fps": "Кадр/с",
|
||||||
|
"test.avg_capture": "Сред",
|
||||||
"targets.brightness_vs": "Источник яркости:",
|
"targets.brightness_vs": "Источник яркости:",
|
||||||
"targets.brightness_vs.hint": "Необязательный источник значений для динамического управления яркостью каждый кадр (переопределяет яркость устройства)",
|
"targets.brightness_vs.hint": "Необязательный источник значений для динамического управления яркостью каждый кадр (переопределяет яркость устройства)",
|
||||||
"targets.brightness_vs.none": "Нет (яркость устройства)",
|
"targets.brightness_vs.none": "Нет (яркость устройства)",
|
||||||
|
|||||||
@@ -903,6 +903,9 @@
|
|||||||
"value_source.test.current": "当前",
|
"value_source.test.current": "当前",
|
||||||
"value_source.test.min": "最小",
|
"value_source.test.min": "最小",
|
||||||
"value_source.test.max": "最大",
|
"value_source.test.max": "最大",
|
||||||
|
"test.frames": "帧数",
|
||||||
|
"test.fps": "帧率",
|
||||||
|
"test.avg_capture": "平均",
|
||||||
"targets.brightness_vs": "亮度源:",
|
"targets.brightness_vs": "亮度源:",
|
||||||
"targets.brightness_vs.hint": "可选的值源,每帧动态控制亮度(覆盖设备亮度)",
|
"targets.brightness_vs.hint": "可选的值源,每帧动态控制亮度(覆盖设备亮度)",
|
||||||
"targets.brightness_vs.none": "无(设备亮度)",
|
"targets.brightness_vs.none": "无(设备亮度)",
|
||||||
|
|||||||
Reference in New Issue
Block a user