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.automations import router as automations_router
from .routes.scene_presets import router as scene_presets_router from .routes.scene_presets import router as scene_presets_router
from .routes.webhooks import router as webhooks_router from .routes.webhooks import router as webhooks_router
from .routes.sync_clocks import router as sync_clocks_router
router = APIRouter() router = APIRouter()
router.include_router(system_router) router.include_router(system_router)
@@ -34,5 +35,6 @@ router.include_router(picture_targets_router)
router.include_router(automations_router) router.include_router(automations_router)
router.include_router(scene_presets_router) router.include_router(scene_presets_router)
router.include_router(webhooks_router) router.include_router(webhooks_router)
router.include_router(sync_clocks_router)
__all__ = ["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.value_source_store import ValueSourceStore
from wled_controller.storage.automation_store import AutomationStore from wled_controller.storage.automation_store import AutomationStore
from wled_controller.storage.scene_preset_store import ScenePresetStore 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.automations.automation_engine import AutomationEngine
from wled_controller.core.backup.auto_backup import AutoBackupEngine 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) # Global instances (initialized in main.py)
_auto_backup_engine: AutoBackupEngine | None = None _auto_backup_engine: AutoBackupEngine | None = None
@@ -32,6 +34,8 @@ _processor_manager: ProcessorManager | None = None
_automation_store: AutomationStore | None = None _automation_store: AutomationStore | None = None
_scene_preset_store: ScenePresetStore | None = None _scene_preset_store: ScenePresetStore | None = None
_automation_engine: AutomationEngine | None = None _automation_engine: AutomationEngine | None = None
_sync_clock_store: SyncClockStore | None = None
_sync_clock_manager: SyncClockManager | None = None
def get_device_store() -> DeviceStore: def get_device_store() -> DeviceStore:
@@ -139,6 +143,20 @@ def get_auto_backup_engine() -> AutoBackupEngine:
return _auto_backup_engine 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( def init_dependencies(
device_store: DeviceStore, device_store: DeviceStore,
template_store: TemplateStore, template_store: TemplateStore,
@@ -155,12 +173,15 @@ def init_dependencies(
scene_preset_store: ScenePresetStore | None = None, scene_preset_store: ScenePresetStore | None = None,
automation_engine: AutomationEngine | None = None, automation_engine: AutomationEngine | None = None,
auto_backup_engine: AutoBackupEngine | None = None, auto_backup_engine: AutoBackupEngine | None = None,
sync_clock_store: SyncClockStore | None = None,
sync_clock_manager: SyncClockManager | None = None,
): ):
"""Initialize global dependencies.""" """Initialize global dependencies."""
global _device_store, _template_store, _processor_manager global _device_store, _template_store, _processor_manager
global _pp_template_store, _pattern_template_store, _picture_source_store, _picture_target_store global _pp_template_store, _pattern_template_store, _picture_source_store, _picture_target_store
global _color_strip_store, _audio_source_store, _audio_template_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 _value_source_store, _automation_store, _scene_preset_store, _automation_engine, _auto_backup_engine
global _sync_clock_store, _sync_clock_manager
_device_store = device_store _device_store = device_store
_template_store = template_store _template_store = template_store
_processor_manager = processor_manager _processor_manager = processor_manager
@@ -176,3 +197,5 @@ def init_dependencies(
_scene_preset_store = scene_preset_store _scene_preset_store = scene_preset_store
_automation_engine = automation_engine _automation_engine = automation_engine
_auto_backup_engine = auto_backup_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), scale=getattr(source, "scale", None),
mirror=getattr(source, "mirror", None), mirror=getattr(source, "mirror", None),
description=source.description, description=source.description,
clock_id=source.clock_id,
frame_interpolation=getattr(source, "frame_interpolation", None), frame_interpolation=getattr(source, "frame_interpolation", None),
animation=getattr(source, "animation", None), animation=getattr(source, "animation", None),
layers=getattr(source, "layers", None), layers=getattr(source, "layers", None),
@@ -177,6 +178,7 @@ async def create_color_strip_source(
color_peak=data.color_peak, color_peak=data.color_peak,
fallback_color=data.fallback_color, fallback_color=data.fallback_color,
timeout=data.timeout, timeout=data.timeout,
clock_id=data.clock_id,
) )
return _css_to_response(source) return _css_to_response(source)
@@ -254,6 +256,7 @@ async def update_color_strip_source(
color_peak=data.color_peak, color_peak=data.color_peak,
fallback_color=data.fallback_color, fallback_color=data.fallback_color,
timeout=data.timeout, timeout=data.timeout,
clock_id=data.clock_id,
) )
# Hot-reload running stream (no restart needed for in-place param changes) # 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_sources": "audio_sources_file",
"audio_templates": "audio_templates_file", "audio_templates": "audio_templates_file",
"value_sources": "value_sources_file", "value_sources": "value_sources_file",
"sync_clocks": "sync_clocks_file",
"automations": "automations_file", "automations": "automations_file",
"scene_presets": "scene_presets_file", "scene_presets": "scene_presets_file",
} }

View File

@@ -89,6 +89,8 @@ class ColorStripSourceCreate(BaseModel):
# api_input-type fields # 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)") 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) 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): class ColorStripSourceUpdate(BaseModel):
@@ -134,6 +136,8 @@ class ColorStripSourceUpdate(BaseModel):
# api_input-type fields # api_input-type fields
fallback_color: Optional[List[int]] = Field(None, description="Fallback RGB color [R,G,B] (api_input type)") 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) 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): class ColorStripSourceResponse(BaseModel):
@@ -181,6 +185,8 @@ class ColorStripSourceResponse(BaseModel):
# api_input-type fields # api_input-type fields
fallback_color: Optional[List[int]] = Field(None, description="Fallback RGB color [R,G,B] (api_input type)") 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)") 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") overlay_active: bool = Field(False, description="Whether the screen overlay is currently active")
created_at: datetime = Field(description="Creation timestamp") created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update 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" value_sources_file: str = "data/value_sources.json"
automations_file: str = "data/automations.json" automations_file: str = "data/automations.json"
scene_presets_file: str = "data/scene_presets.json" scene_presets_file: str = "data/scene_presets.json"
sync_clocks_file: str = "data/sync_clocks.json"
class MQTTConfig(BaseSettings): class MQTTConfig(BaseSettings):

View File

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

View File

@@ -58,21 +58,46 @@ class ColorStripStreamManager:
keyed by ``{css_id}:{consumer_id}``. 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: Args:
color_strip_store: ColorStripStore for resolving source configs color_strip_store: ColorStripStore for resolving source configs
live_stream_manager: LiveStreamManager for acquiring picture streams live_stream_manager: LiveStreamManager for acquiring picture streams
audio_capture_manager: AudioCaptureManager for audio-reactive sources audio_capture_manager: AudioCaptureManager for audio-reactive sources
audio_source_store: AudioSourceStore for resolving audio source chains 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._color_strip_store = color_strip_store
self._live_stream_manager = live_stream_manager self._live_stream_manager = live_stream_manager
self._audio_capture_manager = audio_capture_manager self._audio_capture_manager = audio_capture_manager
self._audio_source_store = audio_source_store self._audio_source_store = audio_source_store
self._audio_template_store = audio_template_store self._audio_template_store = audio_template_store
self._sync_clock_manager = sync_clock_manager
self._streams: Dict[str, _ColorStripEntry] = {} 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: def _resolve_key(self, css_id: str, consumer_id: str) -> str:
"""Resolve internal registry key for a (css_id, consumer_id) pair. """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}" f"Unsupported color strip source type '{source.source_type}' for {css_id}"
) )
css_stream = stream_cls(source) css_stream = stream_cls(source)
# Inject sync clock runtime if source references a clock
self._inject_clock(css_stream, source)
css_stream.start() css_stream.start()
key = f"{css_id}:{consumer_id}" if consumer_id else css_id key = f"{css_id}:{consumer_id}" if consumer_id else css_id
self._streams[key] = _ColorStripEntry( self._streams[key] = _ColorStripEntry(
@@ -196,6 +223,10 @@ class ColorStripStreamManager:
except Exception as e: except Exception as e:
logger.error(f"Error stopping color strip stream {key}: {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 picture_source_id = entry.picture_source_id
del self._streams[key] del self._streams[key]
logger.info(f"Removed color strip stream {key}") logger.info(f"Removed color strip stream {key}")
@@ -227,6 +258,25 @@ class ColorStripStreamManager:
for key in matching_keys: for key in matching_keys:
entry = self._streams[key] entry = self._streams[key]
entry.stream.update_source(new_source) 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 # Track picture_source_id change for future reference counting
from wled_controller.storage.color_strip_source import PictureColorStripSource from wled_controller.storage.color_strip_source import PictureColorStripSource

View File

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

View File

@@ -69,7 +69,7 @@ class ProcessorManager:
Targets are registered for processing via polymorphic TargetProcessor subclasses. 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.""" """Initialize processor manager."""
self._devices: Dict[str, DeviceState] = {} self._devices: Dict[str, DeviceState] = {}
self._processors: Dict[str, TargetProcessor] = {} self._processors: Dict[str, TargetProcessor] = {}
@@ -89,12 +89,14 @@ class ProcessorManager:
picture_source_store, capture_template_store, pp_template_store picture_source_store, capture_template_store, pp_template_store
) )
self._audio_capture_manager = AudioCaptureManager() self._audio_capture_manager = AudioCaptureManager()
self._sync_clock_manager = sync_clock_manager
self._color_strip_stream_manager = ColorStripStreamManager( self._color_strip_stream_manager = ColorStripStreamManager(
color_strip_store=color_strip_store, color_strip_store=color_strip_store,
live_stream_manager=self._live_stream_manager, live_stream_manager=self._live_stream_manager,
audio_capture_manager=self._audio_capture_manager, audio_capture_manager=self._audio_capture_manager,
audio_source_store=audio_source_store, audio_source_store=audio_source_store,
audio_template_store=audio_template_store, audio_template_store=audio_template_store,
sync_clock_manager=sync_clock_manager,
) )
self._value_stream_manager = ValueStreamManager( self._value_stream_manager = ValueStreamManager(
value_source_store=value_source_store, 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.value_source_store import ValueSourceStore
from wled_controller.storage.automation_store import AutomationStore from wled_controller.storage.automation_store import AutomationStore
from wled_controller.storage.scene_preset_store import ScenePresetStore 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.automations.automation_engine import AutomationEngine
from wled_controller.core.mqtt.mqtt_service import MQTTService from wled_controller.core.mqtt.mqtt_service import MQTTService
from wled_controller.core.devices.mqtt_client import set_mqtt_service 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) value_source_store = ValueSourceStore(config.storage.value_sources_file)
automation_store = AutomationStore(config.storage.automations_file) automation_store = AutomationStore(config.storage.automations_file)
scene_preset_store = ScenePresetStore(config.storage.scene_presets_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( processor_manager = ProcessorManager(
picture_source_store=picture_source_store, picture_source_store=picture_source_store,
@@ -67,6 +71,7 @@ processor_manager = ProcessorManager(
audio_source_store=audio_source_store, audio_source_store=audio_source_store,
value_source_store=value_source_store, value_source_store=value_source_store,
audio_template_store=audio_template_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, scene_preset_store=scene_preset_store,
automation_engine=automation_engine, automation_engine=automation_engine,
auto_backup_engine=auto_backup_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 # Register devices in processor manager for health monitoring

View File

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

View File

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

View File

@@ -3,6 +3,7 @@
*/ */
import { fetchWithAuth, escapeHtml } from '../core/api.js'; import { fetchWithAuth, escapeHtml } from '../core/api.js';
import { _cachedSyncClocks } from '../core/state.js';
import { t } from '../core/i18n.js'; import { t } from '../core/i18n.js';
import { showToast, showConfirm } from '../core/ui.js'; import { showToast, showConfirm } from '../core/ui.js';
import { Modal } from '../core/modal.js'; import { Modal } from '../core/modal.js';
@@ -11,7 +12,7 @@ import {
ICON_CLONE, ICON_EDIT, ICON_CALIBRATION, ICON_CLONE, ICON_EDIT, ICON_CALIBRATION,
ICON_LED, ICON_PALETTE, ICON_FPS, ICON_MAP_PIN, ICON_MUSIC, ICON_LED, ICON_PALETTE, ICON_FPS, ICON_MAP_PIN, ICON_MUSIC,
ICON_AUDIO_LOOPBACK, ICON_TIMER, ICON_LINK_SOURCE, ICON_FILM, 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'; } from '../core/icons.js';
import { wrapCard } from '../core/card-colors.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, audio_mirror: document.getElementById('css-editor-audio-mirror').checked,
api_input_fallback_color: document.getElementById('css-editor-api-input-fallback-color').value, api_input_fallback_color: document.getElementById('css-editor-api-input-fallback-color').value,
api_input_timeout: document.getElementById('css-editor-api-input-timeout').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 = document.getElementById('css-editor-led-count-group').style.display =
(type === 'composite' || type === 'mapped' || type === 'audio' || type === 'api_input') ? 'none' : ''; (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') { if (type === 'audio') {
_loadAudioSources(); _loadAudioSources();
} else if (type === 'composite') { } 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() { function _getAnimationPayload() {
const type = document.getElementById('css-editor-animation-type').value; const type = document.getElementById('css-editor-animation-type').value;
return { return {
@@ -589,10 +617,16 @@ export function createColorStripCard(source, pictureSourceMap, audioSourceMap) {
const isAudio = source.source_type === 'audio'; const isAudio = source.source_type === 'audio';
const isApiInput = source.source_type === 'api_input'; 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 anim = (isStatic || isGradient) && source.animation && source.animation.enabled ? source.animation : null;
const animBadge = anim 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')}">${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; let propsHtml;
@@ -604,6 +638,7 @@ export function createColorStripCard(source, pictureSourceMap, audioSourceMap) {
</span> </span>
${source.led_count ? `<span class="stream-card-prop" title="${t('color_strip.leds')}">${ICON_LED} ${source.led_count}</span>` : ''} ${source.led_count ? `<span class="stream-card-prop" title="${t('color_strip.leds')}">${ICON_LED} ${source.led_count}</span>` : ''}
${animBadge} ${animBadge}
${clockBadge}
`; `;
} else if (isColorCycle) { } else if (isColorCycle) {
const colors = source.colors || []; const colors = source.colors || [];
@@ -612,8 +647,9 @@ export function createColorStripCard(source, pictureSourceMap, audioSourceMap) {
).join(''); ).join('');
propsHtml = ` propsHtml = `
<span class="stream-card-prop">${swatches}</span> <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>` : ''} ${source.led_count ? `<span class="stream-card-prop" title="${t('color_strip.leds')}">${ICON_LED} ${source.led_count}</span>` : ''}
${clockBadge}
`; `;
} else if (isGradient) { } else if (isGradient) {
const stops = source.stops || []; 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> <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>` : ''} ${source.led_count ? `<span class="stream-card-prop" title="${t('color_strip.leds')}">${ICON_LED} ${source.led_count}</span>` : ''}
${animBadge} ${animBadge}
${clockBadge}
`; `;
} else if (isEffect) { } else if (isEffect) {
const effectLabel = t('color_strip.effect.' + (source.effect_type || 'fire')) || source.effect_type || 'fire'; 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 = ` propsHtml = `
<span class="stream-card-prop">${ICON_FPS} ${escapeHtml(effectLabel)}</span> <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>` : ''} ${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>` : ''} ${source.led_count ? `<span class="stream-card-prop" title="${t('color_strip.leds')}">${ICON_LED} ${source.led_count}</span>` : ''}
${clockBadge}
`; `;
} else if (isComposite) { } else if (isComposite) {
const layerCount = (source.layers || []).length; const layerCount = (source.layers || []).length;
@@ -762,6 +800,12 @@ export async function showCSSEditor(cssId = null, cloneData = null) {
document.getElementById('css-editor-type').value = sourceType; document.getElementById('css-editor-type').value = sourceType;
onCSSTypeChange(); onCSSTypeChange();
// Set clock dropdown value (must be after onCSSTypeChange populates it)
if (css.clock_id) {
_populateClockDropdown(css.clock_id);
onCSSClockChange();
}
if (sourceType === 'static') { if (sourceType === 'static') {
document.getElementById('css-editor-color').value = rgbArrayToHex(css.color); document.getElementById('css-editor-color').value = rgbArrayToHex(css.color);
_loadAnimationState(css.animation); _loadAnimationState(css.animation);
@@ -1040,6 +1084,13 @@ export async function saveCSSEditor() {
if (!cssId) payload.source_type = 'picture'; 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 { try {
let response; let response;
if (cssId) { if (cssId) {

View File

@@ -21,6 +21,7 @@ import {
_lastValidatedImageSource, set_lastValidatedImageSource, _lastValidatedImageSource, set_lastValidatedImageSource,
_cachedAudioSources, _cachedAudioSources,
_cachedValueSources, _cachedValueSources,
_cachedSyncClocks,
_cachedAudioTemplates, _cachedAudioTemplates,
availableAudioEngines, setAvailableAudioEngines, availableAudioEngines, setAvailableAudioEngines,
currentEditingAudioTemplateId, setCurrentEditingAudioTemplateId, currentEditingAudioTemplateId, setCurrentEditingAudioTemplateId,
@@ -28,7 +29,7 @@ import {
_sourcesLoading, set_sourcesLoading, _sourcesLoading, set_sourcesLoading,
apiKey, apiKey,
streamsCache, ppTemplatesCache, captureTemplatesCache, streamsCache, ppTemplatesCache, captureTemplatesCache,
audioSourcesCache, audioTemplatesCache, valueSourcesCache, filtersCache, audioSourcesCache, audioTemplatesCache, valueSourcesCache, syncClocksCache, filtersCache,
} from '../core/state.js'; } from '../core/state.js';
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.js'; import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.js';
import { t } from '../core/i18n.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 { CardSection } from '../core/card-sections.js';
import { updateSubTabHash } from './tabs.js'; import { updateSubTabHash } from './tabs.js';
import { createValueSourceCard } from './value-sources.js'; import { createValueSourceCard } from './value-sources.js';
import { createSyncClockCard } from './sync-clocks.js';
import { import {
getEngineIcon, getPictureSourceIcon, getAudioSourceIcon, getEngineIcon, getPictureSourceIcon, getAudioSourceIcon,
ICON_TEMPLATE, ICON_CLONE, ICON_EDIT, ICON_TEST, ICON_LINK_SOURCE, 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_AUDIO_TEMPLATE, ICON_MONITOR, ICON_WRENCH, ICON_RADIO,
ICON_CAPTURE_TEMPLATE, ICON_PP_TEMPLATE, ICON_HELP, ICON_CAPTURE_TEMPLATE, ICON_PP_TEMPLATE, ICON_HELP,
} from '../core/icons.js'; } 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 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 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 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 // Re-render picture sources when language changes
document.addEventListener('languageChanged', () => { if (apiKey) loadPictureSources(); }); document.addEventListener('languageChanged', () => { if (apiKey) loadPictureSources(); });
@@ -1111,6 +1114,7 @@ export async function loadPictureSources() {
captureTemplatesCache.fetch(), captureTemplatesCache.fetch(),
audioSourcesCache.fetch(), audioSourcesCache.fetch(),
valueSourcesCache.fetch(), valueSourcesCache.fetch(),
syncClocksCache.fetch(),
audioTemplatesCache.fetch(), audioTemplatesCache.fetch(),
filtersCache.data.length === 0 ? filtersCache.fetch() : Promise.resolve(filtersCache.data), filtersCache.data.length === 0 ? filtersCache.fetch() : Promise.resolve(filtersCache.data),
]); ]);
@@ -1144,6 +1148,7 @@ const _streamSectionMap = {
processed: [csProcStreams, csProcTemplates], processed: [csProcStreams, csProcTemplates],
audio: [csAudioMulti, csAudioMono], audio: [csAudioMulti, csAudioMono],
value: [csValueSources], value: [csValueSources],
sync: [csSyncClocks],
}; };
export function expandAllStreamSections() { export function expandAllStreamSections() {
@@ -1292,6 +1297,7 @@ function renderPictureSourcesList(streams) {
{ key: 'processed', icon: getPictureSourceIcon('processed'), titleKey: 'streams.group.processed', count: processedStreams.length }, { 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: '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: '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 => 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 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 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 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()) { if (csRawStreams.isMounted()) {
// Incremental update: reconcile cards in-place // Incremental update: reconcile cards in-place
@@ -1405,6 +1412,7 @@ function renderPictureSourcesList(streams) {
csAudioTemplates.reconcile(audioTemplateItems); csAudioTemplates.reconcile(audioTemplateItems);
csStaticStreams.reconcile(staticItems); csStaticStreams.reconcile(staticItems);
csValueSources.reconcile(valueItems); csValueSources.reconcile(valueItems);
csSyncClocks.reconcile(syncClockItems);
} else { } else {
// First render: build full HTML // First render: build full HTML
const panels = tabs.map(tab => { 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 === '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 === 'audio') panelContent = csAudioMulti.render(multiItems) + csAudioMono.render(monoItems) + csAudioTemplates.render(audioTemplateItems);
else if (tab.key === 'value') panelContent = csValueSources.render(valueItems); else if (tab.key === 'value') panelContent = csValueSources.render(valueItems);
else if (tab.key === 'sync') panelContent = csSyncClocks.render(syncClockItems);
else panelContent = csStaticStreams.render(staticItems); else panelContent = csStaticStreams.render(staticItems);
return `<div class="stream-tab-panel${tab.key === activeTab ? ' active' : ''}" id="stream-tab-${tab.key}">${panelContent}</div>`; return `<div class="stream-tab-panel${tab.key === activeTab ? ' active' : ''}" id="stream-tab-${tab.key}">${panelContent}</div>`;
}).join(''); }).join('');
container.innerHTML = tabBar + panels; 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, kcWebSockets,
ledPreviewWebSockets, ledPreviewWebSockets,
_cachedValueSources, valueSourcesCache, _cachedValueSources, valueSourcesCache,
streamsCache, audioSourcesCache, syncClocksCache,
} from '../core/state.js'; } from '../core/state.js';
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, isOpenrgbDevice } from '../core/api.js'; import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, isOpenrgbDevice } from '../core/api.js';
import { t } from '../core/i18n.js'; import { t } from '../core/i18n.js';
@@ -475,15 +476,17 @@ export async function loadTargetsTab() {
if (!csDevices.isMounted()) setTabRefreshing('targets-panel-content', true); if (!csDevices.isMounted()) setTabRefreshing('targets-panel-content', true);
try { try {
// Fetch devices, targets, CSS sources, picture sources, pattern templates, and value sources in parallel // Fetch devices, targets, CSS sources, pattern templates in parallel;
const [devicesResp, targetsResp, cssResp, psResp, patResp, valueSrcArr, asResp] = await Promise.all([ // 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('/devices'),
fetchWithAuth('/picture-targets'), fetchWithAuth('/picture-targets'),
fetchWithAuth('/color-strip-sources').catch(() => null), fetchWithAuth('/color-strip-sources').catch(() => null),
fetchWithAuth('/picture-sources').catch(() => null),
fetchWithAuth('/pattern-templates').catch(() => null), fetchWithAuth('/pattern-templates').catch(() => null),
streamsCache.fetch().catch(() => []),
valueSourcesCache.fetch().catch(() => []), valueSourcesCache.fetch().catch(() => []),
fetchWithAuth('/audio-sources').catch(() => null), audioSourcesCache.fetch().catch(() => []),
syncClocksCache.fetch().catch(() => []),
]); ]);
const devicesData = await devicesResp.json(); const devicesData = await devicesResp.json();
@@ -499,10 +502,7 @@ export async function loadTargetsTab() {
} }
let pictureSourceMap = {}; let pictureSourceMap = {};
if (psResp && psResp.ok) { psArr.forEach(s => { pictureSourceMap[s.id] = s; });
const psData = await psResp.json();
(psData.streams || []).forEach(s => { pictureSourceMap[s.id] = s; });
}
let patternTemplates = []; let patternTemplates = [];
let patternTemplateMap = {}; let patternTemplateMap = {};
@@ -516,10 +516,7 @@ export async function loadTargetsTab() {
valueSrcArr.forEach(s => { valueSourceMap[s.id] = s; }); valueSrcArr.forEach(s => { valueSourceMap[s.id] = s; });
let audioSourceMap = {}; let audioSourceMap = {};
if (asResp && asResp.ok) { asSrcArr.forEach(s => { audioSourceMap[s.id] = s; });
const asData = await asResp.json();
(asData.sources || []).forEach(s => { audioSourceMap[s.id] = s; });
}
// Fetch all device states, target states, and target metrics in batch // Fetch all device states, target states, and target metrics in batch
const [batchDevStatesResp, batchTgtStatesResp, batchTgtMetricsResp] = await Promise.all([ const [batchDevStatesResp, batchTgtStatesResp, batchTgtMetricsResp] = await Promise.all([

View File

@@ -307,6 +307,7 @@
"common.delete": "Delete", "common.delete": "Delete",
"common.edit": "Edit", "common.edit": "Edit",
"common.clone": "Clone", "common.clone": "Clone",
"common.none": "None",
"section.filter.placeholder": "Filter...", "section.filter.placeholder": "Filter...",
"section.filter.reset": "Clear filter", "section.filter.reset": "Clear filter",
"section.expand_all": "Expand all sections", "section.expand_all": "Expand all sections",
@@ -950,6 +951,7 @@
"audio_template.error.required": "Please fill in all required fields", "audio_template.error.required": "Please fill in all required fields",
"audio_template.error.delete": "Failed to delete audio template", "audio_template.error.delete": "Failed to delete audio template",
"streams.group.value": "Value Sources", "streams.group.value": "Value Sources",
"streams.group.sync": "Sync Clocks",
"value_source.group.title": "Value Sources", "value_source.group.title": "Value Sources",
"value_source.add": "Add Value Source", "value_source.add": "Add Value Source",
"value_source.edit": "Edit Value Source", "value_source.edit": "Edit Value Source",
@@ -1137,5 +1139,32 @@
"theme.switched.dark": "Switched to dark theme", "theme.switched.dark": "Switched to dark theme",
"theme.switched.light": "Switched to light theme", "theme.switched.light": "Switched to light theme",
"accent.color.updated": "Accent color updated", "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.delete": "Удалить",
"common.edit": "Редактировать", "common.edit": "Редактировать",
"common.clone": "Клонировать", "common.clone": "Клонировать",
"common.none": "Нет",
"section.filter.placeholder": "Фильтр...", "section.filter.placeholder": "Фильтр...",
"section.filter.reset": "Очистить фильтр", "section.filter.reset": "Очистить фильтр",
"section.expand_all": "Развернуть все секции", "section.expand_all": "Развернуть все секции",
@@ -950,6 +951,7 @@
"audio_template.error.required": "Пожалуйста, заполните все обязательные поля", "audio_template.error.required": "Пожалуйста, заполните все обязательные поля",
"audio_template.error.delete": "Не удалось удалить аудиошаблон", "audio_template.error.delete": "Не удалось удалить аудиошаблон",
"streams.group.value": "Источники значений", "streams.group.value": "Источники значений",
"streams.group.sync": "Часы синхронизации",
"value_source.group.title": "Источники значений", "value_source.group.title": "Источники значений",
"value_source.add": "Добавить источник значений", "value_source.add": "Добавить источник значений",
"value_source.edit": "Редактировать источник значений", "value_source.edit": "Редактировать источник значений",
@@ -1137,5 +1139,32 @@
"theme.switched.dark": "Переключено на тёмную тему", "theme.switched.dark": "Переключено на тёмную тему",
"theme.switched.light": "Переключено на светлую тему", "theme.switched.light": "Переключено на светлую тему",
"accent.color.updated": "Цвет акцента обновлён", "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.delete": "删除",
"common.edit": "编辑", "common.edit": "编辑",
"common.clone": "克隆", "common.clone": "克隆",
"common.none": "无",
"section.filter.placeholder": "筛选...", "section.filter.placeholder": "筛选...",
"section.filter.reset": "清除筛选", "section.filter.reset": "清除筛选",
"section.expand_all": "全部展开", "section.expand_all": "全部展开",
@@ -950,6 +951,7 @@
"audio_template.error.required": "请填写所有必填项", "audio_template.error.required": "请填写所有必填项",
"audio_template.error.delete": "删除音频模板失败", "audio_template.error.delete": "删除音频模板失败",
"streams.group.value": "值源", "streams.group.value": "值源",
"streams.group.sync": "同步时钟",
"value_source.group.title": "值源", "value_source.group.title": "值源",
"value_source.add": "添加值源", "value_source.add": "添加值源",
"value_source.edit": "编辑值源", "value_source.edit": "编辑值源",
@@ -1137,5 +1139,32 @@
"theme.switched.dark": "已切换到深色主题", "theme.switched.dark": "已切换到深色主题",
"theme.switched.light": "已切换到浅色主题", "theme.switched.light": "已切换到浅色主题",
"accent.color.updated": "强调色已更新", "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 * - Navigation: network-first with offline fallback
*/ */
const CACHE_NAME = 'ledgrab-v6'; const CACHE_NAME = 'ledgrab-v7';
// Only pre-cache static assets (no auth required). // Only pre-cache static assets (no auth required).
// Do NOT pre-cache '/' — it requires API key auth and would cache an error page. // 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 created_at: datetime
updated_at: datetime updated_at: datetime
description: Optional[str] = None description: Optional[str] = None
clock_id: Optional[str] = None # optional SyncClock reference
@property @property
def sharable(self) -> bool: def sharable(self) -> bool:
@@ -53,6 +54,7 @@ class ColorStripSource:
"created_at": self.created_at.isoformat(), "created_at": self.created_at.isoformat(),
"updated_at": self.updated_at.isoformat(), "updated_at": self.updated_at.isoformat(),
"description": self.description, "description": self.description,
"clock_id": self.clock_id,
# Subclass fields default to None for forward compat # Subclass fields default to None for forward compat
"picture_source_id": None, "picture_source_id": None,
"fps": None, "fps": None,
@@ -91,6 +93,8 @@ class ColorStripSource:
name: str = data["name"] name: str = data["name"]
description: str | None = data.get("description") description: str | None = data.get("description")
clock_id: str | None = data.get("clock_id")
raw_created = data.get("created_at") raw_created = data.get("created_at")
created_at: datetime = ( created_at: datetime = (
datetime.fromisoformat(raw_created) datetime.fromisoformat(raw_created)
@@ -122,7 +126,7 @@ class ColorStripSource:
return StaticColorStripSource( return StaticColorStripSource(
id=sid, name=name, source_type="static", id=sid, name=name, source_type="static",
created_at=created_at, updated_at=updated_at, description=description, 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, led_count=data.get("led_count") or 0,
animation=data.get("animation"), animation=data.get("animation"),
) )
@@ -133,7 +137,7 @@ class ColorStripSource:
return GradientColorStripSource( return GradientColorStripSource(
id=sid, name=name, source_type="gradient", id=sid, name=name, source_type="gradient",
created_at=created_at, updated_at=updated_at, description=description, 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, led_count=data.get("led_count") or 0,
animation=data.get("animation"), animation=data.get("animation"),
) )
@@ -144,7 +148,7 @@ class ColorStripSource:
return ColorCycleColorStripSource( return ColorCycleColorStripSource(
id=sid, name=name, source_type="color_cycle", id=sid, name=name, source_type="color_cycle",
created_at=created_at, updated_at=updated_at, description=description, 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), cycle_speed=float(data.get("cycle_speed") or 1.0),
led_count=data.get("led_count") or 0, led_count=data.get("led_count") or 0,
) )
@@ -153,7 +157,7 @@ class ColorStripSource:
return CompositeColorStripSource( return CompositeColorStripSource(
id=sid, name=name, source_type="composite", id=sid, name=name, source_type="composite",
created_at=created_at, updated_at=updated_at, description=description, 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, led_count=data.get("led_count") or 0,
) )
@@ -161,7 +165,7 @@ class ColorStripSource:
return MappedColorStripSource( return MappedColorStripSource(
id=sid, name=name, source_type="mapped", id=sid, name=name, source_type="mapped",
created_at=created_at, updated_at=updated_at, description=description, 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, led_count=data.get("led_count") or 0,
) )
@@ -173,7 +177,7 @@ class ColorStripSource:
return AudioColorStripSource( return AudioColorStripSource(
id=sid, name=name, source_type="audio", id=sid, name=name, source_type="audio",
created_at=created_at, updated_at=updated_at, description=description, 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 "", audio_source_id=data.get("audio_source_id") or "",
sensitivity=float(data.get("sensitivity") or 1.0), sensitivity=float(data.get("sensitivity") or 1.0),
smoothing=float(data.get("smoothing") or 0.3), smoothing=float(data.get("smoothing") or 0.3),
@@ -193,7 +197,7 @@ class ColorStripSource:
return EffectColorStripSource( return EffectColorStripSource(
id=sid, name=name, source_type="effect", id=sid, name=name, source_type="effect",
created_at=created_at, updated_at=updated_at, description=description, 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), speed=float(data.get("speed") or 1.0),
led_count=data.get("led_count") or 0, led_count=data.get("led_count") or 0,
palette=data.get("palette") or "fire", palette=data.get("palette") or "fire",
@@ -212,7 +216,7 @@ class ColorStripSource:
return ApiInputColorStripSource( return ApiInputColorStripSource(
id=sid, name=name, source_type="api_input", id=sid, name=name, source_type="api_input",
created_at=created_at, updated_at=updated_at, description=description, 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, fallback_color=fallback_color,
timeout=float(data.get("timeout") or 5.0), timeout=float(data.get("timeout") or 5.0),
) )
@@ -221,7 +225,7 @@ class ColorStripSource:
return PictureColorStripSource( return PictureColorStripSource(
id=sid, name=name, source_type=source_type, id=sid, name=name, source_type=source_type,
created_at=created_at, updated_at=updated_at, description=description, 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, fps=data.get("fps") or 30,
brightness=data["brightness"] if data.get("brightness") is not None else 1.0, 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, 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, color_peak: Optional[list] = None,
fallback_color: Optional[list] = None, fallback_color: Optional[list] = None,
timeout: Optional[float] = None, timeout: Optional[float] = None,
clock_id: Optional[str] = None,
) -> ColorStripSource: ) -> ColorStripSource:
"""Create a new color strip source. """Create a new color strip source.
@@ -146,6 +147,7 @@ class ColorStripStore:
created_at=now, created_at=now,
updated_at=now, updated_at=now,
description=description, description=description,
clock_id=clock_id,
color=rgb, color=rgb,
led_count=led_count, led_count=led_count,
animation=animation, animation=animation,
@@ -158,6 +160,7 @@ class ColorStripStore:
created_at=now, created_at=now,
updated_at=now, updated_at=now,
description=description, description=description,
clock_id=clock_id,
stops=stops if isinstance(stops, list) else [ stops=stops if isinstance(stops, list) else [
{"position": 0.0, "color": [255, 0, 0]}, {"position": 0.0, "color": [255, 0, 0]},
{"position": 1.0, "color": [0, 0, 255]}, {"position": 1.0, "color": [0, 0, 255]},
@@ -177,6 +180,7 @@ class ColorStripStore:
created_at=now, created_at=now,
updated_at=now, updated_at=now,
description=description, description=description,
clock_id=clock_id,
colors=colors if isinstance(colors, list) and len(colors) >= 2 else default_colors, colors=colors if isinstance(colors, list) and len(colors) >= 2 else default_colors,
cycle_speed=float(cycle_speed) if cycle_speed else 1.0, cycle_speed=float(cycle_speed) if cycle_speed else 1.0,
led_count=led_count, led_count=led_count,
@@ -190,6 +194,7 @@ class ColorStripStore:
created_at=now, created_at=now,
updated_at=now, updated_at=now,
description=description, description=description,
clock_id=clock_id,
effect_type=effect_type or "fire", effect_type=effect_type or "fire",
speed=float(speed) if speed else 1.0, speed=float(speed) if speed else 1.0,
led_count=led_count, led_count=led_count,
@@ -209,6 +214,7 @@ class ColorStripStore:
created_at=now, created_at=now,
updated_at=now, updated_at=now,
description=description, description=description,
clock_id=clock_id,
visualization_mode=visualization_mode or "spectrum", visualization_mode=visualization_mode or "spectrum",
audio_source_id=audio_source_id or "", audio_source_id=audio_source_id or "",
sensitivity=float(sensitivity) if sensitivity else 1.0, sensitivity=float(sensitivity) if sensitivity else 1.0,
@@ -227,6 +233,7 @@ class ColorStripStore:
created_at=now, created_at=now,
updated_at=now, updated_at=now,
description=description, description=description,
clock_id=clock_id,
layers=layers if isinstance(layers, list) else [], layers=layers if isinstance(layers, list) else [],
led_count=led_count, led_count=led_count,
) )
@@ -238,6 +245,7 @@ class ColorStripStore:
created_at=now, created_at=now,
updated_at=now, updated_at=now,
description=description, description=description,
clock_id=clock_id,
zones=zones if isinstance(zones, list) else [], zones=zones if isinstance(zones, list) else [],
led_count=led_count, led_count=led_count,
) )
@@ -250,6 +258,7 @@ class ColorStripStore:
created_at=now, created_at=now,
updated_at=now, updated_at=now,
description=description, description=description,
clock_id=clock_id,
led_count=led_count, led_count=led_count,
fallback_color=fb, fallback_color=fb,
timeout=float(timeout) if timeout is not None else 5.0, timeout=float(timeout) if timeout is not None else 5.0,
@@ -264,6 +273,7 @@ class ColorStripStore:
created_at=now, created_at=now,
updated_at=now, updated_at=now,
description=description, description=description,
clock_id=clock_id,
picture_source_id=picture_source_id, picture_source_id=picture_source_id,
fps=fps, fps=fps,
brightness=brightness, brightness=brightness,
@@ -316,6 +326,7 @@ class ColorStripStore:
color_peak: Optional[list] = None, color_peak: Optional[list] = None,
fallback_color: Optional[list] = None, fallback_color: Optional[list] = None,
timeout: Optional[float] = None, timeout: Optional[float] = None,
clock_id: Optional[str] = None,
) -> ColorStripSource: ) -> ColorStripSource:
"""Update an existing color strip source. """Update an existing color strip source.
@@ -336,6 +347,9 @@ class ColorStripStore:
if description is not None: if description is not None:
source.description = description source.description = description
if clock_id is not None:
source.clock_id = clock_id if clock_id else None
if isinstance(source, PictureColorStripSource): if isinstance(source, PictureColorStripSource):
if picture_source_id is not None: if picture_source_id is not None:
source.picture_source_id = picture_source_id 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/test-audio-template.html' %}
{% include 'modals/value-source-editor.html' %} {% include 'modals/value-source-editor.html' %}
{% include 'modals/test-value-source.html' %} {% include 'modals/test-value-source.html' %}
{% include 'modals/sync-clock-editor.html' %}
{% include 'modals/settings.html' %} {% include 'modals/settings.html' %}
{% include 'partials/tutorial-overlay.html' %} {% include 'partials/tutorial-overlay.html' %}

View File

@@ -146,7 +146,7 @@
<div id="color-cycle-colors-list"></div> <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> <button type="button" class="btn btn-secondary" onclick="colorCycleAddColor()" data-i18n="color_strip.color_cycle.add_color">+ Add Color</button>
</div> </div>
<div class="form-group"> <div id="css-editor-cycle-speed-group" class="form-group">
<div class="label-row"> <div class="label-row">
<label for="css-editor-cycle-speed"> <label for="css-editor-cycle-speed">
<span data-i18n="color_strip.color_cycle.speed">Speed:</span> <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 id="css-editor-effect-preview" class="effect-palette-preview"></div>
</div> </div>
<div class="form-group"> <div id="css-editor-effect-speed-group" class="form-group">
<div class="label-row"> <div class="label-row">
<label for="css-editor-effect-speed"> <label for="css-editor-effect-speed">
<span data-i18n="color_strip.effect.speed">Speed:</span> <span data-i18n="color_strip.effect.speed">Speed:</span>
@@ -492,7 +492,7 @@
</select> </select>
<small id="css-editor-animation-type-desc" class="field-desc"></small> <small id="css-editor-animation-type-desc" class="field-desc"></small>
</div> </div>
<div class="form-group"> <div id="css-editor-animation-speed-group" class="form-group">
<div class="label-row"> <div class="label-row">
<label for="css-editor-animation-speed"> <label for="css-editor-animation-speed">
<span data-i18n="color_strip.animation.speed">Speed:</span> <span data-i18n="color_strip.animation.speed">Speed:</span>
@@ -508,6 +508,18 @@
</details> </details>
</div> </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> <div id="css-editor-error" class="error-message" style="display: none;"></div>
</form> </form>
</div> </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>