diff --git a/server/src/wled_controller/api/routes/picture_targets.py b/server/src/wled_controller/api/routes/picture_targets.py index 50a304e..0b17de1 100644 --- a/server/src/wled_controller/api/routes/picture_targets.py +++ b/server/src/wled_controller/api/routes/picture_targets.py @@ -789,3 +789,71 @@ async def target_colors_ws( pass finally: manager.remove_kc_ws_client(target_id, websocket) + + +# ===== OVERLAY VISUALIZATION ===== + +@router.post("/api/v1/picture-targets/{target_id}/overlay/start", tags=["Visualization"]) +async def start_target_overlay( + target_id: str, + _auth: AuthRequired, + manager: ProcessorManager = Depends(get_processor_manager), + target_store: PictureTargetStore = Depends(get_picture_target_store), +): + """Start screen overlay visualization for a target. + + Displays a transparent overlay on the target display showing: + - Border sampling zones (colored rectangles) + - LED position markers (numbered dots) + - Pixel-to-LED mapping ranges (colored segments) + - Calibration info text + """ + try: + # Get target name from store + target = target_store.get_target(target_id) + if not target: + raise ValueError(f"Target {target_id} not found") + + await manager.start_overlay(target_id, target.name) + return {"status": "started", "target_id": target_id} + + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) + except RuntimeError as e: + raise HTTPException(status_code=409, detail=str(e)) + except Exception as e: + logger.error(f"Failed to start overlay: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/api/v1/picture-targets/{target_id}/overlay/stop", tags=["Visualization"]) +async def stop_target_overlay( + target_id: str, + _auth: AuthRequired, + manager: ProcessorManager = Depends(get_processor_manager), +): + """Stop screen overlay visualization for a target.""" + try: + await manager.stop_overlay(target_id) + return {"status": "stopped", "target_id": target_id} + + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) + except Exception as e: + logger.error(f"Failed to stop overlay: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/api/v1/picture-targets/{target_id}/overlay/status", tags=["Visualization"]) +async def get_overlay_status( + target_id: str, + _auth: AuthRequired, + manager: ProcessorManager = Depends(get_processor_manager), +): + """Check if overlay is active for a target.""" + try: + active = manager.is_overlay_active(target_id) + return {"target_id": target_id, "active": active} + + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) diff --git a/server/src/wled_controller/api/schemas/picture_targets.py b/server/src/wled_controller/api/schemas/picture_targets.py index 5d2c955..a733742 100644 --- a/server/src/wled_controller/api/schemas/picture_targets.py +++ b/server/src/wled_controller/api/schemas/picture_targets.py @@ -134,6 +134,7 @@ class TargetProcessingState(BaseModel): timing_send_ms: Optional[float] = Field(None, description="DDP send time (ms)") timing_total_ms: Optional[float] = Field(None, description="Total processing time (ms)") display_index: int = Field(default=0, description="Current display index") + overlay_active: bool = Field(default=False, description="Whether visualization overlay is active") last_update: Optional[datetime] = Field(None, description="Last successful update") errors: List[str] = Field(default_factory=list, description="Recent errors") device_online: bool = Field(default=False, description="Whether device is reachable") diff --git a/server/src/wled_controller/core/processor_manager.py b/server/src/wled_controller/core/processor_manager.py index c7b2999..6adfce3 100644 --- a/server/src/wled_controller/core/processor_manager.py +++ b/server/src/wled_controller/core/processor_manager.py @@ -31,6 +31,8 @@ from wled_controller.core.led_client import ( check_device_health, create_led_client, ) +from wled_controller.core.screen_overlay import OverlayManager +from wled_controller.core.screen_capture import get_available_displays from wled_controller.utils import get_logger logger = get_logger(__name__) @@ -185,6 +187,8 @@ class TargetState: live_stream: Optional[LiveStream] = None # Device state snapshot taken before streaming starts (to restore on stop) device_state_before: Optional[dict] = None + # Overlay visualization state + overlay_active: bool = False @dataclass @@ -226,6 +230,7 @@ class ProcessorManager: self._live_stream_manager = LiveStreamManager( picture_source_store, capture_template_store, pp_template_store ) + self._overlay_manager = OverlayManager() logger.info("Processor manager initialized") async def _get_http_client(self) -> httpx.AsyncClient: @@ -847,6 +852,7 @@ class ProcessorManager: "timing_send_ms": round(metrics.timing_send_ms, 1) if state.is_running else None, "timing_total_ms": round(metrics.timing_total_ms, 1) if state.is_running else None, "display_index": state.resolved_display_index if state.resolved_display_index is not None else state.settings.display_index, + "overlay_active": state.overlay_active, "last_update": metrics.last_update, "errors": [metrics.last_error] if metrics.last_error else [], **health_info, @@ -897,6 +903,68 @@ class ProcessorManager: return ts.target_id return None + # ===== OVERLAY VISUALIZATION ===== + + async def start_overlay(self, target_id: str, target_name: str = None) -> None: + """Start screen overlay visualization for a target.""" + if target_id not in self._targets: + raise ValueError(f"Target {target_id} not found") + + state = self._targets[target_id] + + if state.overlay_active: + raise RuntimeError(f"Overlay already active for {target_id}") + + # Get device for calibration + if state.device_id not in self._devices: + raise ValueError(f"Device {state.device_id} not found") + + device_state = self._devices[state.device_id] + calibration = device_state.calibration + + # Get display info + display_index = state.resolved_display_index or state.settings.display_index + displays = get_available_displays() + + if display_index >= len(displays): + raise ValueError(f"Invalid display index {display_index}") + + display_info = displays[display_index] + + # Start overlay in background thread + await asyncio.to_thread( + self._overlay_manager.start_overlay, + target_id, display_info, calibration, target_name + ) + + state.overlay_active = True + logger.info(f"Started overlay for target {target_id}") + + async def stop_overlay(self, target_id: str) -> None: + """Stop screen overlay visualization for a target.""" + if target_id not in self._targets: + raise ValueError(f"Target {target_id} not found") + + state = self._targets[target_id] + + if not state.overlay_active: + logger.warning(f"Overlay not active for {target_id}") + return + + await asyncio.to_thread( + self._overlay_manager.stop_overlay, + target_id + ) + + state.overlay_active = False + logger.info(f"Stopped overlay for target {target_id}") + + def is_overlay_active(self, target_id: str) -> bool: + """Check if overlay is active for a target.""" + if target_id not in self._targets: + raise ValueError(f"Target {target_id} not found") + return self._targets[target_id].overlay_active + # ===== CALIBRATION TEST MODE (on device) ===== async def set_test_mode(self, device_id: str, edges: Dict[str, List[int]]) -> None: diff --git a/server/src/wled_controller/core/screen_overlay.py b/server/src/wled_controller/core/screen_overlay.py new file mode 100644 index 0000000..a961bc6 --- /dev/null +++ b/server/src/wled_controller/core/screen_overlay.py @@ -0,0 +1,640 @@ +"""Screen overlay visualization for LED calibration testing.""" + +import colorsys +import logging +import sys +import threading +import time +import tkinter as tk +from typing import Dict, List, Tuple, Optional + +from wled_controller.core.calibration import CalibrationConfig +from wled_controller.core.capture_engines.base import DisplayInfo + +logger = logging.getLogger(__name__) + + +class OverlayWindow: + """Transparent overlay window for LED calibration visualization. + + Runs in a separate thread with its own Tkinter event loop. + Draws border sampling zones, LED position markers, pixel mapping ranges, + and calibration info text. + """ + + def __init__( + self, + display_info: DisplayInfo, + calibration: CalibrationConfig, + target_id: str, + target_name: str = None + ): + """Initialize overlay for a specific display and calibration. + + Args: + display_info: Display geometry (x, y, width, height) + calibration: LED calibration configuration + target_id: Target ID for logging + target_name: Target friendly name for display + """ + self.display_info = display_info + self.calibration = calibration + self.target_id = target_id + self.target_name = target_name or target_id + self.root: Optional[tk.Tk] = None + self.canvas: Optional[tk.Canvas] = None + self.running = False + self._thread: Optional[threading.Thread] = None + self._stop_event = threading.Event() + self._after_id: Optional[str] = None + + def start(self) -> None: + """Start overlay in background thread.""" + if self._thread and self._thread.is_alive(): + raise RuntimeError("Overlay already running") + + self._stop_event.clear() + self._thread = threading.Thread( + target=self._run_tkinter_loop, + name=f"Overlay-{self.target_id}", + daemon=True + ) + self._thread.start() + + def stop(self) -> None: + """Stop overlay and clean up thread.""" + self._stop_event.set() + # Wait for the tkinter thread to fully exit + if self._thread and self._thread.is_alive(): + self._thread.join(timeout=3.0) + # Give extra time for Tcl interpreter cleanup + import time + time.sleep(0.2) + + def _run_tkinter_loop(self) -> None: + """Tkinter event loop (runs in background thread).""" + try: + self.root = tk.Tk() + self._setup_window() + self._draw_visualization() + + # Check stop event periodically + def check_stop(): + if self._stop_event.is_set(): + # Cancel the pending after callback before quitting + if self._after_id: + try: + self.root.after_cancel(self._after_id) + except Exception: + pass + self.root.quit() # Exit mainloop cleanly + else: + self._after_id = self.root.after(100, check_stop) + + self._after_id = self.root.after(100, check_stop) + self.running = True + logger.info(f"Overlay window started for {self.target_id}") + self.root.mainloop() + except Exception as e: + logger.error(f"Overlay error for {self.target_id}: {e}", exc_info=True) + finally: + self.running = False + # Clean up the window properly + if self.root: + try: + # Cancel any remaining after callbacks + if self._after_id: + self.root.after_cancel(self._after_id) + self._after_id = None + # Destroy window and clean up Tcl interpreter + self.root.destroy() + self.root = None + except Exception as e: + logger.debug(f"Cleanup error: {e}") + logger.info(f"Overlay window stopped for {self.target_id}") + + def _setup_window(self) -> None: + """Configure transparent, frameless, always-on-top window.""" + # Position at display coordinates + geometry = f"{self.display_info.width}x{self.display_info.height}" + geometry += f"+{self.display_info.x}+{self.display_info.y}" + self.root.geometry(geometry) + + # Remove window decorations + self.root.overrideredirect(True) + + # Transparent background and always on top + self.root.attributes('-topmost', True) + + # Create canvas for drawing + self.canvas = tk.Canvas( + self.root, + width=self.display_info.width, + height=self.display_info.height, + highlightthickness=0, + bg='black' + ) + self.canvas.pack() + + # Make canvas itself transparent + self.root.wm_attributes('-transparentcolor', 'black') + self.root.attributes('-alpha', 0.85) # Semi-transparent overlay + + # Windows-specific: make click-through + if sys.platform == 'win32': + try: + import ctypes + # Get window handle + hwnd = ctypes.windll.user32.GetParent(self.root.winfo_id()) + # Set WS_EX_LAYERED | WS_EX_TRANSPARENT extended styles + style = ctypes.windll.user32.GetWindowLongW(hwnd, -20) # GWL_EXSTYLE + style |= 0x80000 | 0x20 # WS_EX_LAYERED | WS_EX_TRANSPARENT + ctypes.windll.user32.SetWindowLongW(hwnd, -20, style) + except Exception as e: + logger.warning(f"Could not set click-through: {e}") + + def _draw_visualization(self) -> None: + """Draw all visualization elements on canvas.""" + w = self.display_info.width + h = self.display_info.height + bw = self.calibration.border_width + + # 1. Border sampling zones (colored semi-transparent rectangles) + self._draw_border_zones(w, h, bw) + + # 2. LED axes with tick marks + self._draw_led_axes(w, h, bw) + + # 3. Calibration info text overlay + self._draw_info_text(w, h) + + def _draw_border_zones(self, w: int, h: int, bw: int) -> None: + """Draw colored rectangles showing border sampling zones.""" + # Top zone + self.canvas.create_rectangle( + 0, 0, w, bw, + fill='#FF0000', stipple='gray25', outline='#FF0000', width=2 + ) + # Right zone + self.canvas.create_rectangle( + w - bw, 0, w, h, + fill='#00FF00', stipple='gray25', outline='#00FF00', width=2 + ) + # Bottom zone + self.canvas.create_rectangle( + 0, h - bw, w, h, + fill='#0000FF', stipple='gray25', outline='#0000FF', width=2 + ) + # Left zone + self.canvas.create_rectangle( + 0, 0, bw, h, + fill='#FFFF00', stipple='gray25', outline='#FFFF00', width=2 + ) + + def _draw_led_axes(self, w: int, h: int, bw: int) -> None: + """Draw axes with tick marks showing LED positions.""" + segments = self.calibration.segments + total_leds = self.calibration.get_total_leds() + + # Determine tick interval based on total LEDs + if total_leds <= 50: + tick_interval = 5 + elif total_leds <= 100: + tick_interval = 10 + elif total_leds <= 200: + tick_interval = 20 + else: + tick_interval = 50 + + led_index = 0 + for seg in segments: + edge = seg.edge + count = seg.led_count + reverse = seg.reverse + + # Get span for this edge + span_start, span_end = self.calibration.get_edge_span(edge) + + # Draw axis line and tick marks for this edge + if edge == 'top': + edge_len = w + start_px = span_start * edge_len + end_px = span_end * edge_len + y_axis = bw / 2 + + # Draw axis line + self.canvas.create_line( + start_px, y_axis, end_px, y_axis, + fill='white', width=3 + ) + + # Draw tick marks + for i in range(count): + display_index = (led_index + self.calibration.offset) % total_leds + + if i == 0 or i == count - 1 or display_index % tick_interval == 0: + frac = i / count if count > 1 else 0.5 + x = start_px + frac * (end_px - start_px) + + # Tick mark + tick_len = 15 if display_index % tick_interval == 0 else 10 + self.canvas.create_line( + x, y_axis - tick_len, x, y_axis + tick_len, + fill='white', width=2 + ) + + # Label with background (positioned INSIDE screen) + label_y = bw + 20 + self.canvas.create_rectangle( + x - 30, label_y - 12, x + 30, label_y + 12, + fill='white', outline='black', width=2 + ) + self.canvas.create_text( + x, label_y, + text=str(display_index), + fill='black', + font=('Arial', 13, 'bold') + ) + + led_index += 1 + + elif edge == 'bottom': + edge_len = w + start_px = span_start * edge_len + end_px = span_end * edge_len + y_axis = h - bw / 2 + + # Draw axis line + self.canvas.create_line( + start_px, y_axis, end_px, y_axis, + fill='white', width=3 + ) + + # Draw tick marks + for i in range(count): + display_index = (led_index + self.calibration.offset) % total_leds + + if i == 0 or i == count - 1 or display_index % tick_interval == 0: + frac = i / count if count > 1 else 0.5 + x = start_px + frac * (end_px - start_px) + + # Tick mark + tick_len = 15 if display_index % tick_interval == 0 else 10 + self.canvas.create_line( + x, y_axis - tick_len, x, y_axis + tick_len, + fill='white', width=2 + ) + + # Label with background (positioned INSIDE screen) + label_y = h - bw - 20 + self.canvas.create_rectangle( + x - 30, label_y - 12, x + 30, label_y + 12, + fill='white', outline='black', width=2 + ) + self.canvas.create_text( + x, label_y, + text=str(display_index), + fill='black', + font=('Arial', 13, 'bold') + ) + + led_index += 1 + + elif edge == 'left': + edge_len = h + start_px = span_start * edge_len + end_px = span_end * edge_len + x_axis = bw / 2 + + # Draw axis line + self.canvas.create_line( + x_axis, start_px, x_axis, end_px, + fill='white', width=3 + ) + + # Draw tick marks + for i in range(count): + display_index = (led_index + self.calibration.offset) % total_leds + + if i == 0 or i == count - 1 or display_index % tick_interval == 0: + frac = i / count if count > 1 else 0.5 + y = start_px + frac * (end_px - start_px) + + # Tick mark + tick_len = 15 if display_index % tick_interval == 0 else 10 + self.canvas.create_line( + x_axis - tick_len, y, x_axis + tick_len, y, + fill='white', width=2 + ) + + # Label with background (positioned INSIDE screen) + label_x = bw + 40 + self.canvas.create_rectangle( + label_x - 30, y - 12, label_x + 30, y + 12, + fill='white', outline='black', width=2 + ) + self.canvas.create_text( + label_x, y, + text=str(display_index), + fill='black', + font=('Arial', 13, 'bold') + ) + + led_index += 1 + + elif edge == 'right': + edge_len = h + start_px = span_start * edge_len + end_px = span_end * edge_len + x_axis = w - bw / 2 + + # Draw axis line + self.canvas.create_line( + x_axis, start_px, x_axis, end_px, + fill='white', width=3 + ) + + # Draw tick marks + for i in range(count): + display_index = (led_index + self.calibration.offset) % total_leds + + if i == 0 or i == count - 1 or display_index % tick_interval == 0: + frac = i / count if count > 1 else 0.5 + y = start_px + frac * (end_px - start_px) + + # Tick mark + tick_len = 15 if display_index % tick_interval == 0 else 10 + self.canvas.create_line( + x_axis - tick_len, y, x_axis + tick_len, y, + fill='white', width=2 + ) + + # Label with background (positioned INSIDE screen) + label_x = w - bw - 40 + self.canvas.create_rectangle( + label_x - 30, y - 12, label_x + 30, y + 12, + fill='white', outline='black', width=2 + ) + self.canvas.create_text( + label_x, y, + text=str(display_index), + fill='black', + font=('Arial', 13, 'bold') + ) + + led_index += 1 + + def _calculate_led_positions( + self, + segment, + w: int, h: int, bw: int + ) -> List[Tuple[float, float]]: + """Calculate (x, y) positions for each LED in a segment.""" + edge = segment.edge + count = segment.led_count + reverse = segment.reverse + + # Get span for this edge + span_start, span_end = self.calibration.get_edge_span(edge) + + positions = [] + + if edge == 'top': + edge_len = w + start_px = span_start * edge_len + end_px = span_end * edge_len + y = bw / 2 # Middle of border zone + + for i in range(count): + frac = i / count if count > 1 else 0.5 + x = start_px + frac * (end_px - start_px) + positions.append((x, y)) + + elif edge == 'bottom': + edge_len = w + start_px = span_start * edge_len + end_px = span_end * edge_len + y = h - bw / 2 + + for i in range(count): + frac = i / count if count > 1 else 0.5 + x = start_px + frac * (end_px - start_px) + positions.append((x, y)) + + elif edge == 'left': + edge_len = h + start_px = span_start * edge_len + end_px = span_end * edge_len + x = bw / 2 + + for i in range(count): + frac = i / count if count > 1 else 0.5 + y = start_px + frac * (end_px - start_px) + positions.append((x, y)) + + elif edge == 'right': + edge_len = h + start_px = span_start * edge_len + end_px = span_end * edge_len + x = w - bw / 2 + + for i in range(count): + frac = i / count if count > 1 else 0.5 + y = start_px + frac * (end_px - start_px) + positions.append((x, y)) + + if reverse: + positions.reverse() + + return positions + + def _draw_mapping_ranges(self, w: int, h: int, bw: int) -> None: + """Draw colored segments showing pixel ranges for each LED.""" + segments = self.calibration.segments + + # Generate distinct colors for each LED using HSV + total_leds = self.calibration.get_total_leds() + colors = [] + for i in range(total_leds): + hue = (i / total_leds) * 360 + colors.append(self._hsv_to_rgb(hue, 0.7, 0.9)) + + led_index = 0 + for seg in segments: + ranges = self._calculate_pixel_ranges(seg, w, h) + + for i, (x1, y1, x2, y2) in enumerate(ranges): + color = colors[led_index % len(colors)] + + # Draw line with LED's color + self.canvas.create_line( + x1, y1, x2, y2, + fill=color, width=3, capstyle='round' + ) + + led_index += 1 + + def _calculate_pixel_ranges( + self, + segment, + w: int, h: int + ) -> List[Tuple[float, float, float, float]]: + """Calculate pixel range boundaries for each LED in segment. + + Returns list of (x1, y1, x2, y2) line coordinates. + """ + edge = segment.edge + count = segment.led_count + span_start, span_end = self.calibration.get_edge_span(edge) + + ranges = [] + + if edge == 'top': + edge_len = w + start_px = span_start * edge_len + end_px = span_end * edge_len + segment_len = (end_px - start_px) / count if count > 0 else 0 + + for i in range(count): + x1 = start_px + i * segment_len + x2 = start_px + (i + 1) * segment_len + ranges.append((x1, 0, x2, 0)) + + elif edge == 'bottom': + edge_len = w + start_px = span_start * edge_len + end_px = span_end * edge_len + segment_len = (end_px - start_px) / count if count > 0 else 0 + + for i in range(count): + x1 = start_px + i * segment_len + x2 = start_px + (i + 1) * segment_len + ranges.append((x1, h, x2, h)) + + elif edge == 'left': + edge_len = h + start_px = span_start * edge_len + end_px = span_end * edge_len + segment_len = (end_px - start_px) / count if count > 0 else 0 + + for i in range(count): + y1 = start_px + i * segment_len + y2 = start_px + (i + 1) * segment_len + ranges.append((0, y1, 0, y2)) + + elif edge == 'right': + edge_len = h + start_px = span_start * edge_len + end_px = span_end * edge_len + segment_len = (end_px - start_px) / count if count > 0 else 0 + + for i in range(count): + y1 = start_px + i * segment_len + y2 = start_px + (i + 1) * segment_len + ranges.append((w, y1, w, y2)) + + return ranges + + def _draw_info_text(self, w: int, h: int) -> None: + """Draw calibration info text overlay.""" + info_lines = [ + f"Target: {self.target_name}", + f"Total LEDs: {self.calibration.get_total_leds()}", + f"Start: {self.calibration.start_position.replace('_', ' ').title()}", + f"Direction: {self.calibration.layout.title()}", + f"Offset: {self.calibration.offset}", + f"Border Width: {self.calibration.border_width}px", + "", + f"Top: {self.calibration.leds_top} LEDs", + f"Right: {self.calibration.leds_right} LEDs", + f"Bottom: {self.calibration.leds_bottom} LEDs", + f"Left: {self.calibration.leds_left} LEDs", + ] + + # Draw background box with better readability + text_x = 30 + text_y = 30 + line_height = 28 + padding = 20 + box_width = 320 + box_height = len(info_lines) * line_height + padding * 2 + + # Solid dark background with border + self.canvas.create_rectangle( + text_x - padding, text_y - padding, + text_x + box_width, text_y + box_height, + fill='#1a1a1a', outline='white', width=3 + ) + + # Draw text lines with better spacing + for i, line in enumerate(info_lines): + self.canvas.create_text( + text_x, text_y + i * line_height, + text=line, + anchor='nw', + fill='yellow', + font=('Arial', 14, 'bold') + ) + + @staticmethod + def _hsv_to_rgb(h: float, s: float, v: float) -> str: + """Convert HSV to hex RGB color string.""" + r, g, b = colorsys.hsv_to_rgb(h / 360, s, v) + return f"#{int(r*255):02x}{int(g*255):02x}{int(b*255):02x}" + + +class OverlayManager: + """Manages overlay windows across multiple targets.""" + + def __init__(self): + self._overlays: Dict[str, OverlayWindow] = {} + self._lock = threading.Lock() + + def start_overlay( + self, + target_id: str, + display_info: DisplayInfo, + calibration: CalibrationConfig, + target_name: str = None + ) -> None: + """Start overlay for a target.""" + with self._lock: + if target_id in self._overlays: + raise RuntimeError(f"Overlay already running for {target_id}") + + overlay = OverlayWindow(display_info, calibration, target_id, target_name) + overlay.start() + + # Wait for overlay to initialize + timeout = 3.0 + start = time.time() + while not overlay.running and time.time() - start < timeout: + time.sleep(0.1) + + if not overlay.running: + overlay.stop() + raise RuntimeError("Overlay failed to start within timeout") + + self._overlays[target_id] = overlay + logger.info(f"Started overlay for target {target_id}") + + def stop_overlay(self, target_id: str) -> None: + """Stop overlay for a target.""" + with self._lock: + overlay = self._overlays.pop(target_id, None) + if overlay: + overlay.stop() + logger.info(f"Stopped overlay for target {target_id}") + + def is_running(self, target_id: str) -> bool: + """Check if overlay is running for a target.""" + with self._lock: + return target_id in self._overlays + + def stop_all(self) -> None: + """Stop all overlays.""" + with self._lock: + for target_id in list(self._overlays.keys()): + overlay = self._overlays.pop(target_id) + overlay.stop() + logger.info("Stopped all overlays") diff --git a/server/src/wled_controller/static/app.js b/server/src/wled_controller/static/app.js index cbab035..8da24e4 100644 --- a/server/src/wled_controller/static/app.js +++ b/server/src/wled_controller/static/app.js @@ -4704,6 +4704,15 @@ function createTargetCard(target, deviceMap, sourceMap) { + ${state.overlay_active ? ` + + ` : ` + + `} `; @@ -4747,6 +4756,44 @@ async function stopTargetProcessing(targetId) { } } +async function startTargetOverlay(targetId) { + try { + const response = await fetch(`${API_BASE}/picture-targets/${targetId}/overlay/start`, { + method: 'POST', + headers: getHeaders() + }); + if (response.status === 401) { handle401Error(); return; } + if (response.ok) { + showToast(t('overlay.started'), 'success'); + loadTargets(); + } else { + const error = await response.json(); + showToast(t('overlay.error.start') + ': ' + error.detail, 'error'); + } + } catch (error) { + showToast(t('overlay.error.start'), 'error'); + } +} + +async function stopTargetOverlay(targetId) { + try { + const response = await fetch(`${API_BASE}/picture-targets/${targetId}/overlay/stop`, { + method: 'POST', + headers: getHeaders() + }); + if (response.status === 401) { handle401Error(); return; } + if (response.ok) { + showToast(t('overlay.stopped'), 'success'); + loadTargets(); + } else { + const error = await response.json(); + showToast(t('overlay.error.stop') + ': ' + error.detail, 'error'); + } + } catch (error) { + showToast(t('overlay.error.stop'), 'error'); + } +} + async function deleteTarget(targetId) { const confirmed = await showConfirm(t('targets.delete.confirm')); if (!confirmed) return; diff --git a/server/src/wled_controller/static/locales/en.json b/server/src/wled_controller/static/locales/en.json index a9ddab5..e4c2154 100644 --- a/server/src/wled_controller/static/locales/en.json +++ b/server/src/wled_controller/static/locales/en.json @@ -439,5 +439,17 @@ "pattern.name.hint": "A descriptive name for this rectangle layout", "pattern.description.hint": "Optional notes about where or how this pattern is used", "pattern.visual_editor.hint": "Click + buttons to add rectangles. Drag edges to resize, drag inside to move.", - "pattern.rectangles.hint": "Fine-tune rectangle positions and sizes with exact coordinates (0.0 to 1.0)" + "pattern.rectangles.hint": "Fine-tune rectangle positions and sizes with exact coordinates (0.0 to 1.0)", + "overlay": { + "button": { + "show": "Show overlay visualization", + "hide": "Hide overlay visualization" + }, + "started": "Overlay visualization started", + "stopped": "Overlay visualization stopped", + "error": { + "start": "Failed to start overlay", + "stop": "Failed to stop overlay" + } + } } diff --git a/server/src/wled_controller/static/locales/ru.json b/server/src/wled_controller/static/locales/ru.json index 1a17137..2f8c8c0 100644 --- a/server/src/wled_controller/static/locales/ru.json +++ b/server/src/wled_controller/static/locales/ru.json @@ -439,5 +439,17 @@ "pattern.name.hint": "Описательное имя для этой раскладки прямоугольников", "pattern.description.hint": "Необязательные заметки о назначении этого паттерна", "pattern.visual_editor.hint": "Нажмите кнопки + чтобы добавить прямоугольники. Тяните края для изменения размера, тяните внутри для перемещения.", - "pattern.rectangles.hint": "Точная настройка позиций и размеров прямоугольников в координатах (0.0 до 1.0)" + "pattern.rectangles.hint": "Точная настройка позиций и размеров прямоугольников в координатах (0.0 до 1.0)", + "overlay": { + "button": { + "show": "Показать визуализацию наложения", + "hide": "Скрыть визуализацию наложения" + }, + "started": "Визуализация наложения запущена", + "stopped": "Визуализация наложения остановлена", + "error": { + "start": "Не удалось запустить наложение", + "stop": "Не удалось остановить наложение" + } + } }