Remove target segments, use single color strip source per target

Segments are redundant now that the "mapped" CSS type handles spatial
multiplexing internally. Each target now references one color_strip_source_id
instead of an array of segments with start/end/reverse ranges.

Backward compat: existing targets with old segments format are migrated
on load by extracting the first segment's CSS source ID.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-24 00:00:26 +03:00
parent 9efb08acb6
commit 808037775f
14 changed files with 171 additions and 513 deletions

View File

@@ -82,6 +82,10 @@ powershell -ExecutionPolicy Bypass -File "c:\Users\Alexei\Documents\wled-screen-
**Do NOT use** `Stop-Process -Name python` (kills unrelated Python processes like VS Code extensions) or bash background `&` jobs (get killed when the shell session ends).
## Default Config & API Key
The server configuration is in `/server/config/default_config.yaml`. The default API key for development is `development-key-change-in-production` (label: `dev`). The server runs on port **8080** by default.
## Project Structure
This is a monorepo containing:

View File

@@ -32,7 +32,6 @@ from wled_controller.api.schemas.picture_targets import (
PictureTargetUpdate,
TargetMetricsResponse,
TargetProcessingState,
TargetSegmentSchema,
)
from wled_controller.config import get_config
from wled_controller.core.capture_engines import EngineRegistry
@@ -94,15 +93,7 @@ def _target_to_response(target) -> PictureTargetResponse:
name=target.name,
target_type=target.target_type,
device_id=target.device_id,
segments=[
TargetSegmentSchema(
color_strip_source_id=s.color_strip_source_id,
start=s.start,
end=s.end,
reverse=s.reverse,
)
for s in target.segments
],
color_strip_source_id=target.color_strip_source_id,
fps=target.fps,
keepalive_interval=target.keepalive_interval,
state_check_interval=target.state_check_interval,
@@ -157,7 +148,7 @@ async def create_target(
name=data.name,
target_type=data.target_type,
device_id=data.device_id,
segments=[s.model_dump() for s in data.segments] if data.segments else None,
color_strip_source_id=data.color_strip_source_id,
fps=data.fps,
keepalive_interval=data.keepalive_interval,
state_check_interval=data.state_check_interval,
@@ -267,12 +258,11 @@ async def update_target(
kc_settings = _kc_schema_to_settings(data.key_colors_settings)
# Update in store
segments_dicts = [s.model_dump() for s in data.segments] if data.segments is not None else None
target = target_store.update_target(
target_id=target_id,
name=data.name,
device_id=data.device_id,
segments=segments_dicts,
color_strip_source_id=data.color_strip_source_id,
fps=data.fps,
keepalive_interval=data.keepalive_interval,
state_check_interval=data.state_check_interval,
@@ -288,7 +278,7 @@ async def update_target(
data.keepalive_interval is not None or
data.state_check_interval is not None or
data.key_colors_settings is not None),
segments_changed=data.segments is not None,
css_changed=data.color_strip_source_id is not None,
device_changed=data.device_id is not None,
)
except ValueError:
@@ -756,9 +746,8 @@ async def start_target_overlay(
# can start even when processing is not currently running.
calibration = None
display_info = None
if isinstance(target, WledPictureTarget) and target.segments:
# Use the first segment's CSS for calibration/overlay
first_css_id = target.segments[0].color_strip_source_id
if isinstance(target, WledPictureTarget) and target.color_strip_source_id:
first_css_id = target.color_strip_source_id
if first_css_id:
try:
css = color_strip_store.get_source(first_css_id)

View File

@@ -1,7 +1,7 @@
"""Picture target schemas (CRUD, processing state, metrics)."""
from datetime import datetime
from typing import Dict, List, Optional
from typing import Dict, Optional, List
from pydantic import BaseModel, Field
@@ -45,15 +45,6 @@ class KeyColorsResponse(BaseModel):
timestamp: Optional[datetime] = Field(None, description="Extraction timestamp")
class TargetSegmentSchema(BaseModel):
"""A segment mapping a color strip source to a pixel range on the device."""
color_strip_source_id: str = Field(default="", description="Color strip source ID")
start: int = Field(default=0, ge=0, description="Start pixel (inclusive)")
end: int = Field(default=0, ge=0, description="End pixel (exclusive, 0 = auto-fit)")
reverse: bool = Field(default=False, description="Reverse pixel order within segment")
class PictureTargetCreate(BaseModel):
"""Request to create a picture target."""
@@ -61,7 +52,7 @@ class PictureTargetCreate(BaseModel):
target_type: str = Field(default="led", description="Target type (led, key_colors)")
# LED target fields
device_id: str = Field(default="", description="LED device ID")
segments: List[TargetSegmentSchema] = Field(default_factory=list, description="LED segments")
color_strip_source_id: str = Field(default="", description="Color strip source ID")
fps: int = Field(default=30, ge=1, le=90, description="Target send FPS (1-90)")
keepalive_interval: float = Field(default=1.0, description="Keepalive send interval when screen is static (0.5-5.0s)", ge=0.5, le=5.0)
state_check_interval: int = Field(default=DEFAULT_STATE_CHECK_INTERVAL, description="Device health check interval (5-600s)", ge=5, le=600)
@@ -77,7 +68,7 @@ class PictureTargetUpdate(BaseModel):
name: Optional[str] = Field(None, description="Target name", min_length=1, max_length=100)
# LED target fields
device_id: Optional[str] = Field(None, description="LED device ID")
segments: Optional[List[TargetSegmentSchema]] = Field(None, description="LED segments")
color_strip_source_id: Optional[str] = Field(None, description="Color strip source ID")
fps: Optional[int] = Field(None, ge=1, le=90, description="Target send FPS (1-90)")
keepalive_interval: Optional[float] = Field(None, description="Keepalive interval (0.5-5.0s)", ge=0.5, le=5.0)
state_check_interval: Optional[int] = Field(None, description="Health check interval (5-600s)", ge=5, le=600)
@@ -95,7 +86,7 @@ class PictureTargetResponse(BaseModel):
target_type: str = Field(description="Target type")
# LED target fields
device_id: str = Field(default="", description="LED device ID")
segments: List[TargetSegmentSchema] = Field(default_factory=list, description="LED segments")
color_strip_source_id: str = Field(default="", description="Color strip source ID")
fps: Optional[int] = Field(None, description="Target send FPS")
keepalive_interval: float = Field(default=1.0, description="Keepalive interval (s)")
state_check_interval: int = Field(default=DEFAULT_STATE_CHECK_INTERVAL, description="Health check interval (s)")
@@ -119,7 +110,7 @@ class TargetProcessingState(BaseModel):
target_id: str = Field(description="Target ID")
device_id: Optional[str] = Field(None, description="Device ID")
segments: List[TargetSegmentSchema] = Field(default_factory=list, description="LED segments")
color_strip_source_id: str = Field(default="", description="Color strip source ID")
processing: bool = Field(description="Whether processing is active")
fps_actual: Optional[float] = Field(None, description="Actual FPS achieved")
fps_potential: Optional[float] = Field(None, description="Potential FPS (processing speed without throttle)")

View File

@@ -272,7 +272,7 @@ class ProcessorManager:
self,
target_id: str,
device_id: str,
segments: Optional[list] = None,
color_strip_source_id: str = "",
fps: int = 30,
keepalive_interval: float = 1.0,
state_check_interval: int = DEFAULT_STATE_CHECK_INTERVAL,
@@ -286,7 +286,7 @@ class ProcessorManager:
proc = WledTargetProcessor(
target_id=target_id,
device_id=device_id,
segments=segments,
color_strip_source_id=color_strip_source_id,
fps=fps,
keepalive_interval=keepalive_interval,
state_check_interval=state_check_interval,
@@ -335,10 +335,10 @@ class ProcessorManager:
proc = self._get_processor(target_id)
proc.update_source(picture_source_id)
def update_target_segments(self, target_id: str, segments: list):
"""Update the segments for a WLED target."""
def update_target_css(self, target_id: str, color_strip_source_id: str):
"""Update the color strip source for a WLED target."""
proc = self._get_processor(target_id)
proc.update_segments(segments)
proc.update_css_source(color_strip_source_id)
def update_target_device(self, target_id: str, device_id: str):
"""Update the device for a target."""

View File

@@ -160,8 +160,8 @@ class TargetProcessor(ABC):
"""Update device association. Raises for targets without devices."""
raise ValueError(f"Target {self._target_id} does not support device assignment")
def update_segments(self, segments: list) -> None:
"""Update segments. No-op for targets that don't use segments."""
def update_css_source(self, color_strip_source_id: str) -> None:
"""Update the color strip source. No-op for targets that don't use CSS."""
pass
# ----- Device / display info (overridden by device-aware subclasses) -----

View File

@@ -1,4 +1,4 @@
"""WLED/LED target processor — gets colors from ColorStripStreams, sends via DDP."""
"""WLED/LED target processor — gets colors from a ColorStripStream, sends via DDP."""
from __future__ import annotations
@@ -6,7 +6,7 @@ import asyncio
import collections
import time
from datetime import datetime
from typing import List, Optional, Tuple
from typing import Optional
import numpy as np
@@ -24,47 +24,14 @@ from wled_controller.utils.timer import high_resolution_timer
logger = get_logger(__name__)
# ---------------------------------------------------------------------------
# Resolved segment info used inside the processing loop
# ---------------------------------------------------------------------------
def _resolve_segments(segments: List[dict], device_led_count: int) -> List[dict]:
"""Resolve auto-fit segments based on device LED count.
A single segment with ``end == 0`` auto-fits to the full device.
Multiple segments with ``end == 0`` are left as-is (invalid but we
clamp gracefully).
"""
resolved = []
for seg in segments:
css_id = seg.get("color_strip_source_id", "")
start = max(0, seg.get("start", 0))
end = seg.get("end", 0)
reverse = seg.get("reverse", False)
if end <= 0:
end = device_led_count
end = min(end, device_led_count)
start = min(start, end)
resolved.append({"css_id": css_id, "start": start, "end": end, "reverse": reverse})
return resolved
# ---------------------------------------------------------------------------
# WledTargetProcessor
# ---------------------------------------------------------------------------
class WledTargetProcessor(TargetProcessor):
"""Streams LED colors from one or more ColorStripStreams to a WLED/LED device.
Each segment maps a CSS source to a pixel range on the device.
Gaps between segments stay black.
"""
"""Streams LED colors from a single ColorStripStream to a WLED/LED device."""
def __init__(
self,
target_id: str,
device_id: str,
segments: Optional[List[dict]] = None,
color_strip_source_id: str = "",
fps: int = 30,
keepalive_interval: float = 1.0,
state_check_interval: int = 30,
@@ -75,12 +42,11 @@ class WledTargetProcessor(TargetProcessor):
self._target_fps = fps if fps > 0 else 30
self._keepalive_interval = keepalive_interval
self._state_check_interval = state_check_interval
self._segments = list(segments) if segments else []
self._css_id = color_strip_source_id
# Runtime state (populated on start)
self._led_client: Optional[LEDClient] = None
# List of (resolved_seg_dict, stream) tuples — read by the loop
self._segment_streams: List[Tuple[dict, object]] = []
self._css_stream: Optional[object] = None # active stream reference
self._device_state_before: Optional[dict] = None
self._overlay_active = False
self._needs_keepalive = True
@@ -126,57 +92,35 @@ class WledTargetProcessor(TargetProcessor):
logger.error(f"Failed to connect to LED device for target {self._target_id}: {e}")
raise RuntimeError(f"Failed to connect to LED device: {e}")
# Acquire color strip streams for each segment
# Acquire color strip stream
css_manager = self._ctx.color_strip_stream_manager
if css_manager is None:
await self._led_client.close()
self._led_client = None
raise RuntimeError("Color strip stream manager not available in context")
if not self._segments:
if not self._css_id:
await self._led_client.close()
self._led_client = None
raise RuntimeError(f"Target {self._target_id} has no segments configured")
resolved = _resolve_segments(self._segments, device_info.led_count)
segment_streams: List[Tuple[dict, object]] = []
raise RuntimeError(f"Target {self._target_id} has no color strip source configured")
try:
for seg in resolved:
if not seg["css_id"]:
continue
stream = await asyncio.to_thread(css_manager.acquire, seg["css_id"], self._target_id)
seg_len = seg["end"] - seg["start"]
if hasattr(stream, "configure") and seg_len > 0:
stream.configure(seg_len)
css_manager.notify_target_fps(seg["css_id"], self._target_id, self._target_fps)
segment_streams.append((seg, stream))
stream = await asyncio.to_thread(css_manager.acquire, self._css_id, self._target_id)
if hasattr(stream, "configure") and device_info.led_count > 0:
stream.configure(device_info.led_count)
css_manager.notify_target_fps(self._css_id, self._target_id, self._target_fps)
# Resolve display index from first stream that has one
self._resolved_display_index = None
for _, s in segment_streams:
di = getattr(s, "display_index", None)
if di is not None:
self._resolved_display_index = di
break
self._resolved_display_index = getattr(stream, "display_index", None)
self._css_stream = stream
self._segment_streams = segment_streams
seg_desc = ", ".join(f"{s['css_id']}[{s['start']}:{s['end']}]" for s in resolved if s["css_id"])
logger.info(
f"Acquired {len(segment_streams)} segment stream(s) for target {self._target_id}: {seg_desc}"
f"Acquired CSS stream '{self._css_id}' for target {self._target_id}"
)
except Exception as e:
# Release any streams we already acquired
for seg, stream in segment_streams:
try:
css_manager.release(seg["css_id"], self._target_id)
except Exception:
pass
if self._led_client:
await self._led_client.close()
self._led_client = None
raise RuntimeError(f"Failed to acquire segment streams: {e}")
raise RuntimeError(f"Failed to acquire CSS stream: {e}")
# Reset metrics and start loop
self._metrics = ProcessingMetrics(start_time=datetime.utcnow())
@@ -213,16 +157,15 @@ class WledTargetProcessor(TargetProcessor):
await self._led_client.close()
self._led_client = None
# Release all segment streams
# Release CSS stream
css_manager = self._ctx.color_strip_stream_manager
if css_manager:
for seg, stream in self._segment_streams:
try:
css_manager.remove_target_fps(seg["css_id"], self._target_id)
await asyncio.to_thread(css_manager.release, seg["css_id"], self._target_id)
except Exception as e:
logger.warning(f"Error releasing segment stream {seg['css_id']} for {self._target_id}: {e}")
self._segment_streams = []
if css_manager and self._css_stream is not None:
try:
css_manager.remove_target_fps(self._css_id, self._target_id)
await asyncio.to_thread(css_manager.release, self._css_id, self._target_id)
except Exception as e:
logger.warning(f"Error releasing CSS stream {self._css_id} for {self._target_id}: {e}")
self._css_stream = None
logger.info(f"Stopped processing for target {self._target_id}")
self._ctx.fire_event({"type": "state_change", "target_id": self._target_id, "processing": False})
@@ -235,9 +178,8 @@ class WledTargetProcessor(TargetProcessor):
if "fps" in settings:
self._target_fps = settings["fps"] if settings["fps"] > 0 else 30
css_manager = self._ctx.color_strip_stream_manager
if css_manager and self._is_running:
for seg, _ in self._segment_streams:
css_manager.notify_target_fps(seg["css_id"], self._target_id, self._target_fps)
if css_manager and self._is_running and self._css_id:
css_manager.notify_target_fps(self._css_id, self._target_id, self._target_fps)
if "keepalive_interval" in settings:
self._keepalive_interval = settings["keepalive_interval"]
if "state_check_interval" in settings:
@@ -248,9 +190,10 @@ class WledTargetProcessor(TargetProcessor):
"""Update the device this target streams to."""
self._device_id = device_id
def update_segments(self, new_segments: List[dict]) -> None:
"""Hot-swap all segments for a running target."""
self._segments = list(new_segments)
def update_css_source(self, new_css_id: str) -> None:
"""Hot-swap the color strip source for a running target."""
old_css_id = self._css_id
self._css_id = new_css_id
if not self._is_running:
return
@@ -262,42 +205,35 @@ class WledTargetProcessor(TargetProcessor):
device_info = self._ctx.get_device_info(self._device_id)
device_leds = device_info.led_count if device_info else 0
# Release old streams
for seg, stream in self._segment_streams:
# Release old stream
if self._css_stream is not None and old_css_id:
try:
css_manager.remove_target_fps(seg["css_id"], self._target_id)
css_manager.release(seg["css_id"], self._target_id)
css_manager.remove_target_fps(old_css_id, self._target_id)
css_manager.release(old_css_id, self._target_id)
except Exception as e:
logger.warning(f"Error releasing segment {seg['css_id']}: {e}")
logger.warning(f"Error releasing old CSS {old_css_id}: {e}")
# Acquire new streams
resolved = _resolve_segments(new_segments, device_leds)
new_stream_list: List[Tuple[dict, object]] = []
for seg in resolved:
if not seg["css_id"]:
continue
# Acquire new stream
new_stream = None
if new_css_id:
try:
stream = css_manager.acquire(seg["css_id"], self._target_id)
seg_len = seg["end"] - seg["start"]
if hasattr(stream, "configure") and seg_len > 0:
stream.configure(seg_len)
css_manager.notify_target_fps(seg["css_id"], self._target_id, self._target_fps)
new_stream_list.append((seg, stream))
new_stream = css_manager.acquire(new_css_id, self._target_id)
if hasattr(new_stream, "configure") and device_leds > 0:
new_stream.configure(device_leds)
css_manager.notify_target_fps(new_css_id, self._target_id, self._target_fps)
except Exception as e:
logger.error(f"Failed to acquire segment {seg['css_id']}: {e}")
logger.error(f"Failed to acquire new CSS {new_css_id}: {e}")
# Atomic swap — the processing loop re-reads this reference each tick
self._segment_streams = new_stream_list
logger.info(f"Hot-swapped segments for {self._target_id}: {len(new_stream_list)} segment(s)")
# Atomic swap — the processing loop detects via identity check
self._css_stream = new_stream
logger.info(f"Hot-swapped CSS for {self._target_id}: {old_css_id} -> {new_css_id}")
def get_display_index(self) -> Optional[int]:
"""Display index being captured, from the first active stream."""
"""Display index being captured, from the active stream."""
if self._resolved_display_index is not None:
return self._resolved_display_index
for _, stream in self._segment_streams:
di = getattr(stream, "display_index", None)
if di is not None:
return di
if self._css_stream is not None:
return getattr(self._css_stream, "display_index", None)
return None
# ----- State / Metrics -----
@@ -307,8 +243,8 @@ class WledTargetProcessor(TargetProcessor):
fps_target = self._target_fps
css_timing: dict = {}
if self._is_running and self._segment_streams:
css_timing = self._segment_streams[0][1].get_last_timing()
if self._is_running and self._css_stream is not None:
css_timing = self._css_stream.get_last_timing()
send_ms = round(metrics.timing_send_ms, 1) if self._is_running else None
extract_ms = round(css_timing.get("extract_ms", 0), 1) if css_timing else None
@@ -321,19 +257,10 @@ class WledTargetProcessor(TargetProcessor):
else:
total_ms = None
# Serialize segments for the dashboard
segments_info = [
{"color_strip_source_id": seg["css_id"], "start": seg["start"],
"end": seg["end"], "reverse": seg.get("reverse", False)}
for seg, _ in self._segment_streams
] if self._segment_streams else [
s for s in self._segments
]
return {
"target_id": self._target_id,
"device_id": self._device_id,
"segments": segments_info,
"color_strip_source_id": self._css_id,
"processing": self._is_running,
"fps_actual": metrics.fps_actual if self._is_running else None,
"fps_potential": metrics.fps_potential if self._is_running else None,
@@ -383,25 +310,19 @@ class WledTargetProcessor(TargetProcessor):
raise RuntimeError(f"Overlay already active for {self._target_id}")
if calibration is None or display_info is None:
# Find calibration from the first picture stream
stream_with_cal = None
for _, s in self._segment_streams:
if hasattr(s, "calibration") and s.calibration:
stream_with_cal = s
break
if stream_with_cal is None:
stream = self._css_stream
if stream is None or not (hasattr(stream, "calibration") and stream.calibration):
raise ValueError(
f"Cannot start overlay for {self._target_id}: no stream with calibration"
)
if calibration is None:
calibration = stream_with_cal.calibration
calibration = stream.calibration
if display_info is None:
display_index = self._resolved_display_index
if display_index is None:
display_index = getattr(stream_with_cal, "display_index", None)
display_index = getattr(stream, "display_index", None)
if display_index is None or display_index < 0:
raise ValueError(f"Invalid display index {display_index} for overlay")
displays = get_available_displays()
@@ -435,13 +356,6 @@ class WledTargetProcessor(TargetProcessor):
# ----- Private: processing loop -----
@staticmethod
def _apply_brightness(colors: np.ndarray, device_info: Optional[DeviceInfo]) -> np.ndarray:
"""Apply device software_brightness if < 255."""
if device_info and device_info.software_brightness < 255:
return (colors.astype(np.uint16) * device_info.software_brightness >> 8).astype(np.uint8)
return colors
@staticmethod
def _fit_to_device(colors: np.ndarray, device_led_count: int) -> np.ndarray:
"""Resample colors to match the target LED count."""
@@ -457,7 +371,7 @@ class WledTargetProcessor(TargetProcessor):
return result
async def _processing_loop(self) -> None:
"""Main processing loop — poll segment streams → compose → brightness send."""
"""Main processing loop — poll CSS stream -> brightness -> send."""
keepalive_interval = self._keepalive_interval
fps_samples: collections.deque = collections.deque(maxlen=10)
@@ -468,13 +382,9 @@ class WledTargetProcessor(TargetProcessor):
_init_device_info = self._ctx.get_device_info(self._device_id)
_total_leds = _init_device_info.led_count if _init_device_info else 0
# Device-sized output buffer (persistent between frames; gaps stay black)
device_buf = np.zeros((_total_leds, 3), dtype=np.uint8)
# Segment stream references — re-read each tick to detect hot-swaps
segment_streams = self._segment_streams
# Per-stream identity tracking for "same frame" detection
prev_refs: list = [None] * len(segment_streams)
# Stream reference — re-read each tick to detect hot-swaps
stream = self._css_stream
prev_frame_ref = None
has_any_frame = False
# Pre-allocate brightness scratch (uint16 intermediate + uint8 output)
@@ -510,7 +420,7 @@ class WledTargetProcessor(TargetProcessor):
logger.info(
f"Processing loop started for target {self._target_id} "
f"({len(segment_streams)} segments, {_total_leds} LEDs, fps={self._target_fps})"
f"(css={self._css_id}, {_total_leds} LEDs, fps={self._target_fps})"
)
next_frame_time = time.perf_counter()
@@ -523,13 +433,12 @@ class WledTargetProcessor(TargetProcessor):
frame_time = 1.0 / target_fps
keepalive_interval = self._keepalive_interval
# Detect hot-swapped segments
cur_streams = self._segment_streams
if cur_streams is not segment_streams:
segment_streams = cur_streams
prev_refs = [None] * len(segment_streams)
# Detect hot-swapped CSS stream
cur_stream = self._css_stream
if cur_stream is not stream:
stream = cur_stream
prev_frame_ref = None
has_any_frame = False
device_buf[:] = 0
_diag_device_info_age += 1
if _diag_device_info is None or _diag_device_info_age >= 30:
@@ -542,34 +451,28 @@ class WledTargetProcessor(TargetProcessor):
await asyncio.sleep(frame_time)
continue
if not segment_streams:
if stream is None:
await asyncio.sleep(frame_time)
continue
try:
# Poll all segment streams
any_new = False
all_none = True
for i, (seg, stream) in enumerate(segment_streams):
frame = stream.get_latest_colors()
if frame is not prev_refs[i]:
any_new = True
prev_refs[i] = frame
if frame is not None:
all_none = False
# Poll the CSS stream
frame = stream.get_latest_colors()
if all_none:
if frame is None:
if self._metrics.frames_processed == 0:
logger.info(f"No data from any segment stream for {self._target_id}")
logger.info(f"No data from CSS stream for {self._target_id}")
await asyncio.sleep(frame_time)
continue
if not any_new:
# All streams returned same frame — keepalive or skip
if frame is prev_frame_ref:
# Same frame — keepalive or skip
if self._needs_keepalive and has_any_frame and (loop_start - last_send_time) >= keepalive_interval:
if not self._is_running or self._led_client is None:
break
send_colors = _cached_brightness(device_buf, device_info)
send_colors = _cached_brightness(
self._fit_to_device(prev_frame_ref, _total_leds), device_info
)
if self._led_client.supports_fast_send:
self._led_client.send_pixels_fast(send_colors)
else:
@@ -582,31 +485,17 @@ class WledTargetProcessor(TargetProcessor):
while send_timestamps and send_timestamps[0] < now - 1.0:
send_timestamps.popleft()
self._metrics.fps_current = len(send_timestamps)
is_animated = any(s.is_animated for _, s in segment_streams)
is_animated = stream.is_animated
repoll = SKIP_REPOLL if is_animated else frame_time
await asyncio.sleep(repoll)
continue
prev_frame_ref = frame
has_any_frame = True
# Compose new frame from all segments
device_buf[:] = 0
for i, (seg, stream) in enumerate(segment_streams):
frame = prev_refs[i]
if frame is None:
continue
seg_start, seg_end = seg["start"], seg["end"]
seg_len = seg_end - seg_start
if seg_len <= 0:
continue
if len(frame) != seg_len:
frame = self._fit_to_device(frame, seg_len)
if seg.get("reverse"):
frame = frame[::-1]
device_buf[seg_start:seg_end] = frame
# Apply device software brightness
send_colors = _cached_brightness(device_buf, device_info)
# Fit to device LED count and apply brightness
device_colors = self._fit_to_device(frame, _total_leds)
send_colors = _cached_brightness(device_colors, device_info)
# Send to LED device
if not self._is_running or self._led_client is None:

View File

@@ -85,7 +85,6 @@ import {
import {
loadTargetsTab, loadTargets, switchTargetSubTab,
showTargetEditor, closeTargetEditorModal, forceCloseTargetEditorModal, saveTargetEditor,
addTargetSegment, removeTargetSegment, syncSegmentsMappedMode,
startTargetProcessing, stopTargetProcessing,
startTargetOverlay, stopTargetOverlay, deleteTarget,
cloneTarget,
@@ -283,9 +282,6 @@ Object.assign(window, {
closeTargetEditorModal,
forceCloseTargetEditorModal,
saveTargetEditor,
addTargetSegment,
removeTargetSegment,
syncSegmentsMappedMode,
startTargetProcessing,
stopTargetProcessing,
startTargetOverlay,

View File

@@ -447,14 +447,11 @@ function renderDashboardTarget(target, isRunning, devicesMap = {}, cssSourceMap
if (device) {
subtitleParts.push((device.device_type || '').toUpperCase());
}
const segments = target.segments || [];
if (segments.length > 0) {
const firstCss = cssSourceMap[segments[0].color_strip_source_id];
if (firstCss) {
subtitleParts.push(t(`color_strip.type.${firstCss.source_type}`) || firstCss.source_type);
}
if (segments.length > 1) {
subtitleParts.push(`${segments.length} seg`);
const cssId = target.color_strip_source_id || '';
if (cssId) {
const css = cssSourceMap[cssId];
if (css) {
subtitleParts.push(t(`color_strip.type.${css.source_type}`) || css.source_type);
}
}
}

View File

@@ -74,73 +74,9 @@ function _createTargetFpsChart(canvasId, history, fpsTarget, maxHwFps) {
});
}
// --- Segment editor state ---
// --- Editor state ---
let _editorCssSources = []; // populated when editor opens
/**
* When the selected CSS source is a mapped type, collapse the segment UI
* to a single source dropdown — range fields, reverse, header, and "Add Segment"
* are hidden because the mapped CSS already defines spatial zones internally.
*/
export function syncSegmentsMappedMode() {
const list = document.getElementById('target-editor-segment-list');
if (!list) return;
const rows = list.querySelectorAll('.segment-row');
if (rows.length === 0) return;
const firstSelect = rows[0].querySelector('.segment-css-select');
const selectedId = firstSelect ? firstSelect.value : '';
const selectedSource = _editorCssSources.find(s => s.id === selectedId);
const isMapped = selectedSource && selectedSource.source_type === 'mapped';
// Remove extra segments when switching to mapped
if (isMapped && rows.length > 1) {
for (let i = rows.length - 1; i >= 1; i--) rows[i].remove();
}
// Toggle visibility of range/reverse/header within the first row
const firstRow = list.querySelector('.segment-row');
if (firstRow) {
const header = firstRow.querySelector('.segment-row-header');
const rangeFields = firstRow.querySelector('.segment-range-fields');
const reverseLabel = firstRow.querySelector('.segment-reverse-label');
if (header) header.style.display = isMapped ? 'none' : '';
if (rangeFields) rangeFields.style.display = isMapped ? 'none' : '';
if (reverseLabel) reverseLabel.style.display = isMapped ? 'none' : '';
}
// Hide/show "Add Segment" button
const addBtn = document.querySelector('#target-editor-segments-group > .btn-sm');
if (addBtn) addBtn.style.display = isMapped ? 'none' : '';
// Swap label: "Segments:" ↔ "Color Strip Source:"
const group = document.getElementById('target-editor-segments-group');
if (group) {
const label = group.querySelector('.label-row label');
const hintToggle = group.querySelector('.hint-toggle');
const hint = group.querySelector('.input-hint');
if (label) label.textContent = isMapped
? t('targets.color_strip_source')
: t('targets.segments');
if (hintToggle) hintToggle.style.display = isMapped ? 'none' : '';
if (hint) hint.style.display = 'none'; // collapse hint on switch
}
}
function _serializeSegments() {
const rows = document.querySelectorAll('.segment-row');
const segments = [];
rows.forEach(row => {
segments.push({
css: row.querySelector('.segment-css-select').value,
start: row.querySelector('.segment-start').value,
end: row.querySelector('.segment-end').value,
reverse: row.querySelector('.segment-reverse').checked,
});
});
return JSON.stringify(segments);
}
class TargetEditorModal extends Modal {
constructor() {
super('target-editor-modal');
@@ -150,7 +86,7 @@ class TargetEditorModal extends Modal {
return {
name: document.getElementById('target-editor-name').value,
device: document.getElementById('target-editor-device').value,
segments: _serializeSegments(),
css_source: document.getElementById('target-editor-css-source').value,
fps: document.getElementById('target-editor-fps').value,
keepalive_interval: document.getElementById('target-editor-keepalive-interval').value,
};
@@ -166,9 +102,8 @@ function _autoGenerateTargetName() {
if (document.getElementById('target-editor-id').value) return;
const deviceSelect = document.getElementById('target-editor-device');
const deviceName = deviceSelect.selectedOptions[0]?.dataset?.name || '';
// Use first segment's CSS name
const firstCssSelect = document.querySelector('.segment-css-select');
const cssName = firstCssSelect?.selectedOptions[0]?.dataset?.name || '';
const cssSelect = document.getElementById('target-editor-css-source');
const cssName = cssSelect?.selectedOptions[0]?.dataset?.name || '';
if (!deviceName || !cssName) return;
document.getElementById('target-editor-name').value = `${deviceName} \u00b7 ${cssName}`;
}
@@ -210,54 +145,11 @@ function _updateKeepaliveVisibility() {
keepaliveGroup.style.display = caps.includes('standby_required') ? '' : 'none';
}
export function addTargetSegment(segment = null) {
const list = document.getElementById('target-editor-segment-list');
const index = list.querySelectorAll('.segment-row').length;
const row = document.createElement('div');
row.className = 'segment-row';
row.innerHTML = _renderSegmentRowInner(index, segment);
list.appendChild(row);
}
export function removeTargetSegment(btn) {
const row = btn.closest('.segment-row');
row.remove();
// Re-index labels
document.querySelectorAll('.segment-row').forEach((r, i) => {
const label = r.querySelector('.segment-index-label');
if (label) label.textContent = `#${i + 1}`;
});
}
function _renderSegmentRowInner(index, segment) {
const cssId = segment?.color_strip_source_id || '';
const start = segment?.start ?? 0;
const end = segment?.end ?? 0;
const reverse = segment?.reverse || false;
const options = _editorCssSources.map(s =>
`<option value="${s.id}" data-name="${escapeHtml(s.name)}" ${s.id === cssId ? 'selected' : ''}>\uD83C\uDFAC ${escapeHtml(s.name)}</option>`
function _populateCssDropdown(selectedId = '') {
const select = document.getElementById('target-editor-css-source');
select.innerHTML = _editorCssSources.map(s =>
`<option value="${s.id}" data-name="${escapeHtml(s.name)}" ${s.id === selectedId ? 'selected' : ''}>${escapeHtml(s.name)}</option>`
).join('');
return `
<div class="segment-row-header">
<span class="segment-index-label">#${index + 1}</span>
<button type="button" class="btn-icon-inline btn-danger-text" onclick="removeTargetSegment(this)" title="${t('targets.segment.remove')}">&times;</button>
</div>
<div class="segment-row-fields">
<select class="segment-css-select" onchange="window._targetAutoName && window._targetAutoName(); syncSegmentsMappedMode()">${options}</select>
<div class="segment-range-fields">
<label>${t('targets.segment.start')}</label>
<input type="number" class="segment-start" min="0" value="${start}" placeholder="0">
<label>${t('targets.segment.end')}</label>
<input type="number" class="segment-end" min="0" value="${end}" placeholder="0 = auto">
</div>
<label class="segment-reverse-label">
<input type="checkbox" class="segment-reverse" ${reverse ? 'checked' : ''}>
<span>${t('targets.segment.reverse')}</span>
</label>
</div>
`;
}
export async function showTargetEditor(targetId = null, cloneData = null) {
@@ -286,10 +178,6 @@ export async function showTargetEditor(targetId = null, cloneData = null) {
deviceSelect.appendChild(opt);
});
// Clear segment list
const segmentList = document.getElementById('target-editor-segment-list');
segmentList.innerHTML = '';
if (targetId) {
// Editing existing target
const resp = await fetch(`${API_BASE}/picture-targets/${targetId}`, { headers: getHeaders() });
@@ -306,13 +194,7 @@ export async function showTargetEditor(targetId = null, cloneData = null) {
document.getElementById('target-editor-keepalive-interval-value').textContent = target.keepalive_interval ?? 1.0;
document.getElementById('target-editor-title').textContent = t('targets.edit');
// Populate segments
const segments = target.segments || [];
if (segments.length === 0) {
addTargetSegment();
} else {
segments.forEach(seg => addTargetSegment(seg));
}
_populateCssDropdown(target.color_strip_source_id || '');
} else if (cloneData) {
// Cloning — create mode but pre-filled from clone data
document.getElementById('target-editor-id').value = '';
@@ -325,14 +207,9 @@ export async function showTargetEditor(targetId = null, cloneData = null) {
document.getElementById('target-editor-keepalive-interval-value').textContent = cloneData.keepalive_interval ?? 1.0;
document.getElementById('target-editor-title').textContent = t('targets.add');
const segments = cloneData.segments || [];
if (segments.length === 0) {
addTargetSegment();
} else {
segments.forEach(seg => addTargetSegment(seg));
}
_populateCssDropdown(cloneData.color_strip_source_id || '');
} else {
// Creating new target — start with one empty segment
// Creating new target
document.getElementById('target-editor-id').value = '';
document.getElementById('target-editor-name').value = '';
document.getElementById('target-editor-fps').value = 30;
@@ -340,16 +217,16 @@ export async function showTargetEditor(targetId = null, cloneData = null) {
document.getElementById('target-editor-keepalive-interval').value = 1.0;
document.getElementById('target-editor-keepalive-interval-value').textContent = '1.0';
document.getElementById('target-editor-title').textContent = t('targets.add');
addTargetSegment();
}
syncSegmentsMappedMode();
_populateCssDropdown('');
}
// Auto-name generation
_targetNameManuallyEdited = !!(targetId || cloneData);
document.getElementById('target-editor-name').oninput = () => { _targetNameManuallyEdited = true; };
window._targetAutoName = _autoGenerateTargetName;
deviceSelect.onchange = () => { _updateDeviceInfo(); _updateKeepaliveVisibility(); _updateFpsRecommendation(); _autoGenerateTargetName(); };
document.getElementById('target-editor-css-source').onchange = () => { _autoGenerateTargetName(); };
if (!targetId && !cloneData) _autoGenerateTargetName();
// Show/hide standby interval based on selected device capabilities
@@ -392,27 +269,12 @@ export async function saveTargetEditor() {
}
const fps = parseInt(document.getElementById('target-editor-fps').value) || 30;
// Collect segments from DOM
const segmentRows = document.querySelectorAll('.segment-row');
const segments = [];
for (const row of segmentRows) {
const cssId = row.querySelector('.segment-css-select').value;
const start = parseInt(row.querySelector('.segment-start').value) || 0;
const end = parseInt(row.querySelector('.segment-end').value) || 0;
const reverse = row.querySelector('.segment-reverse').checked;
segments.push({
color_strip_source_id: cssId,
start,
end,
reverse,
});
}
const colorStripSourceId = document.getElementById('target-editor-css-source').value;
const payload = {
name,
device_id: deviceId,
segments,
color_strip_source_id: colorStripSourceId,
fps,
keepalive_interval: standbyInterval,
};
@@ -693,17 +555,10 @@ export async function loadTargetsTab() {
}
}
function _segmentsSummary(segments, colorStripSourceMap) {
if (!segments || segments.length === 0) return t('targets.no_segments');
return segments.map(seg => {
const css = colorStripSourceMap[seg.color_strip_source_id];
const name = css ? css.name : (seg.color_strip_source_id || '?');
let range = '';
if (seg.start || seg.end) {
range = ` [${seg.start}-${seg.end || '\u221e'}]`;
}
return escapeHtml(name) + range;
}).join(', ');
function _cssSourceName(cssId, colorStripSourceMap) {
if (!cssId) return t('targets.no_css');
const css = colorStripSourceMap[cssId];
return css ? escapeHtml(css.name) : escapeHtml(cssId);
}
export function createTargetCard(target, deviceMap, colorStripSourceMap) {
@@ -715,13 +570,12 @@ export function createTargetCard(target, deviceMap, colorStripSourceMap) {
const device = deviceMap[target.device_id];
const deviceName = device ? device.name : (target.device_id || 'No device');
const segments = target.segments || [];
const segSummary = _segmentsSummary(segments, colorStripSourceMap);
const cssId = target.color_strip_source_id || '';
const cssSummary = _cssSourceName(cssId, colorStripSourceMap);
// Determine if overlay is available (first segment has a picture-based CSS)
const firstCssId = segments.length > 0 ? segments[0].color_strip_source_id : '';
const firstCss = firstCssId ? colorStripSourceMap[firstCssId] : null;
const overlayAvailable = !firstCss || firstCss.source_type === 'picture';
// Determine if overlay is available (picture-based CSS)
const css = cssId ? colorStripSourceMap[cssId] : null;
const overlayAvailable = !css || css.source_type === 'picture';
// Health info from target state (forwarded from device)
const devOnline = state.device_online || false;
@@ -745,7 +599,7 @@ export function createTargetCard(target, deviceMap, colorStripSourceMap) {
<div class="stream-card-props">
<span class="stream-card-prop" title="${t('targets.device')}">💡 ${escapeHtml(deviceName)}</span>
<span class="stream-card-prop" title="${t('targets.fps')}">⚡ ${target.fps || 30} fps</span>
<span class="stream-card-prop stream-card-prop-full" title="${t('targets.segments')}">🎞️ ${segSummary}</span>
<span class="stream-card-prop stream-card-prop-full" title="${t('targets.color_strip_source')}">🎞️ ${cssSummary}</span>
</div>
<div class="card-content">
${isProcessing ? `

View File

@@ -365,15 +365,9 @@
"targets.device": "Device:",
"targets.device.hint": "Select the LED device to send data to",
"targets.device.none": "-- Select a device --",
"targets.segments": "Segments:",
"targets.color_strip_source": "Color Strip Source:",
"targets.segments.hint": "Each segment maps a color strip source to a pixel range on the LED strip. Gaps between segments stay black. A single segment with Start=0, End=0 auto-fits to the full strip.",
"targets.segments.add": "+ Add Segment",
"targets.segment.start": "Start:",
"targets.segment.end": "End:",
"targets.segment.reverse": "Reverse",
"targets.segment.remove": "Remove segment",
"targets.no_segments": "No segments",
"targets.color_strip_source.hint": "Select the color strip source that provides LED colors for this target",
"targets.no_css": "No source",
"targets.source": "Source:",
"targets.source.hint": "Which picture source to capture and process",
"targets.source.none": "-- No source assigned --",

View File

@@ -365,15 +365,9 @@
"targets.device": "Устройство:",
"targets.device.hint": "Выберите LED устройство для передачи данных",
"targets.device.none": "-- Выберите устройство --",
"targets.segments": "Сегменты:",
"targets.color_strip_source": "Источник цветовой полосы:",
"targets.segments.hint": "Каждый сегмент отображает источник цветовой полосы на диапазон пикселей LED ленты. Промежутки между сегментами остаются чёрными. Один сегмент с Начало=0, Конец=0 авто-подгоняется под всю ленту.",
"targets.segments.add": "+ Добавить сегмент",
"targets.segment.start": "Начало:",
"targets.segment.end": "Конец:",
"targets.segment.reverse": "Реверс",
"targets.segment.remove": "Удалить сегмент",
"targets.no_segments": "Нет сегментов",
"targets.color_strip_source.hint": "Выберите источник цветовой полосы, который предоставляет цвета LED для этой цели",
"targets.no_css": "Нет источника",
"targets.source": "Источник:",
"targets.source.hint": "Какой источник изображения захватывать и обрабатывать",
"targets.source.none": "-- Источник не назначен --",

View File

@@ -7,7 +7,7 @@ from pathlib import Path
from typing import Dict, List, Optional
from wled_controller.storage.picture_target import PictureTarget
from wled_controller.storage.wled_picture_target import TargetSegment, WledPictureTarget
from wled_controller.storage.wled_picture_target import WledPictureTarget
from wled_controller.storage.key_colors_picture_target import (
KeyColorsSettings,
KeyColorsPictureTarget,
@@ -101,7 +101,7 @@ class PictureTargetStore:
name: str,
target_type: str,
device_id: str = "",
segments: Optional[List[dict]] = None,
color_strip_source_id: str = "",
fps: int = 30,
keepalive_interval: float = 1.0,
state_check_interval: int = DEFAULT_STATE_CHECK_INTERVAL,
@@ -126,14 +126,12 @@ class PictureTargetStore:
now = datetime.utcnow()
if target_type == "led":
seg_list = [TargetSegment.from_dict(s) for s in segments] if segments else []
target: PictureTarget = WledPictureTarget(
id=target_id,
name=name,
target_type="led",
device_id=device_id,
segments=seg_list,
color_strip_source_id=color_strip_source_id,
fps=fps,
keepalive_interval=keepalive_interval,
state_check_interval=state_check_interval,
@@ -166,7 +164,7 @@ class PictureTargetStore:
target_id: str,
name: Optional[str] = None,
device_id: Optional[str] = None,
segments: Optional[List[dict]] = None,
color_strip_source_id: Optional[str] = None,
fps: Optional[int] = None,
keepalive_interval: Optional[float] = None,
state_check_interval: Optional[int] = None,
@@ -192,7 +190,7 @@ class PictureTargetStore:
target.update_fields(
name=name,
device_id=device_id,
segments=segments,
color_strip_source_id=color_strip_source_id,
fps=fps,
keepalive_interval=keepalive_interval,
state_check_interval=state_check_interval,
@@ -239,7 +237,7 @@ class PictureTargetStore:
return [
target.name for target in self._targets.values()
if isinstance(target, WledPictureTarget)
and any(seg.color_strip_source_id == css_id for seg in target.segments)
and target.color_strip_source_id == css_id
]
def count(self) -> int:

View File

@@ -1,56 +1,20 @@
"""LED picture target — sends color strip sources to an LED device."""
from dataclasses import dataclass, field
from dataclasses import dataclass
from datetime import datetime
from typing import List, Optional
from typing import Optional
from wled_controller.storage.picture_target import PictureTarget
DEFAULT_STATE_CHECK_INTERVAL = 30 # seconds
@dataclass
class TargetSegment:
"""Maps a color strip source to a pixel range on the LED device.
``start`` is inclusive, ``end`` is exclusive. When a target has a single
segment with ``end == 0`` the range auto-fits to the full device LED count.
"""
color_strip_source_id: str = ""
start: int = 0
end: int = 0
reverse: bool = False
def to_dict(self) -> dict:
return {
"color_strip_source_id": self.color_strip_source_id,
"start": self.start,
"end": self.end,
"reverse": self.reverse,
}
@staticmethod
def from_dict(d: dict) -> "TargetSegment":
return TargetSegment(
color_strip_source_id=d.get("color_strip_source_id", ""),
start=d.get("start", 0),
end=d.get("end", 0),
reverse=d.get("reverse", False),
)
@dataclass
class WledPictureTarget(PictureTarget):
"""LED picture target — pairs an LED device with one or more ColorStripSources.
Each segment maps a ColorStripSource to a pixel range on the device.
Gaps between segments stay black. A single segment with ``end == 0``
auto-fits to the full device LED count.
"""
"""LED picture target — pairs an LED device with a ColorStripSource."""
device_id: str = ""
segments: List[TargetSegment] = field(default_factory=list)
color_strip_source_id: str = ""
fps: int = 30 # target send FPS (1-90)
keepalive_interval: float = 1.0 # seconds between keepalive sends when screen is static
state_check_interval: int = DEFAULT_STATE_CHECK_INTERVAL
@@ -61,14 +25,14 @@ class WledPictureTarget(PictureTarget):
manager.add_target(
target_id=self.id,
device_id=self.device_id,
segments=[s.to_dict() for s in self.segments],
color_strip_source_id=self.color_strip_source_id,
fps=self.fps,
keepalive_interval=self.keepalive_interval,
state_check_interval=self.state_check_interval,
)
def sync_with_manager(self, manager, *, settings_changed: bool,
segments_changed: bool = False,
css_changed: bool = False,
device_changed: bool = False) -> None:
"""Push changed fields to the processor manager."""
if settings_changed:
@@ -77,23 +41,20 @@ class WledPictureTarget(PictureTarget):
"keepalive_interval": self.keepalive_interval,
"state_check_interval": self.state_check_interval,
})
if segments_changed:
manager.update_target_segments(self.id, [s.to_dict() for s in self.segments])
if css_changed:
manager.update_target_css(self.id, self.color_strip_source_id)
if device_changed:
manager.update_target_device(self.id, self.device_id)
def update_fields(self, *, name=None, device_id=None, segments=None,
def update_fields(self, *, name=None, device_id=None, color_strip_source_id=None,
fps=None, keepalive_interval=None, state_check_interval=None,
description=None, **_kwargs) -> None:
"""Apply mutable field updates for WLED targets."""
super().update_fields(name=name, description=description)
if device_id is not None:
self.device_id = device_id
if segments is not None:
self.segments = [
TargetSegment.from_dict(s) if isinstance(s, dict) else s
for s in segments
]
if color_strip_source_id is not None:
self.color_strip_source_id = color_strip_source_id
if fps is not None:
self.fps = fps
if keepalive_interval is not None:
@@ -103,13 +64,13 @@ class WledPictureTarget(PictureTarget):
@property
def has_picture_source(self) -> bool:
return any(s.color_strip_source_id for s in self.segments)
return bool(self.color_strip_source_id)
def to_dict(self) -> dict:
"""Convert to dictionary."""
d = super().to_dict()
d["device_id"] = self.device_id
d["segments"] = [s.to_dict() for s in self.segments]
d["color_strip_source_id"] = self.color_strip_source_id
d["fps"] = self.fps
d["keepalive_interval"] = self.keepalive_interval
d["state_check_interval"] = self.state_check_interval
@@ -118,30 +79,22 @@ class WledPictureTarget(PictureTarget):
@classmethod
def from_dict(cls, data: dict) -> "WledPictureTarget":
"""Create from dictionary with backward compatibility."""
# Migrate old single-source format to segments
if "segments" in data:
segments = [TargetSegment.from_dict(s) for s in data["segments"]]
elif "color_strip_source_id" in data:
css_id = data.get("color_strip_source_id", "")
skip_start = data.get("led_skip_start", 0)
skip_end = data.get("led_skip_end", 0)
if css_id:
segments = [TargetSegment(
color_strip_source_id=css_id,
start=skip_start,
end=0, # auto-fit; skip_end handled by processor
)]
else:
segments = []
# New format: direct color_strip_source_id
if "color_strip_source_id" in data:
css_id = data["color_strip_source_id"]
# Old format: segments array — take first segment's css_id
elif "segments" in data:
segs = data["segments"]
css_id = segs[0].get("color_strip_source_id", "") if segs else ""
else:
segments = []
css_id = ""
return cls(
id=data["id"],
name=data["name"],
target_type="led",
device_id=data.get("device_id", ""),
segments=segments,
color_strip_source_id=css_id,
fps=data.get("fps", 30),
keepalive_interval=data.get("keepalive_interval", data.get("standby_interval", 1.0)),
state_check_interval=data.get("state_check_interval", DEFAULT_STATE_CHECK_INTERVAL),

View File

@@ -24,14 +24,13 @@
<small id="target-editor-device-info" class="device-led-info" style="display:none"></small>
</div>
<div class="form-group" id="target-editor-segments-group">
<div class="form-group">
<div class="label-row">
<label data-i18n="targets.segments">Segments:</label>
<label for="target-editor-css-source" data-i18n="targets.color_strip_source">Color Strip Source:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="targets.segments.hint">Each segment maps a color strip source to a pixel range on the LED strip. Gaps between segments stay black. A single segment with Start=0, End=0 auto-fits to the full strip.</small>
<div id="target-editor-segment-list"></div>
<button type="button" class="btn btn-secondary btn-sm" onclick="addTargetSegment()" data-i18n="targets.segments.add">+ Add Segment</button>
<small class="input-hint" style="display:none" data-i18n="targets.color_strip_source.hint">Select the color strip source that provides LED colors for this target</small>
<select id="target-editor-css-source"></select>
</div>
<div class="form-group" id="target-editor-fps-group">