From aa1e4a6afc769369dd96c026bf47759278b67792 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Sun, 1 Mar 2026 21:46:55 +0300 Subject: [PATCH] Add sync clock entity for synchronized animation timing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- server/src/wled_controller/api/__init__.py | 2 + .../src/wled_controller/api/dependencies.py | 23 ++ .../api/routes/color_strip_sources.py | 3 + .../wled_controller/api/routes/sync_clocks.py | 184 +++++++++++++++ .../src/wled_controller/api/routes/system.py | 1 + .../api/schemas/color_strip_sources.py | 6 + .../api/schemas/sync_clocks.py | 42 ++++ server/src/wled_controller/config.py | 1 + .../core/processing/color_strip_stream.py | 64 ++++- .../processing/color_strip_stream_manager.py | 52 ++++- .../core/processing/effect_stream.py | 35 ++- .../core/processing/processor_manager.py | 4 +- .../core/processing/sync_clock_manager.py | 99 ++++++++ .../core/processing/sync_clock_runtime.py | 72 ++++++ server/src/wled_controller/main.py | 7 + server/src/wled_controller/static/js/app.js | 3 +- .../wled_controller/static/js/core/state.js | 9 + .../static/js/features/color-strips.js | 59 ++++- .../static/js/features/streams.js | 15 +- .../static/js/features/sync-clocks.js | 221 ++++++++++++++++++ .../static/js/features/targets.js | 21 +- .../wled_controller/static/locales/en.json | 31 ++- .../wled_controller/static/locales/ru.json | 31 ++- .../wled_controller/static/locales/zh.json | 31 ++- server/src/wled_controller/static/sw.js | 2 +- .../storage/color_strip_source.py | 22 +- .../storage/color_strip_store.py | 14 ++ .../src/wled_controller/storage/sync_clock.py | 43 ++++ .../storage/sync_clock_store.py | 146 ++++++++++++ .../src/wled_controller/templates/index.html | 1 + .../templates/modals/css-editor.html | 18 +- .../templates/modals/sync-clock-editor.html | 51 ++++ 32 files changed, 1255 insertions(+), 58 deletions(-) create mode 100644 server/src/wled_controller/api/routes/sync_clocks.py create mode 100644 server/src/wled_controller/api/schemas/sync_clocks.py create mode 100644 server/src/wled_controller/core/processing/sync_clock_manager.py create mode 100644 server/src/wled_controller/core/processing/sync_clock_runtime.py create mode 100644 server/src/wled_controller/static/js/features/sync-clocks.js create mode 100644 server/src/wled_controller/storage/sync_clock.py create mode 100644 server/src/wled_controller/storage/sync_clock_store.py create mode 100644 server/src/wled_controller/templates/modals/sync-clock-editor.html diff --git a/server/src/wled_controller/api/__init__.py b/server/src/wled_controller/api/__init__.py index 97d01e8..f3e8c0c 100644 --- a/server/src/wled_controller/api/__init__.py +++ b/server/src/wled_controller/api/__init__.py @@ -17,6 +17,7 @@ from .routes.value_sources import router as value_sources_router from .routes.automations import router as automations_router from .routes.scene_presets import router as scene_presets_router from .routes.webhooks import router as webhooks_router +from .routes.sync_clocks import router as sync_clocks_router router = APIRouter() router.include_router(system_router) @@ -34,5 +35,6 @@ router.include_router(picture_targets_router) router.include_router(automations_router) router.include_router(scene_presets_router) router.include_router(webhooks_router) +router.include_router(sync_clocks_router) __all__ = ["router"] diff --git a/server/src/wled_controller/api/dependencies.py b/server/src/wled_controller/api/dependencies.py index 02c84f1..380e4b9 100644 --- a/server/src/wled_controller/api/dependencies.py +++ b/server/src/wled_controller/api/dependencies.py @@ -13,8 +13,10 @@ from wled_controller.storage.audio_template_store import AudioTemplateStore from wled_controller.storage.value_source_store import ValueSourceStore from wled_controller.storage.automation_store import AutomationStore from wled_controller.storage.scene_preset_store import ScenePresetStore +from wled_controller.storage.sync_clock_store import SyncClockStore from wled_controller.core.automations.automation_engine import AutomationEngine from wled_controller.core.backup.auto_backup import AutoBackupEngine +from wled_controller.core.processing.sync_clock_manager import SyncClockManager # Global instances (initialized in main.py) _auto_backup_engine: AutoBackupEngine | None = None @@ -32,6 +34,8 @@ _processor_manager: ProcessorManager | None = None _automation_store: AutomationStore | None = None _scene_preset_store: ScenePresetStore | None = None _automation_engine: AutomationEngine | None = None +_sync_clock_store: SyncClockStore | None = None +_sync_clock_manager: SyncClockManager | None = None def get_device_store() -> DeviceStore: @@ -139,6 +143,20 @@ def get_auto_backup_engine() -> AutoBackupEngine: return _auto_backup_engine +def get_sync_clock_store() -> SyncClockStore: + """Get sync clock store dependency.""" + if _sync_clock_store is None: + raise RuntimeError("Sync clock store not initialized") + return _sync_clock_store + + +def get_sync_clock_manager() -> SyncClockManager: + """Get sync clock manager dependency.""" + if _sync_clock_manager is None: + raise RuntimeError("Sync clock manager not initialized") + return _sync_clock_manager + + def init_dependencies( device_store: DeviceStore, template_store: TemplateStore, @@ -155,12 +173,15 @@ def init_dependencies( scene_preset_store: ScenePresetStore | None = None, automation_engine: AutomationEngine | None = None, auto_backup_engine: AutoBackupEngine | None = None, + sync_clock_store: SyncClockStore | None = None, + sync_clock_manager: SyncClockManager | None = None, ): """Initialize global dependencies.""" global _device_store, _template_store, _processor_manager global _pp_template_store, _pattern_template_store, _picture_source_store, _picture_target_store global _color_strip_store, _audio_source_store, _audio_template_store global _value_source_store, _automation_store, _scene_preset_store, _automation_engine, _auto_backup_engine + global _sync_clock_store, _sync_clock_manager _device_store = device_store _template_store = template_store _processor_manager = processor_manager @@ -176,3 +197,5 @@ def init_dependencies( _scene_preset_store = scene_preset_store _automation_engine = automation_engine _auto_backup_engine = auto_backup_engine + _sync_clock_store = sync_clock_store + _sync_clock_manager = sync_clock_manager diff --git a/server/src/wled_controller/api/routes/color_strip_sources.py b/server/src/wled_controller/api/routes/color_strip_sources.py index 15964a9..69c7b05 100644 --- a/server/src/wled_controller/api/routes/color_strip_sources.py +++ b/server/src/wled_controller/api/routes/color_strip_sources.py @@ -82,6 +82,7 @@ def _css_to_response(source, overlay_active: bool = False) -> ColorStripSourceRe scale=getattr(source, "scale", None), mirror=getattr(source, "mirror", None), description=source.description, + clock_id=source.clock_id, frame_interpolation=getattr(source, "frame_interpolation", None), animation=getattr(source, "animation", None), layers=getattr(source, "layers", None), @@ -177,6 +178,7 @@ async def create_color_strip_source( color_peak=data.color_peak, fallback_color=data.fallback_color, timeout=data.timeout, + clock_id=data.clock_id, ) return _css_to_response(source) @@ -254,6 +256,7 @@ async def update_color_strip_source( color_peak=data.color_peak, fallback_color=data.fallback_color, timeout=data.timeout, + clock_id=data.clock_id, ) # Hot-reload running stream (no restart needed for in-place param changes) diff --git a/server/src/wled_controller/api/routes/sync_clocks.py b/server/src/wled_controller/api/routes/sync_clocks.py new file mode 100644 index 0000000..010f567 --- /dev/null +++ b/server/src/wled_controller/api/routes/sync_clocks.py @@ -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) diff --git a/server/src/wled_controller/api/routes/system.py b/server/src/wled_controller/api/routes/system.py index f8e4def..67b511f 100644 --- a/server/src/wled_controller/api/routes/system.py +++ b/server/src/wled_controller/api/routes/system.py @@ -271,6 +271,7 @@ STORE_MAP = { "audio_sources": "audio_sources_file", "audio_templates": "audio_templates_file", "value_sources": "value_sources_file", + "sync_clocks": "sync_clocks_file", "automations": "automations_file", "scene_presets": "scene_presets_file", } diff --git a/server/src/wled_controller/api/schemas/color_strip_sources.py b/server/src/wled_controller/api/schemas/color_strip_sources.py index 6d321f5..b14af7b 100644 --- a/server/src/wled_controller/api/schemas/color_strip_sources.py +++ b/server/src/wled_controller/api/schemas/color_strip_sources.py @@ -89,6 +89,8 @@ class ColorStripSourceCreate(BaseModel): # api_input-type fields fallback_color: Optional[List[int]] = Field(None, description="Fallback RGB color [R,G,B] when no data received (api_input type)") timeout: Optional[float] = Field(None, description="Seconds before reverting to fallback (api_input type)", ge=0.0, le=300.0) + # sync clock + clock_id: Optional[str] = Field(None, description="Optional sync clock ID for synchronized animation") class ColorStripSourceUpdate(BaseModel): @@ -134,6 +136,8 @@ class ColorStripSourceUpdate(BaseModel): # api_input-type fields fallback_color: Optional[List[int]] = Field(None, description="Fallback RGB color [R,G,B] (api_input type)") timeout: Optional[float] = Field(None, description="Timeout before fallback (api_input type)", ge=0.0, le=300.0) + # sync clock + clock_id: Optional[str] = Field(None, description="Optional sync clock ID for synchronized animation") class ColorStripSourceResponse(BaseModel): @@ -181,6 +185,8 @@ class ColorStripSourceResponse(BaseModel): # api_input-type fields fallback_color: Optional[List[int]] = Field(None, description="Fallback RGB color [R,G,B] (api_input type)") timeout: Optional[float] = Field(None, description="Timeout before fallback (api_input type)") + # sync clock + clock_id: Optional[str] = Field(None, description="Optional sync clock ID for synchronized animation") overlay_active: bool = Field(False, description="Whether the screen overlay is currently active") created_at: datetime = Field(description="Creation timestamp") updated_at: datetime = Field(description="Last update timestamp") diff --git a/server/src/wled_controller/api/schemas/sync_clocks.py b/server/src/wled_controller/api/schemas/sync_clocks.py new file mode 100644 index 0000000..40dc26b --- /dev/null +++ b/server/src/wled_controller/api/schemas/sync_clocks.py @@ -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.1–10.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.1–10.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") diff --git a/server/src/wled_controller/config.py b/server/src/wled_controller/config.py index c67bd7a..8afd968 100644 --- a/server/src/wled_controller/config.py +++ b/server/src/wled_controller/config.py @@ -39,6 +39,7 @@ class StorageConfig(BaseSettings): value_sources_file: str = "data/value_sources.json" automations_file: str = "data/automations.json" scene_presets_file: str = "data/scene_presets.json" + sync_clocks_file: str = "data/sync_clocks.json" class MQTTConfig(BaseSettings): diff --git a/server/src/wled_controller/core/processing/color_strip_stream.py b/server/src/wled_controller/core/processing/color_strip_stream.py index 7bca596..e0effbe 100644 --- a/server/src/wled_controller/core/processing/color_strip_stream.py +++ b/server/src/wled_controller/core/processing/color_strip_stream.py @@ -567,6 +567,7 @@ class StaticColorStripStream(ColorStripStream): self._running = False self._thread: Optional[threading.Thread] = None self._fps = 30 + self._clock = None # optional SyncClockRuntime self._update_from_source(source) def _update_from_source(self, source) -> None: @@ -651,6 +652,10 @@ class StaticColorStripStream(ColorStripStream): self._rebuild_colors() logger.info("StaticColorStripStream params updated in-place") + def set_clock(self, clock) -> None: + """Set or clear the sync clock runtime. Thread-safe (read atomically by loop).""" + self._clock = clock + def _animate_loop(self) -> None: """Background thread: compute animated colors at target fps when animation is active. @@ -666,14 +671,22 @@ class StaticColorStripStream(ColorStripStream): try: with high_resolution_timer(): while self._running: - loop_start = time.perf_counter() + wall_start = time.perf_counter() frame_time = 1.0 / self._fps try: anim = self._animation if anim and anim.get("enabled"): - speed = float(anim.get("speed", 1.0)) + clock = self._clock + if clock: + if not clock.is_running: + time.sleep(0.1) + continue + speed = clock.speed + t = clock.get_time() + else: + speed = float(anim.get("speed", 1.0)) + t = wall_start atype = anim.get("type", "breathing") - t = loop_start n = self._led_count if n != _pool_n: @@ -748,7 +761,7 @@ class StaticColorStripStream(ColorStripStream): except Exception as e: logger.error(f"StaticColorStripStream animation error: {e}") - elapsed = time.perf_counter() - loop_start + elapsed = time.perf_counter() - wall_start sleep_target = frame_time if anim and anim.get("enabled") else 0.25 time.sleep(max(sleep_target - elapsed, 0.001)) except Exception as e: @@ -773,6 +786,7 @@ class ColorCycleColorStripStream(ColorStripStream): self._running = False self._thread: Optional[threading.Thread] = None self._fps = 30 + self._clock = None # optional SyncClockRuntime self._update_from_source(source) def _update_from_source(self, source) -> None: @@ -850,6 +864,10 @@ class ColorCycleColorStripStream(ColorStripStream): self._rebuild_colors() logger.info("ColorCycleColorStripStream params updated in-place") + def set_clock(self, clock) -> None: + """Set or clear the sync clock runtime. Thread-safe (read atomically by loop).""" + self._clock = clock + def _animate_loop(self) -> None: """Background thread: interpolate between colors at target fps. @@ -862,11 +880,20 @@ class ColorCycleColorStripStream(ColorStripStream): try: with high_resolution_timer(): while self._running: - loop_start = time.perf_counter() + wall_start = time.perf_counter() frame_time = 1.0 / self._fps try: color_list = self._color_list - speed = self._cycle_speed + clock = self._clock + if clock: + if not clock.is_running: + time.sleep(0.1) + continue + speed = clock.speed + t = clock.get_time() + else: + speed = self._cycle_speed + t = wall_start n = self._led_count num = len(color_list) if num >= 2: @@ -879,7 +906,7 @@ class ColorCycleColorStripStream(ColorStripStream): _use_a = not _use_a # 0.05 factor → one full cycle every 20s at speed=1.0 - cycle_pos = (speed * loop_start * 0.05) % 1.0 + cycle_pos = (speed * t * 0.05) % 1.0 seg = cycle_pos * num idx = int(seg) % num t_i = seg - int(seg) @@ -894,7 +921,7 @@ class ColorCycleColorStripStream(ColorStripStream): self._colors = buf except Exception as e: logger.error(f"ColorCycleColorStripStream animation error: {e}") - elapsed = time.perf_counter() - loop_start + elapsed = time.perf_counter() - wall_start time.sleep(max(frame_time - elapsed, 0.001)) except Exception as e: logger.error(f"Fatal ColorCycleColorStripStream loop error: {e}", exc_info=True) @@ -919,6 +946,7 @@ class GradientColorStripStream(ColorStripStream): self._running = False self._thread: Optional[threading.Thread] = None self._fps = 30 + self._clock = None # optional SyncClockRuntime self._update_from_source(source) def _update_from_source(self, source) -> None: @@ -1002,6 +1030,10 @@ class GradientColorStripStream(ColorStripStream): self._rebuild_colors() logger.info("GradientColorStripStream params updated in-place") + def set_clock(self, clock) -> None: + """Set or clear the sync clock runtime. Thread-safe (read atomically by loop).""" + self._clock = clock + def _animate_loop(self) -> None: """Background thread: apply animation effects at target fps when animation is active. @@ -1022,14 +1054,22 @@ class GradientColorStripStream(ColorStripStream): try: with high_resolution_timer(): while self._running: - loop_start = time.perf_counter() + wall_start = time.perf_counter() frame_time = 1.0 / self._fps try: anim = self._animation if anim and anim.get("enabled"): - speed = float(anim.get("speed", 1.0)) + clock = self._clock + if clock: + if not clock.is_running: + time.sleep(0.1) + continue + speed = clock.speed + t = clock.get_time() + else: + speed = float(anim.get("speed", 1.0)) + t = wall_start atype = anim.get("type", "breathing") - t = loop_start n = self._led_count stops = self._stops colors = None @@ -1147,7 +1187,7 @@ class GradientColorStripStream(ColorStripStream): except Exception as e: logger.error(f"GradientColorStripStream animation error: {e}") - elapsed = time.perf_counter() - loop_start + elapsed = time.perf_counter() - wall_start sleep_target = frame_time if anim and anim.get("enabled") else 0.25 time.sleep(max(sleep_target - elapsed, 0.001)) except Exception as e: diff --git a/server/src/wled_controller/core/processing/color_strip_stream_manager.py b/server/src/wled_controller/core/processing/color_strip_stream_manager.py index 0738a2a..95127b2 100644 --- a/server/src/wled_controller/core/processing/color_strip_stream_manager.py +++ b/server/src/wled_controller/core/processing/color_strip_stream_manager.py @@ -58,21 +58,46 @@ class ColorStripStreamManager: keyed by ``{css_id}:{consumer_id}``. """ - def __init__(self, color_strip_store, live_stream_manager, audio_capture_manager=None, audio_source_store=None, audio_template_store=None): + def __init__(self, color_strip_store, live_stream_manager, audio_capture_manager=None, audio_source_store=None, audio_template_store=None, sync_clock_manager=None): """ Args: color_strip_store: ColorStripStore for resolving source configs live_stream_manager: LiveStreamManager for acquiring picture streams audio_capture_manager: AudioCaptureManager for audio-reactive sources audio_source_store: AudioSourceStore for resolving audio source chains + sync_clock_manager: SyncClockManager for acquiring clock runtimes """ self._color_strip_store = color_strip_store self._live_stream_manager = live_stream_manager self._audio_capture_manager = audio_capture_manager self._audio_source_store = audio_source_store self._audio_template_store = audio_template_store + self._sync_clock_manager = sync_clock_manager self._streams: Dict[str, _ColorStripEntry] = {} + def _inject_clock(self, css_stream, source) -> None: + """Inject a SyncClockRuntime into the stream if source has clock_id.""" + clock_id = getattr(source, "clock_id", None) + if clock_id and self._sync_clock_manager and hasattr(css_stream, "set_clock"): + try: + clock_rt = self._sync_clock_manager.acquire(clock_id) + css_stream.set_clock(clock_rt) + logger.debug(f"Injected clock {clock_id} into stream for {source.id}") + except Exception as e: + logger.warning(f"Could not inject clock {clock_id}: {e}") + + def _release_clock(self, source_id: str, stream) -> None: + """Release the clock runtime acquired for a stream.""" + if not self._sync_clock_manager: + return + try: + source = self._color_strip_store.get_source(source_id) + clock_id = getattr(source, "clock_id", None) + if clock_id: + self._sync_clock_manager.release(clock_id) + except Exception: + pass # source may have been deleted already + def _resolve_key(self, css_id: str, consumer_id: str) -> str: """Resolve internal registry key for a (css_id, consumer_id) pair. @@ -123,6 +148,8 @@ class ColorStripStreamManager: f"Unsupported color strip source type '{source.source_type}' for {css_id}" ) css_stream = stream_cls(source) + # Inject sync clock runtime if source references a clock + self._inject_clock(css_stream, source) css_stream.start() key = f"{css_id}:{consumer_id}" if consumer_id else css_id self._streams[key] = _ColorStripEntry( @@ -196,6 +223,10 @@ class ColorStripStreamManager: except Exception as e: logger.error(f"Error stopping color strip stream {key}: {e}") + # Release clock runtime if acquired + source_id = key.split(":")[0] if ":" in key else key + self._release_clock(source_id, entry.stream) + picture_source_id = entry.picture_source_id del self._streams[key] logger.info(f"Removed color strip stream {key}") @@ -227,6 +258,25 @@ class ColorStripStreamManager: for key in matching_keys: entry = self._streams[key] entry.stream.update_source(new_source) + # Hot-swap clock if clock_id changed + if hasattr(entry.stream, "set_clock") and self._sync_clock_manager: + new_clock_id = getattr(new_source, "clock_id", None) + old_clock = getattr(entry.stream, "_clock", None) + if new_clock_id: + try: + clock_rt = self._sync_clock_manager.acquire(new_clock_id) + entry.stream.set_clock(clock_rt) + # Release old clock if different + if old_clock: + # Find the old clock_id (best-effort) + source_id = key.split(":")[0] if ":" in key else key + self._release_clock(source_id, entry.stream) + except Exception as e: + logger.warning(f"Could not hot-swap clock {new_clock_id}: {e}") + elif old_clock: + entry.stream.set_clock(None) + source_id = key.split(":")[0] if ":" in key else key + self._release_clock(source_id, entry.stream) # Track picture_source_id change for future reference counting from wled_controller.storage.color_strip_source import PictureColorStripSource diff --git a/server/src/wled_controller/core/processing/effect_stream.py b/server/src/wled_controller/core/processing/effect_stream.py index 0d454b7..60afe09 100644 --- a/server/src/wled_controller/core/processing/effect_stream.py +++ b/server/src/wled_controller/core/processing/effect_stream.py @@ -182,6 +182,8 @@ class EffectColorStripStream(ColorStripStream): self._running = False self._thread: Optional[threading.Thread] = None self._fps = 30 + self._clock = None # optional SyncClockRuntime + self._effective_speed = 1.0 # resolved speed (from clock or source) self._noise = _ValueNoise1D(seed=42) # Fire state — allocated lazily in render loop self._heat: Optional[np.ndarray] = None @@ -268,6 +270,10 @@ class EffectColorStripStream(ColorStripStream): self._led_count = prev_led_count logger.info("EffectColorStripStream params updated in-place") + def set_clock(self, clock) -> None: + """Set or clear the sync clock runtime. Thread-safe (read atomically by loop).""" + self._clock = clock + # ── Main animation loop ────────────────────────────────────────── def _animate_loop(self) -> None: @@ -287,9 +293,22 @@ class EffectColorStripStream(ColorStripStream): try: with high_resolution_timer(): while self._running: - loop_start = time.perf_counter() + wall_start = time.perf_counter() frame_time = 1.0 / self._fps try: + # Resolve animation time and speed from clock or local + clock = self._clock + if clock: + if not clock.is_running: + # Clock paused — output frozen, sleep and skip render + time.sleep(0.1) + continue + anim_time = clock.get_time() + self._effective_speed = clock.speed + else: + anim_time = wall_start + self._effective_speed = self._speed + n = self._led_count if n != _pool_n: _pool_n = n @@ -310,14 +329,14 @@ class EffectColorStripStream(ColorStripStream): _use_a = not _use_a render_fn = renderers.get(self._effect_type, self._render_fire) - render_fn(buf, n, loop_start) + render_fn(buf, n, anim_time) with self._colors_lock: self._colors = buf except Exception as e: logger.error(f"EffectColorStripStream render error: {e}") - elapsed = time.perf_counter() - loop_start + elapsed = time.perf_counter() - wall_start time.sleep(max(frame_time - elapsed, 0.001)) except Exception as e: logger.error(f"Fatal EffectColorStripStream loop error: {e}", exc_info=True) @@ -332,7 +351,7 @@ class EffectColorStripStream(ColorStripStream): A 1-D heat array cools, diffuses upward, and receives random sparks at the bottom. Heat values are mapped to the palette LUT. """ - speed = self._speed + speed = self._effective_speed intensity = self._intensity lut = self._palette_lut @@ -378,7 +397,7 @@ class EffectColorStripStream(ColorStripStream): def _render_meteor(self, buf: np.ndarray, n: int, t: float) -> None: """Bright meteor head with exponential-decay trail.""" - speed = self._speed + speed = self._effective_speed intensity = self._intensity color = self._color mirror = self._mirror @@ -443,7 +462,7 @@ class EffectColorStripStream(ColorStripStream): def _render_plasma(self, buf: np.ndarray, n: int, t: float) -> None: """Overlapping sine waves creating colorful plasma patterns.""" - speed = self._speed + speed = self._effective_speed scale = self._scale lut = self._palette_lut @@ -470,7 +489,7 @@ class EffectColorStripStream(ColorStripStream): def _render_noise(self, buf: np.ndarray, n: int, t: float) -> None: """Smooth scrolling fractal noise mapped to a color palette.""" - speed = self._speed + speed = self._effective_speed scale = self._scale lut = self._palette_lut @@ -488,7 +507,7 @@ class EffectColorStripStream(ColorStripStream): def _render_aurora(self, buf: np.ndarray, n: int, t: float) -> None: """Layered noise bands simulating aurora borealis.""" - speed = self._speed + speed = self._effective_speed scale = self._scale intensity = self._intensity lut = self._palette_lut diff --git a/server/src/wled_controller/core/processing/processor_manager.py b/server/src/wled_controller/core/processing/processor_manager.py index 20c794a..d9b3dd8 100644 --- a/server/src/wled_controller/core/processing/processor_manager.py +++ b/server/src/wled_controller/core/processing/processor_manager.py @@ -69,7 +69,7 @@ class ProcessorManager: Targets are registered for processing via polymorphic TargetProcessor subclasses. """ - def __init__(self, picture_source_store=None, capture_template_store=None, pp_template_store=None, pattern_template_store=None, device_store=None, color_strip_store=None, audio_source_store=None, value_source_store=None, audio_template_store=None): + def __init__(self, picture_source_store=None, capture_template_store=None, pp_template_store=None, pattern_template_store=None, device_store=None, color_strip_store=None, audio_source_store=None, value_source_store=None, audio_template_store=None, sync_clock_manager=None): """Initialize processor manager.""" self._devices: Dict[str, DeviceState] = {} self._processors: Dict[str, TargetProcessor] = {} @@ -89,12 +89,14 @@ class ProcessorManager: picture_source_store, capture_template_store, pp_template_store ) self._audio_capture_manager = AudioCaptureManager() + self._sync_clock_manager = sync_clock_manager self._color_strip_stream_manager = ColorStripStreamManager( color_strip_store=color_strip_store, live_stream_manager=self._live_stream_manager, audio_capture_manager=self._audio_capture_manager, audio_source_store=audio_source_store, audio_template_store=audio_template_store, + sync_clock_manager=sync_clock_manager, ) self._value_stream_manager = ValueStreamManager( value_source_store=value_source_store, diff --git a/server/src/wled_controller/core/processing/sync_clock_manager.py b/server/src/wled_controller/core/processing/sync_clock_manager.py new file mode 100644 index 0000000..034a4e0 --- /dev/null +++ b/server/src/wled_controller/core/processing/sync_clock_manager.py @@ -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") diff --git a/server/src/wled_controller/core/processing/sync_clock_runtime.py b/server/src/wled_controller/core/processing/sync_clock_runtime.py new file mode 100644 index 0000000..2d99a4e --- /dev/null +++ b/server/src/wled_controller/core/processing/sync_clock_runtime.py @@ -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 diff --git a/server/src/wled_controller/main.py b/server/src/wled_controller/main.py index f5a2834..3d43c8d 100644 --- a/server/src/wled_controller/main.py +++ b/server/src/wled_controller/main.py @@ -29,6 +29,8 @@ import wled_controller.core.audio # noqa: F401 — trigger engine auto-registra from wled_controller.storage.value_source_store import ValueSourceStore from wled_controller.storage.automation_store import AutomationStore from wled_controller.storage.scene_preset_store import ScenePresetStore +from wled_controller.storage.sync_clock_store import SyncClockStore +from wled_controller.core.processing.sync_clock_manager import SyncClockManager from wled_controller.core.automations.automation_engine import AutomationEngine from wled_controller.core.mqtt.mqtt_service import MQTTService from wled_controller.core.devices.mqtt_client import set_mqtt_service @@ -56,6 +58,8 @@ audio_template_store = AudioTemplateStore(config.storage.audio_templates_file) value_source_store = ValueSourceStore(config.storage.value_sources_file) automation_store = AutomationStore(config.storage.automations_file) scene_preset_store = ScenePresetStore(config.storage.scene_presets_file) +sync_clock_store = SyncClockStore(config.storage.sync_clocks_file) +sync_clock_manager = SyncClockManager(sync_clock_store) processor_manager = ProcessorManager( picture_source_store=picture_source_store, @@ -67,6 +71,7 @@ processor_manager = ProcessorManager( audio_source_store=audio_source_store, value_source_store=value_source_store, audio_template_store=audio_template_store, + sync_clock_manager=sync_clock_manager, ) @@ -135,6 +140,8 @@ async def lifespan(app: FastAPI): scene_preset_store=scene_preset_store, automation_engine=automation_engine, auto_backup_engine=auto_backup_engine, + sync_clock_store=sync_clock_store, + sync_clock_manager=sync_clock_manager, ) # Register devices in processor manager for health monitoring diff --git a/server/src/wled_controller/static/js/app.js b/server/src/wled_controller/static/js/app.js index 9afbd80..850ed9b 100644 --- a/server/src/wled_controller/static/js/app.js +++ b/server/src/wled_controller/static/js/app.js @@ -107,7 +107,7 @@ import { // Layer 5: color-strip sources import { showCSSEditor, closeCSSEditorModal, forceCSSEditorClose, saveCSSEditor, deleteColorStrip, - onCSSTypeChange, onEffectTypeChange, onAnimationTypeChange, updateEffectPreview, + onCSSTypeChange, onEffectTypeChange, onAnimationTypeChange, onCSSClockChange, updateEffectPreview, colorCycleAddColor, colorCycleRemoveColor, compositeAddLayer, compositeRemoveLayer, mappedAddZone, mappedRemoveZone, @@ -363,6 +363,7 @@ Object.assign(window, { deleteColorStrip, onCSSTypeChange, onEffectTypeChange, + onCSSClockChange, onAnimationTypeChange, updateEffectPreview, colorCycleAddColor, diff --git a/server/src/wled_controller/static/js/core/state.js b/server/src/wled_controller/static/js/core/state.js index 906c0e4..a0514cf 100644 --- a/server/src/wled_controller/static/js/core/state.js +++ b/server/src/wled_controller/static/js/core/state.js @@ -184,6 +184,9 @@ export function set_audioTemplateNameManuallyEdited(v) { _audioTemplateNameManua // Value sources export let _cachedValueSources = []; +// Sync clocks +export let _cachedSyncClocks = []; + // Automations export let _automationsCache = null; @@ -234,6 +237,12 @@ export const valueSourcesCache = new DataCache({ }); valueSourcesCache.subscribe(v => { _cachedValueSources = v; }); +export const syncClocksCache = new DataCache({ + endpoint: '/sync-clocks', + extractData: json => json.clocks || [], +}); +syncClocksCache.subscribe(v => { _cachedSyncClocks = v; }); + export const filtersCache = new DataCache({ endpoint: '/filters', extractData: json => json.filters || [], diff --git a/server/src/wled_controller/static/js/features/color-strips.js b/server/src/wled_controller/static/js/features/color-strips.js index b6bb195..3e16cc8 100644 --- a/server/src/wled_controller/static/js/features/color-strips.js +++ b/server/src/wled_controller/static/js/features/color-strips.js @@ -3,6 +3,7 @@ */ import { fetchWithAuth, escapeHtml } from '../core/api.js'; +import { _cachedSyncClocks } from '../core/state.js'; import { t } from '../core/i18n.js'; import { showToast, showConfirm } from '../core/ui.js'; import { Modal } from '../core/modal.js'; @@ -11,7 +12,7 @@ import { ICON_CLONE, ICON_EDIT, ICON_CALIBRATION, ICON_LED, ICON_PALETTE, ICON_FPS, ICON_MAP_PIN, ICON_MUSIC, ICON_AUDIO_LOOPBACK, ICON_TIMER, ICON_LINK_SOURCE, ICON_FILM, - ICON_LINK, ICON_SPARKLES, ICON_FAST_FORWARD, ICON_ACTIVITY, + ICON_LINK, ICON_SPARKLES, ICON_FAST_FORWARD, ICON_ACTIVITY, ICON_CLOCK, } from '../core/icons.js'; import { wrapCard } from '../core/card-colors.js'; @@ -58,6 +59,7 @@ class CSSEditorModal extends Modal { audio_mirror: document.getElementById('css-editor-audio-mirror').checked, api_input_fallback_color: document.getElementById('css-editor-api-input-fallback-color').value, api_input_timeout: document.getElementById('css-editor-api-input-timeout').value, + clock_id: document.getElementById('css-editor-clock').value, }; } } @@ -114,6 +116,11 @@ export function onCSSTypeChange() { document.getElementById('css-editor-led-count-group').style.display = (type === 'composite' || type === 'mapped' || type === 'audio' || type === 'api_input') ? 'none' : ''; + // Sync clock — shown for animated types (static, gradient, color_cycle, effect) + const clockTypes = ['static', 'gradient', 'color_cycle', 'effect']; + document.getElementById('css-editor-clock-group').style.display = clockTypes.includes(type) ? '' : 'none'; + if (clockTypes.includes(type)) _populateClockDropdown(); + if (type === 'audio') { _loadAudioSources(); } else if (type === 'composite') { @@ -125,6 +132,27 @@ export function onCSSTypeChange() { } } +function _populateClockDropdown(selectedId) { + const sel = document.getElementById('css-editor-clock'); + const prev = selectedId !== undefined ? selectedId : sel.value; + sel.innerHTML = `` + + _cachedSyncClocks.map(c => ``).join(''); + sel.value = prev || ''; +} + +export function onCSSClockChange() { + // When a clock is selected, hide speed sliders (speed comes from clock) + const clockId = document.getElementById('css-editor-clock').value; + const type = document.getElementById('css-editor-type').value; + if (type === 'effect') { + document.getElementById('css-editor-effect-speed-group').style.display = clockId ? 'none' : ''; + } else if (type === 'color_cycle') { + document.getElementById('css-editor-cycle-speed-group').style.display = clockId ? 'none' : ''; + } else if (type === 'static' || type === 'gradient') { + document.getElementById('css-editor-animation-speed-group').style.display = clockId ? 'none' : ''; + } +} + function _getAnimationPayload() { const type = document.getElementById('css-editor-animation-type').value; return { @@ -589,10 +617,16 @@ export function createColorStripCard(source, pictureSourceMap, audioSourceMap) { const isAudio = source.source_type === 'audio'; const isApiInput = source.source_type === 'api_input'; + // Clock crosslink badge (replaces speed badge when clock is assigned) + const clockObj = source.clock_id ? _cachedSyncClocks.find(c => c.id === source.clock_id) : null; + const clockBadge = clockObj + ? `${ICON_CLOCK} ${escapeHtml(clockObj.name)}` + : source.clock_id ? `${ICON_CLOCK} ${source.clock_id}` : ''; + const anim = (isStatic || isGradient) && source.animation && source.animation.enabled ? source.animation : null; const animBadge = anim ? `${ICON_SPARKLES} ${t('color_strip.animation.type.' + anim.type) || anim.type}` - + `${ICON_FAST_FORWARD} ${(anim.speed || 1.0).toFixed(1)}×` + + (source.clock_id ? '' : `${ICON_FAST_FORWARD} ${(anim.speed || 1.0).toFixed(1)}×`) : ''; let propsHtml; @@ -604,6 +638,7 @@ export function createColorStripCard(source, pictureSourceMap, audioSourceMap) { ${source.led_count ? `${ICON_LED} ${source.led_count}` : ''} ${animBadge} + ${clockBadge} `; } else if (isColorCycle) { const colors = source.colors || []; @@ -612,8 +647,9 @@ export function createColorStripCard(source, pictureSourceMap, audioSourceMap) { ).join(''); propsHtml = ` ${swatches} - ${ICON_FAST_FORWARD} ${(source.cycle_speed || 1.0).toFixed(1)}× + ${source.clock_id ? '' : `${ICON_FAST_FORWARD} ${(source.cycle_speed || 1.0).toFixed(1)}×`} ${source.led_count ? `${ICON_LED} ${source.led_count}` : ''} + ${clockBadge} `; } else if (isGradient) { const stops = source.stops || []; @@ -636,6 +672,7 @@ export function createColorStripCard(source, pictureSourceMap, audioSourceMap) { ${ICON_PALETTE} ${stops.length} ${t('color_strip.gradient.stops_count')} ${source.led_count ? `${ICON_LED} ${source.led_count}` : ''} ${animBadge} + ${clockBadge} `; } else if (isEffect) { const effectLabel = t('color_strip.effect.' + (source.effect_type || 'fire')) || source.effect_type || 'fire'; @@ -643,8 +680,9 @@ export function createColorStripCard(source, pictureSourceMap, audioSourceMap) { propsHtml = ` ${ICON_FPS} ${escapeHtml(effectLabel)} ${paletteLabel ? `${ICON_PALETTE} ${escapeHtml(paletteLabel)}` : ''} - ${ICON_FAST_FORWARD} ${(source.speed || 1.0).toFixed(1)}× + ${source.clock_id ? '' : `${ICON_FAST_FORWARD} ${(source.speed || 1.0).toFixed(1)}×`} ${source.led_count ? `${ICON_LED} ${source.led_count}` : ''} + ${clockBadge} `; } else if (isComposite) { const layerCount = (source.layers || []).length; @@ -762,6 +800,12 @@ export async function showCSSEditor(cssId = null, cloneData = null) { document.getElementById('css-editor-type').value = sourceType; onCSSTypeChange(); + // Set clock dropdown value (must be after onCSSTypeChange populates it) + if (css.clock_id) { + _populateClockDropdown(css.clock_id); + onCSSClockChange(); + } + if (sourceType === 'static') { document.getElementById('css-editor-color').value = rgbArrayToHex(css.color); _loadAnimationState(css.animation); @@ -1040,6 +1084,13 @@ export async function saveCSSEditor() { if (!cssId) payload.source_type = 'picture'; } + // Attach clock_id for animated types + const clockTypes = ['static', 'gradient', 'color_cycle', 'effect']; + if (clockTypes.includes(sourceType)) { + const clockVal = document.getElementById('css-editor-clock').value; + payload.clock_id = clockVal || null; + } + try { let response; if (cssId) { diff --git a/server/src/wled_controller/static/js/features/streams.js b/server/src/wled_controller/static/js/features/streams.js index ea38de5..f9001c6 100644 --- a/server/src/wled_controller/static/js/features/streams.js +++ b/server/src/wled_controller/static/js/features/streams.js @@ -21,6 +21,7 @@ import { _lastValidatedImageSource, set_lastValidatedImageSource, _cachedAudioSources, _cachedValueSources, + _cachedSyncClocks, _cachedAudioTemplates, availableAudioEngines, setAvailableAudioEngines, currentEditingAudioTemplateId, setCurrentEditingAudioTemplateId, @@ -28,7 +29,7 @@ import { _sourcesLoading, set_sourcesLoading, apiKey, streamsCache, ppTemplatesCache, captureTemplatesCache, - audioSourcesCache, audioTemplatesCache, valueSourcesCache, filtersCache, + audioSourcesCache, audioTemplatesCache, valueSourcesCache, syncClocksCache, filtersCache, } from '../core/state.js'; import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.js'; import { t } from '../core/i18n.js'; @@ -38,10 +39,11 @@ import { openDisplayPicker, formatDisplayLabel } from './displays.js'; import { CardSection } from '../core/card-sections.js'; import { updateSubTabHash } from './tabs.js'; import { createValueSourceCard } from './value-sources.js'; +import { createSyncClockCard } from './sync-clocks.js'; import { getEngineIcon, getPictureSourceIcon, getAudioSourceIcon, ICON_TEMPLATE, ICON_CLONE, ICON_EDIT, ICON_TEST, ICON_LINK_SOURCE, - ICON_FPS, ICON_WEB, ICON_VALUE_SOURCE, ICON_AUDIO_LOOPBACK, ICON_AUDIO_INPUT, + ICON_FPS, ICON_WEB, ICON_VALUE_SOURCE, ICON_CLOCK, ICON_AUDIO_LOOPBACK, ICON_AUDIO_INPUT, ICON_AUDIO_TEMPLATE, ICON_MONITOR, ICON_WRENCH, ICON_RADIO, ICON_CAPTURE_TEMPLATE, ICON_PP_TEMPLATE, ICON_HELP, } from '../core/icons.js'; @@ -57,6 +59,7 @@ const csAudioMono = new CardSection('audio-mono', { titleKey: 'audio_source.grou const csStaticStreams = new CardSection('static-streams', { titleKey: 'streams.group.static_image', gridClass: 'templates-grid', addCardOnclick: "showAddStreamModal('static_image')", keyAttr: 'data-stream-id' }); const csAudioTemplates = new CardSection('audio-templates', { titleKey: 'audio_template.title', gridClass: 'templates-grid', addCardOnclick: "showAddAudioTemplateModal()", keyAttr: 'data-audio-template-id' }); const csValueSources = new CardSection('value-sources', { titleKey: 'value_source.group.title', gridClass: 'templates-grid', addCardOnclick: "showValueSourceModal()", keyAttr: 'data-id' }); +const csSyncClocks = new CardSection('sync-clocks', { titleKey: 'sync_clock.group.title', gridClass: 'templates-grid', addCardOnclick: "showSyncClockModal()", keyAttr: 'data-id' }); // Re-render picture sources when language changes document.addEventListener('languageChanged', () => { if (apiKey) loadPictureSources(); }); @@ -1111,6 +1114,7 @@ export async function loadPictureSources() { captureTemplatesCache.fetch(), audioSourcesCache.fetch(), valueSourcesCache.fetch(), + syncClocksCache.fetch(), audioTemplatesCache.fetch(), filtersCache.data.length === 0 ? filtersCache.fetch() : Promise.resolve(filtersCache.data), ]); @@ -1144,6 +1148,7 @@ const _streamSectionMap = { processed: [csProcStreams, csProcTemplates], audio: [csAudioMulti, csAudioMono], value: [csValueSources], + sync: [csSyncClocks], }; export function expandAllStreamSections() { @@ -1292,6 +1297,7 @@ function renderPictureSourcesList(streams) { { key: 'processed', icon: getPictureSourceIcon('processed'), titleKey: 'streams.group.processed', count: processedStreams.length }, { key: 'audio', icon: getAudioSourceIcon('multichannel'), titleKey: 'streams.group.audio', count: _cachedAudioSources.length }, { key: 'value', icon: ICON_VALUE_SOURCE, titleKey: 'streams.group.value', count: _cachedValueSources.length }, + { key: 'sync', icon: ICON_CLOCK, titleKey: 'streams.group.sync', count: _cachedSyncClocks.length }, ]; const tabBar = `
${tabs.map(tab => @@ -1389,6 +1395,7 @@ function renderPictureSourcesList(streams) { const audioTemplateItems = csAudioTemplates.applySortOrder(_cachedAudioTemplates.map(t => ({ key: t.id, html: renderAudioTemplateCard(t) }))); const staticItems = csStaticStreams.applySortOrder(staticImageStreams.map(s => ({ key: s.id, html: renderStreamCard(s) }))); const valueItems = csValueSources.applySortOrder(_cachedValueSources.map(s => ({ key: s.id, html: createValueSourceCard(s) }))); + const syncClockItems = csSyncClocks.applySortOrder(_cachedSyncClocks.map(s => ({ key: s.id, html: createSyncClockCard(s) }))); if (csRawStreams.isMounted()) { // Incremental update: reconcile cards in-place @@ -1405,6 +1412,7 @@ function renderPictureSourcesList(streams) { csAudioTemplates.reconcile(audioTemplateItems); csStaticStreams.reconcile(staticItems); csValueSources.reconcile(valueItems); + csSyncClocks.reconcile(syncClockItems); } else { // First render: build full HTML const panels = tabs.map(tab => { @@ -1413,12 +1421,13 @@ function renderPictureSourcesList(streams) { else if (tab.key === 'processed') panelContent = csProcStreams.render(procStreamItems) + csProcTemplates.render(procTemplateItems); else if (tab.key === 'audio') panelContent = csAudioMulti.render(multiItems) + csAudioMono.render(monoItems) + csAudioTemplates.render(audioTemplateItems); else if (tab.key === 'value') panelContent = csValueSources.render(valueItems); + else if (tab.key === 'sync') panelContent = csSyncClocks.render(syncClockItems); else panelContent = csStaticStreams.render(staticItems); return `
${panelContent}
`; }).join(''); container.innerHTML = tabBar + panels; - CardSection.bindAll([csRawStreams, csRawTemplates, csProcStreams, csProcTemplates, csAudioMulti, csAudioMono, csAudioTemplates, csStaticStreams, csValueSources]); + CardSection.bindAll([csRawStreams, csRawTemplates, csProcStreams, csProcTemplates, csAudioMulti, csAudioMono, csAudioTemplates, csStaticStreams, csValueSources, csSyncClocks]); } } diff --git a/server/src/wled_controller/static/js/features/sync-clocks.js b/server/src/wled_controller/static/js/features/sync-clocks.js new file mode 100644 index 0000000..915dbee --- /dev/null +++ b/server/src/wled_controller/static/js/features/sync-clocks.js @@ -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: ` +
+
${ICON_CLOCK} ${escapeHtml(clock.name)}
+
+
+ ${statusIcon} ${statusLabel} + ${ICON_CLOCK} ${clock.speed}x +
+ ${clock.description ? `
${escapeHtml(clock.description)}
` : ''}`, + actions: ` + + + + `, + }); +} + +// ── 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; diff --git a/server/src/wled_controller/static/js/features/targets.js b/server/src/wled_controller/static/js/features/targets.js index a8164c2..b7def12 100644 --- a/server/src/wled_controller/static/js/features/targets.js +++ b/server/src/wled_controller/static/js/features/targets.js @@ -9,6 +9,7 @@ import { kcWebSockets, ledPreviewWebSockets, _cachedValueSources, valueSourcesCache, + streamsCache, audioSourcesCache, syncClocksCache, } from '../core/state.js'; import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, isOpenrgbDevice } from '../core/api.js'; import { t } from '../core/i18n.js'; @@ -475,15 +476,17 @@ export async function loadTargetsTab() { if (!csDevices.isMounted()) setTabRefreshing('targets-panel-content', true); try { - // Fetch devices, targets, CSS sources, picture sources, pattern templates, and value sources in parallel - const [devicesResp, targetsResp, cssResp, psResp, patResp, valueSrcArr, asResp] = await Promise.all([ + // Fetch devices, targets, CSS sources, pattern templates in parallel; + // use DataCache for picture sources, audio sources, value sources, sync clocks + const [devicesResp, targetsResp, cssResp, patResp, psArr, valueSrcArr, asSrcArr] = await Promise.all([ fetchWithAuth('/devices'), fetchWithAuth('/picture-targets'), fetchWithAuth('/color-strip-sources').catch(() => null), - fetchWithAuth('/picture-sources').catch(() => null), fetchWithAuth('/pattern-templates').catch(() => null), + streamsCache.fetch().catch(() => []), valueSourcesCache.fetch().catch(() => []), - fetchWithAuth('/audio-sources').catch(() => null), + audioSourcesCache.fetch().catch(() => []), + syncClocksCache.fetch().catch(() => []), ]); const devicesData = await devicesResp.json(); @@ -499,10 +502,7 @@ export async function loadTargetsTab() { } let pictureSourceMap = {}; - if (psResp && psResp.ok) { - const psData = await psResp.json(); - (psData.streams || []).forEach(s => { pictureSourceMap[s.id] = s; }); - } + psArr.forEach(s => { pictureSourceMap[s.id] = s; }); let patternTemplates = []; let patternTemplateMap = {}; @@ -516,10 +516,7 @@ export async function loadTargetsTab() { valueSrcArr.forEach(s => { valueSourceMap[s.id] = s; }); let audioSourceMap = {}; - if (asResp && asResp.ok) { - const asData = await asResp.json(); - (asData.sources || []).forEach(s => { audioSourceMap[s.id] = s; }); - } + asSrcArr.forEach(s => { audioSourceMap[s.id] = s; }); // Fetch all device states, target states, and target metrics in batch const [batchDevStatesResp, batchTgtStatesResp, batchTgtMetricsResp] = await Promise.all([ diff --git a/server/src/wled_controller/static/locales/en.json b/server/src/wled_controller/static/locales/en.json index fa7a4ac..7db79b8 100644 --- a/server/src/wled_controller/static/locales/en.json +++ b/server/src/wled_controller/static/locales/en.json @@ -307,6 +307,7 @@ "common.delete": "Delete", "common.edit": "Edit", "common.clone": "Clone", + "common.none": "None", "section.filter.placeholder": "Filter...", "section.filter.reset": "Clear filter", "section.expand_all": "Expand all sections", @@ -950,6 +951,7 @@ "audio_template.error.required": "Please fill in all required fields", "audio_template.error.delete": "Failed to delete audio template", "streams.group.value": "Value Sources", + "streams.group.sync": "Sync Clocks", "value_source.group.title": "Value Sources", "value_source.add": "Add Value Source", "value_source.edit": "Edit Value Source", @@ -1137,5 +1139,32 @@ "theme.switched.dark": "Switched to dark theme", "theme.switched.light": "Switched to light theme", "accent.color.updated": "Accent color updated", - "search.footer": "↑↓ navigate · Enter select · Esc close" + "search.footer": "↑↓ navigate · Enter select · Esc close", + "sync_clock.group.title": "Sync Clocks", + "sync_clock.add": "Add Sync Clock", + "sync_clock.edit": "Edit Sync Clock", + "sync_clock.name": "Name:", + "sync_clock.name.placeholder": "Main Animation Clock", + "sync_clock.name.hint": "A descriptive name for this synchronization clock", + "sync_clock.speed": "Speed:", + "sync_clock.speed.hint": "Speed multiplier shared by all linked sources. 1.0 = normal speed.", + "sync_clock.description": "Description (optional):", + "sync_clock.description.placeholder": "Optional description", + "sync_clock.description.hint": "Optional notes about this clock's purpose", + "sync_clock.status.running": "Running", + "sync_clock.status.paused": "Paused", + "sync_clock.action.pause": "Pause", + "sync_clock.action.resume": "Resume", + "sync_clock.action.reset": "Reset", + "sync_clock.error.name_required": "Clock name is required", + "sync_clock.error.load": "Failed to load sync clock", + "sync_clock.created": "Sync clock created", + "sync_clock.updated": "Sync clock updated", + "sync_clock.deleted": "Sync clock deleted", + "sync_clock.paused": "Clock paused", + "sync_clock.resumed": "Clock resumed", + "sync_clock.reset_done": "Clock reset to zero", + "sync_clock.delete.confirm": "Delete this sync clock? Sources using it will revert to their own speed.", + "color_strip.clock": "Sync Clock:", + "color_strip.clock.hint": "Link to a sync clock for synchronized animation. When set, speed comes from the clock." } diff --git a/server/src/wled_controller/static/locales/ru.json b/server/src/wled_controller/static/locales/ru.json index ebed4cd..ef6157d 100644 --- a/server/src/wled_controller/static/locales/ru.json +++ b/server/src/wled_controller/static/locales/ru.json @@ -307,6 +307,7 @@ "common.delete": "Удалить", "common.edit": "Редактировать", "common.clone": "Клонировать", + "common.none": "Нет", "section.filter.placeholder": "Фильтр...", "section.filter.reset": "Очистить фильтр", "section.expand_all": "Развернуть все секции", @@ -950,6 +951,7 @@ "audio_template.error.required": "Пожалуйста, заполните все обязательные поля", "audio_template.error.delete": "Не удалось удалить аудиошаблон", "streams.group.value": "Источники значений", + "streams.group.sync": "Часы синхронизации", "value_source.group.title": "Источники значений", "value_source.add": "Добавить источник значений", "value_source.edit": "Редактировать источник значений", @@ -1137,5 +1139,32 @@ "theme.switched.dark": "Переключено на тёмную тему", "theme.switched.light": "Переключено на светлую тему", "accent.color.updated": "Цвет акцента обновлён", - "search.footer": "↑↓ навигация · Enter выбор · Esc закрыть" + "search.footer": "↑↓ навигация · Enter выбор · Esc закрыть", + "sync_clock.group.title": "Часы синхронизации", + "sync_clock.add": "Добавить часы", + "sync_clock.edit": "Редактировать часы", + "sync_clock.name": "Название:", + "sync_clock.name.placeholder": "Основные часы анимации", + "sync_clock.name.hint": "Описательное название для этих часов синхронизации", + "sync_clock.speed": "Скорость:", + "sync_clock.speed.hint": "Множитель скорости, общий для всех привязанных источников. 1.0 = нормальная скорость.", + "sync_clock.description": "Описание (необязательно):", + "sync_clock.description.placeholder": "Необязательное описание", + "sync_clock.description.hint": "Необязательные заметки о назначении этих часов", + "sync_clock.status.running": "Работает", + "sync_clock.status.paused": "Приостановлено", + "sync_clock.action.pause": "Приостановить", + "sync_clock.action.resume": "Возобновить", + "sync_clock.action.reset": "Сбросить", + "sync_clock.error.name_required": "Название часов обязательно", + "sync_clock.error.load": "Не удалось загрузить часы синхронизации", + "sync_clock.created": "Часы синхронизации созданы", + "sync_clock.updated": "Часы синхронизации обновлены", + "sync_clock.deleted": "Часы синхронизации удалены", + "sync_clock.paused": "Часы приостановлены", + "sync_clock.resumed": "Часы возобновлены", + "sync_clock.reset_done": "Часы сброшены на ноль", + "sync_clock.delete.confirm": "Удалить эти часы синхронизации? Источники, использующие их, вернутся к собственной скорости.", + "color_strip.clock": "Часы синхронизации:", + "color_strip.clock.hint": "Привязка к часам синхронизации для синхронной анимации. При установке скорость берётся из часов." } diff --git a/server/src/wled_controller/static/locales/zh.json b/server/src/wled_controller/static/locales/zh.json index 71f9b35..c971bf1 100644 --- a/server/src/wled_controller/static/locales/zh.json +++ b/server/src/wled_controller/static/locales/zh.json @@ -307,6 +307,7 @@ "common.delete": "删除", "common.edit": "编辑", "common.clone": "克隆", + "common.none": "无", "section.filter.placeholder": "筛选...", "section.filter.reset": "清除筛选", "section.expand_all": "全部展开", @@ -950,6 +951,7 @@ "audio_template.error.required": "请填写所有必填项", "audio_template.error.delete": "删除音频模板失败", "streams.group.value": "值源", + "streams.group.sync": "同步时钟", "value_source.group.title": "值源", "value_source.add": "添加值源", "value_source.edit": "编辑值源", @@ -1137,5 +1139,32 @@ "theme.switched.dark": "已切换到深色主题", "theme.switched.light": "已切换到浅色主题", "accent.color.updated": "强调色已更新", - "search.footer": "↑↓ 导航 · Enter 选择 · Esc 关闭" + "search.footer": "↑↓ 导航 · Enter 选择 · Esc 关闭", + "sync_clock.group.title": "同步时钟", + "sync_clock.add": "添加同步时钟", + "sync_clock.edit": "编辑同步时钟", + "sync_clock.name": "名称:", + "sync_clock.name.placeholder": "主动画时钟", + "sync_clock.name.hint": "此同步时钟的描述性名称", + "sync_clock.speed": "速度:", + "sync_clock.speed.hint": "所有关联源共享的速度倍率。1.0 = 正常速度。", + "sync_clock.description": "描述(可选):", + "sync_clock.description.placeholder": "可选描述", + "sync_clock.description.hint": "关于此时钟用途的可选备注", + "sync_clock.status.running": "运行中", + "sync_clock.status.paused": "已暂停", + "sync_clock.action.pause": "暂停", + "sync_clock.action.resume": "恢复", + "sync_clock.action.reset": "重置", + "sync_clock.error.name_required": "时钟名称为必填项", + "sync_clock.error.load": "加载同步时钟失败", + "sync_clock.created": "同步时钟已创建", + "sync_clock.updated": "同步时钟已更新", + "sync_clock.deleted": "同步时钟已删除", + "sync_clock.paused": "时钟已暂停", + "sync_clock.resumed": "时钟已恢复", + "sync_clock.reset_done": "时钟已重置为零", + "sync_clock.delete.confirm": "删除此同步时钟?使用它的源将恢复为各自的速度。", + "color_strip.clock": "同步时钟:", + "color_strip.clock.hint": "关联同步时钟以实现同步动画。设置后,速度将来自时钟。" } diff --git a/server/src/wled_controller/static/sw.js b/server/src/wled_controller/static/sw.js index de3fa0c..75ff449 100644 --- a/server/src/wled_controller/static/sw.js +++ b/server/src/wled_controller/static/sw.js @@ -7,7 +7,7 @@ * - Navigation: network-first with offline fallback */ -const CACHE_NAME = 'ledgrab-v6'; +const CACHE_NAME = 'ledgrab-v7'; // Only pre-cache static assets (no auth required). // Do NOT pre-cache '/' — it requires API key auth and would cache an error page. diff --git a/server/src/wled_controller/storage/color_strip_source.py b/server/src/wled_controller/storage/color_strip_source.py index acbb6fe..33fa5ab 100644 --- a/server/src/wled_controller/storage/color_strip_source.py +++ b/server/src/wled_controller/storage/color_strip_source.py @@ -34,6 +34,7 @@ class ColorStripSource: created_at: datetime updated_at: datetime description: Optional[str] = None + clock_id: Optional[str] = None # optional SyncClock reference @property def sharable(self) -> bool: @@ -53,6 +54,7 @@ class ColorStripSource: "created_at": self.created_at.isoformat(), "updated_at": self.updated_at.isoformat(), "description": self.description, + "clock_id": self.clock_id, # Subclass fields default to None for forward compat "picture_source_id": None, "fps": None, @@ -91,6 +93,8 @@ class ColorStripSource: name: str = data["name"] description: str | None = data.get("description") + clock_id: str | None = data.get("clock_id") + raw_created = data.get("created_at") created_at: datetime = ( datetime.fromisoformat(raw_created) @@ -122,7 +126,7 @@ class ColorStripSource: return StaticColorStripSource( id=sid, name=name, source_type="static", created_at=created_at, updated_at=updated_at, description=description, - color=color, + clock_id=clock_id, color=color, led_count=data.get("led_count") or 0, animation=data.get("animation"), ) @@ -133,7 +137,7 @@ class ColorStripSource: return GradientColorStripSource( id=sid, name=name, source_type="gradient", created_at=created_at, updated_at=updated_at, description=description, - stops=stops, + clock_id=clock_id, stops=stops, led_count=data.get("led_count") or 0, animation=data.get("animation"), ) @@ -144,7 +148,7 @@ class ColorStripSource: return ColorCycleColorStripSource( id=sid, name=name, source_type="color_cycle", created_at=created_at, updated_at=updated_at, description=description, - colors=colors, + clock_id=clock_id, colors=colors, cycle_speed=float(data.get("cycle_speed") or 1.0), led_count=data.get("led_count") or 0, ) @@ -153,7 +157,7 @@ class ColorStripSource: return CompositeColorStripSource( id=sid, name=name, source_type="composite", created_at=created_at, updated_at=updated_at, description=description, - layers=data.get("layers") or [], + clock_id=clock_id, layers=data.get("layers") or [], led_count=data.get("led_count") or 0, ) @@ -161,7 +165,7 @@ class ColorStripSource: return MappedColorStripSource( id=sid, name=name, source_type="mapped", created_at=created_at, updated_at=updated_at, description=description, - zones=data.get("zones") or [], + clock_id=clock_id, zones=data.get("zones") or [], led_count=data.get("led_count") or 0, ) @@ -173,7 +177,7 @@ class ColorStripSource: return AudioColorStripSource( id=sid, name=name, source_type="audio", created_at=created_at, updated_at=updated_at, description=description, - visualization_mode=data.get("visualization_mode") or "spectrum", + clock_id=clock_id, visualization_mode=data.get("visualization_mode") or "spectrum", audio_source_id=data.get("audio_source_id") or "", sensitivity=float(data.get("sensitivity") or 1.0), smoothing=float(data.get("smoothing") or 0.3), @@ -193,7 +197,7 @@ class ColorStripSource: return EffectColorStripSource( id=sid, name=name, source_type="effect", created_at=created_at, updated_at=updated_at, description=description, - effect_type=data.get("effect_type") or "fire", + clock_id=clock_id, effect_type=data.get("effect_type") or "fire", speed=float(data.get("speed") or 1.0), led_count=data.get("led_count") or 0, palette=data.get("palette") or "fire", @@ -212,7 +216,7 @@ class ColorStripSource: return ApiInputColorStripSource( id=sid, name=name, source_type="api_input", created_at=created_at, updated_at=updated_at, description=description, - led_count=data.get("led_count") or 0, + clock_id=clock_id, led_count=data.get("led_count") or 0, fallback_color=fallback_color, timeout=float(data.get("timeout") or 5.0), ) @@ -221,7 +225,7 @@ class ColorStripSource: return PictureColorStripSource( id=sid, name=name, source_type=source_type, created_at=created_at, updated_at=updated_at, description=description, - picture_source_id=data.get("picture_source_id") or "", + clock_id=clock_id, picture_source_id=data.get("picture_source_id") or "", fps=data.get("fps") or 30, brightness=data["brightness"] if data.get("brightness") is not None else 1.0, saturation=data["saturation"] if data.get("saturation") is not None else 1.0, diff --git a/server/src/wled_controller/storage/color_strip_store.py b/server/src/wled_controller/storage/color_strip_store.py index eefe0e7..3624ef0 100644 --- a/server/src/wled_controller/storage/color_strip_store.py +++ b/server/src/wled_controller/storage/color_strip_store.py @@ -121,6 +121,7 @@ class ColorStripStore: color_peak: Optional[list] = None, fallback_color: Optional[list] = None, timeout: Optional[float] = None, + clock_id: Optional[str] = None, ) -> ColorStripSource: """Create a new color strip source. @@ -146,6 +147,7 @@ class ColorStripStore: created_at=now, updated_at=now, description=description, + clock_id=clock_id, color=rgb, led_count=led_count, animation=animation, @@ -158,6 +160,7 @@ class ColorStripStore: created_at=now, updated_at=now, description=description, + clock_id=clock_id, stops=stops if isinstance(stops, list) else [ {"position": 0.0, "color": [255, 0, 0]}, {"position": 1.0, "color": [0, 0, 255]}, @@ -177,6 +180,7 @@ class ColorStripStore: created_at=now, updated_at=now, description=description, + clock_id=clock_id, colors=colors if isinstance(colors, list) and len(colors) >= 2 else default_colors, cycle_speed=float(cycle_speed) if cycle_speed else 1.0, led_count=led_count, @@ -190,6 +194,7 @@ class ColorStripStore: created_at=now, updated_at=now, description=description, + clock_id=clock_id, effect_type=effect_type or "fire", speed=float(speed) if speed else 1.0, led_count=led_count, @@ -209,6 +214,7 @@ class ColorStripStore: created_at=now, updated_at=now, description=description, + clock_id=clock_id, visualization_mode=visualization_mode or "spectrum", audio_source_id=audio_source_id or "", sensitivity=float(sensitivity) if sensitivity else 1.0, @@ -227,6 +233,7 @@ class ColorStripStore: created_at=now, updated_at=now, description=description, + clock_id=clock_id, layers=layers if isinstance(layers, list) else [], led_count=led_count, ) @@ -238,6 +245,7 @@ class ColorStripStore: created_at=now, updated_at=now, description=description, + clock_id=clock_id, zones=zones if isinstance(zones, list) else [], led_count=led_count, ) @@ -250,6 +258,7 @@ class ColorStripStore: created_at=now, updated_at=now, description=description, + clock_id=clock_id, led_count=led_count, fallback_color=fb, timeout=float(timeout) if timeout is not None else 5.0, @@ -264,6 +273,7 @@ class ColorStripStore: created_at=now, updated_at=now, description=description, + clock_id=clock_id, picture_source_id=picture_source_id, fps=fps, brightness=brightness, @@ -316,6 +326,7 @@ class ColorStripStore: color_peak: Optional[list] = None, fallback_color: Optional[list] = None, timeout: Optional[float] = None, + clock_id: Optional[str] = None, ) -> ColorStripSource: """Update an existing color strip source. @@ -336,6 +347,9 @@ class ColorStripStore: if description is not None: source.description = description + if clock_id is not None: + source.clock_id = clock_id if clock_id else None + if isinstance(source, PictureColorStripSource): if picture_source_id is not None: source.picture_source_id = picture_source_id diff --git a/server/src/wled_controller/storage/sync_clock.py b/server/src/wled_controller/storage/sync_clock.py new file mode 100644 index 0000000..33341da --- /dev/null +++ b/server/src/wled_controller/storage/sync_clock.py @@ -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.1–10.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"]), + ) diff --git a/server/src/wled_controller/storage/sync_clock_store.py b/server/src/wled_controller/storage/sync_clock_store.py new file mode 100644 index 0000000..6ff6c97 --- /dev/null +++ b/server/src/wled_controller/storage/sync_clock_store.py @@ -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}") diff --git a/server/src/wled_controller/templates/index.html b/server/src/wled_controller/templates/index.html index 65a5d0b..0f68cc1 100644 --- a/server/src/wled_controller/templates/index.html +++ b/server/src/wled_controller/templates/index.html @@ -169,6 +169,7 @@ {% include 'modals/test-audio-template.html' %} {% include 'modals/value-source-editor.html' %} {% include 'modals/test-value-source.html' %} + {% include 'modals/sync-clock-editor.html' %} {% include 'modals/settings.html' %} {% include 'partials/tutorial-overlay.html' %} diff --git a/server/src/wled_controller/templates/modals/css-editor.html b/server/src/wled_controller/templates/modals/css-editor.html index d0048a6..7dbf11e 100644 --- a/server/src/wled_controller/templates/modals/css-editor.html +++ b/server/src/wled_controller/templates/modals/css-editor.html @@ -146,7 +146,7 @@
-
+
-
+
-
+
+ + +
diff --git a/server/src/wled_controller/templates/modals/sync-clock-editor.html b/server/src/wled_controller/templates/modals/sync-clock-editor.html new file mode 100644 index 0000000..1770467 --- /dev/null +++ b/server/src/wled_controller/templates/modals/sync-clock-editor.html @@ -0,0 +1,51 @@ + +