KC test uses shared LiveStreamManager, tree-nav dropdown, KC card badge fix

- KC test WS now acquires from LiveStreamManager instead of creating its
  own DXGI duplicator, eliminating capture contention with running LED targets
- Tree-nav refactored to compact dropdown on click with outside-click dismiss
  (closes on click outside the trigger+panel, not just outside the container)
- KC target card badge (e.g. "Daylight Cycle") no longer wastes empty space

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-18 14:50:33 +03:00
parent 6a31814900
commit 1f047d6561
4 changed files with 346 additions and 365 deletions

View File

@@ -849,59 +849,42 @@ async def test_kc_target_ws(
await websocket.accept()
logger.info(f"KC test WS connected for {target_id} (fps={fps})")
capture_stream = None
# Use the shared LiveStreamManager so we share the capture stream with
# running LED targets instead of creating a competing DXGI duplicator.
live_stream_mgr = processor_manager_inst._live_stream_manager
live_stream = None
try:
live_stream = await asyncio.to_thread(
live_stream_mgr.acquire, target.picture_source_id
)
logger.info(f"KC test WS acquired shared live stream for {target.picture_source_id}")
prev_frame_ref = None
while True:
loop_start = time.monotonic()
pil_image = None
capture_stream_local = None
try:
import httpx
# Reload chain each iteration for dynamic sources
chain = source_store_inst.resolve_stream_chain(target.picture_source_id)
raw_stream = chain["raw_stream"]
capture = await asyncio.to_thread(live_stream.get_latest_frame)
if isinstance(raw_stream, StaticImagePictureSource):
source = raw_stream.image_source
if source.startswith(("http://", "https://")):
async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client:
resp = await client.get(source)
resp.raise_for_status()
pil_image = Image.open(io.BytesIO(resp.content)).convert("RGB")
else:
from pathlib import Path
path = Path(source)
if path.exists():
pil_image = Image.open(path).convert("RGB")
if capture is None or capture.image is None:
await asyncio.sleep(frame_interval)
continue
elif isinstance(raw_stream, ScreenCapturePictureSource):
try:
capture_tmpl = template_store_inst.get_template(raw_stream.capture_template_id)
except ValueError:
break
if capture_tmpl.engine_type not in EngineRegistry.get_available_engines():
break
capture_stream_local = EngineRegistry.create_stream(
capture_tmpl.engine_type, raw_stream.display_index, capture_tmpl.engine_config
)
capture_stream_local.initialize()
screen_capture = capture_stream_local.capture_frame()
if screen_capture is not None and isinstance(screen_capture.image, np.ndarray):
pil_image = Image.fromarray(screen_capture.image)
else:
# VideoCaptureSource or other — not directly supported in WS test
break
# Skip if same frame object (no new capture yet)
if capture is prev_frame_ref:
await asyncio.sleep(frame_interval * 0.5)
continue
prev_frame_ref = capture
pil_image = Image.fromarray(capture.image) if isinstance(capture.image, np.ndarray) else None
if pil_image is None:
await asyncio.sleep(frame_interval)
continue
# Apply postprocessing
# Apply postprocessing (if the source chain has PP templates)
chain = source_store_inst.resolve_stream_chain(target.picture_source_id)
pp_template_ids = chain.get("postprocessing_template_ids", [])
if pp_template_ids and pp_template_store_inst:
img_array = np.array(pil_image)
@@ -920,7 +903,7 @@ async def test_kc_target_ws(
img_array = result
except ValueError:
pass
pil_image = Image.fromarray(img_array)
pil_image = Image.fromarray(img_array)
# Extract colors
img_array = np.array(pil_image)
@@ -971,12 +954,6 @@ async def test_kc_target_ws(
if isinstance(inner_e, WebSocketDisconnect):
raise
logger.warning(f"KC test WS frame error for {target_id}: {inner_e}")
finally:
if capture_stream_local:
try:
capture_stream_local.cleanup()
except Exception:
pass
elapsed = time.monotonic() - loop_start
sleep_time = frame_interval - elapsed
@@ -988,9 +965,11 @@ async def test_kc_target_ws(
except Exception as e:
logger.error(f"KC test WS error for {target_id}: {e}", exc_info=True)
finally:
if capture_stream:
if live_stream is not None:
try:
capture_stream.cleanup()
await asyncio.to_thread(
live_stream_mgr.release, target.picture_source_id
)
except Exception:
pass
logger.info(f"KC test WS closed for {target_id}")