Compare commits

..

2 Commits

Author SHA1 Message Date
4c21ae5178 Fix DDP multi-bus color issues with PUSH flag and packet alignment
- Add PUSH flag (0x01) on last DDP packet to signal frame completion
- Align packet payload to multiples of 3 bytes to prevent splitting
  pixel RGB channels across packet boundaries
- Add per-bus WLED configuration logging (pin, color order, LED range)
- Add BusConfig dataclass and per-bus color reorder infrastructure
  for future use
- Remove old per-pixel color reordering (WLED handles internally)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 00:39:15 +03:00
d229c9a0d5 Improve property description hints for dialogs 2026-02-12 00:31:44 +03:00
7 changed files with 262 additions and 65 deletions

View File

@@ -1,46 +1,55 @@
"""DDP (Distributed Display Protocol) client for WLED.""" """DDP (Distributed Display Protocol) client for WLED."""
import asyncio import asyncio
import struct import struct
from typing import List, Tuple from dataclasses import dataclass
from typing import Dict, List, Optional, Tuple
from wled_controller.utils import get_logger from wled_controller.utils import get_logger
logger = get_logger(__name__) logger = get_logger(__name__)
# DDP color order codes (matches WLED's order enum)
COLOR_ORDER_MAP: Dict[int, Tuple[int, int, int]] = {
0: (1, 0, 2), # GRB → indices into (R,G,B)
1: (0, 1, 2), # RGB → no reorder
2: (2, 0, 1), # BRG
3: (0, 2, 1), # RBG
4: (2, 1, 0), # BGR
5: (1, 2, 0), # GBR
}
@dataclass
class BusConfig:
"""Physical LED bus/output configuration from WLED."""
start: int # First LED index
length: int # Number of LEDs on this bus
color_order: int # Color order code (0=GRB, 1=RGB, etc.)
class DDPClient: class DDPClient:
"""UDP DDP client for sending pixel data to WLED devices.""" """UDP DDP client for sending pixel data to WLED devices."""
DDP_PORT = 4048 DDP_PORT = 4048
DDP_FLAGS_VER1 = 0x40 # VER=1, TIMECODE=0, STORAGE=0, REPLY=0, QUERY=0, PUSH=1 DDP_FLAGS_VER1 = 0x40 # VER=1, all other flags 0
DDP_FLAGS_PUSH = 0x01 # PUSH flag (set on last packet of a frame)
DDP_TYPE_RGB = 0x01 DDP_TYPE_RGB = 0x01
# Color order mappings: input (R,G,B) -> output indices def __init__(self, host: str, port: int = DDP_PORT, rgbw: bool = False):
COLOR_ORDER_MAP = {
0: (1, 0, 2), # GRB: G=idx1, R=idx0, B=idx2
1: (0, 1, 2), # RGB: R=idx0, G=idx1, B=idx2
2: (2, 0, 1), # BRG: B=idx2, R=idx0, G=idx1
3: (0, 2, 1), # RBG: R=idx0, B=idx2, G=idx1
4: (2, 1, 0), # BGR: B=idx2, G=idx1, R=idx0
5: (1, 2, 0), # GBR: G=idx1, B=idx2, R=idx0
}
def __init__(self, host: str, port: int = DDP_PORT, rgbw: bool = False, color_order: int = 1):
"""Initialize DDP client. """Initialize DDP client.
Args: Args:
host: WLED device IP address or hostname host: WLED device IP address or hostname
port: DDP port (default 4048) port: DDP port (default 4048)
rgbw: True for RGBW LEDs (4 bytes/LED), False for RGB (3 bytes/LED) rgbw: True for RGBW LEDs (4 bytes/LED), False for RGB (3 bytes/LED)
color_order: Color order (0=GRB, 1=RGB, 2=BRG, 3=RBG, 4=BGR, 5=GBR)
""" """
self.host = host self.host = host
self.port = port self.port = port
self.rgbw = rgbw self.rgbw = rgbw
self.color_order = color_order
self._transport = None self._transport = None
self._protocol = None self._protocol = None
self._sequence = 0 self._sequence = 0
self._buses: List[BusConfig] = []
async def connect(self): async def connect(self):
"""Establish UDP connection.""" """Establish UDP connection."""
@@ -64,32 +73,50 @@ class DDPClient:
self._protocol = None self._protocol = None
logger.debug(f"Closed DDP connection to {self.host}:{self.port}") logger.debug(f"Closed DDP connection to {self.host}:{self.port}")
def set_buses(self, buses: List[BusConfig]) -> None:
"""Set WLED bus configurations for per-bus color order reordering.
Args:
buses: List of BusConfig from WLED device
"""
self._buses = buses
for bus in buses:
order_name = {0: "GRB", 1: "RGB", 2: "BRG", 3: "RBG", 4: "BGR", 5: "GBR"}
logger.info(
f"DDP bus: LEDs {bus.start}-{bus.start + bus.length - 1}, "
f"color order={order_name.get(bus.color_order, '?')} ({bus.color_order})"
)
def _build_ddp_packet( def _build_ddp_packet(
self, self,
rgb_data: bytes, rgb_data: bytes,
offset: int = 0, offset: int = 0,
sequence: int = 1 sequence: int = 1,
push: bool = False,
) -> bytes: ) -> bytes:
"""Build a DDP packet. """Build a DDP packet.
DDP packet format (10-byte header + data): DDP packet format (10-byte header + data):
- Byte 0: Flags (0x40 = VER1, PUSH) - Byte 0: Flags (VER1 | PUSH on last packet)
- Byte 1: Sequence number - Byte 1: Sequence number
- Byte 2: Data type (0x01 = RGB) - Byte 2: Data type (0x01 = RGB)
- Byte 3: Source/Destination ID - Byte 3: Source/Destination ID
- Bytes 4-7: Data offset in bytes (4-byte little-endian) - Bytes 4-7: Data offset (4 bytes, big-endian)
- Bytes 8-9: Data length in bytes (2-byte big-endian) - Bytes 8-9: Data length (2 bytes, big-endian)
- Bytes 10+: Pixel data - Bytes 10+: Pixel data
Args: Args:
rgb_data: RGB pixel data as bytes rgb_data: RGB pixel data as bytes
offset: Byte offset (pixel_index * 3) offset: Byte offset (pixel_index * 3)
sequence: Sequence number (0-255) sequence: Sequence number (0-255)
push: True for the last packet of a frame
Returns: Returns:
Complete DDP packet as bytes Complete DDP packet as bytes
""" """
flags = self.DDP_FLAGS_VER1 flags = self.DDP_FLAGS_VER1
if push:
flags |= self.DDP_FLAGS_PUSH
data_type = self.DDP_TYPE_RGB data_type = self.DDP_TYPE_RGB
source_id = 0x01 source_id = 0x01
data_len = len(rgb_data) data_len = len(rgb_data)
@@ -107,6 +134,40 @@ class DDPClient:
return header + rgb_data return header + rgb_data
def _reorder_pixels(
self,
pixels: List[Tuple[int, int, int]],
) -> List[Tuple[int, int, int]]:
"""Apply per-bus color order reordering.
WLED may not apply per-bus color order conversion for DDP data on
all buses (observed in multi-bus setups). We reorder pixel channels
here so the hardware receives the correct byte order directly.
Args:
pixels: List of (R, G, B) tuples in standard RGB order
Returns:
List of reordered tuples matching each bus's hardware color order
"""
if not self._buses:
return pixels
result = list(pixels)
for bus in self._buses:
order_map = COLOR_ORDER_MAP.get(bus.color_order)
if not order_map or order_map == (0, 1, 2):
continue # RGB order = no reordering needed
start = bus.start
end = min(bus.start + bus.length, len(result))
for i in range(start, end):
r, g, b = result[i]
rgb = (r, g, b)
result[i] = (rgb[order_map[0]], rgb[order_map[1]], rgb[order_map[2]])
return result
async def send_pixels( async def send_pixels(
self, self,
pixels: List[Tuple[int, int, int]], pixels: List[Tuple[int, int, int]],
@@ -128,21 +189,20 @@ class DDPClient:
raise RuntimeError("DDP client not connected") raise RuntimeError("DDP client not connected")
try: try:
# Get color order mapping # Send plain RGB — WLED handles per-bus color order conversion
order_map = self.COLOR_ORDER_MAP.get(self.color_order, (0, 1, 2)) # internally when outputting to hardware.
bpp = 4 if self.rgbw else 3 # bytes per pixel
# Convert pixels to flat RGB or RGBW bytes with color reordering
pixel_bytes = bytearray() pixel_bytes = bytearray()
for r, g, b in pixels: for r, g, b in pixels:
# Reorder color channels based on WLED configuration pixel_bytes.extend((int(r), int(g), int(b)))
rgb = [int(r), int(g), int(b)]
reordered = [rgb[order_map[0]], rgb[order_map[1]], rgb[order_map[2]]]
pixel_bytes.extend(reordered)
if self.rgbw: if self.rgbw:
pixel_bytes.append(0) # White channel = 0 pixel_bytes.append(0) # White channel = 0
total_bytes = len(pixel_bytes) total_bytes = len(pixel_bytes)
bytes_per_packet = max_packet_size - 10 # Account for 10-byte header # Align payload to full pixels (multiple of bpp) to avoid splitting
# a pixel's channels across packets
max_payload = max_packet_size - 10 # 10-byte header
bytes_per_packet = (max_payload // bpp) * bpp
# Split into multiple packets if needed # Split into multiple packets if needed
num_packets = (total_bytes + bytes_per_packet - 1) // bytes_per_packet num_packets = (total_bytes + bytes_per_packet - 1) // bytes_per_packet
@@ -156,17 +216,22 @@ class DDPClient:
start = i * bytes_per_packet start = i * bytes_per_packet
end = min(start + bytes_per_packet, total_bytes) end = min(start + bytes_per_packet, total_bytes)
chunk = bytes(pixel_bytes[start:end]) chunk = bytes(pixel_bytes[start:end])
is_last = (i == num_packets - 1)
# Increment sequence number # Increment sequence number
self._sequence = (self._sequence + 1) % 256 self._sequence = (self._sequence + 1) % 256
# Build and send packet # Build and send packet (set PUSH on last packet)
packet = self._build_ddp_packet(chunk, offset=start, sequence=self._sequence) packet = self._build_ddp_packet(
chunk, offset=start,
sequence=self._sequence, push=is_last,
)
self._transport.sendto(packet) self._transport.sendto(packet)
logger.debug( logger.debug(
f"Sent DDP packet {i+1}/{num_packets}: " f"Sent DDP packet {i+1}/{num_packets}: "
f"{len(chunk)} bytes at offset {start}" f"{len(chunk)} bytes at offset {start}"
f"{' [PUSH]' if is_last else ''}"
) )
return True return True

View File

@@ -1,14 +1,14 @@
"""WLED client for controlling LED devices via HTTP or DDP.""" """WLED client for controlling LED devices via HTTP or DDP."""
import asyncio import asyncio
from dataclasses import dataclass from dataclasses import dataclass, field
from typing import List, Tuple, Optional, Dict, Any from typing import List, Tuple, Optional, Dict, Any
from urllib.parse import urlparse from urllib.parse import urlparse
import httpx import httpx
from wled_controller.utils import get_logger from wled_controller.utils import get_logger
from wled_controller.core.ddp_client import DDPClient from wled_controller.core.ddp_client import BusConfig, DDPClient
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -26,6 +26,7 @@ class WLEDInfo:
ip: str ip: str
rgbw: bool = False # True if RGBW LEDs (4 bytes/pixel), False if RGB (3 bytes/pixel) rgbw: bool = False # True if RGBW LEDs (4 bytes/pixel), False if RGB (3 bytes/pixel)
color_order: int = 1 # Color order: 0=GRB, 1=RGB, 2=BRG, 3=RBG, 4=BGR, 5=GBR color_order: int = 1 # Color order: 0=GRB, 1=RGB, 2=BRG, 3=RBG, 4=BGR, 5=GBR
buses: List[BusConfig] = field(default_factory=list) # Per-bus/GPIO config
class WLEDClient: class WLEDClient:
@@ -100,8 +101,10 @@ class WLEDClient:
# Create DDP client if needed # Create DDP client if needed
if self.use_ddp: if self.use_ddp:
# DDP always uses RGB mode (3 bytes/LED) - WLED handles RGBW and color order internally self._ddp_client = DDPClient(self.host, rgbw=False)
self._ddp_client = DDPClient(self.host, rgbw=False, color_order=1) # Pass per-bus config so DDP client can apply per-bus color reordering
if info.buses:
self._ddp_client.set_buses(info.buses)
await self._ddp_client.connect() await self._ddp_client.connect()
# Turn on the device and disable Audio Reactive mode for DDP # Turn on the device and disable Audio Reactive mode for DDP
@@ -230,14 +233,32 @@ class WLEDClient:
data = await self._request("GET", "/json/info") data = await self._request("GET", "/json/info")
leds_info = data.get("leds", {}) leds_info = data.get("leds", {})
# Get LED configuration for color order # Get LED configuration for color order and per-bus info
cfg_data = await self._request("GET", "/json/cfg") cfg_data = await self._request("GET", "/json/cfg")
color_order = 1 # Default to RGB color_order = 1 # Default to RGB
buses: List[BusConfig] = []
if "hw" in cfg_data and "led" in cfg_data["hw"]: if "hw" in cfg_data and "led" in cfg_data["hw"]:
led_cfg = cfg_data["hw"]["led"] led_cfg = cfg_data["hw"]["led"]
if "ins" in led_cfg and len(led_cfg["ins"]) > 0: ins_list = led_cfg.get("ins", [])
if ins_list:
# Use color order from first LED strip # Use color order from first LED strip
color_order = led_cfg["ins"][0].get("order", 1) color_order = ins_list[0].get("order", 1)
# Parse all buses for per-segment color reordering
order_names = {0: "GRB", 1: "RGB", 2: "BRG", 3: "RBG", 4: "BGR", 5: "GBR"}
for idx, bus_cfg in enumerate(ins_list):
bus = BusConfig(
start=bus_cfg.get("start", 0),
length=bus_cfg.get("len", 0),
color_order=bus_cfg.get("order", 1),
)
buses.append(bus)
logger.info(
f"WLED bus {idx}: LEDs {bus.start}-{bus.start + bus.length - 1}, "
f"pin={bus_cfg.get('pin', '?')}, "
f"order={order_names.get(bus.color_order, '?')} ({bus.color_order}), "
f"type={bus_cfg.get('type', '?')}"
)
return WLEDInfo( return WLEDInfo(
name=data.get("name", "Unknown"), name=data.get("name", "Unknown"),
@@ -247,8 +268,9 @@ class WLEDClient:
product=data.get("product", "FOSS"), product=data.get("product", "FOSS"),
mac=data.get("mac", ""), mac=data.get("mac", ""),
ip=data.get("ip", ""), ip=data.get("ip", ""),
rgbw=leds_info.get("rgbw", False), # Auto-detect RGBW vs RGB rgbw=leds_info.get("rgbw", False),
color_order=color_order, # Auto-detect color order (0=GRB, 1=RGB, etc.) color_order=color_order,
buses=buses,
) )
except Exception as e: except Exception as e:

View File

@@ -2,6 +2,16 @@ const API_BASE = '/api/v1';
let refreshInterval = null; let refreshInterval = null;
let apiKey = null; let apiKey = null;
// Toggle hint description visibility next to a label
function toggleHint(btn) {
const hint = btn.closest('.label-row').nextElementSibling;
if (hint && hint.classList.contains('input-hint')) {
const visible = hint.style.display !== 'none';
hint.style.display = visible ? 'none' : 'block';
btn.classList.toggle('active', !visible);
}
}
// Backdrop click helper: only closes modal if both mousedown and mouseup were on the backdrop itself. // Backdrop click helper: only closes modal if both mousedown and mouseup were on the backdrop itself.
// Prevents accidental close when user drags text selection outside the dialog. // Prevents accidental close when user drags text selection outside the dialog.
function setupBackdropClose(modal, closeFn) { function setupBackdropClose(modal, closeFn) {

View File

@@ -203,15 +203,21 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<div class="label-row">
<label for="settings-device-url" data-i18n="device.url">WLED URL:</label> <label for="settings-device-url" data-i18n="device.url">WLED URL:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="settings.url.hint">IP address or hostname of your WLED device</small>
<input type="url" id="settings-device-url" data-i18n-placeholder="device.url.placeholder" placeholder="http://192.168.1.100" required> <input type="url" id="settings-device-url" data-i18n-placeholder="device.url.placeholder" placeholder="http://192.168.1.100" required>
<small class="input-hint" data-i18n="settings.url.hint">IP address or hostname of your WLED device</small>
</div> </div>
<div class="form-group"> <div class="form-group">
<div class="label-row">
<label for="settings-health-interval" data-i18n="settings.health_interval">Health Check Interval (s):</label> <label for="settings-health-interval" data-i18n="settings.health_interval">Health Check Interval (s):</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="settings.health_interval.hint">How often to check the WLED device status (5-600 seconds)</small>
<input type="number" id="settings-health-interval" min="5" max="600" value="30"> <input type="number" id="settings-health-interval" min="5" max="600" value="30">
<small class="input-hint" data-i18n="settings.health_interval.hint">How often to check the WLED device status (5-600 seconds)</small>
</div> </div>
<div id="settings-error" class="error-message" style="display: none;"></div> <div id="settings-error" class="error-message" style="display: none;"></div>
@@ -236,35 +242,47 @@
<input type="hidden" id="stream-selector-device-id"> <input type="hidden" id="stream-selector-device-id">
<div class="form-group"> <div class="form-group">
<div class="label-row">
<label for="stream-selector-stream" data-i18n="device.stream_selector.label">Stream:</label> <label for="stream-selector-stream" data-i18n="device.stream_selector.label">Stream:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="device.stream_selector.hint">Select a stream that defines what this device captures and processes</small>
<select id="stream-selector-stream"></select> <select id="stream-selector-stream"></select>
<div id="stream-selector-info" class="stream-info-panel" style="display: none;"></div> <div id="stream-selector-info" class="stream-info-panel" style="display: none;"></div>
<small class="input-hint" data-i18n="device.stream_selector.hint">Select a stream that defines what this device captures and processes</small>
</div> </div>
<div class="form-group"> <div class="form-group">
<div class="label-row">
<label for="stream-selector-border-width" data-i18n="device.stream_settings.border_width">Border Width (px):</label> <label for="stream-selector-border-width" data-i18n="device.stream_settings.border_width">Border Width (px):</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="device.stream_settings.border_width_hint">How many pixels from the screen edge to sample for LED colors (1-100)</small>
<input type="number" id="stream-selector-border-width" min="1" max="100" value="10"> <input type="number" id="stream-selector-border-width" min="1" max="100" value="10">
<small class="input-hint" data-i18n="device.stream_settings.border_width_hint">How many pixels from the screen edge to sample for LED colors (1-100)</small>
</div> </div>
<div class="form-group"> <div class="form-group">
<div class="label-row">
<label for="stream-selector-interpolation" data-i18n="device.stream_settings.interpolation">Interpolation Mode:</label> <label for="stream-selector-interpolation" data-i18n="device.stream_settings.interpolation">Interpolation Mode:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="device.stream_settings.interpolation_hint">How to calculate LED color from sampled pixels</small>
<select id="stream-selector-interpolation"> <select id="stream-selector-interpolation">
<option value="average" data-i18n="device.stream_settings.interpolation.average">Average</option> <option value="average" data-i18n="device.stream_settings.interpolation.average">Average</option>
<option value="median" data-i18n="device.stream_settings.interpolation.median">Median</option> <option value="median" data-i18n="device.stream_settings.interpolation.median">Median</option>
<option value="dominant" data-i18n="device.stream_settings.interpolation.dominant">Dominant</option> <option value="dominant" data-i18n="device.stream_settings.interpolation.dominant">Dominant</option>
</select> </select>
<small class="input-hint" data-i18n="device.stream_settings.interpolation_hint">How to calculate LED color from sampled pixels</small>
</div> </div>
<div class="form-group"> <div class="form-group">
<div class="label-row">
<label for="stream-selector-smoothing"> <label for="stream-selector-smoothing">
<span data-i18n="device.stream_settings.smoothing">Smoothing:</span> <span data-i18n="device.stream_settings.smoothing">Smoothing:</span>
<span id="stream-selector-smoothing-value">0.3</span> <span id="stream-selector-smoothing-value">0.3</span>
</label> </label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="device.stream_settings.smoothing_hint">Temporal blending between frames (0=none, 1=full). Reduces flicker.</small>
<input type="range" id="stream-selector-smoothing" min="0.0" max="1.0" step="0.05" value="0.3" oninput="document.getElementById('stream-selector-smoothing-value').textContent = this.value"> <input type="range" id="stream-selector-smoothing" min="0.0" max="1.0" step="0.05" value="0.3" oninput="document.getElementById('stream-selector-smoothing-value').textContent = this.value">
<small class="input-hint" data-i18n="device.stream_settings.smoothing_hint">Temporal blending between frames (0=none, 1=full). Reduces flicker.</small>
</div> </div>
<div id="stream-selector-error" class="error-message" style="display: none;"></div> <div id="stream-selector-error" class="error-message" style="display: none;"></div>
@@ -290,7 +308,11 @@
Please enter your API key to authenticate and access the WLED Grab. Please enter your API key to authenticate and access the WLED Grab.
</p> </p>
<div class="form-group"> <div class="form-group">
<div class="label-row">
<label for="api-key-input" data-i18n="auth.label">API Key:</label> <label for="api-key-input" data-i18n="auth.label">API Key:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="auth.hint">Your API key will be stored securely in your browser's local storage.</small>
<div class="password-input-wrapper"> <div class="password-input-wrapper">
<input <input
type="password" type="password"
@@ -303,7 +325,6 @@
👁️ 👁️
</button> </button>
</div> </div>
<small class="input-hint" data-i18n="auth.hint">Your API key will be stored securely in your browser's local storage.</small>
</div> </div>
<div id="api-key-error" class="error-message" style="display: none;"></div> <div id="api-key-error" class="error-message" style="display: none;"></div>
</div> </div>
@@ -380,7 +401,11 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<div class="label-row">
<label for="template-engine" data-i18n="templates.engine">Capture Engine:</label> <label for="template-engine" data-i18n="templates.engine">Capture Engine:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="templates.engine.hint">Select the screen capture technology to use</small>
<select id="template-engine" onchange="onEngineChange()" required> <select id="template-engine" onchange="onEngineChange()" required>
<option value="" data-i18n="templates.engine.select">Select an engine...</option> <option value="" data-i18n="templates.engine.select">Select an engine...</option>
</select> </select>
@@ -506,18 +531,30 @@
<!-- Raw stream fields --> <!-- Raw stream fields -->
<div id="stream-raw-fields"> <div id="stream-raw-fields">
<div class="form-group"> <div class="form-group">
<div class="label-row">
<label data-i18n="streams.display">Display:</label> <label data-i18n="streams.display">Display:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="streams.display.hint">Which screen to capture</small>
<input type="hidden" id="stream-display-index" value=""> <input type="hidden" id="stream-display-index" value="">
<button type="button" class="btn btn-display-picker" id="stream-display-picker-btn" onclick="openDisplayPicker(onStreamDisplaySelected, document.getElementById('stream-display-index').value)"> <button type="button" class="btn btn-display-picker" id="stream-display-picker-btn" onclick="openDisplayPicker(onStreamDisplaySelected, document.getElementById('stream-display-index').value)">
<span id="stream-display-picker-label" data-i18n="displays.picker.select">Select display...</span> <span id="stream-display-picker-label" data-i18n="displays.picker.select">Select display...</span>
</button> </button>
</div> </div>
<div class="form-group"> <div class="form-group">
<div class="label-row">
<label for="stream-capture-template" data-i18n="streams.capture_template">Capture Template:</label> <label for="stream-capture-template" data-i18n="streams.capture_template">Capture Template:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="streams.capture_template.hint">Engine template defining how the screen is captured</small>
<select id="stream-capture-template"></select> <select id="stream-capture-template"></select>
</div> </div>
<div class="form-group"> <div class="form-group">
<div class="label-row">
<label for="stream-target-fps" data-i18n="streams.target_fps">Target FPS:</label> <label for="stream-target-fps" data-i18n="streams.target_fps">Target FPS:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="streams.target_fps.hint">Target frames per second for capture (10-90)</small>
<div class="slider-row"> <div class="slider-row">
<input type="range" id="stream-target-fps" min="10" max="90" value="30" oninput="document.getElementById('stream-target-fps-value').textContent = this.value"> <input type="range" id="stream-target-fps" min="10" max="90" value="30" oninput="document.getElementById('stream-target-fps-value').textContent = this.value">
<span id="stream-target-fps-value" class="slider-value">30</span> <span id="stream-target-fps-value" class="slider-value">30</span>
@@ -528,11 +565,19 @@
<!-- Processed stream fields --> <!-- Processed stream fields -->
<div id="stream-processed-fields" style="display: none;"> <div id="stream-processed-fields" style="display: none;">
<div class="form-group"> <div class="form-group">
<div class="label-row">
<label for="stream-source" data-i18n="streams.source">Source Stream:</label> <label for="stream-source" data-i18n="streams.source">Source Stream:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="streams.source.hint">The stream to apply processing filters to</small>
<select id="stream-source"></select> <select id="stream-source"></select>
</div> </div>
<div class="form-group"> <div class="form-group">
<div class="label-row">
<label for="stream-pp-template" data-i18n="streams.pp_template">Processing Template:</label> <label for="stream-pp-template" data-i18n="streams.pp_template">Processing Template:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="streams.pp_template.hint">Filter template to apply to the source stream</small>
<select id="stream-pp-template"></select> <select id="stream-pp-template"></select>
</div> </div>
</div> </div>
@@ -540,9 +585,12 @@
<!-- Static image fields --> <!-- Static image fields -->
<div id="stream-static-image-fields" style="display: none;"> <div id="stream-static-image-fields" style="display: none;">
<div class="form-group"> <div class="form-group">
<div class="label-row">
<label for="stream-image-source" data-i18n="streams.image_source">Image Source:</label> <label for="stream-image-source" data-i18n="streams.image_source">Image Source:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="streams.image_source.hint">Enter a URL (http/https) or local file path to an image</small>
<input type="text" id="stream-image-source" data-i18n-placeholder="streams.image_source.placeholder" placeholder="https://example.com/image.jpg or C:\path\to\image.png"> <input type="text" id="stream-image-source" data-i18n-placeholder="streams.image_source.placeholder" placeholder="https://example.com/image.jpg or C:\path\to\image.png">
<small class="form-hint" data-i18n="streams.image_source.hint">Enter a URL (http/https) or local file path to an image</small>
</div> </div>
<div id="stream-image-preview-container" class="image-preview-container" style="display: none;"> <div id="stream-image-preview-container" class="image-preview-container" style="display: none;">
<img id="stream-image-preview" class="stream-image-preview" src="" alt="Preview"> <img id="stream-image-preview" class="stream-image-preview" src="" alt="Preview">

View File

@@ -46,6 +46,7 @@
"templates.description.label": "Description (optional):", "templates.description.label": "Description (optional):",
"templates.description.placeholder": "Describe this template...", "templates.description.placeholder": "Describe this template...",
"templates.engine": "Capture Engine:", "templates.engine": "Capture Engine:",
"templates.engine.hint": "Select the screen capture technology to use",
"templates.engine.select": "Select an engine...", "templates.engine.select": "Select an engine...",
"templates.engine.unavailable": "Unavailable", "templates.engine.unavailable": "Unavailable",
"templates.engine.unavailable.hint": "This engine is not available on your system", "templates.engine.unavailable.hint": "This engine is not available on your system",
@@ -215,10 +216,15 @@
"streams.type.raw": "Screen Capture", "streams.type.raw": "Screen Capture",
"streams.type.processed": "Processed", "streams.type.processed": "Processed",
"streams.display": "Display:", "streams.display": "Display:",
"streams.display.hint": "Which screen to capture",
"streams.capture_template": "Engine Template:", "streams.capture_template": "Engine Template:",
"streams.capture_template.hint": "Engine template defining how the screen is captured",
"streams.target_fps": "Target FPS:", "streams.target_fps": "Target FPS:",
"streams.target_fps.hint": "Target frames per second for capture (10-90)",
"streams.source": "Source Stream:", "streams.source": "Source Stream:",
"streams.source.hint": "The stream to apply processing filters to",
"streams.pp_template": "Filter Template:", "streams.pp_template": "Filter Template:",
"streams.pp_template.hint": "Filter template to apply to the source stream",
"streams.description_label": "Description (optional):", "streams.description_label": "Description (optional):",
"streams.description_placeholder": "Describe this stream...", "streams.description_placeholder": "Describe this stream...",
"streams.created": "Stream created successfully", "streams.created": "Stream created successfully",

View File

@@ -46,6 +46,7 @@
"templates.description.label": "Описание (необязательно):", "templates.description.label": "Описание (необязательно):",
"templates.description.placeholder": "Опишите этот шаблон...", "templates.description.placeholder": "Опишите этот шаблон...",
"templates.engine": "Движок Захвата:", "templates.engine": "Движок Захвата:",
"templates.engine.hint": "Выберите технологию захвата экрана",
"templates.engine.select": "Выберите движок...", "templates.engine.select": "Выберите движок...",
"templates.engine.unavailable": "Недоступен", "templates.engine.unavailable": "Недоступен",
"templates.engine.unavailable.hint": "Этот движок недоступен в вашей системе", "templates.engine.unavailable.hint": "Этот движок недоступен в вашей системе",
@@ -215,10 +216,15 @@
"streams.type.raw": "Захват экрана", "streams.type.raw": "Захват экрана",
"streams.type.processed": "Обработанный", "streams.type.processed": "Обработанный",
"streams.display": "Дисплей:", "streams.display": "Дисплей:",
"streams.display.hint": "Какой экран захватывать",
"streams.capture_template": "Шаблон Движка:", "streams.capture_template": "Шаблон Движка:",
"streams.capture_template.hint": "Шаблон движка, определяющий способ захвата экрана",
"streams.target_fps": "Целевой FPS:", "streams.target_fps": "Целевой FPS:",
"streams.target_fps.hint": "Целевое количество кадров в секунду (10-90)",
"streams.source": "Исходный Поток:", "streams.source": "Исходный Поток:",
"streams.source.hint": "Поток, к которому применяются фильтры обработки",
"streams.pp_template": "Шаблон Фильтра:", "streams.pp_template": "Шаблон Фильтра:",
"streams.pp_template.hint": "Шаблон фильтра для применения к исходному потоку",
"streams.description_label": "Описание (необязательно):", "streams.description_label": "Описание (необязательно):",
"streams.description_placeholder": "Опишите этот поток...", "streams.description_placeholder": "Опишите этот поток...",
"streams.created": "Поток успешно создан", "streams.created": "Поток успешно создан",

View File

@@ -966,9 +966,49 @@ input:-webkit-autofill:focus {
opacity: 1; opacity: 1;
} }
.label-row {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 5px;
}
.label-row label {
margin-bottom: 0;
}
.hint-toggle {
background: none;
border: 1px solid var(--border-color);
border-radius: 50%;
width: 18px;
height: 18px;
font-size: 0.7rem;
line-height: 1;
color: var(--text-secondary, #888);
cursor: pointer;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
opacity: 0.6;
transition: opacity 0.2s;
flex-shrink: 0;
}
.hint-toggle:hover {
opacity: 1;
}
.hint-toggle.active {
opacity: 1;
color: var(--primary-color, #4CAF50);
border-color: var(--primary-color, #4CAF50);
}
.input-hint { .input-hint {
display: block; display: block;
margin-top: 8px; margin: 0 0 6px 0;
color: #666; color: #666;
font-size: 0.85rem; font-size: 0.85rem;
} }