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 = `
-