Add sync clock entity for synchronized animation timing

Introduces Synchronization Clocks — shared, controllable time bases
that CSS sources can optionally reference for synchronized animation.

Backend:
- New SyncClock dataclass, JSON store, Pydantic schemas, REST API
- Runtime clock with thread-safe pause/resume/reset and speed control
- Ref-counted runtime pool with eager creation for API control
- clock_id field on all ColorStripSource types
- Stream integration: clock time/speed replaces source-local values
- Paused clock skips rendering (saves CPU + stops frame pushes)
- Included in backup/restore via STORE_MAP

Frontend:
- Sync Clocks tab in Streams section with cards and controls
- Clock dropdown in CSS editor (hidden speed slider when clock set)
- Clock crosslink badge on CSS source cards (replaces speed badge)
- Targets tab uses DataCache for picture/audio sources and sync clocks

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-01 21:46:55 +03:00
parent 52ee4bdeb6
commit aa1e4a6afc
32 changed files with 1255 additions and 58 deletions

View File

@@ -17,6 +17,7 @@ from .routes.value_sources import router as value_sources_router
from .routes.automations import router as automations_router
from .routes.scene_presets import router as scene_presets_router
from .routes.webhooks import router as webhooks_router
from .routes.sync_clocks import router as sync_clocks_router
router = APIRouter()
router.include_router(system_router)
@@ -34,5 +35,6 @@ router.include_router(picture_targets_router)
router.include_router(automations_router)
router.include_router(scene_presets_router)
router.include_router(webhooks_router)
router.include_router(sync_clocks_router)
__all__ = ["router"]

View File

@@ -13,8 +13,10 @@ from wled_controller.storage.audio_template_store import AudioTemplateStore
from wled_controller.storage.value_source_store import ValueSourceStore
from wled_controller.storage.automation_store import AutomationStore
from wled_controller.storage.scene_preset_store import ScenePresetStore
from wled_controller.storage.sync_clock_store import SyncClockStore
from wled_controller.core.automations.automation_engine import AutomationEngine
from wled_controller.core.backup.auto_backup import AutoBackupEngine
from wled_controller.core.processing.sync_clock_manager import SyncClockManager
# Global instances (initialized in main.py)
_auto_backup_engine: AutoBackupEngine | None = None
@@ -32,6 +34,8 @@ _processor_manager: ProcessorManager | None = None
_automation_store: AutomationStore | None = None
_scene_preset_store: ScenePresetStore | None = None
_automation_engine: AutomationEngine | None = None
_sync_clock_store: SyncClockStore | None = None
_sync_clock_manager: SyncClockManager | None = None
def get_device_store() -> DeviceStore:
@@ -139,6 +143,20 @@ def get_auto_backup_engine() -> AutoBackupEngine:
return _auto_backup_engine
def get_sync_clock_store() -> SyncClockStore:
"""Get sync clock store dependency."""
if _sync_clock_store is None:
raise RuntimeError("Sync clock store not initialized")
return _sync_clock_store
def get_sync_clock_manager() -> SyncClockManager:
"""Get sync clock manager dependency."""
if _sync_clock_manager is None:
raise RuntimeError("Sync clock manager not initialized")
return _sync_clock_manager
def init_dependencies(
device_store: DeviceStore,
template_store: TemplateStore,
@@ -155,12 +173,15 @@ def init_dependencies(
scene_preset_store: ScenePresetStore | None = None,
automation_engine: AutomationEngine | None = None,
auto_backup_engine: AutoBackupEngine | None = None,
sync_clock_store: SyncClockStore | None = None,
sync_clock_manager: SyncClockManager | None = None,
):
"""Initialize global dependencies."""
global _device_store, _template_store, _processor_manager
global _pp_template_store, _pattern_template_store, _picture_source_store, _picture_target_store
global _color_strip_store, _audio_source_store, _audio_template_store
global _value_source_store, _automation_store, _scene_preset_store, _automation_engine, _auto_backup_engine
global _sync_clock_store, _sync_clock_manager
_device_store = device_store
_template_store = template_store
_processor_manager = processor_manager
@@ -176,3 +197,5 @@ def init_dependencies(
_scene_preset_store = scene_preset_store
_automation_engine = automation_engine
_auto_backup_engine = auto_backup_engine
_sync_clock_store = sync_clock_store
_sync_clock_manager = sync_clock_manager

View File

@@ -82,6 +82,7 @@ def _css_to_response(source, overlay_active: bool = False) -> ColorStripSourceRe
scale=getattr(source, "scale", None),
mirror=getattr(source, "mirror", None),
description=source.description,
clock_id=source.clock_id,
frame_interpolation=getattr(source, "frame_interpolation", None),
animation=getattr(source, "animation", None),
layers=getattr(source, "layers", None),
@@ -177,6 +178,7 @@ async def create_color_strip_source(
color_peak=data.color_peak,
fallback_color=data.fallback_color,
timeout=data.timeout,
clock_id=data.clock_id,
)
return _css_to_response(source)
@@ -254,6 +256,7 @@ async def update_color_strip_source(
color_peak=data.color_peak,
fallback_color=data.fallback_color,
timeout=data.timeout,
clock_id=data.clock_id,
)
# Hot-reload running stream (no restart needed for in-place param changes)

View File

@@ -0,0 +1,184 @@
"""Sync clock routes: CRUD + runtime control for synchronization clocks."""
from fastapi import APIRouter, Depends, HTTPException
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import (
get_color_strip_store,
get_sync_clock_manager,
get_sync_clock_store,
)
from wled_controller.api.schemas.sync_clocks import (
SyncClockCreate,
SyncClockListResponse,
SyncClockResponse,
SyncClockUpdate,
)
from wled_controller.storage.sync_clock import SyncClock
from wled_controller.storage.sync_clock_store import SyncClockStore
from wled_controller.storage.color_strip_store import ColorStripStore
from wled_controller.core.processing.sync_clock_manager import SyncClockManager
from wled_controller.utils import get_logger
logger = get_logger(__name__)
router = APIRouter()
def _to_response(clock: SyncClock, manager: SyncClockManager) -> SyncClockResponse:
"""Convert a SyncClock to a SyncClockResponse (with runtime state)."""
rt = manager.get_runtime(clock.id)
return SyncClockResponse(
id=clock.id,
name=clock.name,
speed=rt.speed if rt else clock.speed,
description=clock.description,
is_running=rt.is_running if rt else True,
elapsed_time=rt.get_time() if rt else 0.0,
created_at=clock.created_at,
updated_at=clock.updated_at,
)
@router.get("/api/v1/sync-clocks", response_model=SyncClockListResponse, tags=["Sync Clocks"])
async def list_sync_clocks(
_auth: AuthRequired,
store: SyncClockStore = Depends(get_sync_clock_store),
manager: SyncClockManager = Depends(get_sync_clock_manager),
):
"""List all synchronization clocks."""
clocks = store.get_all_clocks()
return SyncClockListResponse(
clocks=[_to_response(c, manager) for c in clocks],
count=len(clocks),
)
@router.post("/api/v1/sync-clocks", response_model=SyncClockResponse, status_code=201, tags=["Sync Clocks"])
async def create_sync_clock(
data: SyncClockCreate,
_auth: AuthRequired,
store: SyncClockStore = Depends(get_sync_clock_store),
manager: SyncClockManager = Depends(get_sync_clock_manager),
):
"""Create a new synchronization clock."""
try:
clock = store.create_clock(
name=data.name,
speed=data.speed,
description=data.description,
)
return _to_response(clock, manager)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@router.get("/api/v1/sync-clocks/{clock_id}", response_model=SyncClockResponse, tags=["Sync Clocks"])
async def get_sync_clock(
clock_id: str,
_auth: AuthRequired,
store: SyncClockStore = Depends(get_sync_clock_store),
manager: SyncClockManager = Depends(get_sync_clock_manager),
):
"""Get a synchronization clock by ID."""
try:
clock = store.get_clock(clock_id)
return _to_response(clock, manager)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
@router.put("/api/v1/sync-clocks/{clock_id}", response_model=SyncClockResponse, tags=["Sync Clocks"])
async def update_sync_clock(
clock_id: str,
data: SyncClockUpdate,
_auth: AuthRequired,
store: SyncClockStore = Depends(get_sync_clock_store),
manager: SyncClockManager = Depends(get_sync_clock_manager),
):
"""Update a synchronization clock. Speed changes are hot-applied to running streams."""
try:
clock = store.update_clock(
clock_id=clock_id,
name=data.name,
speed=data.speed,
description=data.description,
)
# Hot-update runtime speed
if data.speed is not None:
manager.update_speed(clock_id, clock.speed)
return _to_response(clock, manager)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@router.delete("/api/v1/sync-clocks/{clock_id}", status_code=204, tags=["Sync Clocks"])
async def delete_sync_clock(
clock_id: str,
_auth: AuthRequired,
store: SyncClockStore = Depends(get_sync_clock_store),
css_store: ColorStripStore = Depends(get_color_strip_store),
manager: SyncClockManager = Depends(get_sync_clock_manager),
):
"""Delete a synchronization clock (fails if referenced by CSS sources)."""
try:
# Check references
for source in css_store.get_all_sources():
if getattr(source, "clock_id", None) == clock_id:
raise ValueError(
f"Cannot delete: referenced by color strip source '{source.name}'"
)
manager.release_all_for(clock_id)
store.delete_clock(clock_id)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
# ── Runtime control ──────────────────────────────────────────────────
@router.post("/api/v1/sync-clocks/{clock_id}/pause", response_model=SyncClockResponse, tags=["Sync Clocks"])
async def pause_sync_clock(
clock_id: str,
_auth: AuthRequired,
store: SyncClockStore = Depends(get_sync_clock_store),
manager: SyncClockManager = Depends(get_sync_clock_manager),
):
"""Pause a synchronization clock — all linked animations freeze."""
try:
clock = store.get_clock(clock_id)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
manager.pause(clock_id)
return _to_response(clock, manager)
@router.post("/api/v1/sync-clocks/{clock_id}/resume", response_model=SyncClockResponse, tags=["Sync Clocks"])
async def resume_sync_clock(
clock_id: str,
_auth: AuthRequired,
store: SyncClockStore = Depends(get_sync_clock_store),
manager: SyncClockManager = Depends(get_sync_clock_manager),
):
"""Resume a paused synchronization clock."""
try:
clock = store.get_clock(clock_id)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
manager.resume(clock_id)
return _to_response(clock, manager)
@router.post("/api/v1/sync-clocks/{clock_id}/reset", response_model=SyncClockResponse, tags=["Sync Clocks"])
async def reset_sync_clock(
clock_id: str,
_auth: AuthRequired,
store: SyncClockStore = Depends(get_sync_clock_store),
manager: SyncClockManager = Depends(get_sync_clock_manager),
):
"""Reset a synchronization clock to t=0 — all linked animations restart."""
try:
clock = store.get_clock(clock_id)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
manager.reset(clock_id)
return _to_response(clock, manager)

View File

@@ -271,6 +271,7 @@ STORE_MAP = {
"audio_sources": "audio_sources_file",
"audio_templates": "audio_templates_file",
"value_sources": "value_sources_file",
"sync_clocks": "sync_clocks_file",
"automations": "automations_file",
"scene_presets": "scene_presets_file",
}

View File

@@ -89,6 +89,8 @@ class ColorStripSourceCreate(BaseModel):
# api_input-type fields
fallback_color: Optional[List[int]] = Field(None, description="Fallback RGB color [R,G,B] when no data received (api_input type)")
timeout: Optional[float] = Field(None, description="Seconds before reverting to fallback (api_input type)", ge=0.0, le=300.0)
# sync clock
clock_id: Optional[str] = Field(None, description="Optional sync clock ID for synchronized animation")
class ColorStripSourceUpdate(BaseModel):
@@ -134,6 +136,8 @@ class ColorStripSourceUpdate(BaseModel):
# api_input-type fields
fallback_color: Optional[List[int]] = Field(None, description="Fallback RGB color [R,G,B] (api_input type)")
timeout: Optional[float] = Field(None, description="Timeout before fallback (api_input type)", ge=0.0, le=300.0)
# sync clock
clock_id: Optional[str] = Field(None, description="Optional sync clock ID for synchronized animation")
class ColorStripSourceResponse(BaseModel):
@@ -181,6 +185,8 @@ class ColorStripSourceResponse(BaseModel):
# api_input-type fields
fallback_color: Optional[List[int]] = Field(None, description="Fallback RGB color [R,G,B] (api_input type)")
timeout: Optional[float] = Field(None, description="Timeout before fallback (api_input type)")
# sync clock
clock_id: Optional[str] = Field(None, description="Optional sync clock ID for synchronized animation")
overlay_active: bool = Field(False, description="Whether the screen overlay is currently active")
created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp")

View File

@@ -0,0 +1,42 @@
"""Sync clock schemas (CRUD + control)."""
from datetime import datetime
from typing import List, Optional
from pydantic import BaseModel, Field
class SyncClockCreate(BaseModel):
"""Request to create a synchronization clock."""
name: str = Field(description="Clock name", min_length=1, max_length=100)
speed: float = Field(default=1.0, description="Speed multiplier (0.110.0)", ge=0.1, le=10.0)
description: Optional[str] = Field(None, description="Optional description", max_length=500)
class SyncClockUpdate(BaseModel):
"""Request to update a synchronization clock."""
name: Optional[str] = Field(None, description="Clock name", min_length=1, max_length=100)
speed: Optional[float] = Field(None, description="Speed multiplier (0.110.0)", ge=0.1, le=10.0)
description: Optional[str] = Field(None, description="Optional description", max_length=500)
class SyncClockResponse(BaseModel):
"""Synchronization clock response."""
id: str = Field(description="Clock ID")
name: str = Field(description="Clock name")
speed: float = Field(description="Speed multiplier")
description: Optional[str] = Field(None, description="Description")
is_running: bool = Field(True, description="Whether clock is currently running")
elapsed_time: float = Field(0.0, description="Current elapsed time in seconds")
created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp")
class SyncClockListResponse(BaseModel):
"""List of synchronization clocks."""
clocks: List[SyncClockResponse] = Field(description="List of sync clocks")
count: int = Field(description="Number of clocks")

View File

@@ -39,6 +39,7 @@ class StorageConfig(BaseSettings):
value_sources_file: str = "data/value_sources.json"
automations_file: str = "data/automations.json"
scene_presets_file: str = "data/scene_presets.json"
sync_clocks_file: str = "data/sync_clocks.json"
class MQTTConfig(BaseSettings):

View File

@@ -567,6 +567,7 @@ class StaticColorStripStream(ColorStripStream):
self._running = False
self._thread: Optional[threading.Thread] = None
self._fps = 30
self._clock = None # optional SyncClockRuntime
self._update_from_source(source)
def _update_from_source(self, source) -> None:
@@ -651,6 +652,10 @@ class StaticColorStripStream(ColorStripStream):
self._rebuild_colors()
logger.info("StaticColorStripStream params updated in-place")
def set_clock(self, clock) -> None:
"""Set or clear the sync clock runtime. Thread-safe (read atomically by loop)."""
self._clock = clock
def _animate_loop(self) -> None:
"""Background thread: compute animated colors at target fps when animation is active.
@@ -666,14 +671,22 @@ class StaticColorStripStream(ColorStripStream):
try:
with high_resolution_timer():
while self._running:
loop_start = time.perf_counter()
wall_start = time.perf_counter()
frame_time = 1.0 / self._fps
try:
anim = self._animation
if anim and anim.get("enabled"):
speed = float(anim.get("speed", 1.0))
clock = self._clock
if clock:
if not clock.is_running:
time.sleep(0.1)
continue
speed = clock.speed
t = clock.get_time()
else:
speed = float(anim.get("speed", 1.0))
t = wall_start
atype = anim.get("type", "breathing")
t = loop_start
n = self._led_count
if n != _pool_n:
@@ -748,7 +761,7 @@ class StaticColorStripStream(ColorStripStream):
except Exception as e:
logger.error(f"StaticColorStripStream animation error: {e}")
elapsed = time.perf_counter() - loop_start
elapsed = time.perf_counter() - wall_start
sleep_target = frame_time if anim and anim.get("enabled") else 0.25
time.sleep(max(sleep_target - elapsed, 0.001))
except Exception as e:
@@ -773,6 +786,7 @@ class ColorCycleColorStripStream(ColorStripStream):
self._running = False
self._thread: Optional[threading.Thread] = None
self._fps = 30
self._clock = None # optional SyncClockRuntime
self._update_from_source(source)
def _update_from_source(self, source) -> None:
@@ -850,6 +864,10 @@ class ColorCycleColorStripStream(ColorStripStream):
self._rebuild_colors()
logger.info("ColorCycleColorStripStream params updated in-place")
def set_clock(self, clock) -> None:
"""Set or clear the sync clock runtime. Thread-safe (read atomically by loop)."""
self._clock = clock
def _animate_loop(self) -> None:
"""Background thread: interpolate between colors at target fps.
@@ -862,11 +880,20 @@ class ColorCycleColorStripStream(ColorStripStream):
try:
with high_resolution_timer():
while self._running:
loop_start = time.perf_counter()
wall_start = time.perf_counter()
frame_time = 1.0 / self._fps
try:
color_list = self._color_list
speed = self._cycle_speed
clock = self._clock
if clock:
if not clock.is_running:
time.sleep(0.1)
continue
speed = clock.speed
t = clock.get_time()
else:
speed = self._cycle_speed
t = wall_start
n = self._led_count
num = len(color_list)
if num >= 2:
@@ -879,7 +906,7 @@ class ColorCycleColorStripStream(ColorStripStream):
_use_a = not _use_a
# 0.05 factor → one full cycle every 20s at speed=1.0
cycle_pos = (speed * loop_start * 0.05) % 1.0
cycle_pos = (speed * t * 0.05) % 1.0
seg = cycle_pos * num
idx = int(seg) % num
t_i = seg - int(seg)
@@ -894,7 +921,7 @@ class ColorCycleColorStripStream(ColorStripStream):
self._colors = buf
except Exception as e:
logger.error(f"ColorCycleColorStripStream animation error: {e}")
elapsed = time.perf_counter() - loop_start
elapsed = time.perf_counter() - wall_start
time.sleep(max(frame_time - elapsed, 0.001))
except Exception as e:
logger.error(f"Fatal ColorCycleColorStripStream loop error: {e}", exc_info=True)
@@ -919,6 +946,7 @@ class GradientColorStripStream(ColorStripStream):
self._running = False
self._thread: Optional[threading.Thread] = None
self._fps = 30
self._clock = None # optional SyncClockRuntime
self._update_from_source(source)
def _update_from_source(self, source) -> None:
@@ -1002,6 +1030,10 @@ class GradientColorStripStream(ColorStripStream):
self._rebuild_colors()
logger.info("GradientColorStripStream params updated in-place")
def set_clock(self, clock) -> None:
"""Set or clear the sync clock runtime. Thread-safe (read atomically by loop)."""
self._clock = clock
def _animate_loop(self) -> None:
"""Background thread: apply animation effects at target fps when animation is active.
@@ -1022,14 +1054,22 @@ class GradientColorStripStream(ColorStripStream):
try:
with high_resolution_timer():
while self._running:
loop_start = time.perf_counter()
wall_start = time.perf_counter()
frame_time = 1.0 / self._fps
try:
anim = self._animation
if anim and anim.get("enabled"):
speed = float(anim.get("speed", 1.0))
clock = self._clock
if clock:
if not clock.is_running:
time.sleep(0.1)
continue
speed = clock.speed
t = clock.get_time()
else:
speed = float(anim.get("speed", 1.0))
t = wall_start
atype = anim.get("type", "breathing")
t = loop_start
n = self._led_count
stops = self._stops
colors = None
@@ -1147,7 +1187,7 @@ class GradientColorStripStream(ColorStripStream):
except Exception as e:
logger.error(f"GradientColorStripStream animation error: {e}")
elapsed = time.perf_counter() - loop_start
elapsed = time.perf_counter() - wall_start
sleep_target = frame_time if anim and anim.get("enabled") else 0.25
time.sleep(max(sleep_target - elapsed, 0.001))
except Exception as e:

View File

@@ -58,21 +58,46 @@ class ColorStripStreamManager:
keyed by ``{css_id}:{consumer_id}``.
"""
def __init__(self, color_strip_store, live_stream_manager, audio_capture_manager=None, audio_source_store=None, audio_template_store=None):
def __init__(self, color_strip_store, live_stream_manager, audio_capture_manager=None, audio_source_store=None, audio_template_store=None, sync_clock_manager=None):
"""
Args:
color_strip_store: ColorStripStore for resolving source configs
live_stream_manager: LiveStreamManager for acquiring picture streams
audio_capture_manager: AudioCaptureManager for audio-reactive sources
audio_source_store: AudioSourceStore for resolving audio source chains
sync_clock_manager: SyncClockManager for acquiring clock runtimes
"""
self._color_strip_store = color_strip_store
self._live_stream_manager = live_stream_manager
self._audio_capture_manager = audio_capture_manager
self._audio_source_store = audio_source_store
self._audio_template_store = audio_template_store
self._sync_clock_manager = sync_clock_manager
self._streams: Dict[str, _ColorStripEntry] = {}
def _inject_clock(self, css_stream, source) -> None:
"""Inject a SyncClockRuntime into the stream if source has clock_id."""
clock_id = getattr(source, "clock_id", None)
if clock_id and self._sync_clock_manager and hasattr(css_stream, "set_clock"):
try:
clock_rt = self._sync_clock_manager.acquire(clock_id)
css_stream.set_clock(clock_rt)
logger.debug(f"Injected clock {clock_id} into stream for {source.id}")
except Exception as e:
logger.warning(f"Could not inject clock {clock_id}: {e}")
def _release_clock(self, source_id: str, stream) -> None:
"""Release the clock runtime acquired for a stream."""
if not self._sync_clock_manager:
return
try:
source = self._color_strip_store.get_source(source_id)
clock_id = getattr(source, "clock_id", None)
if clock_id:
self._sync_clock_manager.release(clock_id)
except Exception:
pass # source may have been deleted already
def _resolve_key(self, css_id: str, consumer_id: str) -> str:
"""Resolve internal registry key for a (css_id, consumer_id) pair.
@@ -123,6 +148,8 @@ class ColorStripStreamManager:
f"Unsupported color strip source type '{source.source_type}' for {css_id}"
)
css_stream = stream_cls(source)
# Inject sync clock runtime if source references a clock
self._inject_clock(css_stream, source)
css_stream.start()
key = f"{css_id}:{consumer_id}" if consumer_id else css_id
self._streams[key] = _ColorStripEntry(
@@ -196,6 +223,10 @@ class ColorStripStreamManager:
except Exception as e:
logger.error(f"Error stopping color strip stream {key}: {e}")
# Release clock runtime if acquired
source_id = key.split(":")[0] if ":" in key else key
self._release_clock(source_id, entry.stream)
picture_source_id = entry.picture_source_id
del self._streams[key]
logger.info(f"Removed color strip stream {key}")
@@ -227,6 +258,25 @@ class ColorStripStreamManager:
for key in matching_keys:
entry = self._streams[key]
entry.stream.update_source(new_source)
# Hot-swap clock if clock_id changed
if hasattr(entry.stream, "set_clock") and self._sync_clock_manager:
new_clock_id = getattr(new_source, "clock_id", None)
old_clock = getattr(entry.stream, "_clock", None)
if new_clock_id:
try:
clock_rt = self._sync_clock_manager.acquire(new_clock_id)
entry.stream.set_clock(clock_rt)
# Release old clock if different
if old_clock:
# Find the old clock_id (best-effort)
source_id = key.split(":")[0] if ":" in key else key
self._release_clock(source_id, entry.stream)
except Exception as e:
logger.warning(f"Could not hot-swap clock {new_clock_id}: {e}")
elif old_clock:
entry.stream.set_clock(None)
source_id = key.split(":")[0] if ":" in key else key
self._release_clock(source_id, entry.stream)
# Track picture_source_id change for future reference counting
from wled_controller.storage.color_strip_source import PictureColorStripSource

View File

@@ -182,6 +182,8 @@ class EffectColorStripStream(ColorStripStream):
self._running = False
self._thread: Optional[threading.Thread] = None
self._fps = 30
self._clock = None # optional SyncClockRuntime
self._effective_speed = 1.0 # resolved speed (from clock or source)
self._noise = _ValueNoise1D(seed=42)
# Fire state — allocated lazily in render loop
self._heat: Optional[np.ndarray] = None
@@ -268,6 +270,10 @@ class EffectColorStripStream(ColorStripStream):
self._led_count = prev_led_count
logger.info("EffectColorStripStream params updated in-place")
def set_clock(self, clock) -> None:
"""Set or clear the sync clock runtime. Thread-safe (read atomically by loop)."""
self._clock = clock
# ── Main animation loop ──────────────────────────────────────────
def _animate_loop(self) -> None:
@@ -287,9 +293,22 @@ class EffectColorStripStream(ColorStripStream):
try:
with high_resolution_timer():
while self._running:
loop_start = time.perf_counter()
wall_start = time.perf_counter()
frame_time = 1.0 / self._fps
try:
# Resolve animation time and speed from clock or local
clock = self._clock
if clock:
if not clock.is_running:
# Clock paused — output frozen, sleep and skip render
time.sleep(0.1)
continue
anim_time = clock.get_time()
self._effective_speed = clock.speed
else:
anim_time = wall_start
self._effective_speed = self._speed
n = self._led_count
if n != _pool_n:
_pool_n = n
@@ -310,14 +329,14 @@ class EffectColorStripStream(ColorStripStream):
_use_a = not _use_a
render_fn = renderers.get(self._effect_type, self._render_fire)
render_fn(buf, n, loop_start)
render_fn(buf, n, anim_time)
with self._colors_lock:
self._colors = buf
except Exception as e:
logger.error(f"EffectColorStripStream render error: {e}")
elapsed = time.perf_counter() - loop_start
elapsed = time.perf_counter() - wall_start
time.sleep(max(frame_time - elapsed, 0.001))
except Exception as e:
logger.error(f"Fatal EffectColorStripStream loop error: {e}", exc_info=True)
@@ -332,7 +351,7 @@ class EffectColorStripStream(ColorStripStream):
A 1-D heat array cools, diffuses upward, and receives random sparks
at the bottom. Heat values are mapped to the palette LUT.
"""
speed = self._speed
speed = self._effective_speed
intensity = self._intensity
lut = self._palette_lut
@@ -378,7 +397,7 @@ class EffectColorStripStream(ColorStripStream):
def _render_meteor(self, buf: np.ndarray, n: int, t: float) -> None:
"""Bright meteor head with exponential-decay trail."""
speed = self._speed
speed = self._effective_speed
intensity = self._intensity
color = self._color
mirror = self._mirror
@@ -443,7 +462,7 @@ class EffectColorStripStream(ColorStripStream):
def _render_plasma(self, buf: np.ndarray, n: int, t: float) -> None:
"""Overlapping sine waves creating colorful plasma patterns."""
speed = self._speed
speed = self._effective_speed
scale = self._scale
lut = self._palette_lut
@@ -470,7 +489,7 @@ class EffectColorStripStream(ColorStripStream):
def _render_noise(self, buf: np.ndarray, n: int, t: float) -> None:
"""Smooth scrolling fractal noise mapped to a color palette."""
speed = self._speed
speed = self._effective_speed
scale = self._scale
lut = self._palette_lut
@@ -488,7 +507,7 @@ class EffectColorStripStream(ColorStripStream):
def _render_aurora(self, buf: np.ndarray, n: int, t: float) -> None:
"""Layered noise bands simulating aurora borealis."""
speed = self._speed
speed = self._effective_speed
scale = self._scale
intensity = self._intensity
lut = self._palette_lut

View File

@@ -69,7 +69,7 @@ class ProcessorManager:
Targets are registered for processing via polymorphic TargetProcessor subclasses.
"""
def __init__(self, picture_source_store=None, capture_template_store=None, pp_template_store=None, pattern_template_store=None, device_store=None, color_strip_store=None, audio_source_store=None, value_source_store=None, audio_template_store=None):
def __init__(self, picture_source_store=None, capture_template_store=None, pp_template_store=None, pattern_template_store=None, device_store=None, color_strip_store=None, audio_source_store=None, value_source_store=None, audio_template_store=None, sync_clock_manager=None):
"""Initialize processor manager."""
self._devices: Dict[str, DeviceState] = {}
self._processors: Dict[str, TargetProcessor] = {}
@@ -89,12 +89,14 @@ class ProcessorManager:
picture_source_store, capture_template_store, pp_template_store
)
self._audio_capture_manager = AudioCaptureManager()
self._sync_clock_manager = sync_clock_manager
self._color_strip_stream_manager = ColorStripStreamManager(
color_strip_store=color_strip_store,
live_stream_manager=self._live_stream_manager,
audio_capture_manager=self._audio_capture_manager,
audio_source_store=audio_source_store,
audio_template_store=audio_template_store,
sync_clock_manager=sync_clock_manager,
)
self._value_stream_manager = ValueStreamManager(
value_source_store=value_source_store,

View File

@@ -0,0 +1,99 @@
"""Ref-counted pool of SyncClockRuntime instances.
Runtimes are created lazily when a stream first acquires a clock and
destroyed when the last consumer releases it.
"""
from typing import Dict, Optional
from wled_controller.core.processing.sync_clock_runtime import SyncClockRuntime
from wled_controller.storage.sync_clock_store import SyncClockStore
from wled_controller.utils import get_logger
logger = get_logger(__name__)
class SyncClockManager:
"""Manages SyncClockRuntime instances with reference counting."""
def __init__(self, store: SyncClockStore) -> None:
self._store = store
self._runtimes: Dict[str, SyncClockRuntime] = {}
self._ref_counts: Dict[str, int] = {}
# ── Acquire / release (used by stream manager) ────────────────
def acquire(self, clock_id: str) -> SyncClockRuntime:
"""Get or create a runtime for *clock_id* (ref-counted)."""
if clock_id in self._runtimes:
self._ref_counts[clock_id] += 1
logger.debug(f"SyncClock {clock_id} ref++ → {self._ref_counts[clock_id]}")
return self._runtimes[clock_id]
clock_cfg = self._store.get_clock(clock_id) # raises ValueError if missing
rt = SyncClockRuntime(speed=clock_cfg.speed)
self._runtimes[clock_id] = rt
self._ref_counts[clock_id] = 1
logger.info(f"SyncClock runtime created: {clock_id} (speed={clock_cfg.speed})")
return rt
def release(self, clock_id: str) -> None:
"""Decrement ref count; destroy runtime when it reaches zero."""
if clock_id not in self._ref_counts:
return
self._ref_counts[clock_id] -= 1
logger.debug(f"SyncClock {clock_id} ref-- → {self._ref_counts[clock_id]}")
if self._ref_counts[clock_id] <= 0:
del self._runtimes[clock_id]
del self._ref_counts[clock_id]
logger.info(f"SyncClock runtime destroyed: {clock_id}")
def release_all_for(self, clock_id: str) -> None:
"""Force-release all references to *clock_id* (used on delete)."""
self._runtimes.pop(clock_id, None)
self._ref_counts.pop(clock_id, None)
def release_all(self) -> None:
"""Destroy all runtimes (shutdown)."""
self._runtimes.clear()
self._ref_counts.clear()
# ── Lookup (no ref counting) ──────────────────────────────────
def get_runtime(self, clock_id: str) -> Optional[SyncClockRuntime]:
"""Return an existing runtime or *None* (does not create one)."""
return self._runtimes.get(clock_id)
def _ensure_runtime(self, clock_id: str) -> SyncClockRuntime:
"""Return existing runtime or create a zero-ref one for API control."""
rt = self._runtimes.get(clock_id)
if rt:
return rt
clock_cfg = self._store.get_clock(clock_id)
rt = SyncClockRuntime(speed=clock_cfg.speed)
self._runtimes[clock_id] = rt
self._ref_counts[clock_id] = 0
logger.info(f"SyncClock runtime created (API): {clock_id} (speed={clock_cfg.speed})")
return rt
# ── Delegated control ─────────────────────────────────────────
def update_speed(self, clock_id: str, speed: float) -> None:
rt = self._ensure_runtime(clock_id)
rt.speed = speed
logger.info(f"SyncClock {clock_id} speed → {speed}")
def pause(self, clock_id: str) -> None:
rt = self._ensure_runtime(clock_id)
rt.pause()
logger.info(f"SyncClock {clock_id} paused")
def resume(self, clock_id: str) -> None:
rt = self._ensure_runtime(clock_id)
rt.resume()
logger.info(f"SyncClock {clock_id} resumed")
def reset(self, clock_id: str) -> None:
rt = self._ensure_runtime(clock_id)
rt.reset()
logger.info(f"SyncClock {clock_id} reset")

View File

@@ -0,0 +1,72 @@
"""Thread-safe synchronization clock runtime.
Provides a pause-aware elapsed-time counter and a shared speed multiplier
that animation streams read every frame.
"""
import threading
import time
class SyncClockRuntime:
"""In-memory clock instance used by animation streams for synchronized timing.
``get_time()`` returns pause-aware **real** elapsed seconds (NOT
speed-scaled). Streams combine this with ``speed`` themselves so that
per-frame physics effects (fire cooling, sparkle density) also scale
with clock speed uniformly.
"""
__slots__ = ("_speed", "_running", "_epoch", "_offset", "_lock")
def __init__(self, speed: float = 1.0) -> None:
self._speed: float = speed
self._running: bool = True
self._epoch: float = time.perf_counter()
self._offset: float = 0.0
self._lock = threading.Lock()
# ── Speed ──────────────────────────────────────────────────────
@property
def speed(self) -> float:
"""Current speed multiplier (lock-free read; CPython float is atomic)."""
return self._speed
@speed.setter
def speed(self, value: float) -> None:
self._speed = value
# ── Time ───────────────────────────────────────────────────────
def get_time(self) -> float:
"""Pause-aware elapsed seconds since creation/last reset.
Returns *real* (wall-clock) elapsed time, not speed-scaled.
"""
if not self._running:
return self._offset
return self._offset + (time.perf_counter() - self._epoch)
# ── Control ────────────────────────────────────────────────────
def pause(self) -> None:
with self._lock:
if self._running:
self._offset += time.perf_counter() - self._epoch
self._running = False
def resume(self) -> None:
with self._lock:
if not self._running:
self._epoch = time.perf_counter()
self._running = True
def reset(self) -> None:
with self._lock:
self._offset = 0.0
self._epoch = time.perf_counter()
@property
def is_running(self) -> bool:
return self._running

View File

@@ -29,6 +29,8 @@ import wled_controller.core.audio # noqa: F401 — trigger engine auto-registra
from wled_controller.storage.value_source_store import ValueSourceStore
from wled_controller.storage.automation_store import AutomationStore
from wled_controller.storage.scene_preset_store import ScenePresetStore
from wled_controller.storage.sync_clock_store import SyncClockStore
from wled_controller.core.processing.sync_clock_manager import SyncClockManager
from wled_controller.core.automations.automation_engine import AutomationEngine
from wled_controller.core.mqtt.mqtt_service import MQTTService
from wled_controller.core.devices.mqtt_client import set_mqtt_service
@@ -56,6 +58,8 @@ audio_template_store = AudioTemplateStore(config.storage.audio_templates_file)
value_source_store = ValueSourceStore(config.storage.value_sources_file)
automation_store = AutomationStore(config.storage.automations_file)
scene_preset_store = ScenePresetStore(config.storage.scene_presets_file)
sync_clock_store = SyncClockStore(config.storage.sync_clocks_file)
sync_clock_manager = SyncClockManager(sync_clock_store)
processor_manager = ProcessorManager(
picture_source_store=picture_source_store,
@@ -67,6 +71,7 @@ processor_manager = ProcessorManager(
audio_source_store=audio_source_store,
value_source_store=value_source_store,
audio_template_store=audio_template_store,
sync_clock_manager=sync_clock_manager,
)
@@ -135,6 +140,8 @@ async def lifespan(app: FastAPI):
scene_preset_store=scene_preset_store,
automation_engine=automation_engine,
auto_backup_engine=auto_backup_engine,
sync_clock_store=sync_clock_store,
sync_clock_manager=sync_clock_manager,
)
# Register devices in processor manager for health monitoring

View File

@@ -107,7 +107,7 @@ import {
// Layer 5: color-strip sources
import {
showCSSEditor, closeCSSEditorModal, forceCSSEditorClose, saveCSSEditor, deleteColorStrip,
onCSSTypeChange, onEffectTypeChange, onAnimationTypeChange, updateEffectPreview,
onCSSTypeChange, onEffectTypeChange, onAnimationTypeChange, onCSSClockChange, updateEffectPreview,
colorCycleAddColor, colorCycleRemoveColor,
compositeAddLayer, compositeRemoveLayer,
mappedAddZone, mappedRemoveZone,
@@ -363,6 +363,7 @@ Object.assign(window, {
deleteColorStrip,
onCSSTypeChange,
onEffectTypeChange,
onCSSClockChange,
onAnimationTypeChange,
updateEffectPreview,
colorCycleAddColor,

View File

@@ -184,6 +184,9 @@ export function set_audioTemplateNameManuallyEdited(v) { _audioTemplateNameManua
// Value sources
export let _cachedValueSources = [];
// Sync clocks
export let _cachedSyncClocks = [];
// Automations
export let _automationsCache = null;
@@ -234,6 +237,12 @@ export const valueSourcesCache = new DataCache({
});
valueSourcesCache.subscribe(v => { _cachedValueSources = v; });
export const syncClocksCache = new DataCache({
endpoint: '/sync-clocks',
extractData: json => json.clocks || [],
});
syncClocksCache.subscribe(v => { _cachedSyncClocks = v; });
export const filtersCache = new DataCache({
endpoint: '/filters',
extractData: json => json.filters || [],

View File

@@ -3,6 +3,7 @@
*/
import { fetchWithAuth, escapeHtml } from '../core/api.js';
import { _cachedSyncClocks } from '../core/state.js';
import { t } from '../core/i18n.js';
import { showToast, showConfirm } from '../core/ui.js';
import { Modal } from '../core/modal.js';
@@ -11,7 +12,7 @@ import {
ICON_CLONE, ICON_EDIT, ICON_CALIBRATION,
ICON_LED, ICON_PALETTE, ICON_FPS, ICON_MAP_PIN, ICON_MUSIC,
ICON_AUDIO_LOOPBACK, ICON_TIMER, ICON_LINK_SOURCE, ICON_FILM,
ICON_LINK, ICON_SPARKLES, ICON_FAST_FORWARD, ICON_ACTIVITY,
ICON_LINK, ICON_SPARKLES, ICON_FAST_FORWARD, ICON_ACTIVITY, ICON_CLOCK,
} from '../core/icons.js';
import { wrapCard } from '../core/card-colors.js';
@@ -58,6 +59,7 @@ class CSSEditorModal extends Modal {
audio_mirror: document.getElementById('css-editor-audio-mirror').checked,
api_input_fallback_color: document.getElementById('css-editor-api-input-fallback-color').value,
api_input_timeout: document.getElementById('css-editor-api-input-timeout').value,
clock_id: document.getElementById('css-editor-clock').value,
};
}
}
@@ -114,6 +116,11 @@ export function onCSSTypeChange() {
document.getElementById('css-editor-led-count-group').style.display =
(type === 'composite' || type === 'mapped' || type === 'audio' || type === 'api_input') ? 'none' : '';
// Sync clock — shown for animated types (static, gradient, color_cycle, effect)
const clockTypes = ['static', 'gradient', 'color_cycle', 'effect'];
document.getElementById('css-editor-clock-group').style.display = clockTypes.includes(type) ? '' : 'none';
if (clockTypes.includes(type)) _populateClockDropdown();
if (type === 'audio') {
_loadAudioSources();
} else if (type === 'composite') {
@@ -125,6 +132,27 @@ export function onCSSTypeChange() {
}
}
function _populateClockDropdown(selectedId) {
const sel = document.getElementById('css-editor-clock');
const prev = selectedId !== undefined ? selectedId : sel.value;
sel.innerHTML = `<option value="">${t('common.none')}</option>` +
_cachedSyncClocks.map(c => `<option value="${c.id}">${escapeHtml(c.name)} (${c.speed}x)</option>`).join('');
sel.value = prev || '';
}
export function onCSSClockChange() {
// When a clock is selected, hide speed sliders (speed comes from clock)
const clockId = document.getElementById('css-editor-clock').value;
const type = document.getElementById('css-editor-type').value;
if (type === 'effect') {
document.getElementById('css-editor-effect-speed-group').style.display = clockId ? 'none' : '';
} else if (type === 'color_cycle') {
document.getElementById('css-editor-cycle-speed-group').style.display = clockId ? 'none' : '';
} else if (type === 'static' || type === 'gradient') {
document.getElementById('css-editor-animation-speed-group').style.display = clockId ? 'none' : '';
}
}
function _getAnimationPayload() {
const type = document.getElementById('css-editor-animation-type').value;
return {
@@ -589,10 +617,16 @@ export function createColorStripCard(source, pictureSourceMap, audioSourceMap) {
const isAudio = source.source_type === 'audio';
const isApiInput = source.source_type === 'api_input';
// Clock crosslink badge (replaces speed badge when clock is assigned)
const clockObj = source.clock_id ? _cachedSyncClocks.find(c => c.id === source.clock_id) : null;
const clockBadge = clockObj
? `<span class="stream-card-prop stream-card-link" title="${t('color_strip.clock')}" onclick="event.stopPropagation(); navigateToCard('streams','sync','sync-clocks','data-id','${source.clock_id}')">${ICON_CLOCK} ${escapeHtml(clockObj.name)}</span>`
: source.clock_id ? `<span class="stream-card-prop">${ICON_CLOCK} ${source.clock_id}</span>` : '';
const anim = (isStatic || isGradient) && source.animation && source.animation.enabled ? source.animation : null;
const animBadge = anim
? `<span class="stream-card-prop" title="${t('color_strip.animation')}">${ICON_SPARKLES} ${t('color_strip.animation.type.' + anim.type) || anim.type}</span>`
+ `<span class="stream-card-prop" title="${t('color_strip.animation.speed')}">${ICON_FAST_FORWARD} ${(anim.speed || 1.0).toFixed(1)}×</span>`
+ (source.clock_id ? '' : `<span class="stream-card-prop" title="${t('color_strip.animation.speed')}">${ICON_FAST_FORWARD} ${(anim.speed || 1.0).toFixed(1)}×</span>`)
: '';
let propsHtml;
@@ -604,6 +638,7 @@ export function createColorStripCard(source, pictureSourceMap, audioSourceMap) {
</span>
${source.led_count ? `<span class="stream-card-prop" title="${t('color_strip.leds')}">${ICON_LED} ${source.led_count}</span>` : ''}
${animBadge}
${clockBadge}
`;
} else if (isColorCycle) {
const colors = source.colors || [];
@@ -612,8 +647,9 @@ export function createColorStripCard(source, pictureSourceMap, audioSourceMap) {
).join('');
propsHtml = `
<span class="stream-card-prop">${swatches}</span>
<span class="stream-card-prop" title="${t('color_strip.color_cycle.speed')}">${ICON_FAST_FORWARD} ${(source.cycle_speed || 1.0).toFixed(1)}×</span>
${source.clock_id ? '' : `<span class="stream-card-prop" title="${t('color_strip.color_cycle.speed')}">${ICON_FAST_FORWARD} ${(source.cycle_speed || 1.0).toFixed(1)}×</span>`}
${source.led_count ? `<span class="stream-card-prop" title="${t('color_strip.leds')}">${ICON_LED} ${source.led_count}</span>` : ''}
${clockBadge}
`;
} else if (isGradient) {
const stops = source.stops || [];
@@ -636,6 +672,7 @@ export function createColorStripCard(source, pictureSourceMap, audioSourceMap) {
<span class="stream-card-prop">${ICON_PALETTE} ${stops.length} ${t('color_strip.gradient.stops_count')}</span>
${source.led_count ? `<span class="stream-card-prop" title="${t('color_strip.leds')}">${ICON_LED} ${source.led_count}</span>` : ''}
${animBadge}
${clockBadge}
`;
} else if (isEffect) {
const effectLabel = t('color_strip.effect.' + (source.effect_type || 'fire')) || source.effect_type || 'fire';
@@ -643,8 +680,9 @@ export function createColorStripCard(source, pictureSourceMap, audioSourceMap) {
propsHtml = `
<span class="stream-card-prop">${ICON_FPS} ${escapeHtml(effectLabel)}</span>
${paletteLabel ? `<span class="stream-card-prop" title="${t('color_strip.effect.palette')}">${ICON_PALETTE} ${escapeHtml(paletteLabel)}</span>` : ''}
<span class="stream-card-prop" title="${t('color_strip.effect.speed')}">${ICON_FAST_FORWARD} ${(source.speed || 1.0).toFixed(1)}×</span>
${source.clock_id ? '' : `<span class="stream-card-prop" title="${t('color_strip.effect.speed')}">${ICON_FAST_FORWARD} ${(source.speed || 1.0).toFixed(1)}×</span>`}
${source.led_count ? `<span class="stream-card-prop" title="${t('color_strip.leds')}">${ICON_LED} ${source.led_count}</span>` : ''}
${clockBadge}
`;
} else if (isComposite) {
const layerCount = (source.layers || []).length;
@@ -762,6 +800,12 @@ export async function showCSSEditor(cssId = null, cloneData = null) {
document.getElementById('css-editor-type').value = sourceType;
onCSSTypeChange();
// Set clock dropdown value (must be after onCSSTypeChange populates it)
if (css.clock_id) {
_populateClockDropdown(css.clock_id);
onCSSClockChange();
}
if (sourceType === 'static') {
document.getElementById('css-editor-color').value = rgbArrayToHex(css.color);
_loadAnimationState(css.animation);
@@ -1040,6 +1084,13 @@ export async function saveCSSEditor() {
if (!cssId) payload.source_type = 'picture';
}
// Attach clock_id for animated types
const clockTypes = ['static', 'gradient', 'color_cycle', 'effect'];
if (clockTypes.includes(sourceType)) {
const clockVal = document.getElementById('css-editor-clock').value;
payload.clock_id = clockVal || null;
}
try {
let response;
if (cssId) {

View File

@@ -21,6 +21,7 @@ import {
_lastValidatedImageSource, set_lastValidatedImageSource,
_cachedAudioSources,
_cachedValueSources,
_cachedSyncClocks,
_cachedAudioTemplates,
availableAudioEngines, setAvailableAudioEngines,
currentEditingAudioTemplateId, setCurrentEditingAudioTemplateId,
@@ -28,7 +29,7 @@ import {
_sourcesLoading, set_sourcesLoading,
apiKey,
streamsCache, ppTemplatesCache, captureTemplatesCache,
audioSourcesCache, audioTemplatesCache, valueSourcesCache, filtersCache,
audioSourcesCache, audioTemplatesCache, valueSourcesCache, syncClocksCache, filtersCache,
} from '../core/state.js';
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.js';
import { t } from '../core/i18n.js';
@@ -38,10 +39,11 @@ import { openDisplayPicker, formatDisplayLabel } from './displays.js';
import { CardSection } from '../core/card-sections.js';
import { updateSubTabHash } from './tabs.js';
import { createValueSourceCard } from './value-sources.js';
import { createSyncClockCard } from './sync-clocks.js';
import {
getEngineIcon, getPictureSourceIcon, getAudioSourceIcon,
ICON_TEMPLATE, ICON_CLONE, ICON_EDIT, ICON_TEST, ICON_LINK_SOURCE,
ICON_FPS, ICON_WEB, ICON_VALUE_SOURCE, ICON_AUDIO_LOOPBACK, ICON_AUDIO_INPUT,
ICON_FPS, ICON_WEB, ICON_VALUE_SOURCE, ICON_CLOCK, ICON_AUDIO_LOOPBACK, ICON_AUDIO_INPUT,
ICON_AUDIO_TEMPLATE, ICON_MONITOR, ICON_WRENCH, ICON_RADIO,
ICON_CAPTURE_TEMPLATE, ICON_PP_TEMPLATE, ICON_HELP,
} from '../core/icons.js';
@@ -57,6 +59,7 @@ const csAudioMono = new CardSection('audio-mono', { titleKey: 'audio_source.grou
const csStaticStreams = new CardSection('static-streams', { titleKey: 'streams.group.static_image', gridClass: 'templates-grid', addCardOnclick: "showAddStreamModal('static_image')", keyAttr: 'data-stream-id' });
const csAudioTemplates = new CardSection('audio-templates', { titleKey: 'audio_template.title', gridClass: 'templates-grid', addCardOnclick: "showAddAudioTemplateModal()", keyAttr: 'data-audio-template-id' });
const csValueSources = new CardSection('value-sources', { titleKey: 'value_source.group.title', gridClass: 'templates-grid', addCardOnclick: "showValueSourceModal()", keyAttr: 'data-id' });
const csSyncClocks = new CardSection('sync-clocks', { titleKey: 'sync_clock.group.title', gridClass: 'templates-grid', addCardOnclick: "showSyncClockModal()", keyAttr: 'data-id' });
// Re-render picture sources when language changes
document.addEventListener('languageChanged', () => { if (apiKey) loadPictureSources(); });
@@ -1111,6 +1114,7 @@ export async function loadPictureSources() {
captureTemplatesCache.fetch(),
audioSourcesCache.fetch(),
valueSourcesCache.fetch(),
syncClocksCache.fetch(),
audioTemplatesCache.fetch(),
filtersCache.data.length === 0 ? filtersCache.fetch() : Promise.resolve(filtersCache.data),
]);
@@ -1144,6 +1148,7 @@ const _streamSectionMap = {
processed: [csProcStreams, csProcTemplates],
audio: [csAudioMulti, csAudioMono],
value: [csValueSources],
sync: [csSyncClocks],
};
export function expandAllStreamSections() {
@@ -1292,6 +1297,7 @@ function renderPictureSourcesList(streams) {
{ key: 'processed', icon: getPictureSourceIcon('processed'), titleKey: 'streams.group.processed', count: processedStreams.length },
{ key: 'audio', icon: getAudioSourceIcon('multichannel'), titleKey: 'streams.group.audio', count: _cachedAudioSources.length },
{ key: 'value', icon: ICON_VALUE_SOURCE, titleKey: 'streams.group.value', count: _cachedValueSources.length },
{ key: 'sync', icon: ICON_CLOCK, titleKey: 'streams.group.sync', count: _cachedSyncClocks.length },
];
const tabBar = `<div class="stream-tab-bar">${tabs.map(tab =>
@@ -1389,6 +1395,7 @@ function renderPictureSourcesList(streams) {
const audioTemplateItems = csAudioTemplates.applySortOrder(_cachedAudioTemplates.map(t => ({ key: t.id, html: renderAudioTemplateCard(t) })));
const staticItems = csStaticStreams.applySortOrder(staticImageStreams.map(s => ({ key: s.id, html: renderStreamCard(s) })));
const valueItems = csValueSources.applySortOrder(_cachedValueSources.map(s => ({ key: s.id, html: createValueSourceCard(s) })));
const syncClockItems = csSyncClocks.applySortOrder(_cachedSyncClocks.map(s => ({ key: s.id, html: createSyncClockCard(s) })));
if (csRawStreams.isMounted()) {
// Incremental update: reconcile cards in-place
@@ -1405,6 +1412,7 @@ function renderPictureSourcesList(streams) {
csAudioTemplates.reconcile(audioTemplateItems);
csStaticStreams.reconcile(staticItems);
csValueSources.reconcile(valueItems);
csSyncClocks.reconcile(syncClockItems);
} else {
// First render: build full HTML
const panels = tabs.map(tab => {
@@ -1413,12 +1421,13 @@ function renderPictureSourcesList(streams) {
else if (tab.key === 'processed') panelContent = csProcStreams.render(procStreamItems) + csProcTemplates.render(procTemplateItems);
else if (tab.key === 'audio') panelContent = csAudioMulti.render(multiItems) + csAudioMono.render(monoItems) + csAudioTemplates.render(audioTemplateItems);
else if (tab.key === 'value') panelContent = csValueSources.render(valueItems);
else if (tab.key === 'sync') panelContent = csSyncClocks.render(syncClockItems);
else panelContent = csStaticStreams.render(staticItems);
return `<div class="stream-tab-panel${tab.key === activeTab ? ' active' : ''}" id="stream-tab-${tab.key}">${panelContent}</div>`;
}).join('');
container.innerHTML = tabBar + panels;
CardSection.bindAll([csRawStreams, csRawTemplates, csProcStreams, csProcTemplates, csAudioMulti, csAudioMono, csAudioTemplates, csStaticStreams, csValueSources]);
CardSection.bindAll([csRawStreams, csRawTemplates, csProcStreams, csProcTemplates, csAudioMulti, csAudioMono, csAudioTemplates, csStaticStreams, csValueSources, csSyncClocks]);
}
}

View File

@@ -0,0 +1,221 @@
/**
* Sync Clocks — CRUD, runtime controls, cards.
*/
import { _cachedSyncClocks, syncClocksCache } from '../core/state.js';
import { fetchWithAuth, escapeHtml } from '../core/api.js';
import { t } from '../core/i18n.js';
import { Modal } from '../core/modal.js';
import { showToast, showConfirm } from '../core/ui.js';
import { ICON_CLOCK, ICON_CLONE, ICON_EDIT, ICON_START, ICON_PAUSE } from '../core/icons.js';
import { wrapCard } from '../core/card-colors.js';
import { loadPictureSources } from './streams.js';
// ── Modal ──
class SyncClockModal extends Modal {
constructor() { super('sync-clock-modal'); }
snapshotValues() {
return {
name: document.getElementById('sync-clock-name').value,
speed: document.getElementById('sync-clock-speed').value,
description: document.getElementById('sync-clock-description').value,
};
}
}
const syncClockModal = new SyncClockModal();
// ── Show / Close ──
export async function showSyncClockModal(editData) {
const isEdit = !!editData;
const titleKey = isEdit ? 'sync_clock.edit' : 'sync_clock.add';
document.getElementById('sync-clock-modal-title').innerHTML = `${ICON_CLOCK} ${t(titleKey)}`;
document.getElementById('sync-clock-id').value = isEdit ? editData.id : '';
document.getElementById('sync-clock-error').style.display = 'none';
if (isEdit) {
document.getElementById('sync-clock-name').value = editData.name || '';
document.getElementById('sync-clock-speed').value = editData.speed ?? 1.0;
document.getElementById('sync-clock-speed-display').textContent = editData.speed ?? 1.0;
document.getElementById('sync-clock-description').value = editData.description || '';
} else {
document.getElementById('sync-clock-name').value = '';
document.getElementById('sync-clock-speed').value = 1.0;
document.getElementById('sync-clock-speed-display').textContent = '1';
document.getElementById('sync-clock-description').value = '';
}
syncClockModal.open();
syncClockModal.snapshot();
}
export async function closeSyncClockModal() {
await syncClockModal.close();
}
// ── Save ──
export async function saveSyncClock() {
const id = document.getElementById('sync-clock-id').value;
const name = document.getElementById('sync-clock-name').value.trim();
const speed = parseFloat(document.getElementById('sync-clock-speed').value);
const description = document.getElementById('sync-clock-description').value.trim() || null;
if (!name) {
syncClockModal.showError(t('sync_clock.error.name_required'));
return;
}
const payload = { name, speed, description };
try {
const method = id ? 'PUT' : 'POST';
const url = id ? `/sync-clocks/${id}` : '/sync-clocks';
const resp = await fetchWithAuth(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.detail || `HTTP ${resp.status}`);
}
showToast(t(id ? 'sync_clock.updated' : 'sync_clock.created'), 'success');
syncClockModal.forceClose();
await loadPictureSources();
} catch (e) {
if (e.isAuth) return;
syncClockModal.showError(e.message);
}
}
// ── Edit / Clone / Delete ──
export async function editSyncClock(clockId) {
try {
const resp = await fetchWithAuth(`/sync-clocks/${clockId}`);
if (!resp.ok) throw new Error(t('sync_clock.error.load'));
const data = await resp.json();
await showSyncClockModal(data);
} catch (e) {
if (e.isAuth) return;
showToast(e.message, 'error');
}
}
export async function cloneSyncClock(clockId) {
try {
const resp = await fetchWithAuth(`/sync-clocks/${clockId}`);
if (!resp.ok) throw new Error(t('sync_clock.error.load'));
const data = await resp.json();
delete data.id;
data.name = data.name + ' (copy)';
await showSyncClockModal(data);
} catch (e) {
if (e.isAuth) return;
showToast(e.message, 'error');
}
}
export async function deleteSyncClock(clockId) {
const confirmed = await showConfirm(t('sync_clock.delete.confirm'));
if (!confirmed) return;
try {
const resp = await fetchWithAuth(`/sync-clocks/${clockId}`, { method: 'DELETE' });
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.detail || `HTTP ${resp.status}`);
}
showToast(t('sync_clock.deleted'), 'success');
await loadPictureSources();
} catch (e) {
if (e.isAuth) return;
showToast(e.message, 'error');
}
}
// ── Runtime controls ──
export async function pauseSyncClock(clockId) {
try {
const resp = await fetchWithAuth(`/sync-clocks/${clockId}/pause`, { method: 'POST' });
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
showToast(t('sync_clock.paused'), 'success');
await loadPictureSources();
} catch (e) {
if (e.isAuth) return;
showToast(e.message, 'error');
}
}
export async function resumeSyncClock(clockId) {
try {
const resp = await fetchWithAuth(`/sync-clocks/${clockId}/resume`, { method: 'POST' });
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
showToast(t('sync_clock.resumed'), 'success');
await loadPictureSources();
} catch (e) {
if (e.isAuth) return;
showToast(e.message, 'error');
}
}
export async function resetSyncClock(clockId) {
try {
const resp = await fetchWithAuth(`/sync-clocks/${clockId}/reset`, { method: 'POST' });
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
showToast(t('sync_clock.reset_done'), 'success');
await loadPictureSources();
} catch (e) {
if (e.isAuth) return;
showToast(e.message, 'error');
}
}
// ── Card rendering ──
export function createSyncClockCard(clock) {
const statusIcon = clock.is_running ? ICON_START : ICON_PAUSE;
const statusLabel = clock.is_running ? t('sync_clock.status.running') : t('sync_clock.status.paused');
const toggleAction = clock.is_running
? `pauseSyncClock('${clock.id}')`
: `resumeSyncClock('${clock.id}')`;
const toggleTitle = clock.is_running ? t('sync_clock.action.pause') : t('sync_clock.action.resume');
return wrapCard({
type: 'template-card',
dataAttr: 'data-id',
id: clock.id,
removeOnclick: `deleteSyncClock('${clock.id}')`,
removeTitle: t('common.delete'),
content: `
<div class="template-card-header">
<div class="template-name">${ICON_CLOCK} ${escapeHtml(clock.name)}</div>
</div>
<div class="stream-card-props">
<span class="stream-card-prop">${statusIcon} ${statusLabel}</span>
<span class="stream-card-prop">${ICON_CLOCK} ${clock.speed}x</span>
</div>
${clock.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(clock.description)}</div>` : ''}`,
actions: `
<button class="btn btn-icon btn-secondary" onclick="event.stopPropagation(); ${toggleAction}" title="${toggleTitle}">${clock.is_running ? ICON_PAUSE : ICON_START}</button>
<button class="btn btn-icon btn-secondary" onclick="event.stopPropagation(); resetSyncClock('${clock.id}')" title="${t('sync_clock.action.reset')}">${ICON_CLOCK}</button>
<button class="btn btn-icon btn-secondary" onclick="cloneSyncClock('${clock.id}')" title="${t('common.clone')}">${ICON_CLONE}</button>
<button class="btn btn-icon btn-secondary" onclick="editSyncClock('${clock.id}')" title="${t('common.edit')}">${ICON_EDIT}</button>`,
});
}
// ── Expose to global scope for inline onclick handlers ──
window.showSyncClockModal = showSyncClockModal;
window.closeSyncClockModal = closeSyncClockModal;
window.saveSyncClock = saveSyncClock;
window.editSyncClock = editSyncClock;
window.cloneSyncClock = cloneSyncClock;
window.deleteSyncClock = deleteSyncClock;
window.pauseSyncClock = pauseSyncClock;
window.resumeSyncClock = resumeSyncClock;
window.resetSyncClock = resetSyncClock;

View File

@@ -9,6 +9,7 @@ import {
kcWebSockets,
ledPreviewWebSockets,
_cachedValueSources, valueSourcesCache,
streamsCache, audioSourcesCache, syncClocksCache,
} from '../core/state.js';
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, isOpenrgbDevice } from '../core/api.js';
import { t } from '../core/i18n.js';
@@ -475,15 +476,17 @@ export async function loadTargetsTab() {
if (!csDevices.isMounted()) setTabRefreshing('targets-panel-content', true);
try {
// Fetch devices, targets, CSS sources, picture sources, pattern templates, and value sources in parallel
const [devicesResp, targetsResp, cssResp, psResp, patResp, valueSrcArr, asResp] = await Promise.all([
// Fetch devices, targets, CSS sources, pattern templates in parallel;
// use DataCache for picture sources, audio sources, value sources, sync clocks
const [devicesResp, targetsResp, cssResp, patResp, psArr, valueSrcArr, asSrcArr] = await Promise.all([
fetchWithAuth('/devices'),
fetchWithAuth('/picture-targets'),
fetchWithAuth('/color-strip-sources').catch(() => null),
fetchWithAuth('/picture-sources').catch(() => null),
fetchWithAuth('/pattern-templates').catch(() => null),
streamsCache.fetch().catch(() => []),
valueSourcesCache.fetch().catch(() => []),
fetchWithAuth('/audio-sources').catch(() => null),
audioSourcesCache.fetch().catch(() => []),
syncClocksCache.fetch().catch(() => []),
]);
const devicesData = await devicesResp.json();
@@ -499,10 +502,7 @@ export async function loadTargetsTab() {
}
let pictureSourceMap = {};
if (psResp && psResp.ok) {
const psData = await psResp.json();
(psData.streams || []).forEach(s => { pictureSourceMap[s.id] = s; });
}
psArr.forEach(s => { pictureSourceMap[s.id] = s; });
let patternTemplates = [];
let patternTemplateMap = {};
@@ -516,10 +516,7 @@ export async function loadTargetsTab() {
valueSrcArr.forEach(s => { valueSourceMap[s.id] = s; });
let audioSourceMap = {};
if (asResp && asResp.ok) {
const asData = await asResp.json();
(asData.sources || []).forEach(s => { audioSourceMap[s.id] = s; });
}
asSrcArr.forEach(s => { audioSourceMap[s.id] = s; });
// Fetch all device states, target states, and target metrics in batch
const [batchDevStatesResp, batchTgtStatesResp, batchTgtMetricsResp] = await Promise.all([

View File

@@ -307,6 +307,7 @@
"common.delete": "Delete",
"common.edit": "Edit",
"common.clone": "Clone",
"common.none": "None",
"section.filter.placeholder": "Filter...",
"section.filter.reset": "Clear filter",
"section.expand_all": "Expand all sections",
@@ -950,6 +951,7 @@
"audio_template.error.required": "Please fill in all required fields",
"audio_template.error.delete": "Failed to delete audio template",
"streams.group.value": "Value Sources",
"streams.group.sync": "Sync Clocks",
"value_source.group.title": "Value Sources",
"value_source.add": "Add Value Source",
"value_source.edit": "Edit Value Source",
@@ -1137,5 +1139,32 @@
"theme.switched.dark": "Switched to dark theme",
"theme.switched.light": "Switched to light theme",
"accent.color.updated": "Accent color updated",
"search.footer": "↑↓ navigate · Enter select · Esc close"
"search.footer": "↑↓ navigate · Enter select · Esc close",
"sync_clock.group.title": "Sync Clocks",
"sync_clock.add": "Add Sync Clock",
"sync_clock.edit": "Edit Sync Clock",
"sync_clock.name": "Name:",
"sync_clock.name.placeholder": "Main Animation Clock",
"sync_clock.name.hint": "A descriptive name for this synchronization clock",
"sync_clock.speed": "Speed:",
"sync_clock.speed.hint": "Speed multiplier shared by all linked sources. 1.0 = normal speed.",
"sync_clock.description": "Description (optional):",
"sync_clock.description.placeholder": "Optional description",
"sync_clock.description.hint": "Optional notes about this clock's purpose",
"sync_clock.status.running": "Running",
"sync_clock.status.paused": "Paused",
"sync_clock.action.pause": "Pause",
"sync_clock.action.resume": "Resume",
"sync_clock.action.reset": "Reset",
"sync_clock.error.name_required": "Clock name is required",
"sync_clock.error.load": "Failed to load sync clock",
"sync_clock.created": "Sync clock created",
"sync_clock.updated": "Sync clock updated",
"sync_clock.deleted": "Sync clock deleted",
"sync_clock.paused": "Clock paused",
"sync_clock.resumed": "Clock resumed",
"sync_clock.reset_done": "Clock reset to zero",
"sync_clock.delete.confirm": "Delete this sync clock? Sources using it will revert to their own speed.",
"color_strip.clock": "Sync Clock:",
"color_strip.clock.hint": "Link to a sync clock for synchronized animation. When set, speed comes from the clock."
}

View File

@@ -307,6 +307,7 @@
"common.delete": "Удалить",
"common.edit": "Редактировать",
"common.clone": "Клонировать",
"common.none": "Нет",
"section.filter.placeholder": "Фильтр...",
"section.filter.reset": "Очистить фильтр",
"section.expand_all": "Развернуть все секции",
@@ -950,6 +951,7 @@
"audio_template.error.required": "Пожалуйста, заполните все обязательные поля",
"audio_template.error.delete": "Не удалось удалить аудиошаблон",
"streams.group.value": "Источники значений",
"streams.group.sync": "Часы синхронизации",
"value_source.group.title": "Источники значений",
"value_source.add": "Добавить источник значений",
"value_source.edit": "Редактировать источник значений",
@@ -1137,5 +1139,32 @@
"theme.switched.dark": "Переключено на тёмную тему",
"theme.switched.light": "Переключено на светлую тему",
"accent.color.updated": "Цвет акцента обновлён",
"search.footer": "↑↓ навигация · Enter выбор · Esc закрыть"
"search.footer": "↑↓ навигация · Enter выбор · Esc закрыть",
"sync_clock.group.title": "Часы синхронизации",
"sync_clock.add": "Добавить часы",
"sync_clock.edit": "Редактировать часы",
"sync_clock.name": "Название:",
"sync_clock.name.placeholder": "Основные часы анимации",
"sync_clock.name.hint": "Описательное название для этих часов синхронизации",
"sync_clock.speed": "Скорость:",
"sync_clock.speed.hint": "Множитель скорости, общий для всех привязанных источников. 1.0 = нормальная скорость.",
"sync_clock.description": "Описание (необязательно):",
"sync_clock.description.placeholder": "Необязательное описание",
"sync_clock.description.hint": "Необязательные заметки о назначении этих часов",
"sync_clock.status.running": "Работает",
"sync_clock.status.paused": "Приостановлено",
"sync_clock.action.pause": "Приостановить",
"sync_clock.action.resume": "Возобновить",
"sync_clock.action.reset": "Сбросить",
"sync_clock.error.name_required": "Название часов обязательно",
"sync_clock.error.load": "Не удалось загрузить часы синхронизации",
"sync_clock.created": "Часы синхронизации созданы",
"sync_clock.updated": "Часы синхронизации обновлены",
"sync_clock.deleted": "Часы синхронизации удалены",
"sync_clock.paused": "Часы приостановлены",
"sync_clock.resumed": "Часы возобновлены",
"sync_clock.reset_done": "Часы сброшены на ноль",
"sync_clock.delete.confirm": "Удалить эти часы синхронизации? Источники, использующие их, вернутся к собственной скорости.",
"color_strip.clock": "Часы синхронизации:",
"color_strip.clock.hint": "Привязка к часам синхронизации для синхронной анимации. При установке скорость берётся из часов."
}

View File

@@ -307,6 +307,7 @@
"common.delete": "删除",
"common.edit": "编辑",
"common.clone": "克隆",
"common.none": "无",
"section.filter.placeholder": "筛选...",
"section.filter.reset": "清除筛选",
"section.expand_all": "全部展开",
@@ -950,6 +951,7 @@
"audio_template.error.required": "请填写所有必填项",
"audio_template.error.delete": "删除音频模板失败",
"streams.group.value": "值源",
"streams.group.sync": "同步时钟",
"value_source.group.title": "值源",
"value_source.add": "添加值源",
"value_source.edit": "编辑值源",
@@ -1137,5 +1139,32 @@
"theme.switched.dark": "已切换到深色主题",
"theme.switched.light": "已切换到浅色主题",
"accent.color.updated": "强调色已更新",
"search.footer": "↑↓ 导航 · Enter 选择 · Esc 关闭"
"search.footer": "↑↓ 导航 · Enter 选择 · Esc 关闭",
"sync_clock.group.title": "同步时钟",
"sync_clock.add": "添加同步时钟",
"sync_clock.edit": "编辑同步时钟",
"sync_clock.name": "名称:",
"sync_clock.name.placeholder": "主动画时钟",
"sync_clock.name.hint": "此同步时钟的描述性名称",
"sync_clock.speed": "速度:",
"sync_clock.speed.hint": "所有关联源共享的速度倍率。1.0 = 正常速度。",
"sync_clock.description": "描述(可选):",
"sync_clock.description.placeholder": "可选描述",
"sync_clock.description.hint": "关于此时钟用途的可选备注",
"sync_clock.status.running": "运行中",
"sync_clock.status.paused": "已暂停",
"sync_clock.action.pause": "暂停",
"sync_clock.action.resume": "恢复",
"sync_clock.action.reset": "重置",
"sync_clock.error.name_required": "时钟名称为必填项",
"sync_clock.error.load": "加载同步时钟失败",
"sync_clock.created": "同步时钟已创建",
"sync_clock.updated": "同步时钟已更新",
"sync_clock.deleted": "同步时钟已删除",
"sync_clock.paused": "时钟已暂停",
"sync_clock.resumed": "时钟已恢复",
"sync_clock.reset_done": "时钟已重置为零",
"sync_clock.delete.confirm": "删除此同步时钟?使用它的源将恢复为各自的速度。",
"color_strip.clock": "同步时钟:",
"color_strip.clock.hint": "关联同步时钟以实现同步动画。设置后,速度将来自时钟。"
}

View File

@@ -7,7 +7,7 @@
* - Navigation: network-first with offline fallback
*/
const CACHE_NAME = 'ledgrab-v6';
const CACHE_NAME = 'ledgrab-v7';
// Only pre-cache static assets (no auth required).
// Do NOT pre-cache '/' — it requires API key auth and would cache an error page.

View File

@@ -34,6 +34,7 @@ class ColorStripSource:
created_at: datetime
updated_at: datetime
description: Optional[str] = None
clock_id: Optional[str] = None # optional SyncClock reference
@property
def sharable(self) -> bool:
@@ -53,6 +54,7 @@ class ColorStripSource:
"created_at": self.created_at.isoformat(),
"updated_at": self.updated_at.isoformat(),
"description": self.description,
"clock_id": self.clock_id,
# Subclass fields default to None for forward compat
"picture_source_id": None,
"fps": None,
@@ -91,6 +93,8 @@ class ColorStripSource:
name: str = data["name"]
description: str | None = data.get("description")
clock_id: str | None = data.get("clock_id")
raw_created = data.get("created_at")
created_at: datetime = (
datetime.fromisoformat(raw_created)
@@ -122,7 +126,7 @@ class ColorStripSource:
return StaticColorStripSource(
id=sid, name=name, source_type="static",
created_at=created_at, updated_at=updated_at, description=description,
color=color,
clock_id=clock_id, color=color,
led_count=data.get("led_count") or 0,
animation=data.get("animation"),
)
@@ -133,7 +137,7 @@ class ColorStripSource:
return GradientColorStripSource(
id=sid, name=name, source_type="gradient",
created_at=created_at, updated_at=updated_at, description=description,
stops=stops,
clock_id=clock_id, stops=stops,
led_count=data.get("led_count") or 0,
animation=data.get("animation"),
)
@@ -144,7 +148,7 @@ class ColorStripSource:
return ColorCycleColorStripSource(
id=sid, name=name, source_type="color_cycle",
created_at=created_at, updated_at=updated_at, description=description,
colors=colors,
clock_id=clock_id, colors=colors,
cycle_speed=float(data.get("cycle_speed") or 1.0),
led_count=data.get("led_count") or 0,
)
@@ -153,7 +157,7 @@ class ColorStripSource:
return CompositeColorStripSource(
id=sid, name=name, source_type="composite",
created_at=created_at, updated_at=updated_at, description=description,
layers=data.get("layers") or [],
clock_id=clock_id, layers=data.get("layers") or [],
led_count=data.get("led_count") or 0,
)
@@ -161,7 +165,7 @@ class ColorStripSource:
return MappedColorStripSource(
id=sid, name=name, source_type="mapped",
created_at=created_at, updated_at=updated_at, description=description,
zones=data.get("zones") or [],
clock_id=clock_id, zones=data.get("zones") or [],
led_count=data.get("led_count") or 0,
)
@@ -173,7 +177,7 @@ class ColorStripSource:
return AudioColorStripSource(
id=sid, name=name, source_type="audio",
created_at=created_at, updated_at=updated_at, description=description,
visualization_mode=data.get("visualization_mode") or "spectrum",
clock_id=clock_id, visualization_mode=data.get("visualization_mode") or "spectrum",
audio_source_id=data.get("audio_source_id") or "",
sensitivity=float(data.get("sensitivity") or 1.0),
smoothing=float(data.get("smoothing") or 0.3),
@@ -193,7 +197,7 @@ class ColorStripSource:
return EffectColorStripSource(
id=sid, name=name, source_type="effect",
created_at=created_at, updated_at=updated_at, description=description,
effect_type=data.get("effect_type") or "fire",
clock_id=clock_id, effect_type=data.get("effect_type") or "fire",
speed=float(data.get("speed") or 1.0),
led_count=data.get("led_count") or 0,
palette=data.get("palette") or "fire",
@@ -212,7 +216,7 @@ class ColorStripSource:
return ApiInputColorStripSource(
id=sid, name=name, source_type="api_input",
created_at=created_at, updated_at=updated_at, description=description,
led_count=data.get("led_count") or 0,
clock_id=clock_id, led_count=data.get("led_count") or 0,
fallback_color=fallback_color,
timeout=float(data.get("timeout") or 5.0),
)
@@ -221,7 +225,7 @@ class ColorStripSource:
return PictureColorStripSource(
id=sid, name=name, source_type=source_type,
created_at=created_at, updated_at=updated_at, description=description,
picture_source_id=data.get("picture_source_id") or "",
clock_id=clock_id, picture_source_id=data.get("picture_source_id") or "",
fps=data.get("fps") or 30,
brightness=data["brightness"] if data.get("brightness") is not None else 1.0,
saturation=data["saturation"] if data.get("saturation") is not None else 1.0,

View File

@@ -121,6 +121,7 @@ class ColorStripStore:
color_peak: Optional[list] = None,
fallback_color: Optional[list] = None,
timeout: Optional[float] = None,
clock_id: Optional[str] = None,
) -> ColorStripSource:
"""Create a new color strip source.
@@ -146,6 +147,7 @@ class ColorStripStore:
created_at=now,
updated_at=now,
description=description,
clock_id=clock_id,
color=rgb,
led_count=led_count,
animation=animation,
@@ -158,6 +160,7 @@ class ColorStripStore:
created_at=now,
updated_at=now,
description=description,
clock_id=clock_id,
stops=stops if isinstance(stops, list) else [
{"position": 0.0, "color": [255, 0, 0]},
{"position": 1.0, "color": [0, 0, 255]},
@@ -177,6 +180,7 @@ class ColorStripStore:
created_at=now,
updated_at=now,
description=description,
clock_id=clock_id,
colors=colors if isinstance(colors, list) and len(colors) >= 2 else default_colors,
cycle_speed=float(cycle_speed) if cycle_speed else 1.0,
led_count=led_count,
@@ -190,6 +194,7 @@ class ColorStripStore:
created_at=now,
updated_at=now,
description=description,
clock_id=clock_id,
effect_type=effect_type or "fire",
speed=float(speed) if speed else 1.0,
led_count=led_count,
@@ -209,6 +214,7 @@ class ColorStripStore:
created_at=now,
updated_at=now,
description=description,
clock_id=clock_id,
visualization_mode=visualization_mode or "spectrum",
audio_source_id=audio_source_id or "",
sensitivity=float(sensitivity) if sensitivity else 1.0,
@@ -227,6 +233,7 @@ class ColorStripStore:
created_at=now,
updated_at=now,
description=description,
clock_id=clock_id,
layers=layers if isinstance(layers, list) else [],
led_count=led_count,
)
@@ -238,6 +245,7 @@ class ColorStripStore:
created_at=now,
updated_at=now,
description=description,
clock_id=clock_id,
zones=zones if isinstance(zones, list) else [],
led_count=led_count,
)
@@ -250,6 +258,7 @@ class ColorStripStore:
created_at=now,
updated_at=now,
description=description,
clock_id=clock_id,
led_count=led_count,
fallback_color=fb,
timeout=float(timeout) if timeout is not None else 5.0,
@@ -264,6 +273,7 @@ class ColorStripStore:
created_at=now,
updated_at=now,
description=description,
clock_id=clock_id,
picture_source_id=picture_source_id,
fps=fps,
brightness=brightness,
@@ -316,6 +326,7 @@ class ColorStripStore:
color_peak: Optional[list] = None,
fallback_color: Optional[list] = None,
timeout: Optional[float] = None,
clock_id: Optional[str] = None,
) -> ColorStripSource:
"""Update an existing color strip source.
@@ -336,6 +347,9 @@ class ColorStripStore:
if description is not None:
source.description = description
if clock_id is not None:
source.clock_id = clock_id if clock_id else None
if isinstance(source, PictureColorStripSource):
if picture_source_id is not None:
source.picture_source_id = picture_source_id

View File

@@ -0,0 +1,43 @@
"""Synchronization clock data model.
A SyncClock provides a shared, controllable time base for animation-based
color strip sources. Multiple CSS sources referencing the same clock
animate in sync and share speed / pause / resume / reset controls.
"""
from dataclasses import dataclass
from datetime import datetime
from typing import Optional
@dataclass
class SyncClock:
"""Persistent configuration for a synchronization clock."""
id: str
name: str
speed: float # animation speed multiplier (0.110.0)
created_at: datetime
updated_at: datetime
description: Optional[str] = None
def to_dict(self) -> dict:
return {
"id": self.id,
"name": self.name,
"speed": self.speed,
"description": self.description,
"created_at": self.created_at.isoformat(),
"updated_at": self.updated_at.isoformat(),
}
@staticmethod
def from_dict(data: dict) -> "SyncClock":
return SyncClock(
id=data["id"],
name=data["name"],
speed=float(data.get("speed", 1.0)),
description=data.get("description"),
created_at=datetime.fromisoformat(data["created_at"]),
updated_at=datetime.fromisoformat(data["updated_at"]),
)

View File

@@ -0,0 +1,146 @@
"""Synchronization clock storage using JSON files."""
import json
import uuid
from datetime import datetime
from pathlib import Path
from typing import Dict, List, Optional
from wled_controller.storage.sync_clock import SyncClock
from wled_controller.utils import atomic_write_json, get_logger
logger = get_logger(__name__)
class SyncClockStore:
"""Persistent storage for synchronization clocks."""
def __init__(self, file_path: str):
self.file_path = Path(file_path)
self._clocks: Dict[str, SyncClock] = {}
self._load()
def _load(self) -> None:
if not self.file_path.exists():
logger.info("Sync clock store file not found — starting empty")
return
try:
with open(self.file_path, "r", encoding="utf-8") as f:
data = json.load(f)
clocks_data = data.get("sync_clocks", {})
loaded = 0
for clock_id, clock_dict in clocks_data.items():
try:
clock = SyncClock.from_dict(clock_dict)
self._clocks[clock_id] = clock
loaded += 1
except Exception as e:
logger.error(
f"Failed to load sync clock {clock_id}: {e}",
exc_info=True,
)
if loaded > 0:
logger.info(f"Loaded {loaded} sync clocks from storage")
except Exception as e:
logger.error(f"Failed to load sync clocks from {self.file_path}: {e}")
raise
logger.info(f"Sync clock store initialized with {len(self._clocks)} clocks")
def _save(self) -> None:
try:
data = {
"version": "1.0.0",
"sync_clocks": {
cid: clock.to_dict()
for cid, clock in self._clocks.items()
},
}
atomic_write_json(self.file_path, data)
except Exception as e:
logger.error(f"Failed to save sync clocks to {self.file_path}: {e}")
raise
# ── CRUD ─────────────────────────────────────────────────────────
def get_all_clocks(self) -> List[SyncClock]:
return list(self._clocks.values())
def get_clock(self, clock_id: str) -> SyncClock:
if clock_id not in self._clocks:
raise ValueError(f"Sync clock not found: {clock_id}")
return self._clocks[clock_id]
def create_clock(
self,
name: str,
speed: float = 1.0,
description: Optional[str] = None,
) -> SyncClock:
if not name or not name.strip():
raise ValueError("Name is required")
for clock in self._clocks.values():
if clock.name == name:
raise ValueError(f"Sync clock with name '{name}' already exists")
cid = f"sc_{uuid.uuid4().hex[:8]}"
now = datetime.utcnow()
clock = SyncClock(
id=cid,
name=name,
speed=max(0.1, min(10.0, speed)),
created_at=now,
updated_at=now,
description=description,
)
self._clocks[cid] = clock
self._save()
logger.info(f"Created sync clock: {name} ({cid}, speed={clock.speed})")
return clock
def update_clock(
self,
clock_id: str,
name: Optional[str] = None,
speed: Optional[float] = None,
description: Optional[str] = None,
) -> SyncClock:
if clock_id not in self._clocks:
raise ValueError(f"Sync clock not found: {clock_id}")
clock = self._clocks[clock_id]
if name is not None:
for other in self._clocks.values():
if other.id != clock_id and other.name == name:
raise ValueError(f"Sync clock with name '{name}' already exists")
clock.name = name
if speed is not None:
clock.speed = max(0.1, min(10.0, speed))
if description is not None:
clock.description = description
clock.updated_at = datetime.utcnow()
self._save()
logger.info(f"Updated sync clock: {clock_id}")
return clock
def delete_clock(self, clock_id: str) -> None:
if clock_id not in self._clocks:
raise ValueError(f"Sync clock not found: {clock_id}")
del self._clocks[clock_id]
self._save()
logger.info(f"Deleted sync clock: {clock_id}")

View File

@@ -169,6 +169,7 @@
{% include 'modals/test-audio-template.html' %}
{% include 'modals/value-source-editor.html' %}
{% include 'modals/test-value-source.html' %}
{% include 'modals/sync-clock-editor.html' %}
{% include 'modals/settings.html' %}
{% include 'partials/tutorial-overlay.html' %}

View File

@@ -146,7 +146,7 @@
<div id="color-cycle-colors-list"></div>
<button type="button" class="btn btn-secondary" onclick="colorCycleAddColor()" data-i18n="color_strip.color_cycle.add_color">+ Add Color</button>
</div>
<div class="form-group">
<div id="css-editor-cycle-speed-group" class="form-group">
<div class="label-row">
<label for="css-editor-cycle-speed">
<span data-i18n="color_strip.color_cycle.speed">Speed:</span>
@@ -226,7 +226,7 @@
<div id="css-editor-effect-preview" class="effect-palette-preview"></div>
</div>
<div class="form-group">
<div id="css-editor-effect-speed-group" class="form-group">
<div class="label-row">
<label for="css-editor-effect-speed">
<span data-i18n="color_strip.effect.speed">Speed:</span>
@@ -492,7 +492,7 @@
</select>
<small id="css-editor-animation-type-desc" class="field-desc"></small>
</div>
<div class="form-group">
<div id="css-editor-animation-speed-group" class="form-group">
<div class="label-row">
<label for="css-editor-animation-speed">
<span data-i18n="color_strip.animation.speed">Speed:</span>
@@ -508,6 +508,18 @@
</details>
</div>
<!-- Sync Clock (shown for animated types: static, gradient, color_cycle, effect) -->
<div id="css-editor-clock-group" class="form-group" style="display:none">
<div class="label-row">
<label for="css-editor-clock" data-i18n="color_strip.clock">Sync Clock:</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.clock.hint">Optionally link to a sync clock to synchronize animation timing and speed with other sources</small>
<select id="css-editor-clock" onchange="onCSSClockChange()">
<option value="" data-i18n="common.none">None</option>
</select>
</div>
<div id="css-editor-error" class="error-message" style="display: none;"></div>
</form>
</div>

View File

@@ -0,0 +1,51 @@
<!-- Sync Clock Editor Modal -->
<div id="sync-clock-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="sync-clock-modal-title">
<div class="modal-content">
<div class="modal-header">
<h2 id="sync-clock-modal-title" data-i18n="sync_clock.add">Add Sync Clock</h2>
<button class="modal-close-btn" onclick="closeSyncClockModal()" data-i18n-aria-label="aria.close">&#x2715;</button>
</div>
<div class="modal-body">
<form id="sync-clock-form" onsubmit="return false;">
<input type="hidden" id="sync-clock-id">
<div id="sync-clock-error" class="error-message" style="display: none;"></div>
<!-- Name -->
<div class="form-group">
<div class="label-row">
<label for="sync-clock-name" data-i18n="sync_clock.name">Name:</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="sync_clock.name.hint">A descriptive name for this synchronization clock</small>
<input type="text" id="sync-clock-name" data-i18n-placeholder="sync_clock.name.placeholder" placeholder="Main Clock" required>
</div>
<!-- Speed -->
<div class="form-group">
<div class="label-row">
<label for="sync-clock-speed"><span data-i18n="sync_clock.speed">Speed:</span> <span id="sync-clock-speed-display">1.0</span>x</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="sync_clock.speed.hint">Animation speed multiplier for all linked sources (0.1x slow - 10x fast)</small>
<input type="range" id="sync-clock-speed" min="0.1" max="10" step="0.1" value="1.0"
oninput="document.getElementById('sync-clock-speed-display').textContent = this.value">
</div>
<!-- Description -->
<div class="form-group">
<div class="label-row">
<label for="sync-clock-description" data-i18n="sync_clock.description">Description (optional):</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="sync_clock.description.hint">Optional notes about this clock's purpose</small>
<textarea id="sync-clock-description" rows="2" data-i18n-placeholder="sync_clock.description.placeholder" placeholder=""></textarea>
</div>
</form>
</div>
<div class="modal-footer">
<button class="btn btn-icon btn-secondary" onclick="closeSyncClockModal()" title="Cancel" data-i18n-title="settings.button.cancel" data-i18n-aria-label="aria.cancel">&#x2715;</button>
<button class="btn btn-icon btn-primary" onclick="saveSyncClock()" title="Save" data-i18n-title="settings.button.save" data-i18n-aria-label="aria.save">&#x2713;</button>
</div>
</div>
</div>