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

@@ -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}")