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."""
import asyncio
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
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:
"""UDP DDP client for sending pixel data to WLED devices."""
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
# Color order mappings: input (R,G,B) -> output indices
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):
def __init__(self, host: str, port: int = DDP_PORT, rgbw: bool = False):
"""Initialize DDP client.
Args:
host: WLED device IP address or hostname
port: DDP port (default 4048)
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.port = port
self.rgbw = rgbw
self.color_order = color_order
self._transport = None
self._protocol = None
self._sequence = 0
self._buses: List[BusConfig] = []
async def connect(self):
"""Establish UDP connection."""
@@ -64,32 +73,50 @@ class DDPClient:
self._protocol = None
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(
self,
rgb_data: bytes,
offset: int = 0,
sequence: int = 1
sequence: int = 1,
push: bool = False,
) -> bytes:
"""Build a DDP packet.
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 2: Data type (0x01 = RGB)
- Byte 3: Source/Destination ID
- Bytes 4-7: Data offset in bytes (4-byte little-endian)
- Bytes 8-9: Data length in bytes (2-byte big-endian)
- Bytes 4-7: Data offset (4 bytes, big-endian)
- Bytes 8-9: Data length (2 bytes, big-endian)
- Bytes 10+: Pixel data
Args:
rgb_data: RGB pixel data as bytes
offset: Byte offset (pixel_index * 3)
sequence: Sequence number (0-255)
push: True for the last packet of a frame
Returns:
Complete DDP packet as bytes
"""
flags = self.DDP_FLAGS_VER1
if push:
flags |= self.DDP_FLAGS_PUSH
data_type = self.DDP_TYPE_RGB
source_id = 0x01
data_len = len(rgb_data)
@@ -107,6 +134,40 @@ class DDPClient:
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(
self,
pixels: List[Tuple[int, int, int]],
@@ -128,21 +189,20 @@ class DDPClient:
raise RuntimeError("DDP client not connected")
try:
# Get color order mapping
order_map = self.COLOR_ORDER_MAP.get(self.color_order, (0, 1, 2))
# Convert pixels to flat RGB or RGBW bytes with color reordering
# Send plain RGB — WLED handles per-bus color order conversion
# internally when outputting to hardware.
bpp = 4 if self.rgbw else 3 # bytes per pixel
pixel_bytes = bytearray()
for r, g, b in pixels:
# Reorder color channels based on WLED configuration
rgb = [int(r), int(g), int(b)]
reordered = [rgb[order_map[0]], rgb[order_map[1]], rgb[order_map[2]]]
pixel_bytes.extend(reordered)
pixel_bytes.extend((int(r), int(g), int(b)))
if self.rgbw:
pixel_bytes.append(0) # White channel = 0
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
num_packets = (total_bytes + bytes_per_packet - 1) // bytes_per_packet
@@ -156,17 +216,22 @@ class DDPClient:
start = i * bytes_per_packet
end = min(start + bytes_per_packet, total_bytes)
chunk = bytes(pixel_bytes[start:end])
is_last = (i == num_packets - 1)
# Increment sequence number
self._sequence = (self._sequence + 1) % 256
# Build and send packet
packet = self._build_ddp_packet(chunk, offset=start, sequence=self._sequence)
# Build and send packet (set PUSH on last packet)
packet = self._build_ddp_packet(
chunk, offset=start,
sequence=self._sequence, push=is_last,
)
self._transport.sendto(packet)
logger.debug(
f"Sent DDP packet {i+1}/{num_packets}: "
f"{len(chunk)} bytes at offset {start}"
f"{' [PUSH]' if is_last else ''}"
)
return True

View File

@@ -1,14 +1,14 @@
"""WLED client for controlling LED devices via HTTP or DDP."""
import asyncio
from dataclasses import dataclass
from dataclasses import dataclass, field
from typing import List, Tuple, Optional, Dict, Any
from urllib.parse import urlparse
import httpx
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__)
@@ -26,6 +26,7 @@ class WLEDInfo:
ip: str
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
buses: List[BusConfig] = field(default_factory=list) # Per-bus/GPIO config
class WLEDClient:
@@ -100,8 +101,10 @@ class WLEDClient:
# Create DDP client if needed
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, color_order=1)
self._ddp_client = DDPClient(self.host, rgbw=False)
# 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()
# Turn on the device and disable Audio Reactive mode for DDP
@@ -230,14 +233,32 @@ class WLEDClient:
data = await self._request("GET", "/json/info")
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")
color_order = 1 # Default to RGB
buses: List[BusConfig] = []
if "hw" in cfg_data and "led" in cfg_data["hw"]:
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
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(
name=data.get("name", "Unknown"),
@@ -247,8 +268,9 @@ class WLEDClient:
product=data.get("product", "FOSS"),
mac=data.get("mac", ""),
ip=data.get("ip", ""),
rgbw=leds_info.get("rgbw", False), # Auto-detect RGBW vs RGB
color_order=color_order, # Auto-detect color order (0=GRB, 1=RGB, etc.)
rgbw=leds_info.get("rgbw", False),
color_order=color_order,
buses=buses,
)
except Exception as e:

View File

@@ -2,6 +2,16 @@ const API_BASE = '/api/v1';
let refreshInterval = 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.
// Prevents accidental close when user drags text selection outside the dialog.
function setupBackdropClose(modal, closeFn) {

View File

@@ -203,15 +203,21 @@
</div>
<div class="form-group">
<label for="settings-device-url" data-i18n="device.url">WLED URL:</label>
<div class="label-row">
<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>
<small class="input-hint" data-i18n="settings.url.hint">IP address or hostname of your WLED device</small>
</div>
<div class="form-group">
<label for="settings-health-interval" data-i18n="settings.health_interval">Health Check Interval (s):</label>
<div class="label-row">
<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">
<small class="input-hint" data-i18n="settings.health_interval.hint">How often to check the WLED device status (5-600 seconds)</small>
</div>
<div id="settings-error" class="error-message" style="display: none;"></div>
@@ -236,35 +242,47 @@
<input type="hidden" id="stream-selector-device-id">
<div class="form-group">
<label for="stream-selector-stream" data-i18n="device.stream_selector.label">Stream:</label>
<div class="label-row">
<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>
<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 class="form-group">
<label for="stream-selector-border-width" data-i18n="device.stream_settings.border_width">Border Width (px):</label>
<div class="label-row">
<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">
<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 class="form-group">
<label for="stream-selector-interpolation" data-i18n="device.stream_settings.interpolation">Interpolation Mode:</label>
<div class="label-row">
<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">
<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="dominant" data-i18n="device.stream_settings.interpolation.dominant">Dominant</option>
</select>
<small class="input-hint" data-i18n="device.stream_settings.interpolation_hint">How to calculate LED color from sampled pixels</small>
</div>
<div class="form-group">
<label for="stream-selector-smoothing">
<span data-i18n="device.stream_settings.smoothing">Smoothing:</span>
<span id="stream-selector-smoothing-value">0.3</span>
</label>
<div class="label-row">
<label for="stream-selector-smoothing">
<span data-i18n="device.stream_settings.smoothing">Smoothing:</span>
<span id="stream-selector-smoothing-value">0.3</span>
</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">
<small class="input-hint" data-i18n="device.stream_settings.smoothing_hint">Temporal blending between frames (0=none, 1=full). Reduces flicker.</small>
</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.
</p>
<div class="form-group">
<label for="api-key-input" data-i18n="auth.label">API Key:</label>
<div class="label-row">
<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">
<input
type="password"
@@ -303,7 +325,6 @@
👁️
</button>
</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 id="api-key-error" class="error-message" style="display: none;"></div>
</div>
@@ -380,7 +401,11 @@
</div>
<div class="form-group">
<label for="template-engine" data-i18n="templates.engine">Capture Engine:</label>
<div class="label-row">
<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>
<option value="" data-i18n="templates.engine.select">Select an engine...</option>
</select>
@@ -506,18 +531,30 @@
<!-- Raw stream fields -->
<div id="stream-raw-fields">
<div class="form-group">
<label data-i18n="streams.display">Display:</label>
<div class="label-row">
<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="">
<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>
</button>
</div>
<div class="form-group">
<label for="stream-capture-template" data-i18n="streams.capture_template">Capture Template:</label>
<div class="label-row">
<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>
</div>
<div class="form-group">
<label for="stream-target-fps" data-i18n="streams.target_fps">Target FPS:</label>
<div class="label-row">
<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">
<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>
@@ -528,11 +565,19 @@
<!-- Processed stream fields -->
<div id="stream-processed-fields" style="display: none;">
<div class="form-group">
<label for="stream-source" data-i18n="streams.source">Source Stream:</label>
<div class="label-row">
<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>
</div>
<div class="form-group">
<label for="stream-pp-template" data-i18n="streams.pp_template">Processing Template:</label>
<div class="label-row">
<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>
</div>
</div>
@@ -540,9 +585,12 @@
<!-- Static image fields -->
<div id="stream-static-image-fields" style="display: none;">
<div class="form-group">
<label for="stream-image-source" data-i18n="streams.image_source">Image Source:</label>
<div class="label-row">
<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">
<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 id="stream-image-preview-container" class="image-preview-container" style="display: none;">
<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.placeholder": "Describe this template...",
"templates.engine": "Capture Engine:",
"templates.engine.hint": "Select the screen capture technology to use",
"templates.engine.select": "Select an engine...",
"templates.engine.unavailable": "Unavailable",
"templates.engine.unavailable.hint": "This engine is not available on your system",
@@ -215,10 +216,15 @@
"streams.type.raw": "Screen Capture",
"streams.type.processed": "Processed",
"streams.display": "Display:",
"streams.display.hint": "Which screen to capture",
"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.hint": "Target frames per second for capture (10-90)",
"streams.source": "Source Stream:",
"streams.source.hint": "The stream to apply processing filters to",
"streams.pp_template": "Filter Template:",
"streams.pp_template.hint": "Filter template to apply to the source stream",
"streams.description_label": "Description (optional):",
"streams.description_placeholder": "Describe this stream...",
"streams.created": "Stream created successfully",

View File

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

View File

@@ -966,9 +966,49 @@ input:-webkit-autofill:focus {
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 {
display: block;
margin-top: 8px;
margin: 0 0 6px 0;
color: #666;
font-size: 0.85rem;
}