Animation None option, FPS min 1, serial COM lifecycle fixes
- Replace animation Enable checkbox with None option in effect selector; show effect description tooltip; disable speed slider when None selected - Allow target FPS range 1-90 (was 10-90) across UI and backend validation - Scope serial COM connections to target lifetime (no idle caching); use temporary connections for power-off/test mode - Fix serial black frame on stop: flush after write, delay after task cancel to prevent race with in-flight thread pool write Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -13,7 +13,7 @@ class PictureSourceCreate(BaseModel):
|
|||||||
stream_type: Literal["raw", "processed", "static_image"] = Field(description="Stream type")
|
stream_type: Literal["raw", "processed", "static_image"] = Field(description="Stream type")
|
||||||
display_index: Optional[int] = Field(None, description="Display index (raw streams)", ge=0)
|
display_index: Optional[int] = Field(None, description="Display index (raw streams)", ge=0)
|
||||||
capture_template_id: Optional[str] = Field(None, description="Capture template ID (raw streams)")
|
capture_template_id: Optional[str] = Field(None, description="Capture template ID (raw streams)")
|
||||||
target_fps: Optional[int] = Field(None, description="Target FPS (raw streams)", ge=10, le=90)
|
target_fps: Optional[int] = Field(None, description="Target FPS (raw streams)", ge=1, le=90)
|
||||||
source_stream_id: Optional[str] = Field(None, description="Source stream ID (processed streams)")
|
source_stream_id: Optional[str] = Field(None, description="Source stream ID (processed streams)")
|
||||||
postprocessing_template_id: Optional[str] = Field(None, description="Postprocessing template ID (processed streams)")
|
postprocessing_template_id: Optional[str] = Field(None, description="Postprocessing template ID (processed streams)")
|
||||||
image_source: Optional[str] = Field(None, description="Image URL or file path (static_image streams)")
|
image_source: Optional[str] = Field(None, description="Image URL or file path (static_image streams)")
|
||||||
@@ -26,7 +26,7 @@ class PictureSourceUpdate(BaseModel):
|
|||||||
name: Optional[str] = Field(None, description="Stream name", min_length=1, max_length=100)
|
name: Optional[str] = Field(None, description="Stream name", min_length=1, max_length=100)
|
||||||
display_index: Optional[int] = Field(None, description="Display index (raw streams)", ge=0)
|
display_index: Optional[int] = Field(None, description="Display index (raw streams)", ge=0)
|
||||||
capture_template_id: Optional[str] = Field(None, description="Capture template ID (raw streams)")
|
capture_template_id: Optional[str] = Field(None, description="Capture template ID (raw streams)")
|
||||||
target_fps: Optional[int] = Field(None, description="Target FPS (raw streams)", ge=10, le=90)
|
target_fps: Optional[int] = Field(None, description="Target FPS (raw streams)", ge=1, le=90)
|
||||||
source_stream_id: Optional[str] = Field(None, description="Source stream ID (processed streams)")
|
source_stream_id: Optional[str] = Field(None, description="Source stream ID (processed streams)")
|
||||||
postprocessing_template_id: Optional[str] = Field(None, description="Postprocessing template ID (processed streams)")
|
postprocessing_template_id: Optional[str] = Field(None, description="Postprocessing template ID (processed streams)")
|
||||||
image_source: Optional[str] = Field(None, description="Image URL or file path (static_image streams)")
|
image_source: Optional[str] = Field(None, description="Image URL or file path (static_image streams)")
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ class PictureTargetCreate(BaseModel):
|
|||||||
# LED target fields
|
# LED target fields
|
||||||
device_id: str = Field(default="", description="LED device ID")
|
device_id: str = Field(default="", description="LED device ID")
|
||||||
color_strip_source_id: str = Field(default="", description="Color strip source ID")
|
color_strip_source_id: str = Field(default="", description="Color strip source ID")
|
||||||
fps: int = Field(default=30, ge=10, le=90, description="Target send FPS (10-90)")
|
fps: int = Field(default=30, ge=1, le=90, description="Target send FPS (1-90)")
|
||||||
standby_interval: float = Field(default=1.0, description="Keepalive send interval when screen is static (0.5-5.0s)", ge=0.5, le=5.0)
|
standby_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)
|
state_check_interval: int = Field(default=DEFAULT_STATE_CHECK_INTERVAL, description="Device health check interval (5-600s)", ge=5, le=600)
|
||||||
# KC target fields
|
# KC target fields
|
||||||
@@ -69,7 +69,7 @@ class PictureTargetUpdate(BaseModel):
|
|||||||
# LED target fields
|
# LED target fields
|
||||||
device_id: Optional[str] = Field(None, description="LED device ID")
|
device_id: Optional[str] = Field(None, description="LED device ID")
|
||||||
color_strip_source_id: Optional[str] = Field(None, description="Color strip source ID")
|
color_strip_source_id: Optional[str] = Field(None, description="Color strip source ID")
|
||||||
fps: Optional[int] = Field(None, ge=10, le=90, description="Target send FPS (10-90)")
|
fps: Optional[int] = Field(None, ge=1, le=90, description="Target send FPS (1-90)")
|
||||||
standby_interval: Optional[float] = Field(None, description="Keepalive interval (0.5-5.0s)", ge=0.5, le=5.0)
|
standby_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)
|
state_check_interval: Optional[int] = Field(None, description="Health check interval (5-600s)", ge=5, le=600)
|
||||||
# KC target fields
|
# KC target fields
|
||||||
|
|||||||
@@ -105,9 +105,22 @@ class AdalightClient(LEDClient):
|
|||||||
try:
|
try:
|
||||||
black = np.zeros((self._led_count, 3), dtype=np.uint8)
|
black = np.zeros((self._led_count, 3), dtype=np.uint8)
|
||||||
frame = self._build_frame(black, brightness=255)
|
frame = self._build_frame(black, brightness=255)
|
||||||
|
logger.info(
|
||||||
|
f"Adalight sending black frame: {self._port} "
|
||||||
|
f"({self._led_count} LEDs, {len(frame)} bytes)"
|
||||||
|
)
|
||||||
await asyncio.to_thread(self._serial.write, frame)
|
await asyncio.to_thread(self._serial.write, frame)
|
||||||
|
await asyncio.to_thread(self._serial.flush)
|
||||||
|
logger.info(f"Adalight black frame sent and flushed: {self._port}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"Failed to send black frame on close: {e}")
|
logger.warning(f"Failed to send black frame on close: {e}")
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
f"Adalight close skipped black frame: port={self._port} "
|
||||||
|
f"connected={self._connected} serial={self._serial is not None} "
|
||||||
|
f"is_open={self._serial.is_open if self._serial else 'N/A'} "
|
||||||
|
f"led_count={self._led_count}"
|
||||||
|
)
|
||||||
self._connected = False
|
self._connected = False
|
||||||
if self._serial and self._serial.is_open:
|
if self._serial and self._serial.is_open:
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -219,7 +219,7 @@ class PictureColorStripStream(ColorStripStream):
|
|||||||
|
|
||||||
def set_capture_fps(self, fps: int) -> None:
|
def set_capture_fps(self, fps: int) -> None:
|
||||||
"""Update the internal capture rate. Thread-safe (read atomically by the loop)."""
|
"""Update the internal capture rate. Thread-safe (read atomically by the loop)."""
|
||||||
fps = max(10, min(90, fps))
|
fps = max(1, min(90, fps))
|
||||||
if fps != self._fps:
|
if fps != self._fps:
|
||||||
self._fps = fps
|
self._fps = fps
|
||||||
self._interp_duration = 1.0 / fps
|
self._interp_duration = 1.0 / fps
|
||||||
|
|||||||
@@ -527,8 +527,6 @@ class ProcessorManager:
|
|||||||
ds.test_mode_edges = {}
|
ds.test_mode_edges = {}
|
||||||
ds.test_calibration = None
|
ds.test_calibration = None
|
||||||
await self._send_clear_pixels(device_id)
|
await self._send_clear_pixels(device_id)
|
||||||
# Keep idle client open — serial reconnect causes device reset.
|
|
||||||
# start_processing() closes it before connecting its own client.
|
|
||||||
|
|
||||||
async def _get_idle_client(self, device_id: str):
|
async def _get_idle_client(self, device_id: str):
|
||||||
"""Get or create a cached idle LED client for a device.
|
"""Get or create a cached idle LED client for a device.
|
||||||
@@ -590,22 +588,42 @@ class ProcessorManager:
|
|||||||
if offset > 0:
|
if offset > 0:
|
||||||
pixels = pixels[-offset:] + pixels[:-offset]
|
pixels = pixels[-offset:] + pixels[:-offset]
|
||||||
|
|
||||||
try:
|
await self._send_pixels_to_device(device_id, pixels)
|
||||||
client = await self._get_idle_client(device_id)
|
|
||||||
await client.send_pixels(pixels)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to send test pixels for {device_id}: {e}")
|
|
||||||
|
|
||||||
async def _send_clear_pixels(self, device_id: str) -> None:
|
async def _send_clear_pixels(self, device_id: str) -> None:
|
||||||
"""Send all-black pixels to clear LED output."""
|
"""Send all-black pixels to clear LED output."""
|
||||||
ds = self._devices[device_id]
|
ds = self._devices[device_id]
|
||||||
pixels = [(0, 0, 0)] * ds.led_count
|
pixels = [(0, 0, 0)] * ds.led_count
|
||||||
|
await self._send_pixels_to_device(device_id, pixels)
|
||||||
|
|
||||||
|
def _is_serial_device(self, device_id: str) -> bool:
|
||||||
|
"""Check if a device uses a serial (COM) connection."""
|
||||||
|
ds = self._devices.get(device_id)
|
||||||
|
return ds is not None and ds.device_type not in ("wled",)
|
||||||
|
|
||||||
|
async def _send_pixels_to_device(self, device_id: str, pixels) -> None:
|
||||||
|
"""Send pixels to a device.
|
||||||
|
|
||||||
|
Serial devices: temporary connection (open, send, close).
|
||||||
|
WLED devices: cached idle client.
|
||||||
|
"""
|
||||||
|
ds = self._devices[device_id]
|
||||||
try:
|
try:
|
||||||
|
if self._is_serial_device(device_id):
|
||||||
|
client = create_led_client(
|
||||||
|
ds.device_type, ds.device_url,
|
||||||
|
led_count=ds.led_count, baud_rate=ds.baud_rate,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await client.connect()
|
||||||
|
await client.send_pixels(pixels)
|
||||||
|
finally:
|
||||||
|
await client.close()
|
||||||
|
else:
|
||||||
client = await self._get_idle_client(device_id)
|
client = await self._get_idle_client(device_id)
|
||||||
await client.send_pixels(pixels)
|
await client.send_pixels(pixels)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to clear pixels for {device_id}: {e}")
|
logger.error(f"Failed to send pixels to {device_id}: {e}")
|
||||||
|
|
||||||
def _find_active_led_client(self, device_id: str):
|
def _find_active_led_client(self, device_id: str):
|
||||||
"""Find an active LED client for a device (from a running processor)."""
|
"""Find an active LED client for a device (from a running processor)."""
|
||||||
@@ -644,7 +662,7 @@ class ProcessorManager:
|
|||||||
"""Restore a device to its idle state when all targets stop.
|
"""Restore a device to its idle state when all targets stop.
|
||||||
|
|
||||||
- For WLED: do nothing — stop() already restored the snapshot.
|
- For WLED: do nothing — stop() already restored the snapshot.
|
||||||
- For other devices: power off (send black frame).
|
- For serial: do nothing — AdalightClient.close() already sent black frame.
|
||||||
"""
|
"""
|
||||||
ds = self._devices.get(device_id)
|
ds = self._devices.get(device_id)
|
||||||
if not ds or not ds.auto_shutdown:
|
if not ds or not ds.auto_shutdown:
|
||||||
@@ -653,15 +671,10 @@ class ProcessorManager:
|
|||||||
if self.is_device_processing(device_id):
|
if self.is_device_processing(device_id):
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
if ds.device_type == "wled":
|
||||||
if ds.device_type != "wled":
|
|
||||||
await self._send_clear_pixels(device_id)
|
|
||||||
logger.info(f"Auto-restore: powered off {ds.device_type} device {device_id}")
|
|
||||||
else:
|
|
||||||
# WLED: stop() already called restore_device_state() via snapshot
|
|
||||||
logger.info(f"Auto-restore: WLED device {device_id} restored by snapshot")
|
logger.info(f"Auto-restore: WLED device {device_id} restored by snapshot")
|
||||||
except Exception as e:
|
else:
|
||||||
logger.error(f"Auto-restore failed for device {device_id}: {e}")
|
logger.info(f"Auto-restore: {ds.device_type} device {device_id} dark (closed by processor)")
|
||||||
|
|
||||||
# ===== LIFECYCLE =====
|
# ===== LIFECYCLE =====
|
||||||
|
|
||||||
@@ -678,18 +691,11 @@ class ProcessorManager:
|
|||||||
logger.error(f"Error stopping target {target_id}: {e}")
|
logger.error(f"Error stopping target {target_id}: {e}")
|
||||||
|
|
||||||
# Restore idle state for devices that have auto-restore enabled
|
# Restore idle state for devices that have auto-restore enabled
|
||||||
|
# (serial devices already dark from processor close; WLED restored by snapshot)
|
||||||
for device_id in self._devices:
|
for device_id in self._devices:
|
||||||
await self._restore_device_idle_state(device_id)
|
await self._restore_device_idle_state(device_id)
|
||||||
|
|
||||||
# Power off serial LED devices before closing connections
|
# Close any cached idle LED clients (WLED only; serial has no cached clients)
|
||||||
for device_id, ds in self._devices.items():
|
|
||||||
if ds.device_type != "wled":
|
|
||||||
try:
|
|
||||||
await self._send_clear_pixels(device_id)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to power off {device_id} on shutdown: {e}")
|
|
||||||
|
|
||||||
# Close any cached idle LED clients
|
|
||||||
for did in list(self._idle_clients):
|
for did in list(self._idle_clients):
|
||||||
await self._close_idle_client(did)
|
await self._close_idle_client(did)
|
||||||
|
|
||||||
|
|||||||
@@ -165,6 +165,9 @@ class WledTargetProcessor(TargetProcessor):
|
|||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
pass
|
pass
|
||||||
self._task = None
|
self._task = None
|
||||||
|
# Allow any in-flight thread pool serial write to complete before
|
||||||
|
# close() sends the black frame (to_thread keeps running after cancel)
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
|
||||||
# Restore device state
|
# Restore device state
|
||||||
if self._led_client and self._device_state_before:
|
if self._led_client and self._device_state_before:
|
||||||
|
|||||||
@@ -203,6 +203,14 @@
|
|||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.field-desc {
|
||||||
|
display: block;
|
||||||
|
margin: 4px 0 0 0;
|
||||||
|
color: #888;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
.fps-hint {
|
.fps-hint {
|
||||||
display: block;
|
display: block;
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ import {
|
|||||||
// Layer 5: color-strip sources
|
// Layer 5: color-strip sources
|
||||||
import {
|
import {
|
||||||
showCSSEditor, closeCSSEditorModal, forceCSSEditorClose, saveCSSEditor, deleteColorStrip,
|
showCSSEditor, closeCSSEditorModal, forceCSSEditorClose, saveCSSEditor, deleteColorStrip,
|
||||||
onCSSTypeChange, colorCycleAddColor, colorCycleRemoveColor,
|
onCSSTypeChange, onAnimationTypeChange, colorCycleAddColor, colorCycleRemoveColor,
|
||||||
} from './features/color-strips.js';
|
} from './features/color-strips.js';
|
||||||
|
|
||||||
// Layer 5: calibration
|
// Layer 5: calibration
|
||||||
@@ -274,6 +274,7 @@ Object.assign(window, {
|
|||||||
saveCSSEditor,
|
saveCSSEditor,
|
||||||
deleteColorStrip,
|
deleteColorStrip,
|
||||||
onCSSTypeChange,
|
onCSSTypeChange,
|
||||||
|
onAnimationTypeChange,
|
||||||
colorCycleAddColor,
|
colorCycleAddColor,
|
||||||
colorCycleRemoveColor,
|
colorCycleRemoveColor,
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ class CSSEditorModal extends Modal {
|
|||||||
frame_interpolation: document.getElementById('css-editor-frame-interpolation').checked,
|
frame_interpolation: document.getElementById('css-editor-frame-interpolation').checked,
|
||||||
led_count: document.getElementById('css-editor-led-count').value,
|
led_count: document.getElementById('css-editor-led-count').value,
|
||||||
gradient_stops: type === 'gradient' ? JSON.stringify(_gradientStops) : '[]',
|
gradient_stops: type === 'gradient' ? JSON.stringify(_gradientStops) : '[]',
|
||||||
animation_enabled: document.getElementById('css-editor-animation-enabled').checked,
|
|
||||||
animation_type: document.getElementById('css-editor-animation-type').value,
|
animation_type: document.getElementById('css-editor-animation-type').value,
|
||||||
animation_speed: document.getElementById('css-editor-animation-speed').value,
|
animation_speed: document.getElementById('css-editor-animation-speed').value,
|
||||||
cycle_speed: document.getElementById('css-editor-cycle-speed').value,
|
cycle_speed: document.getElementById('css-editor-cycle-speed').value,
|
||||||
@@ -50,9 +49,10 @@ export function onCSSTypeChange() {
|
|||||||
// Animation section — shown for static/gradient only (color_cycle is always animating)
|
// Animation section — shown for static/gradient only (color_cycle is always animating)
|
||||||
const animSection = document.getElementById('css-editor-animation-section');
|
const animSection = document.getElementById('css-editor-animation-section');
|
||||||
const animTypeSelect = document.getElementById('css-editor-animation-type');
|
const animTypeSelect = document.getElementById('css-editor-animation-type');
|
||||||
|
const noneOpt = `<option value="none">${t('color_strip.animation.type.none')}</option>`;
|
||||||
if (type === 'static') {
|
if (type === 'static') {
|
||||||
animSection.style.display = '';
|
animSection.style.display = '';
|
||||||
animTypeSelect.innerHTML =
|
animTypeSelect.innerHTML = noneOpt +
|
||||||
`<option value="breathing">${t('color_strip.animation.type.breathing')}</option>` +
|
`<option value="breathing">${t('color_strip.animation.type.breathing')}</option>` +
|
||||||
`<option value="strobe">${t('color_strip.animation.type.strobe')}</option>` +
|
`<option value="strobe">${t('color_strip.animation.type.strobe')}</option>` +
|
||||||
`<option value="sparkle">${t('color_strip.animation.type.sparkle')}</option>` +
|
`<option value="sparkle">${t('color_strip.animation.type.sparkle')}</option>` +
|
||||||
@@ -61,7 +61,7 @@ export function onCSSTypeChange() {
|
|||||||
`<option value="rainbow_fade">${t('color_strip.animation.type.rainbow_fade')}</option>`;
|
`<option value="rainbow_fade">${t('color_strip.animation.type.rainbow_fade')}</option>`;
|
||||||
} else if (type === 'gradient') {
|
} else if (type === 'gradient') {
|
||||||
animSection.style.display = '';
|
animSection.style.display = '';
|
||||||
animTypeSelect.innerHTML =
|
animTypeSelect.innerHTML = noneOpt +
|
||||||
`<option value="breathing">${t('color_strip.animation.type.breathing')}</option>` +
|
`<option value="breathing">${t('color_strip.animation.type.breathing')}</option>` +
|
||||||
`<option value="gradient_shift">${t('color_strip.animation.type.gradient_shift')}</option>` +
|
`<option value="gradient_shift">${t('color_strip.animation.type.gradient_shift')}</option>` +
|
||||||
`<option value="wave">${t('color_strip.animation.type.wave')}</option>` +
|
`<option value="wave">${t('color_strip.animation.type.wave')}</option>` +
|
||||||
@@ -73,6 +73,7 @@ export function onCSSTypeChange() {
|
|||||||
} else {
|
} else {
|
||||||
animSection.style.display = 'none';
|
animSection.style.display = 'none';
|
||||||
}
|
}
|
||||||
|
_syncAnimationSpeedState();
|
||||||
|
|
||||||
if (type === 'gradient') {
|
if (type === 'gradient') {
|
||||||
requestAnimationFrame(() => gradientRenderAll());
|
requestAnimationFrame(() => gradientRenderAll());
|
||||||
@@ -80,22 +81,41 @@ export function onCSSTypeChange() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function _getAnimationPayload() {
|
function _getAnimationPayload() {
|
||||||
|
const type = document.getElementById('css-editor-animation-type').value;
|
||||||
return {
|
return {
|
||||||
enabled: document.getElementById('css-editor-animation-enabled').checked,
|
enabled: type !== 'none',
|
||||||
type: document.getElementById('css-editor-animation-type').value,
|
type: type !== 'none' ? type : 'breathing',
|
||||||
speed: parseFloat(document.getElementById('css-editor-animation-speed').value),
|
speed: parseFloat(document.getElementById('css-editor-animation-speed').value),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function _loadAnimationState(anim) {
|
function _loadAnimationState(anim) {
|
||||||
document.getElementById('css-editor-animation-enabled').checked = !!(anim && anim.enabled);
|
|
||||||
const speedEl = document.getElementById('css-editor-animation-speed');
|
const speedEl = document.getElementById('css-editor-animation-speed');
|
||||||
speedEl.value = (anim && anim.speed != null) ? anim.speed : 1.0;
|
speedEl.value = (anim && anim.speed != null) ? anim.speed : 1.0;
|
||||||
document.getElementById('css-editor-animation-speed-val').textContent =
|
document.getElementById('css-editor-animation-speed-val').textContent =
|
||||||
parseFloat(speedEl.value).toFixed(1);
|
parseFloat(speedEl.value).toFixed(1);
|
||||||
// Set type after onCSSTypeChange() has populated the dropdown
|
// Set type after onCSSTypeChange() has populated the dropdown
|
||||||
if (anim && anim.type) {
|
if (anim && anim.enabled && anim.type) {
|
||||||
document.getElementById('css-editor-animation-type').value = anim.type;
|
document.getElementById('css-editor-animation-type').value = anim.type;
|
||||||
|
} else {
|
||||||
|
document.getElementById('css-editor-animation-type').value = 'none';
|
||||||
|
}
|
||||||
|
_syncAnimationSpeedState();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function onAnimationTypeChange() {
|
||||||
|
_syncAnimationSpeedState();
|
||||||
|
}
|
||||||
|
|
||||||
|
function _syncAnimationSpeedState() {
|
||||||
|
const type = document.getElementById('css-editor-animation-type').value;
|
||||||
|
const isNone = type === 'none';
|
||||||
|
document.getElementById('css-editor-animation-speed').disabled = isNone;
|
||||||
|
const descEl = document.getElementById('css-editor-animation-type-desc');
|
||||||
|
if (descEl) {
|
||||||
|
const desc = t('color_strip.animation.type.' + type + '.desc') || '';
|
||||||
|
descEl.textContent = desc;
|
||||||
|
descEl.style.display = desc ? '' : 'none';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -258,7 +258,7 @@
|
|||||||
"streams.capture_template": "Engine Template:",
|
"streams.capture_template": "Engine Template:",
|
||||||
"streams.capture_template.hint": "Engine template defining how the screen is captured",
|
"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.target_fps.hint": "Target frames per second for capture (1-90)",
|
||||||
"streams.source": "Source:",
|
"streams.source": "Source:",
|
||||||
"streams.source.hint": "The source to apply processing filters to",
|
"streams.source.hint": "The source to apply processing filters to",
|
||||||
"streams.pp_template": "Filter Template:",
|
"streams.pp_template": "Filter Template:",
|
||||||
@@ -361,7 +361,7 @@
|
|||||||
"targets.source.hint": "Which picture source to capture and process",
|
"targets.source.hint": "Which picture source to capture and process",
|
||||||
"targets.source.none": "-- No source assigned --",
|
"targets.source.none": "-- No source assigned --",
|
||||||
"targets.fps": "Target FPS:",
|
"targets.fps": "Target FPS:",
|
||||||
"targets.fps.hint": "Target frames per second for capture and LED updates (10-90)",
|
"targets.fps.hint": "Target frames per second for capture and LED updates (1-90)",
|
||||||
"targets.border_width": "Border Width (px):",
|
"targets.border_width": "Border Width (px):",
|
||||||
"targets.border_width.hint": "How many pixels from the screen edge to sample for LED colors (1-100)",
|
"targets.border_width.hint": "How many pixels from the screen edge to sample for LED colors (1-100)",
|
||||||
"targets.interpolation": "Interpolation Mode:",
|
"targets.interpolation": "Interpolation Mode:",
|
||||||
@@ -589,19 +589,26 @@
|
|||||||
"color_strip.gradient.bidir.hint": "Add a second color on the right side of this stop to create a hard edge in the gradient.",
|
"color_strip.gradient.bidir.hint": "Add a second color on the right side of this stop to create a hard edge in the gradient.",
|
||||||
"color_strip.gradient.min_stops": "Gradient must have at least 2 stops",
|
"color_strip.gradient.min_stops": "Gradient must have at least 2 stops",
|
||||||
"color_strip.animation": "Animation",
|
"color_strip.animation": "Animation",
|
||||||
"color_strip.animation.enabled": "Enable Animation:",
|
|
||||||
"color_strip.animation.enabled.hint": "Enables procedural animation. The LEDs will update at 30 fps driven by the selected effect.",
|
|
||||||
"color_strip.animation.type": "Effect:",
|
"color_strip.animation.type": "Effect:",
|
||||||
"color_strip.animation.type.hint": "The animation effect to apply. Breathing, Strobe, Sparkle, Pulse, Candle, and Rainbow Fade work for both static and gradient sources; Gradient Shift and Wave are gradient-only.",
|
"color_strip.animation.type.hint": "Animation effect to apply.",
|
||||||
|
"color_strip.animation.type.none": "None (no animation effect)",
|
||||||
"color_strip.animation.type.breathing": "Breathing",
|
"color_strip.animation.type.breathing": "Breathing",
|
||||||
|
"color_strip.animation.type.breathing.desc": "Smooth brightness fade in and out",
|
||||||
"color_strip.animation.type.color_cycle": "Color Cycle",
|
"color_strip.animation.type.color_cycle": "Color Cycle",
|
||||||
"color_strip.animation.type.gradient_shift": "Gradient Shift",
|
"color_strip.animation.type.gradient_shift": "Gradient Shift",
|
||||||
|
"color_strip.animation.type.gradient_shift.desc": "Slides the gradient along the strip",
|
||||||
"color_strip.animation.type.wave": "Wave",
|
"color_strip.animation.type.wave": "Wave",
|
||||||
|
"color_strip.animation.type.wave.desc": "Sinusoidal brightness wave moving along the strip",
|
||||||
"color_strip.animation.type.strobe": "Strobe",
|
"color_strip.animation.type.strobe": "Strobe",
|
||||||
|
"color_strip.animation.type.strobe.desc": "Rapid on/off flashing",
|
||||||
"color_strip.animation.type.sparkle": "Sparkle",
|
"color_strip.animation.type.sparkle": "Sparkle",
|
||||||
|
"color_strip.animation.type.sparkle.desc": "Random LEDs flash briefly",
|
||||||
"color_strip.animation.type.pulse": "Pulse",
|
"color_strip.animation.type.pulse": "Pulse",
|
||||||
|
"color_strip.animation.type.pulse.desc": "Sharp brightness pulse with quick fade",
|
||||||
"color_strip.animation.type.candle": "Candle",
|
"color_strip.animation.type.candle": "Candle",
|
||||||
|
"color_strip.animation.type.candle.desc": "Warm flickering candle-like glow",
|
||||||
"color_strip.animation.type.rainbow_fade": "Rainbow Fade",
|
"color_strip.animation.type.rainbow_fade": "Rainbow Fade",
|
||||||
|
"color_strip.animation.type.rainbow_fade.desc": "Cycles through the entire hue spectrum",
|
||||||
"color_strip.animation.speed": "Speed:",
|
"color_strip.animation.speed": "Speed:",
|
||||||
"color_strip.animation.speed.hint": "Animation speed multiplier. 1.0 ≈ one cycle per second for Breathing; higher values cycle faster.",
|
"color_strip.animation.speed.hint": "Animation speed multiplier. 1.0 ≈ one cycle per second for Breathing; higher values cycle faster.",
|
||||||
"color_strip.color_cycle.colors": "Colors:",
|
"color_strip.color_cycle.colors": "Colors:",
|
||||||
|
|||||||
@@ -258,7 +258,7 @@
|
|||||||
"streams.capture_template": "Шаблон Движка:",
|
"streams.capture_template": "Шаблон Движка:",
|
||||||
"streams.capture_template.hint": "Шаблон движка, определяющий способ захвата экрана",
|
"streams.capture_template.hint": "Шаблон движка, определяющий способ захвата экрана",
|
||||||
"streams.target_fps": "Целевой FPS:",
|
"streams.target_fps": "Целевой FPS:",
|
||||||
"streams.target_fps.hint": "Целевое количество кадров в секунду (10-90)",
|
"streams.target_fps.hint": "Целевое количество кадров в секунду (1-90)",
|
||||||
"streams.source": "Источник:",
|
"streams.source": "Источник:",
|
||||||
"streams.source.hint": "Источник, к которому применяются фильтры обработки",
|
"streams.source.hint": "Источник, к которому применяются фильтры обработки",
|
||||||
"streams.pp_template": "Шаблон Фильтра:",
|
"streams.pp_template": "Шаблон Фильтра:",
|
||||||
@@ -361,7 +361,7 @@
|
|||||||
"targets.source.hint": "Какой источник изображения захватывать и обрабатывать",
|
"targets.source.hint": "Какой источник изображения захватывать и обрабатывать",
|
||||||
"targets.source.none": "-- Источник не назначен --",
|
"targets.source.none": "-- Источник не назначен --",
|
||||||
"targets.fps": "Целевой FPS:",
|
"targets.fps": "Целевой FPS:",
|
||||||
"targets.fps.hint": "Целевая частота кадров для захвата и обновления LED (10-90)",
|
"targets.fps.hint": "Целевая частота кадров для захвата и обновления LED (1-90)",
|
||||||
"targets.border_width": "Ширина границы (px):",
|
"targets.border_width": "Ширина границы (px):",
|
||||||
"targets.border_width.hint": "Сколько пикселей от края экрана выбирать для цвета LED (1-100)",
|
"targets.border_width.hint": "Сколько пикселей от края экрана выбирать для цвета LED (1-100)",
|
||||||
"targets.interpolation": "Режим интерполяции:",
|
"targets.interpolation": "Режим интерполяции:",
|
||||||
@@ -589,19 +589,26 @@
|
|||||||
"color_strip.gradient.bidir.hint": "Добавить второй цвет справа от этой остановки для создания резкого перехода в градиенте.",
|
"color_strip.gradient.bidir.hint": "Добавить второй цвет справа от этой остановки для создания резкого перехода в градиенте.",
|
||||||
"color_strip.gradient.min_stops": "Градиент должен содержать не менее 2 остановок",
|
"color_strip.gradient.min_stops": "Градиент должен содержать не менее 2 остановок",
|
||||||
"color_strip.animation": "Анимация",
|
"color_strip.animation": "Анимация",
|
||||||
"color_strip.animation.enabled": "Включить анимацию:",
|
|
||||||
"color_strip.animation.enabled.hint": "Включает процедурную анимацию. Светодиоды обновляются со скоростью 30 кадров в секунду по выбранному эффекту.",
|
|
||||||
"color_strip.animation.type": "Эффект:",
|
"color_strip.animation.type": "Эффект:",
|
||||||
"color_strip.animation.type.hint": "Эффект анимации. Дыхание, стробоскоп, искры, пульс, свеча и радужный перелив работают для статического цвета и градиента; сдвиг градиента и волна — только для градиентов.",
|
"color_strip.animation.type.hint": "Эффект анимации.",
|
||||||
|
"color_strip.animation.type.none": "Нет (без эффекта анимации)",
|
||||||
"color_strip.animation.type.breathing": "Дыхание",
|
"color_strip.animation.type.breathing": "Дыхание",
|
||||||
|
"color_strip.animation.type.breathing.desc": "Плавное угасание и нарастание яркости",
|
||||||
"color_strip.animation.type.color_cycle": "Смена цвета",
|
"color_strip.animation.type.color_cycle": "Смена цвета",
|
||||||
"color_strip.animation.type.gradient_shift": "Сдвиг градиента",
|
"color_strip.animation.type.gradient_shift": "Сдвиг градиента",
|
||||||
|
"color_strip.animation.type.gradient_shift.desc": "Сдвигает градиент вдоль ленты",
|
||||||
"color_strip.animation.type.wave": "Волна",
|
"color_strip.animation.type.wave": "Волна",
|
||||||
|
"color_strip.animation.type.wave.desc": "Синусоидальная волна яркости вдоль ленты",
|
||||||
"color_strip.animation.type.strobe": "Стробоскоп",
|
"color_strip.animation.type.strobe": "Стробоскоп",
|
||||||
|
"color_strip.animation.type.strobe.desc": "Быстрое мигание вкл/выкл",
|
||||||
"color_strip.animation.type.sparkle": "Искры",
|
"color_strip.animation.type.sparkle": "Искры",
|
||||||
|
"color_strip.animation.type.sparkle.desc": "Случайные светодиоды кратковременно вспыхивают",
|
||||||
"color_strip.animation.type.pulse": "Пульс",
|
"color_strip.animation.type.pulse": "Пульс",
|
||||||
|
"color_strip.animation.type.pulse.desc": "Резкая вспышка яркости с быстрым затуханием",
|
||||||
"color_strip.animation.type.candle": "Свеча",
|
"color_strip.animation.type.candle": "Свеча",
|
||||||
|
"color_strip.animation.type.candle.desc": "Тёплое мерцание, как у свечи",
|
||||||
"color_strip.animation.type.rainbow_fade": "Радужный перелив",
|
"color_strip.animation.type.rainbow_fade": "Радужный перелив",
|
||||||
|
"color_strip.animation.type.rainbow_fade.desc": "Циклический переход по всему спектру оттенков",
|
||||||
"color_strip.animation.speed": "Скорость:",
|
"color_strip.animation.speed": "Скорость:",
|
||||||
"color_strip.animation.speed.hint": "Множитель скорости анимации. 1.0 ≈ один цикл в секунду для дыхания; большие значения ускоряют анимацию.",
|
"color_strip.animation.speed.hint": "Множитель скорости анимации. 1.0 ≈ один цикл в секунду для дыхания; большие значения ускоряют анимацию.",
|
||||||
"color_strip.color_cycle.colors": "Цвета:",
|
"color_strip.color_cycle.colors": "Цвета:",
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ class WledPictureTarget(PictureTarget):
|
|||||||
|
|
||||||
device_id: str = ""
|
device_id: str = ""
|
||||||
color_strip_source_id: str = ""
|
color_strip_source_id: str = ""
|
||||||
fps: int = 30 # target send FPS (10-90)
|
fps: int = 30 # target send FPS (1-90)
|
||||||
standby_interval: float = 1.0 # seconds between keepalive sends when screen is static
|
standby_interval: float = 1.0 # seconds between keepalive sends when screen is static
|
||||||
state_check_interval: int = DEFAULT_STATE_CHECK_INTERVAL
|
state_check_interval: int = DEFAULT_STATE_CHECK_INTERVAL
|
||||||
|
|
||||||
|
|||||||
@@ -193,26 +193,16 @@
|
|||||||
<details class="form-collapse">
|
<details class="form-collapse">
|
||||||
<summary><span data-i18n="color_strip.animation">Animation</span></summary>
|
<summary><span data-i18n="color_strip.animation">Animation</span></summary>
|
||||||
<div class="form-collapse-body">
|
<div class="form-collapse-body">
|
||||||
<div class="form-group">
|
|
||||||
<div class="label-row">
|
|
||||||
<label for="css-editor-animation-enabled" data-i18n="color_strip.animation.enabled">Enable Animation:</label>
|
|
||||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
|
||||||
</div>
|
|
||||||
<small class="input-hint" style="display:none" data-i18n="color_strip.animation.enabled.hint">Enables procedural animation. The LEDs will update at 30 fps driven by the selected effect.</small>
|
|
||||||
<label class="settings-toggle">
|
|
||||||
<input type="checkbox" id="css-editor-animation-enabled">
|
|
||||||
<span class="settings-toggle-slider"></span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<div class="label-row">
|
<div class="label-row">
|
||||||
<label for="css-editor-animation-type" data-i18n="color_strip.animation.type">Effect:</label>
|
<label for="css-editor-animation-type" data-i18n="color_strip.animation.type">Effect:</label>
|
||||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
||||||
</div>
|
</div>
|
||||||
<small class="input-hint" style="display:none" data-i18n="color_strip.animation.type.hint">The animation effect to apply. Available effects depend on source type.</small>
|
<small class="input-hint" style="display:none" data-i18n="color_strip.animation.type.hint">The animation effect to apply. Available effects depend on source type. Select None to disable animation.</small>
|
||||||
<select id="css-editor-animation-type">
|
<select id="css-editor-animation-type" onchange="onAnimationTypeChange()">
|
||||||
<!-- populated by onCSSTypeChange() -->
|
<!-- populated by onCSSTypeChange() -->
|
||||||
</select>
|
</select>
|
||||||
|
<small id="css-editor-animation-type-desc" class="field-desc"></small>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<div class="label-row">
|
<div class="label-row">
|
||||||
|
|||||||
@@ -41,9 +41,9 @@
|
|||||||
<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>
|
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||||
</div>
|
</div>
|
||||||
<small class="input-hint" style="display:none" data-i18n="streams.target_fps.hint">Target frames per second for capture (10-90)</small>
|
<small class="input-hint" style="display:none" data-i18n="streams.target_fps.hint">Target frames per second for capture (1-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="1" 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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -40,9 +40,9 @@
|
|||||||
</label>
|
</label>
|
||||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||||
</div>
|
</div>
|
||||||
<small class="input-hint" style="display:none" data-i18n="targets.fps.hint">How many frames per second to send to the device (10-90). Higher values give smoother animations but use more bandwidth.</small>
|
<small class="input-hint" style="display:none" data-i18n="targets.fps.hint">How many frames per second to send to the device (1-90). Higher values give smoother animations but use more bandwidth.</small>
|
||||||
<div class="slider-row">
|
<div class="slider-row">
|
||||||
<input type="range" id="target-editor-fps" min="10" max="90" value="30" oninput="document.getElementById('target-editor-fps-value').textContent = this.value">
|
<input type="range" id="target-editor-fps" min="1" max="90" value="30" oninput="document.getElementById('target-editor-fps-value').textContent = this.value">
|
||||||
<span class="slider-value">fps</span>
|
<span class="slider-value">fps</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user