Add CSPT entity, processed CSS source type, reverse filter, and UI improvements
- Add Color Strip Processing Template (CSPT) entity: reusable filter chains for 1D LED strip postprocessing (backend, storage, API, frontend CRUD) - Add "processed" color strip source type that wraps another CSS source and applies a CSPT filter chain (dataclass, stream, schema, modal, cards) - Add Reverse filter for strip LED order reversal - Add CSPT and processed CSS nodes/edges to visual graph editor - Add CSPT test preview WS endpoint with input source selection - Add device settings CSPT template selector (add + edit modals with hints) - Use icon grids for palette quantization preset selector in filter lists - Use EntitySelect for template references and test modal source selectors - Fix filters.css_filter_template.desc missing localization - Fix icon grid cell height inequality (grid-auto-rows: 1fr) - Rename "Processed" subtab to "Processing Templates" - Localize all new strings (en/ru/zh) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
33
TODO.md
33
TODO.md
@@ -28,6 +28,39 @@ Priority: `P1` quick win · `P2` moderate · `P3` large effort
|
||||
- [ ] `P2` **Distinguish "crashed" vs "stopped" in dashboard** — `metrics.last_error` is already populated
|
||||
- [ ] `P3` **CSS source import/export** — share individual sources without full config backup
|
||||
|
||||
## Backend Review Fixes (2026-03-14)
|
||||
|
||||
### Performance
|
||||
- [x] **P1** PIL blocking in async handlers → `asyncio.to_thread`
|
||||
- [x] **P2** `subprocess.run` blocking event loop → `asyncio.create_subprocess_exec`
|
||||
- [x] **P3** Audio enum blocking async → `asyncio.to_thread`
|
||||
- [x] **P4** Display enum blocking async → `asyncio.to_thread`
|
||||
- [x] **P5** `colorsys` scalar loop in hot path → vectorize numpy
|
||||
- [x] **P6** `MappedStream` per-frame allocation → double-buffer
|
||||
- [x] **P7** Audio/effect per-frame temp allocs → pre-allocate
|
||||
- [x] **P8** Blocking `httpx.get` in stream init → documented (callers use to_thread)
|
||||
- [x] **P9** No-cache middleware runs on all requests → scope to static
|
||||
- [x] **P10** Sync file I/O in async handlers (stores) → documented as accepted risk (< 5ms)
|
||||
- [x] **P11** `frame_time` float division every loop iter → cache field
|
||||
- [x] **P12** `_check_name_unique` O(N) + no lock → add threading.Lock
|
||||
- [x] **P13** Imports inside 1-Hz metrics loop → move to module level
|
||||
|
||||
### Code Quality
|
||||
|
||||
- [x] **Q1** `DeviceStore` not using `BaseJsonStore`
|
||||
- [x] **Q2** `ColorStripStore` 275-line god methods → factory dispatch
|
||||
- [x] **Q3** Layer violation: core imports from routes → extract to utility
|
||||
- [x] **Q4** 20+ field-by-field update in Device/routes → dataclass + generic update
|
||||
- [x] **Q5** WebSocket auth copy-pasted 9x → extract helper
|
||||
- [x] **Q6** `set_device_brightness` bypasses store → use update_device
|
||||
- [x] **Q7** DI via 16+ module globals → registry pattern
|
||||
- [x] **Q8** `_css_to_response` 30+ getattr → polymorphic to_response
|
||||
- [x] **Q9** Private attribute access across modules → expose as properties
|
||||
- [x] **Q10** `ColorStripSource.to_dict()` emits ~25 nulls → per-subclass override
|
||||
- [x] **Q11** `DeviceStore.get_device` returns None vs raises → raise ValueError
|
||||
- [x] **Q12** `list_all_tags` fragile method-name probing → use get_all()
|
||||
- [x] **Q13** Route create/update pass 30 individual fields → **kwargs
|
||||
|
||||
## UX
|
||||
|
||||
- [ ] `P1` **Collapse dashboard running target stats** — Show only FPS chart by default; uptime, errors, and pipeline timings in an expandable section collapsed by default
|
||||
|
||||
@@ -18,6 +18,7 @@ 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
|
||||
from .routes.color_strip_processing import router as cspt_router
|
||||
|
||||
router = APIRouter()
|
||||
router.include_router(system_router)
|
||||
@@ -36,5 +37,6 @@ router.include_router(automations_router)
|
||||
router.include_router(scene_presets_router)
|
||||
router.include_router(webhooks_router)
|
||||
router.include_router(sync_clocks_router)
|
||||
router.include_router(cspt_router)
|
||||
|
||||
__all__ = ["router"]
|
||||
|
||||
@@ -75,3 +75,16 @@ def verify_api_key(
|
||||
# Dependency for protected routes
|
||||
# Returns the label/identifier of the authenticated client
|
||||
AuthRequired = Annotated[str, Depends(verify_api_key)]
|
||||
|
||||
|
||||
def verify_ws_token(token: str) -> bool:
|
||||
"""Check a WebSocket query-param token against configured API keys.
|
||||
|
||||
Use this for WebSocket endpoints where FastAPI's Depends() isn't available.
|
||||
"""
|
||||
config = get_config()
|
||||
if token and config.auth.api_keys:
|
||||
for _label, api_key in config.auth.api_keys.items():
|
||||
if secrets.compare_digest(token, api_key):
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
"""Dependency injection for API routes."""
|
||||
"""Dependency injection for API routes.
|
||||
|
||||
Uses a registry dict instead of individual module-level globals.
|
||||
All getter function signatures remain unchanged for FastAPI Depends() compatibility.
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, Type, TypeVar
|
||||
|
||||
from wled_controller.core.processing.processor_manager import ProcessorManager
|
||||
from wled_controller.storage import DeviceStore
|
||||
@@ -14,147 +20,101 @@ 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.storage.color_strip_processing_template_store import ColorStripProcessingTemplateStore
|
||||
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
|
||||
_device_store: DeviceStore | None = None
|
||||
_template_store: TemplateStore | None = None
|
||||
_pp_template_store: PostprocessingTemplateStore | None = None
|
||||
_pattern_template_store: PatternTemplateStore | None = None
|
||||
_picture_source_store: PictureSourceStore | None = None
|
||||
_output_target_store: OutputTargetStore | None = None
|
||||
_color_strip_store: ColorStripStore | None = None
|
||||
_audio_source_store: AudioSourceStore | None = None
|
||||
_audio_template_store: AudioTemplateStore | None = None
|
||||
_value_source_store: ValueSourceStore | None = None
|
||||
_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
|
||||
T = TypeVar("T")
|
||||
|
||||
# Central dependency registry — keyed by type or string label
|
||||
_deps: Dict[str, Any] = {}
|
||||
|
||||
|
||||
def _get(key: str, label: str) -> Any:
|
||||
"""Get a dependency by key, raising RuntimeError if not initialized."""
|
||||
dep = _deps.get(key)
|
||||
if dep is None:
|
||||
raise RuntimeError(f"{label} not initialized")
|
||||
return dep
|
||||
|
||||
|
||||
# ── Typed getters (unchanged signatures for FastAPI Depends()) ──────────
|
||||
|
||||
|
||||
def get_device_store() -> DeviceStore:
|
||||
"""Get device store dependency."""
|
||||
if _device_store is None:
|
||||
raise RuntimeError("Device store not initialized")
|
||||
return _device_store
|
||||
return _get("device_store", "Device store")
|
||||
|
||||
|
||||
def get_template_store() -> TemplateStore:
|
||||
"""Get template store dependency."""
|
||||
if _template_store is None:
|
||||
raise RuntimeError("Template store not initialized")
|
||||
return _template_store
|
||||
return _get("template_store", "Template store")
|
||||
|
||||
|
||||
def get_pp_template_store() -> PostprocessingTemplateStore:
|
||||
"""Get postprocessing template store dependency."""
|
||||
if _pp_template_store is None:
|
||||
raise RuntimeError("Postprocessing template store not initialized")
|
||||
return _pp_template_store
|
||||
return _get("pp_template_store", "Postprocessing template store")
|
||||
|
||||
|
||||
def get_pattern_template_store() -> PatternTemplateStore:
|
||||
"""Get pattern template store dependency."""
|
||||
if _pattern_template_store is None:
|
||||
raise RuntimeError("Pattern template store not initialized")
|
||||
return _pattern_template_store
|
||||
return _get("pattern_template_store", "Pattern template store")
|
||||
|
||||
|
||||
def get_picture_source_store() -> PictureSourceStore:
|
||||
"""Get picture source store dependency."""
|
||||
if _picture_source_store is None:
|
||||
raise RuntimeError("Picture source store not initialized")
|
||||
return _picture_source_store
|
||||
return _get("picture_source_store", "Picture source store")
|
||||
|
||||
|
||||
def get_output_target_store() -> OutputTargetStore:
|
||||
"""Get output target store dependency."""
|
||||
if _output_target_store is None:
|
||||
raise RuntimeError("Picture target store not initialized")
|
||||
return _output_target_store
|
||||
return _get("output_target_store", "Output target store")
|
||||
|
||||
|
||||
def get_color_strip_store() -> ColorStripStore:
|
||||
"""Get color strip store dependency."""
|
||||
if _color_strip_store is None:
|
||||
raise RuntimeError("Color strip store not initialized")
|
||||
return _color_strip_store
|
||||
return _get("color_strip_store", "Color strip store")
|
||||
|
||||
|
||||
def get_audio_source_store() -> AudioSourceStore:
|
||||
"""Get audio source store dependency."""
|
||||
if _audio_source_store is None:
|
||||
raise RuntimeError("Audio source store not initialized")
|
||||
return _audio_source_store
|
||||
return _get("audio_source_store", "Audio source store")
|
||||
|
||||
|
||||
def get_audio_template_store() -> AudioTemplateStore:
|
||||
"""Get audio template store dependency."""
|
||||
if _audio_template_store is None:
|
||||
raise RuntimeError("Audio template store not initialized")
|
||||
return _audio_template_store
|
||||
return _get("audio_template_store", "Audio template store")
|
||||
|
||||
|
||||
def get_value_source_store() -> ValueSourceStore:
|
||||
"""Get value source store dependency."""
|
||||
if _value_source_store is None:
|
||||
raise RuntimeError("Value source store not initialized")
|
||||
return _value_source_store
|
||||
return _get("value_source_store", "Value source store")
|
||||
|
||||
|
||||
def get_processor_manager() -> ProcessorManager:
|
||||
"""Get processor manager dependency."""
|
||||
if _processor_manager is None:
|
||||
raise RuntimeError("Processor manager not initialized")
|
||||
return _processor_manager
|
||||
return _get("processor_manager", "Processor manager")
|
||||
|
||||
|
||||
def get_automation_store() -> AutomationStore:
|
||||
"""Get automation store dependency."""
|
||||
if _automation_store is None:
|
||||
raise RuntimeError("Automation store not initialized")
|
||||
return _automation_store
|
||||
return _get("automation_store", "Automation store")
|
||||
|
||||
|
||||
def get_scene_preset_store() -> ScenePresetStore:
|
||||
"""Get scene preset store dependency."""
|
||||
if _scene_preset_store is None:
|
||||
raise RuntimeError("Scene preset store not initialized")
|
||||
return _scene_preset_store
|
||||
return _get("scene_preset_store", "Scene preset store")
|
||||
|
||||
|
||||
def get_automation_engine() -> AutomationEngine:
|
||||
"""Get automation engine dependency."""
|
||||
if _automation_engine is None:
|
||||
raise RuntimeError("Automation engine not initialized")
|
||||
return _automation_engine
|
||||
return _get("automation_engine", "Automation engine")
|
||||
|
||||
|
||||
def get_auto_backup_engine() -> AutoBackupEngine:
|
||||
"""Get auto-backup engine dependency."""
|
||||
if _auto_backup_engine is None:
|
||||
raise RuntimeError("Auto-backup engine not initialized")
|
||||
return _auto_backup_engine
|
||||
return _get("auto_backup_engine", "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
|
||||
return _get("sync_clock_store", "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
|
||||
return _get("sync_clock_manager", "Sync clock manager")
|
||||
|
||||
|
||||
def get_cspt_store() -> ColorStripProcessingTemplateStore:
|
||||
return _get("cspt_store", "Color strip processing template store")
|
||||
|
||||
|
||||
# ── Event helper ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def fire_entity_event(entity_type: str, action: str, entity_id: str) -> None:
|
||||
@@ -165,8 +125,9 @@ def fire_entity_event(entity_type: str, action: str, entity_id: str) -> None:
|
||||
action: "created", "updated", or "deleted"
|
||||
entity_id: The entity's unique ID
|
||||
"""
|
||||
if _processor_manager is not None:
|
||||
_processor_manager.fire_event({
|
||||
pm = _deps.get("processor_manager")
|
||||
if pm is not None:
|
||||
pm.fire_event({
|
||||
"type": "entity_changed",
|
||||
"entity_type": entity_type,
|
||||
"action": action,
|
||||
@@ -174,6 +135,9 @@ def fire_entity_event(entity_type: str, action: str, entity_id: str) -> None:
|
||||
})
|
||||
|
||||
|
||||
# ── Initialization ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def init_dependencies(
|
||||
device_store: DeviceStore,
|
||||
template_store: TemplateStore,
|
||||
@@ -192,27 +156,26 @@ def init_dependencies(
|
||||
auto_backup_engine: AutoBackupEngine | None = None,
|
||||
sync_clock_store: SyncClockStore | None = None,
|
||||
sync_clock_manager: SyncClockManager | None = None,
|
||||
cspt_store: ColorStripProcessingTemplateStore | None = None,
|
||||
):
|
||||
"""Initialize global dependencies."""
|
||||
global _device_store, _template_store, _processor_manager
|
||||
global _pp_template_store, _pattern_template_store, _picture_source_store, _output_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
|
||||
_pp_template_store = pp_template_store
|
||||
_pattern_template_store = pattern_template_store
|
||||
_picture_source_store = picture_source_store
|
||||
_output_target_store = output_target_store
|
||||
_color_strip_store = color_strip_store
|
||||
_audio_source_store = audio_source_store
|
||||
_audio_template_store = audio_template_store
|
||||
_value_source_store = value_source_store
|
||||
_automation_store = automation_store
|
||||
_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
|
||||
_deps.update({
|
||||
"device_store": device_store,
|
||||
"template_store": template_store,
|
||||
"processor_manager": processor_manager,
|
||||
"pp_template_store": pp_template_store,
|
||||
"pattern_template_store": pattern_template_store,
|
||||
"picture_source_store": picture_source_store,
|
||||
"output_target_store": output_target_store,
|
||||
"color_strip_store": color_strip_store,
|
||||
"audio_source_store": audio_source_store,
|
||||
"audio_template_store": audio_template_store,
|
||||
"value_source_store": value_source_store,
|
||||
"automation_store": automation_store,
|
||||
"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,
|
||||
"cspt_store": cspt_store,
|
||||
})
|
||||
|
||||
@@ -26,13 +26,12 @@ PREVIEW_JPEG_QUALITY = 70
|
||||
|
||||
|
||||
def authenticate_ws_token(token: str) -> bool:
|
||||
"""Check a WebSocket query-param token against configured API keys."""
|
||||
cfg = get_config()
|
||||
if token and cfg.auth.api_keys:
|
||||
for _label, api_key in cfg.auth.api_keys.items():
|
||||
if secrets.compare_digest(token, api_key):
|
||||
return True
|
||||
return False
|
||||
"""Check a WebSocket query-param token against configured API keys.
|
||||
|
||||
Delegates to the canonical implementation in auth module.
|
||||
"""
|
||||
from wled_controller.api.auth import verify_ws_token
|
||||
return verify_ws_token(token)
|
||||
|
||||
|
||||
def _encode_jpeg(pil_image: Image.Image, quality: int = 85) -> str:
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Audio device routes: enumerate available audio devices."""
|
||||
|
||||
import asyncio
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from wled_controller.api.auth import AuthRequired
|
||||
@@ -17,8 +19,12 @@ async def list_audio_devices(_auth: AuthRequired):
|
||||
filter by the selected audio template's engine type.
|
||||
"""
|
||||
try:
|
||||
devices = AudioCaptureManager.enumerate_devices()
|
||||
by_engine = AudioCaptureManager.enumerate_devices_by_engine()
|
||||
devices, by_engine = await asyncio.to_thread(
|
||||
lambda: (
|
||||
AudioCaptureManager.enumerate_devices(),
|
||||
AudioCaptureManager.enumerate_devices_by_engine(),
|
||||
)
|
||||
)
|
||||
return {
|
||||
"devices": devices,
|
||||
"count": len(devices),
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Audio source routes: CRUD for audio sources + real-time test WebSocket."""
|
||||
|
||||
import asyncio
|
||||
import secrets
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
@@ -21,7 +20,6 @@ from wled_controller.api.schemas.audio_sources import (
|
||||
AudioSourceResponse,
|
||||
AudioSourceUpdate,
|
||||
)
|
||||
from wled_controller.config import get_config
|
||||
from wled_controller.storage.audio_source import AudioSource
|
||||
from wled_controller.storage.audio_source_store import AudioSourceStore
|
||||
from wled_controller.storage.color_strip_store import ColorStripStore
|
||||
@@ -169,16 +167,8 @@ async def test_audio_source_ws(
|
||||
(ref-counted — shares with running targets), and streams AudioAnalysis
|
||||
snapshots as JSON at ~20 Hz.
|
||||
"""
|
||||
# Authenticate
|
||||
authenticated = False
|
||||
cfg = get_config()
|
||||
if token and cfg.auth.api_keys:
|
||||
for _label, api_key in cfg.auth.api_keys.items():
|
||||
if secrets.compare_digest(token, api_key):
|
||||
authenticated = True
|
||||
break
|
||||
|
||||
if not authenticated:
|
||||
from wled_controller.api.auth import verify_ws_token
|
||||
if not verify_ws_token(token):
|
||||
await websocket.close(code=4001, reason="Unauthorized")
|
||||
return
|
||||
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import secrets
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Depends, Query
|
||||
from starlette.websockets import WebSocket, WebSocketDisconnect
|
||||
|
||||
@@ -17,7 +15,6 @@ from wled_controller.api.schemas.audio_templates import (
|
||||
AudioTemplateResponse,
|
||||
AudioTemplateUpdate,
|
||||
)
|
||||
from wled_controller.config import get_config
|
||||
from wled_controller.core.audio.factory import AudioEngineRegistry
|
||||
from wled_controller.storage.audio_template_store import AudioTemplateStore
|
||||
from wled_controller.storage.audio_source_store import AudioSourceStore
|
||||
@@ -189,16 +186,8 @@ async def test_audio_template_ws(
|
||||
Auth via ?token=<api_key>. Device specified via ?device_index=N&is_loopback=0|1.
|
||||
Streams AudioAnalysis snapshots as JSON at ~20 Hz.
|
||||
"""
|
||||
# Authenticate
|
||||
authenticated = False
|
||||
cfg = get_config()
|
||||
if token and cfg.auth.api_keys:
|
||||
for _label, api_key in cfg.auth.api_keys.items():
|
||||
if secrets.compare_digest(token, api_key):
|
||||
authenticated = True
|
||||
break
|
||||
|
||||
if not authenticated:
|
||||
from wled_controller.api.auth import verify_ws_token
|
||||
if not verify_ws_token(token):
|
||||
await websocket.close(code=4001, reason="Unauthorized")
|
||||
return
|
||||
|
||||
|
||||
265
server/src/wled_controller/api/routes/color_strip_processing.py
Normal file
265
server/src/wled_controller/api/routes/color_strip_processing.py
Normal file
@@ -0,0 +1,265 @@
|
||||
"""Color strip processing template routes."""
|
||||
|
||||
import asyncio
|
||||
import json as _json
|
||||
import time as _time
|
||||
import uuid as _uuid
|
||||
|
||||
import numpy as np
|
||||
from fastapi import APIRouter, HTTPException, Depends, Query, WebSocket, WebSocketDisconnect
|
||||
|
||||
from wled_controller.api.auth import AuthRequired
|
||||
from wled_controller.api.dependencies import (
|
||||
fire_entity_event,
|
||||
get_color_strip_store,
|
||||
get_cspt_store,
|
||||
get_device_store,
|
||||
get_processor_manager,
|
||||
)
|
||||
from wled_controller.api.schemas.filters import FilterInstanceSchema
|
||||
from wled_controller.api.schemas.color_strip_processing import (
|
||||
ColorStripProcessingTemplateCreate,
|
||||
ColorStripProcessingTemplateListResponse,
|
||||
ColorStripProcessingTemplateResponse,
|
||||
ColorStripProcessingTemplateUpdate,
|
||||
)
|
||||
from wled_controller.core.filters import FilterInstance
|
||||
from wled_controller.storage.color_strip_processing_template_store import ColorStripProcessingTemplateStore
|
||||
from wled_controller.storage.color_strip_store import ColorStripStore
|
||||
from wled_controller.storage import DeviceStore
|
||||
from wled_controller.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _cspt_to_response(t) -> ColorStripProcessingTemplateResponse:
|
||||
"""Convert a ColorStripProcessingTemplate to its API response."""
|
||||
return ColorStripProcessingTemplateResponse(
|
||||
id=t.id,
|
||||
name=t.name,
|
||||
filters=[FilterInstanceSchema(filter_id=f.filter_id, options=f.options) for f in t.filters],
|
||||
created_at=t.created_at,
|
||||
updated_at=t.updated_at,
|
||||
description=t.description,
|
||||
tags=getattr(t, 'tags', []),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/v1/color-strip-processing-templates", response_model=ColorStripProcessingTemplateListResponse, tags=["Color Strip Processing"])
|
||||
async def list_cspt(
|
||||
_auth: AuthRequired,
|
||||
store: ColorStripProcessingTemplateStore = Depends(get_cspt_store),
|
||||
):
|
||||
"""List all color strip processing templates."""
|
||||
try:
|
||||
templates = store.get_all_templates()
|
||||
responses = [_cspt_to_response(t) for t in templates]
|
||||
return ColorStripProcessingTemplateListResponse(templates=responses, count=len(responses))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to list color strip processing templates: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/api/v1/color-strip-processing-templates", response_model=ColorStripProcessingTemplateResponse, tags=["Color Strip Processing"], status_code=201)
|
||||
async def create_cspt(
|
||||
data: ColorStripProcessingTemplateCreate,
|
||||
_auth: AuthRequired,
|
||||
store: ColorStripProcessingTemplateStore = Depends(get_cspt_store),
|
||||
):
|
||||
"""Create a new color strip processing template."""
|
||||
try:
|
||||
filters = [FilterInstance(f.filter_id, f.options) for f in data.filters]
|
||||
template = store.create_template(
|
||||
name=data.name,
|
||||
filters=filters,
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
)
|
||||
fire_entity_event("cspt", "created", template.id)
|
||||
return _cspt_to_response(template)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create color strip processing template: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/api/v1/color-strip-processing-templates/{template_id}", response_model=ColorStripProcessingTemplateResponse, tags=["Color Strip Processing"])
|
||||
async def get_cspt(
|
||||
template_id: str,
|
||||
_auth: AuthRequired,
|
||||
store: ColorStripProcessingTemplateStore = Depends(get_cspt_store),
|
||||
):
|
||||
"""Get color strip processing template by ID."""
|
||||
try:
|
||||
template = store.get_template(template_id)
|
||||
return _cspt_to_response(template)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=404, detail=f"Color strip processing template {template_id} not found")
|
||||
|
||||
|
||||
@router.put("/api/v1/color-strip-processing-templates/{template_id}", response_model=ColorStripProcessingTemplateResponse, tags=["Color Strip Processing"])
|
||||
async def update_cspt(
|
||||
template_id: str,
|
||||
data: ColorStripProcessingTemplateUpdate,
|
||||
_auth: AuthRequired,
|
||||
store: ColorStripProcessingTemplateStore = Depends(get_cspt_store),
|
||||
):
|
||||
"""Update a color strip processing template."""
|
||||
try:
|
||||
filters = [FilterInstance(f.filter_id, f.options) for f in data.filters] if data.filters is not None else None
|
||||
template = store.update_template(
|
||||
template_id=template_id,
|
||||
name=data.name,
|
||||
filters=filters,
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
)
|
||||
fire_entity_event("cspt", "updated", template_id)
|
||||
return _cspt_to_response(template)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update color strip processing template: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.delete("/api/v1/color-strip-processing-templates/{template_id}", status_code=204, tags=["Color Strip Processing"])
|
||||
async def delete_cspt(
|
||||
template_id: str,
|
||||
_auth: AuthRequired,
|
||||
store: ColorStripProcessingTemplateStore = Depends(get_cspt_store),
|
||||
device_store: DeviceStore = Depends(get_device_store),
|
||||
css_store: ColorStripStore = Depends(get_color_strip_store),
|
||||
):
|
||||
"""Delete a color strip processing template."""
|
||||
try:
|
||||
refs = store.get_references(template_id, device_store=device_store, css_store=css_store)
|
||||
if refs:
|
||||
names = ", ".join(refs)
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=f"Cannot delete: template is referenced by: {names}. "
|
||||
"Please reassign before deleting.",
|
||||
)
|
||||
store.delete_template(template_id)
|
||||
fire_entity_event("cspt", "deleted", template_id)
|
||||
except HTTPException:
|
||||
raise
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete color strip processing template: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ── Test / Preview WebSocket ──────────────────────────────────────────
|
||||
|
||||
@router.websocket("/api/v1/color-strip-processing-templates/{template_id}/test/ws")
|
||||
async def test_cspt_ws(
|
||||
websocket: WebSocket,
|
||||
template_id: str,
|
||||
token: str = Query(""),
|
||||
input_source_id: str = Query(""),
|
||||
led_count: int = Query(100),
|
||||
fps: int = Query(20),
|
||||
):
|
||||
"""WebSocket for real-time CSPT preview.
|
||||
|
||||
Takes an input CSS source, applies the CSPT filter chain, and streams
|
||||
the processed RGB frames. Auth via ``?token=<api_key>``.
|
||||
"""
|
||||
from wled_controller.api.auth import verify_ws_token
|
||||
from wled_controller.core.filters import FilterRegistry
|
||||
from wled_controller.core.processing.processor_manager import ProcessorManager
|
||||
|
||||
if not verify_ws_token(token):
|
||||
await websocket.close(code=4001, reason="Unauthorized")
|
||||
return
|
||||
|
||||
# Validate template exists
|
||||
cspt_store = get_cspt_store()
|
||||
try:
|
||||
template = cspt_store.get_template(template_id)
|
||||
except (ValueError, RuntimeError) as e:
|
||||
await websocket.close(code=4004, reason=str(e))
|
||||
return
|
||||
|
||||
if not input_source_id:
|
||||
await websocket.close(code=4003, reason="input_source_id is required")
|
||||
return
|
||||
|
||||
# Validate input source exists
|
||||
css_store = get_color_strip_store()
|
||||
try:
|
||||
input_source = css_store.get_source(input_source_id)
|
||||
except (ValueError, RuntimeError) as e:
|
||||
await websocket.close(code=4004, reason=str(e))
|
||||
return
|
||||
|
||||
# Resolve filter chain
|
||||
try:
|
||||
resolved = cspt_store.resolve_filter_instances(template.filters)
|
||||
filters = [FilterRegistry.create_instance(fi.filter_id, fi.options) for fi in resolved]
|
||||
except Exception as e:
|
||||
logger.error(f"CSPT test: failed to resolve filters for {template_id}: {e}")
|
||||
await websocket.close(code=4003, reason=str(e))
|
||||
return
|
||||
|
||||
# Acquire input stream
|
||||
manager: ProcessorManager = get_processor_manager()
|
||||
csm = manager.color_strip_stream_manager
|
||||
consumer_id = f"__cspt_test_{_uuid.uuid4().hex[:8]}__"
|
||||
try:
|
||||
stream = csm.acquire(input_source_id, consumer_id)
|
||||
except Exception as e:
|
||||
logger.error(f"CSPT test: failed to acquire input stream for {input_source_id}: {e}")
|
||||
await websocket.close(code=4003, reason=str(e))
|
||||
return
|
||||
|
||||
# Configure LED count for auto-sizing streams
|
||||
if hasattr(stream, "configure"):
|
||||
stream.configure(max(1, led_count))
|
||||
|
||||
fps = max(1, min(60, fps))
|
||||
frame_interval = 1.0 / fps
|
||||
|
||||
await websocket.accept()
|
||||
logger.info(f"CSPT test WS connected: template={template_id}, input={input_source_id}")
|
||||
|
||||
try:
|
||||
# Send metadata
|
||||
meta = {
|
||||
"type": "meta",
|
||||
"source_type": input_source.source_type,
|
||||
"source_name": input_source.name,
|
||||
"template_name": template.name,
|
||||
"led_count": stream.led_count,
|
||||
"filter_count": len(filters),
|
||||
}
|
||||
await websocket.send_text(_json.dumps(meta))
|
||||
|
||||
# Stream processed frames
|
||||
while True:
|
||||
colors = stream.get_latest_colors()
|
||||
if colors is not None:
|
||||
# Apply CSPT filters
|
||||
for flt in filters:
|
||||
try:
|
||||
result = flt.process_strip(colors)
|
||||
if result is not None:
|
||||
colors = result
|
||||
except Exception:
|
||||
pass
|
||||
await websocket.send_bytes(colors.tobytes())
|
||||
await asyncio.sleep(frame_interval)
|
||||
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error(f"CSPT test WS error: {e}")
|
||||
finally:
|
||||
csm.release(input_source_id, consumer_id)
|
||||
logger.info(f"CSPT test WS disconnected: template={template_id}")
|
||||
@@ -36,7 +36,7 @@ from wled_controller.core.capture.calibration import (
|
||||
)
|
||||
from wled_controller.core.capture.screen_capture import get_available_displays
|
||||
from wled_controller.core.processing.processor_manager import ProcessorManager
|
||||
from wled_controller.storage.color_strip_source import AdvancedPictureColorStripSource, ApiInputColorStripSource, CompositeColorStripSource, NotificationColorStripSource, PictureColorStripSource
|
||||
from wled_controller.storage.color_strip_source import AdvancedPictureColorStripSource, ApiInputColorStripSource, CompositeColorStripSource, NotificationColorStripSource, PictureColorStripSource, ProcessedColorStripSource
|
||||
from wled_controller.storage.color_strip_store import ColorStripStore
|
||||
from wled_controller.storage.picture_source import ProcessedPictureSource, ScreenCapturePictureSource
|
||||
from wled_controller.storage.picture_source_store import PictureSourceStore
|
||||
@@ -238,6 +238,14 @@ async def delete_color_strip_source(
|
||||
detail=f"Color strip source is used as a zone in mapped source(s): {names}. "
|
||||
"Remove it from the mapped source(s) first.",
|
||||
)
|
||||
processed_names = store.get_processed_referencing(source_id)
|
||||
if processed_names:
|
||||
names = ", ".join(processed_names)
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=f"Color strip source is used as input in processed source(s): {names}. "
|
||||
"Delete or reassign the processed source(s) first.",
|
||||
)
|
||||
store.delete_source(source_id)
|
||||
fire_entity_event("color_strip_source", "deleted", source_id)
|
||||
except HTTPException:
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
"""Device routes: CRUD, health state, brightness, power, calibration, WS stream."""
|
||||
|
||||
import secrets
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, HTTPException, Depends, Query, WebSocket, WebSocketDisconnect
|
||||
|
||||
@@ -157,8 +155,7 @@ async def create_device(
|
||||
|
||||
# WS devices: auto-set URL to ws://{device_id}
|
||||
if device_type == "ws":
|
||||
store.update_device(device_id=device.id, url=f"ws://{device.id}")
|
||||
device = store.get_device(device.id)
|
||||
device = store.update_device(device.id, url=f"ws://{device.id}")
|
||||
|
||||
# Register in processor manager for health monitoring
|
||||
manager.add_device(
|
||||
@@ -309,9 +306,10 @@ async def get_device(
|
||||
store: DeviceStore = Depends(get_device_store),
|
||||
):
|
||||
"""Get device details by ID."""
|
||||
device = store.get_device(device_id)
|
||||
if not device:
|
||||
raise HTTPException(status_code=404, detail=f"Device {device_id} not found")
|
||||
try:
|
||||
device = store.get_device(device_id)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
return _device_to_response(device)
|
||||
|
||||
|
||||
@@ -430,9 +428,10 @@ async def get_device_state(
|
||||
manager: ProcessorManager = Depends(get_processor_manager),
|
||||
):
|
||||
"""Get device health/connection state."""
|
||||
device = store.get_device(device_id)
|
||||
if not device:
|
||||
raise HTTPException(status_code=404, detail=f"Device {device_id} not found")
|
||||
try:
|
||||
device = store.get_device(device_id)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
try:
|
||||
state = manager.get_device_health_dict(device_id)
|
||||
@@ -450,9 +449,10 @@ async def ping_device(
|
||||
manager: ProcessorManager = Depends(get_processor_manager),
|
||||
):
|
||||
"""Force an immediate health check on a device."""
|
||||
device = store.get_device(device_id)
|
||||
if not device:
|
||||
raise HTTPException(status_code=404, detail=f"Device {device_id} not found")
|
||||
try:
|
||||
device = store.get_device(device_id)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
try:
|
||||
state = await manager.force_device_health_check(device_id)
|
||||
@@ -477,9 +477,10 @@ async def get_device_brightness(
|
||||
frontend request — hitting the ESP32 over WiFi in the async event loop
|
||||
causes ~150 ms jitter in the processing loop.
|
||||
"""
|
||||
device = store.get_device(device_id)
|
||||
if not device:
|
||||
raise HTTPException(status_code=404, detail=f"Device {device_id} not found")
|
||||
try:
|
||||
device = store.get_device(device_id)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
if "brightness_control" not in get_device_capabilities(device.device_type):
|
||||
raise HTTPException(status_code=400, detail=f"Brightness control is not supported for {device.device_type} devices")
|
||||
|
||||
@@ -512,9 +513,10 @@ async def set_device_brightness(
|
||||
manager: ProcessorManager = Depends(get_processor_manager),
|
||||
):
|
||||
"""Set brightness on the device."""
|
||||
device = store.get_device(device_id)
|
||||
if not device:
|
||||
raise HTTPException(status_code=404, detail=f"Device {device_id} not found")
|
||||
try:
|
||||
device = store.get_device(device_id)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
if "brightness_control" not in get_device_capabilities(device.device_type):
|
||||
raise HTTPException(status_code=400, detail=f"Brightness control is not supported for {device.device_type} devices")
|
||||
|
||||
@@ -528,10 +530,7 @@ async def set_device_brightness(
|
||||
await provider.set_brightness(device.url, bri)
|
||||
except NotImplementedError:
|
||||
# Provider has no hardware brightness; use software brightness
|
||||
device.software_brightness = bri
|
||||
from datetime import datetime, timezone
|
||||
device.updated_at = datetime.now(timezone.utc)
|
||||
store.save()
|
||||
store.update_device(device_id=device_id, software_brightness=bri)
|
||||
ds = manager.find_device_state(device_id)
|
||||
if ds:
|
||||
ds.software_brightness = bri
|
||||
@@ -557,9 +556,10 @@ async def get_device_power(
|
||||
manager: ProcessorManager = Depends(get_processor_manager),
|
||||
):
|
||||
"""Get current power state from the device."""
|
||||
device = store.get_device(device_id)
|
||||
if not device:
|
||||
raise HTTPException(status_code=404, detail=f"Device {device_id} not found")
|
||||
try:
|
||||
device = store.get_device(device_id)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
if "power_control" not in get_device_capabilities(device.device_type):
|
||||
raise HTTPException(status_code=400, detail=f"Power control is not supported for {device.device_type} devices")
|
||||
|
||||
@@ -586,9 +586,10 @@ async def set_device_power(
|
||||
manager: ProcessorManager = Depends(get_processor_manager),
|
||||
):
|
||||
"""Turn device on or off."""
|
||||
device = store.get_device(device_id)
|
||||
if not device:
|
||||
raise HTTPException(status_code=404, detail=f"Device {device_id} not found")
|
||||
try:
|
||||
device = store.get_device(device_id)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
if "power_control" not in get_device_capabilities(device.device_type):
|
||||
raise HTTPException(status_code=400, detail=f"Power control is not supported for {device.device_type} devices")
|
||||
|
||||
@@ -628,23 +629,15 @@ async def device_ws_stream(
|
||||
Wire format: [brightness_byte][R G B R G B ...]
|
||||
Auth via ?token=<api_key>.
|
||||
"""
|
||||
from wled_controller.config import get_config
|
||||
|
||||
authenticated = False
|
||||
cfg = get_config()
|
||||
if token and cfg.auth.api_keys:
|
||||
for _label, api_key in cfg.auth.api_keys.items():
|
||||
if secrets.compare_digest(token, api_key):
|
||||
authenticated = True
|
||||
break
|
||||
|
||||
if not authenticated:
|
||||
from wled_controller.api.auth import verify_ws_token
|
||||
if not verify_ws_token(token):
|
||||
await websocket.close(code=4001, reason="Unauthorized")
|
||||
return
|
||||
|
||||
store = get_device_store()
|
||||
device = store.get_device(device_id)
|
||||
if not device:
|
||||
try:
|
||||
device = store.get_device(device_id)
|
||||
except ValueError:
|
||||
await websocket.close(code=4004, reason="Device not found")
|
||||
return
|
||||
if device.device_type != "ws":
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import io
|
||||
import secrets
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
@@ -35,7 +34,6 @@ from wled_controller.api.schemas.output_targets import (
|
||||
TargetMetricsResponse,
|
||||
TargetProcessingState,
|
||||
)
|
||||
from wled_controller.config import get_config
|
||||
from wled_controller.core.capture_engines import EngineRegistry
|
||||
from wled_controller.core.filters import FilterRegistry, ImagePool
|
||||
from wled_controller.core.processing.processor_manager import ProcessorManager
|
||||
@@ -151,8 +149,9 @@ async def create_target(
|
||||
try:
|
||||
# Validate device exists if provided
|
||||
if data.device_id:
|
||||
device = device_store.get_device(data.device_id)
|
||||
if not device:
|
||||
try:
|
||||
device_store.get_device(data.device_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=422, detail=f"Device {data.device_id} not found")
|
||||
|
||||
kc_settings = _kc_schema_to_settings(data.key_colors_settings) if data.key_colors_settings else None
|
||||
@@ -250,8 +249,9 @@ async def update_target(
|
||||
try:
|
||||
# Validate device exists if changing
|
||||
if data.device_id is not None and data.device_id:
|
||||
device = device_store.get_device(data.device_id)
|
||||
if not device:
|
||||
try:
|
||||
device_store.get_device(data.device_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=422, detail=f"Device {data.device_id} not found")
|
||||
|
||||
# Build KC settings with partial-update support: only apply fields that were
|
||||
@@ -697,16 +697,8 @@ async def target_colors_ws(
|
||||
token: str = Query(""),
|
||||
):
|
||||
"""WebSocket for real-time key color updates. Auth via ?token=<api_key>."""
|
||||
# Authenticate
|
||||
authenticated = False
|
||||
cfg = get_config()
|
||||
if token and cfg.auth.api_keys:
|
||||
for _label, api_key in cfg.auth.api_keys.items():
|
||||
if secrets.compare_digest(token, api_key):
|
||||
authenticated = True
|
||||
break
|
||||
|
||||
if not authenticated:
|
||||
from wled_controller.api.auth import verify_ws_token
|
||||
if not verify_ws_token(token):
|
||||
await websocket.close(code=4001, reason="Unauthorized")
|
||||
return
|
||||
|
||||
@@ -737,15 +729,8 @@ async def led_preview_ws(
|
||||
token: str = Query(""),
|
||||
):
|
||||
"""WebSocket for real-time LED strip preview. Sends binary RGB frames. Auth via ?token=<api_key>."""
|
||||
authenticated = False
|
||||
cfg = get_config()
|
||||
if token and cfg.auth.api_keys:
|
||||
for _label, api_key in cfg.auth.api_keys.items():
|
||||
if secrets.compare_digest(token, api_key):
|
||||
authenticated = True
|
||||
break
|
||||
|
||||
if not authenticated:
|
||||
from wled_controller.api.auth import verify_ws_token
|
||||
if not verify_ws_token(token):
|
||||
await websocket.close(code=4001, reason="Unauthorized")
|
||||
return
|
||||
|
||||
@@ -777,15 +762,8 @@ async def events_ws(
|
||||
token: str = Query(""),
|
||||
):
|
||||
"""WebSocket for real-time state change events. Auth via ?token=<api_key>."""
|
||||
authenticated = False
|
||||
cfg = get_config()
|
||||
if token and cfg.auth.api_keys:
|
||||
for _label, api_key in cfg.auth.api_keys.items():
|
||||
if secrets.compare_digest(token, api_key):
|
||||
authenticated = True
|
||||
break
|
||||
|
||||
if not authenticated:
|
||||
from wled_controller.api.auth import verify_ws_token
|
||||
if not verify_ws_token(token):
|
||||
await websocket.close(code=4001, reason="Unauthorized")
|
||||
return
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Picture source routes."""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import io
|
||||
import time
|
||||
@@ -97,23 +98,26 @@ async def validate_image(
|
||||
async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client:
|
||||
response = await client.get(source)
|
||||
response.raise_for_status()
|
||||
pil_image = Image.open(io.BytesIO(response.content))
|
||||
img_bytes = response.content
|
||||
else:
|
||||
path = Path(source)
|
||||
if not path.exists():
|
||||
return ImageValidateResponse(valid=False, error=f"File not found: {source}")
|
||||
pil_image = Image.open(path)
|
||||
img_bytes = path
|
||||
|
||||
pil_image = pil_image.convert("RGB")
|
||||
width, height = pil_image.size
|
||||
def _process_image(src):
|
||||
pil_image = Image.open(io.BytesIO(src) if isinstance(src, bytes) else src)
|
||||
pil_image = pil_image.convert("RGB")
|
||||
width, height = pil_image.size
|
||||
thumb = pil_image.copy()
|
||||
thumb.thumbnail((320, 320), Image.Resampling.LANCZOS)
|
||||
buf = io.BytesIO()
|
||||
thumb.save(buf, format="JPEG", quality=80)
|
||||
buf.seek(0)
|
||||
preview = f"data:image/jpeg;base64,{base64.b64encode(buf.getvalue()).decode()}"
|
||||
return width, height, preview
|
||||
|
||||
# Create thumbnail preview (max 320px wide)
|
||||
thumb = pil_image.copy()
|
||||
thumb.thumbnail((320, 320), Image.Resampling.LANCZOS)
|
||||
buf = io.BytesIO()
|
||||
thumb.save(buf, format="JPEG", quality=80)
|
||||
buf.seek(0)
|
||||
preview = f"data:image/jpeg;base64,{base64.b64encode(buf.getvalue()).decode()}"
|
||||
width, height, preview = await asyncio.to_thread(_process_image, img_bytes)
|
||||
|
||||
return ImageValidateResponse(
|
||||
valid=True, width=width, height=height, preview=preview
|
||||
@@ -140,18 +144,22 @@ async def get_full_image(
|
||||
async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client:
|
||||
response = await client.get(source)
|
||||
response.raise_for_status()
|
||||
pil_image = Image.open(io.BytesIO(response.content))
|
||||
img_bytes = response.content
|
||||
else:
|
||||
path = Path(source)
|
||||
if not path.exists():
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
pil_image = Image.open(path)
|
||||
img_bytes = path
|
||||
|
||||
pil_image = pil_image.convert("RGB")
|
||||
buf = io.BytesIO()
|
||||
pil_image.save(buf, format="JPEG", quality=90)
|
||||
buf.seek(0)
|
||||
return Response(content=buf.getvalue(), media_type="image/jpeg")
|
||||
def _encode_full(src):
|
||||
pil_image = Image.open(io.BytesIO(src) if isinstance(src, bytes) else src)
|
||||
pil_image = pil_image.convert("RGB")
|
||||
buf = io.BytesIO()
|
||||
pil_image.save(buf, format="JPEG", quality=90)
|
||||
return buf.getvalue()
|
||||
|
||||
jpeg_bytes = await asyncio.to_thread(_encode_full, img_bytes)
|
||||
return Response(content=jpeg_bytes, media_type="image/jpeg")
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
@@ -326,7 +334,7 @@ async def test_picture_source(
|
||||
path = Path(source)
|
||||
if not path.exists():
|
||||
raise HTTPException(status_code=400, detail=f"Image file not found: {source}")
|
||||
pil_image = Image.open(path).convert("RGB")
|
||||
pil_image = await asyncio.to_thread(lambda: Image.open(path).convert("RGB"))
|
||||
|
||||
actual_duration = time.perf_counter() - start_time
|
||||
frame_count = 1
|
||||
@@ -393,48 +401,50 @@ async def test_picture_source(
|
||||
else:
|
||||
raise ValueError("Unexpected image format from engine")
|
||||
|
||||
# Create thumbnail
|
||||
thumbnail_width = 640
|
||||
aspect_ratio = pil_image.height / pil_image.width
|
||||
thumbnail_height = int(thumbnail_width * aspect_ratio)
|
||||
thumbnail = pil_image.copy()
|
||||
thumbnail.thumbnail((thumbnail_width, thumbnail_height), Image.Resampling.LANCZOS)
|
||||
|
||||
# Apply postprocessing filters if this is a processed stream
|
||||
# Create thumbnail + encode (CPU-bound — run in thread)
|
||||
pp_template_ids = chain["postprocessing_template_ids"]
|
||||
flat_filters = None
|
||||
if pp_template_ids:
|
||||
try:
|
||||
pp_template = pp_store.get_template(pp_template_ids[0])
|
||||
flat_filters = pp_store.resolve_filter_instances(pp_template.filters)
|
||||
if flat_filters:
|
||||
pool = ImagePool()
|
||||
|
||||
def apply_filters(img):
|
||||
arr = np.array(img)
|
||||
for fi in flat_filters:
|
||||
f = FilterRegistry.create_instance(fi.filter_id, fi.options)
|
||||
result = f.process_image(arr, pool)
|
||||
if result is not None:
|
||||
arr = result
|
||||
return Image.fromarray(arr)
|
||||
|
||||
thumbnail = apply_filters(thumbnail)
|
||||
pil_image = apply_filters(pil_image)
|
||||
flat_filters = pp_store.resolve_filter_instances(pp_template.filters) or None
|
||||
except ValueError:
|
||||
logger.warning(f"PP template {pp_template_ids[0]} not found, skipping postprocessing preview")
|
||||
|
||||
# Encode thumbnail
|
||||
img_buffer = io.BytesIO()
|
||||
thumbnail.save(img_buffer, format='JPEG', quality=85)
|
||||
img_buffer.seek(0)
|
||||
thumbnail_b64 = base64.b64encode(img_buffer.getvalue()).decode('utf-8')
|
||||
thumbnail_data_uri = f"data:image/jpeg;base64,{thumbnail_b64}"
|
||||
def _create_thumbnails_and_encode(pil_img, filters):
|
||||
thumbnail_w = 640
|
||||
aspect_ratio = pil_img.height / pil_img.width
|
||||
thumbnail_h = int(thumbnail_w * aspect_ratio)
|
||||
thumb = pil_img.copy()
|
||||
thumb.thumbnail((thumbnail_w, thumbnail_h), Image.Resampling.LANCZOS)
|
||||
|
||||
# Encode full-resolution image
|
||||
full_buffer = io.BytesIO()
|
||||
pil_image.save(full_buffer, format='JPEG', quality=90)
|
||||
full_buffer.seek(0)
|
||||
full_b64 = base64.b64encode(full_buffer.getvalue()).decode('utf-8')
|
||||
if filters:
|
||||
pool = ImagePool()
|
||||
def apply_filters(img):
|
||||
arr = np.array(img)
|
||||
for fi in filters:
|
||||
f = FilterRegistry.create_instance(fi.filter_id, fi.options)
|
||||
result = f.process_image(arr, pool)
|
||||
if result is not None:
|
||||
arr = result
|
||||
return Image.fromarray(arr)
|
||||
thumb = apply_filters(thumb)
|
||||
pil_img = apply_filters(pil_img)
|
||||
|
||||
img_buffer = io.BytesIO()
|
||||
thumb.save(img_buffer, format='JPEG', quality=85)
|
||||
thumb_b64 = base64.b64encode(img_buffer.getvalue()).decode('utf-8')
|
||||
|
||||
full_buffer = io.BytesIO()
|
||||
pil_img.save(full_buffer, format='JPEG', quality=90)
|
||||
full_b64 = base64.b64encode(full_buffer.getvalue()).decode('utf-8')
|
||||
|
||||
return thumbnail_w, thumbnail_h, thumb_b64, full_b64
|
||||
|
||||
thumbnail_width, thumbnail_height, thumbnail_b64, full_b64 = await asyncio.to_thread(
|
||||
_create_thumbnails_and_encode, pil_image, flat_filters
|
||||
)
|
||||
thumbnail_data_uri = f"data:image/jpeg;base64,{thumbnail_b64}"
|
||||
full_data_uri = f"data:image/jpeg;base64,{full_b64}"
|
||||
|
||||
actual_fps = frame_count / actual_duration if actual_duration > 0 else 0
|
||||
|
||||
@@ -59,19 +59,8 @@ logger = get_logger(__name__)
|
||||
# Prime psutil CPU counter (first call always returns 0.0)
|
||||
psutil.cpu_percent(interval=None)
|
||||
|
||||
# Try to initialize NVIDIA GPU monitoring
|
||||
_nvml_available = False
|
||||
try:
|
||||
import pynvml as _pynvml_mod # nvidia-ml-py (the pynvml wrapper is deprecated)
|
||||
|
||||
_pynvml_mod.nvmlInit()
|
||||
_nvml_handle = _pynvml_mod.nvmlDeviceGetHandleByIndex(0)
|
||||
_nvml_available = True
|
||||
_nvml = _pynvml_mod
|
||||
logger.info(f"NVIDIA GPU monitoring enabled: {_nvml.nvmlDeviceGetName(_nvml_handle)}")
|
||||
except Exception:
|
||||
_nvml = None
|
||||
logger.info("NVIDIA GPU monitoring unavailable (pynvml not installed or no NVIDIA GPU)")
|
||||
# GPU monitoring (initialized once in utils.gpu, shared with metrics_history)
|
||||
from wled_controller.utils.gpu import nvml_available as _nvml_available, nvml as _nvml, nvml_handle as _nvml_handle
|
||||
|
||||
|
||||
def _get_cpu_name() -> str | None:
|
||||
@@ -156,17 +145,9 @@ async def list_all_tags(_: AuthRequired):
|
||||
store = getter()
|
||||
except RuntimeError:
|
||||
continue
|
||||
# Each store has a different "get all" method name
|
||||
items = None
|
||||
for method_name in (
|
||||
"get_all_devices", "get_all_targets", "get_all_sources",
|
||||
"get_all_streams", "get_all_clocks", "get_all_automations",
|
||||
"get_all_presets", "get_all_templates",
|
||||
):
|
||||
fn = getattr(store, method_name, None)
|
||||
if fn is not None:
|
||||
items = fn()
|
||||
break
|
||||
# BaseJsonStore subclasses provide get_all(); DeviceStore provides get_all_devices()
|
||||
fn = getattr(store, "get_all", None) or getattr(store, "get_all_devices", None)
|
||||
items = fn() if fn else None
|
||||
if items:
|
||||
for item in items:
|
||||
all_tags.update(getattr(item, 'tags', []))
|
||||
@@ -191,9 +172,9 @@ async def get_displays(
|
||||
from wled_controller.core.capture_engines import EngineRegistry
|
||||
|
||||
engine_cls = EngineRegistry.get_engine(engine_type)
|
||||
display_dataclasses = engine_cls.get_available_displays()
|
||||
display_dataclasses = await asyncio.to_thread(engine_cls.get_available_displays)
|
||||
else:
|
||||
display_dataclasses = get_available_displays()
|
||||
display_dataclasses = await asyncio.to_thread(get_available_displays)
|
||||
|
||||
# Convert dataclass DisplayInfo to Pydantic DisplayInfo
|
||||
displays = [
|
||||
@@ -321,6 +302,7 @@ STORE_MAP = {
|
||||
"audio_templates": "audio_templates_file",
|
||||
"value_sources": "value_sources_file",
|
||||
"sync_clocks": "sync_clocks_file",
|
||||
"color_strip_processing_templates": "color_strip_processing_templates_file",
|
||||
"automations": "automations_file",
|
||||
"scene_presets": "scene_presets_file",
|
||||
}
|
||||
@@ -579,11 +561,13 @@ async def adb_connect(_: AuthRequired, request: AdbConnectRequest):
|
||||
adb = _get_adb_path()
|
||||
logger.info(f"Connecting ADB device: {address}")
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[adb, "connect", address],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
adb, "connect", address,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
output = (result.stdout + result.stderr).strip()
|
||||
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=10)
|
||||
output = (stdout.decode() + stderr.decode()).strip()
|
||||
if "connected" in output.lower():
|
||||
return {"status": "connected", "address": address, "message": output}
|
||||
raise HTTPException(status_code=400, detail=output or "Connection failed")
|
||||
@@ -592,7 +576,7 @@ async def adb_connect(_: AuthRequired, request: AdbConnectRequest):
|
||||
status_code=500,
|
||||
detail="adb not found on PATH. Install Android SDK Platform-Tools.",
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
except asyncio.TimeoutError:
|
||||
raise HTTPException(status_code=504, detail="ADB connect timed out")
|
||||
|
||||
|
||||
@@ -606,12 +590,14 @@ async def adb_disconnect(_: AuthRequired, request: AdbConnectRequest):
|
||||
adb = _get_adb_path()
|
||||
logger.info(f"Disconnecting ADB device: {address}")
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[adb, "disconnect", address],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
adb, "disconnect", address,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
return {"status": "disconnected", "message": result.stdout.strip()}
|
||||
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=10)
|
||||
return {"status": "disconnected", "message": stdout.decode().strip()}
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(status_code=500, detail="adb not found on PATH")
|
||||
except subprocess.TimeoutExpired:
|
||||
except asyncio.TimeoutError:
|
||||
raise HTTPException(status_code=504, detail="ADB disconnect timed out")
|
||||
|
||||
@@ -11,6 +11,7 @@ from fastapi import APIRouter, HTTPException, Depends, Query, WebSocket, WebSock
|
||||
from wled_controller.api.auth import AuthRequired
|
||||
from wled_controller.api.dependencies import (
|
||||
fire_entity_event,
|
||||
get_cspt_store,
|
||||
get_picture_source_store,
|
||||
get_pp_template_store,
|
||||
get_template_store,
|
||||
@@ -479,3 +480,48 @@ async def list_filter_types(
|
||||
options_schema=opt_schemas,
|
||||
))
|
||||
return FilterTypeListResponse(filters=responses, count=len(responses))
|
||||
|
||||
|
||||
@router.get("/api/v1/strip-filters", response_model=FilterTypeListResponse, tags=["Filters"])
|
||||
async def list_strip_filter_types(
|
||||
_auth: AuthRequired,
|
||||
cspt_store=Depends(get_cspt_store),
|
||||
):
|
||||
"""List filter types that support 1D LED strip processing."""
|
||||
all_filters = FilterRegistry.get_all()
|
||||
|
||||
# Pre-build template choices for the css_filter_template filter
|
||||
cspt_choices = None
|
||||
if cspt_store:
|
||||
try:
|
||||
templates = cspt_store.get_all_templates()
|
||||
cspt_choices = [{"value": t.id, "label": t.name} for t in templates]
|
||||
except Exception:
|
||||
cspt_choices = []
|
||||
|
||||
responses = []
|
||||
for filter_id, filter_cls in all_filters.items():
|
||||
if not getattr(filter_cls, "supports_strip", True):
|
||||
continue
|
||||
schema = filter_cls.get_options_schema()
|
||||
opt_schemas = []
|
||||
for opt in schema:
|
||||
choices = opt.choices
|
||||
if filter_id == "css_filter_template" and opt.key == "template_id" and cspt_choices is not None:
|
||||
choices = cspt_choices
|
||||
opt_schemas.append(FilterOptionDefSchema(
|
||||
key=opt.key,
|
||||
label=opt.label,
|
||||
type=opt.option_type,
|
||||
default=opt.default,
|
||||
min_value=opt.min_value,
|
||||
max_value=opt.max_value,
|
||||
step=opt.step,
|
||||
choices=choices,
|
||||
))
|
||||
responses.append(FilterTypeResponse(
|
||||
filter_id=filter_cls.filter_id,
|
||||
filter_name=filter_cls.filter_name,
|
||||
options_schema=opt_schemas,
|
||||
))
|
||||
return FilterTypeListResponse(filters=responses, count=len(responses))
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Value source routes: CRUD for value sources."""
|
||||
|
||||
import asyncio
|
||||
import secrets
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect
|
||||
@@ -13,7 +12,6 @@ from wled_controller.api.dependencies import (
|
||||
get_processor_manager,
|
||||
get_value_source_store,
|
||||
)
|
||||
from wled_controller.config import get_config
|
||||
from wled_controller.api.schemas.value_sources import (
|
||||
ValueSourceCreate,
|
||||
ValueSourceListResponse,
|
||||
@@ -202,16 +200,8 @@ async def test_value_source_ws(
|
||||
Acquires a ValueStream for the given source, polls get_value() at ~20 Hz,
|
||||
and streams {value: float} JSON to the client.
|
||||
"""
|
||||
# Authenticate
|
||||
authenticated = False
|
||||
cfg = get_config()
|
||||
if token and cfg.auth.api_keys:
|
||||
for _label, api_key in cfg.auth.api_keys.items():
|
||||
if secrets.compare_digest(token, api_key):
|
||||
authenticated = True
|
||||
break
|
||||
|
||||
if not authenticated:
|
||||
from wled_controller.api.auth import verify_ws_token
|
||||
if not verify_ws_token(token):
|
||||
await websocket.close(code=4001, reason="Unauthorized")
|
||||
return
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Color strip processing template schemas."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from .filters import FilterInstanceSchema
|
||||
|
||||
|
||||
class ColorStripProcessingTemplateCreate(BaseModel):
|
||||
"""Request to create a color strip processing template."""
|
||||
|
||||
name: str = Field(description="Template name", min_length=1, max_length=100)
|
||||
filters: List[FilterInstanceSchema] = Field(default_factory=list, description="Ordered list of filter instances")
|
||||
description: Optional[str] = Field(None, description="Template description", max_length=500)
|
||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||
|
||||
|
||||
class ColorStripProcessingTemplateUpdate(BaseModel):
|
||||
"""Request to update a color strip processing template."""
|
||||
|
||||
name: Optional[str] = Field(None, description="Template name", min_length=1, max_length=100)
|
||||
filters: Optional[List[FilterInstanceSchema]] = Field(None, description="Ordered list of filter instances")
|
||||
description: Optional[str] = Field(None, description="Template description", max_length=500)
|
||||
tags: Optional[List[str]] = None
|
||||
|
||||
|
||||
class ColorStripProcessingTemplateResponse(BaseModel):
|
||||
"""Color strip processing template information response."""
|
||||
|
||||
id: str = Field(description="Template ID")
|
||||
name: str = Field(description="Template name")
|
||||
filters: List[FilterInstanceSchema] = Field(description="Ordered list of filter instances")
|
||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||
created_at: datetime = Field(description="Creation timestamp")
|
||||
updated_at: datetime = Field(description="Last update timestamp")
|
||||
description: Optional[str] = Field(None, description="Template description")
|
||||
|
||||
|
||||
class ColorStripProcessingTemplateListResponse(BaseModel):
|
||||
"""List of color strip processing templates response."""
|
||||
|
||||
templates: List[ColorStripProcessingTemplateResponse] = Field(description="List of templates")
|
||||
count: int = Field(description="Number of templates")
|
||||
@@ -35,6 +35,7 @@ class CompositeLayer(BaseModel):
|
||||
opacity: float = Field(default=1.0, ge=0.0, le=1.0, description="Layer opacity 0.0-1.0")
|
||||
enabled: bool = Field(default=True, description="Whether this layer is active")
|
||||
brightness_source_id: Optional[str] = Field(None, description="Optional value source ID for dynamic brightness")
|
||||
processing_template_id: Optional[str] = Field(None, description="Optional color strip processing template ID")
|
||||
|
||||
|
||||
class MappedZone(BaseModel):
|
||||
@@ -50,12 +51,9 @@ class ColorStripSourceCreate(BaseModel):
|
||||
"""Request to create a color strip source."""
|
||||
|
||||
name: str = Field(description="Source name", min_length=1, max_length=100)
|
||||
source_type: Literal["picture", "picture_advanced", "static", "gradient", "color_cycle", "effect", "composite", "mapped", "audio", "api_input", "notification", "daylight", "candlelight"] = Field(default="picture", description="Source type")
|
||||
source_type: Literal["picture", "picture_advanced", "static", "gradient", "color_cycle", "effect", "composite", "mapped", "audio", "api_input", "notification", "daylight", "candlelight", "processed"] = Field(default="picture", description="Source type")
|
||||
# picture-type fields
|
||||
picture_source_id: str = Field(default="", description="Picture source ID (for picture type)")
|
||||
brightness: float = Field(default=1.0, description="Brightness multiplier (0.0-2.0)", ge=0.0, le=2.0)
|
||||
saturation: float = Field(default=1.0, description="Saturation (0.0=grayscale, 1.0=unchanged, 2.0=double)", ge=0.0, le=2.0)
|
||||
gamma: float = Field(default=1.0, description="Gamma correction (1.0=none, <1=brighter, >1=darker mids)", ge=0.1, le=3.0)
|
||||
smoothing: float = Field(default=0.3, description="Temporal smoothing (0.0=none, 1.0=full)", ge=0.0, le=1.0)
|
||||
interpolation_mode: str = Field(default="average", description="LED color interpolation mode (average, median, dominant)")
|
||||
calibration: Optional[Calibration] = Field(None, description="LED calibration (position and count per edge)")
|
||||
@@ -83,7 +81,6 @@ class ColorStripSourceCreate(BaseModel):
|
||||
# shared
|
||||
led_count: int = Field(default=0, description="Total LED count (0 = auto from calibration / device)", ge=0)
|
||||
description: Optional[str] = Field(None, description="Optional description", max_length=500)
|
||||
frame_interpolation: bool = Field(default=False, description="Blend between consecutive captured frames for smoother output")
|
||||
animation: Optional[AnimationConfig] = Field(None, description="Procedural animation config (static/gradient only)")
|
||||
# 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)")
|
||||
@@ -102,6 +99,9 @@ class ColorStripSourceCreate(BaseModel):
|
||||
latitude: Optional[float] = Field(None, description="Latitude for daylight timing (-90 to 90)", ge=-90.0, le=90.0)
|
||||
# candlelight-type fields
|
||||
num_candles: Optional[int] = Field(None, description="Number of independent candle sources (1-20)", ge=1, le=20)
|
||||
# processed-type fields
|
||||
input_source_id: Optional[str] = Field(None, description="Input color strip source ID (for processed type)")
|
||||
processing_template_id: Optional[str] = Field(None, description="Color strip processing template ID (for processed type)")
|
||||
# sync clock
|
||||
clock_id: Optional[str] = Field(None, description="Optional sync clock ID for synchronized animation")
|
||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||
@@ -113,9 +113,6 @@ class ColorStripSourceUpdate(BaseModel):
|
||||
name: Optional[str] = Field(None, description="Source name", min_length=1, max_length=100)
|
||||
# picture-type fields
|
||||
picture_source_id: Optional[str] = Field(None, description="Picture source ID")
|
||||
brightness: Optional[float] = Field(None, description="Brightness multiplier (0.0-2.0)", ge=0.0, le=2.0)
|
||||
saturation: Optional[float] = Field(None, description="Saturation (0.0-2.0)", ge=0.0, le=2.0)
|
||||
gamma: Optional[float] = Field(None, description="Gamma correction (0.1-3.0)", ge=0.1, le=3.0)
|
||||
smoothing: Optional[float] = Field(None, description="Temporal smoothing (0.0-1.0)", ge=0.0, le=1.0)
|
||||
interpolation_mode: Optional[str] = Field(None, description="Interpolation mode (average, median, dominant)")
|
||||
calibration: Optional[Calibration] = Field(None, description="LED calibration")
|
||||
@@ -143,7 +140,6 @@ class ColorStripSourceUpdate(BaseModel):
|
||||
# shared
|
||||
led_count: Optional[int] = Field(None, description="Total LED count (0 = auto from calibration / device)", ge=0)
|
||||
description: Optional[str] = Field(None, description="Optional description", max_length=500)
|
||||
frame_interpolation: Optional[bool] = Field(None, description="Blend between consecutive captured frames")
|
||||
animation: Optional[AnimationConfig] = Field(None, description="Procedural animation config (static/gradient only)")
|
||||
# api_input-type fields
|
||||
fallback_color: Optional[List[int]] = Field(None, description="Fallback RGB color [R,G,B] (api_input type)")
|
||||
@@ -162,6 +158,9 @@ class ColorStripSourceUpdate(BaseModel):
|
||||
latitude: Optional[float] = Field(None, description="Latitude for daylight timing (-90 to 90)", ge=-90.0, le=90.0)
|
||||
# candlelight-type fields
|
||||
num_candles: Optional[int] = Field(None, description="Number of independent candle sources (1-20)", ge=1, le=20)
|
||||
# processed-type fields
|
||||
input_source_id: Optional[str] = Field(None, description="Input color strip source ID (for processed type)")
|
||||
processing_template_id: Optional[str] = Field(None, description="Color strip processing template ID (for processed type)")
|
||||
# sync clock
|
||||
clock_id: Optional[str] = Field(None, description="Optional sync clock ID for synchronized animation")
|
||||
tags: Optional[List[str]] = None
|
||||
@@ -175,9 +174,6 @@ class ColorStripSourceResponse(BaseModel):
|
||||
source_type: str = Field(description="Source type")
|
||||
# picture-type fields
|
||||
picture_source_id: Optional[str] = Field(None, description="Picture source ID")
|
||||
brightness: Optional[float] = Field(None, description="Brightness multiplier")
|
||||
saturation: Optional[float] = Field(None, description="Saturation")
|
||||
gamma: Optional[float] = Field(None, description="Gamma correction")
|
||||
smoothing: Optional[float] = Field(None, description="Temporal smoothing")
|
||||
interpolation_mode: Optional[str] = Field(None, description="Interpolation mode")
|
||||
calibration: Optional[Calibration] = Field(None, description="LED calibration")
|
||||
@@ -205,7 +201,6 @@ class ColorStripSourceResponse(BaseModel):
|
||||
# shared
|
||||
led_count: int = Field(0, description="Total LED count (0 = auto from calibration / device)")
|
||||
description: Optional[str] = Field(None, description="Description")
|
||||
frame_interpolation: Optional[bool] = Field(None, description="Blend between consecutive captured frames")
|
||||
animation: Optional[AnimationConfig] = Field(None, description="Procedural animation config (static/gradient only)")
|
||||
# api_input-type fields
|
||||
fallback_color: Optional[List[int]] = Field(None, description="Fallback RGB color [R,G,B] (api_input type)")
|
||||
@@ -224,6 +219,9 @@ class ColorStripSourceResponse(BaseModel):
|
||||
latitude: Optional[float] = Field(None, description="Latitude for daylight timing")
|
||||
# candlelight-type fields
|
||||
num_candles: Optional[int] = Field(None, description="Number of independent candle sources")
|
||||
# processed-type fields
|
||||
input_source_id: Optional[str] = Field(None, description="Input color strip source ID")
|
||||
processing_template_id: Optional[str] = Field(None, description="Color strip processing template ID")
|
||||
# sync clock
|
||||
clock_id: Optional[str] = Field(None, description="Optional sync clock ID for synchronized animation")
|
||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||
|
||||
@@ -37,6 +37,7 @@ class DeviceCreate(BaseModel):
|
||||
chroma_device_type: Optional[str] = Field(None, description="Chroma peripheral type: keyboard, mouse, mousepad, headset, chromalink, keypad")
|
||||
# SteelSeries GameSense fields
|
||||
gamesense_device_type: Optional[str] = Field(None, description="GameSense device type: keyboard, mouse, headset, mousepad, indicator")
|
||||
default_css_processing_template_id: Optional[str] = Field(None, description="Default color strip processing template ID")
|
||||
|
||||
|
||||
class DeviceUpdate(BaseModel):
|
||||
@@ -64,6 +65,7 @@ class DeviceUpdate(BaseModel):
|
||||
spi_led_type: Optional[str] = Field(None, description="LED chipset type")
|
||||
chroma_device_type: Optional[str] = Field(None, description="Chroma peripheral type")
|
||||
gamesense_device_type: Optional[str] = Field(None, description="GameSense device type")
|
||||
default_css_processing_template_id: Optional[str] = Field(None, description="Default color strip processing template ID")
|
||||
|
||||
|
||||
class CalibrationLineSchema(BaseModel):
|
||||
@@ -170,6 +172,7 @@ class DeviceResponse(BaseModel):
|
||||
spi_led_type: str = Field(default="WS2812B", description="LED chipset type")
|
||||
chroma_device_type: str = Field(default="chromalink", description="Chroma peripheral type")
|
||||
gamesense_device_type: str = Field(default="keyboard", description="GameSense device type")
|
||||
default_css_processing_template_id: str = Field(default="", description="Default color strip processing template ID")
|
||||
created_at: datetime = Field(description="Creation timestamp")
|
||||
updated_at: datetime = Field(description="Last update timestamp")
|
||||
|
||||
|
||||
@@ -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"
|
||||
color_strip_processing_templates_file: str = "data/color_strip_processing_templates.json"
|
||||
sync_clocks_file: str = "data/sync_clocks.json"
|
||||
|
||||
|
||||
|
||||
@@ -20,8 +20,10 @@ import wled_controller.core.filters.flip # noqa: F401
|
||||
import wled_controller.core.filters.color_correction # noqa: F401
|
||||
import wled_controller.core.filters.frame_interpolation # noqa: F401
|
||||
import wled_controller.core.filters.filter_template # noqa: F401
|
||||
import wled_controller.core.filters.css_filter_template # noqa: F401
|
||||
import wled_controller.core.filters.noise_gate # noqa: F401
|
||||
import wled_controller.core.filters.palette_quantization # noqa: F401
|
||||
import wled_controller.core.filters.reverse # noqa: F401
|
||||
|
||||
__all__ = [
|
||||
"FilterOptionDef",
|
||||
|
||||
@@ -18,6 +18,7 @@ class AutoCropFilter(PostprocessingFilter):
|
||||
|
||||
filter_id = "auto_crop"
|
||||
filter_name = "Auto Crop"
|
||||
supports_strip = False
|
||||
|
||||
@classmethod
|
||||
def get_options_schema(cls) -> List[FilterOptionDef]:
|
||||
|
||||
@@ -44,11 +44,21 @@ class PostprocessingFilter(ABC):
|
||||
Each filter operates on a full image (np.ndarray H×W×3 uint8).
|
||||
Filters that preserve dimensions modify in-place and return None.
|
||||
Filters that change dimensions return a new array from the image pool.
|
||||
|
||||
Filters that also support 1D LED strip arrays (N×3 uint8) should
|
||||
leave ``supports_strip = True`` (the default). The base class
|
||||
provides a generic ``process_strip`` that reshapes (N,3) → (1,N,3),
|
||||
delegates to ``process_image``, and reshapes back. Subclasses may
|
||||
override for a more efficient implementation.
|
||||
|
||||
Filters that are purely spatial (auto-crop, downscaler, flip) should
|
||||
set ``supports_strip = False``.
|
||||
"""
|
||||
|
||||
filter_id: str = ""
|
||||
filter_name: str = ""
|
||||
supports_idle_frames: bool = False
|
||||
supports_strip: bool = True
|
||||
|
||||
def __init__(self, options: Dict[str, Any]):
|
||||
"""Initialize filter with validated options."""
|
||||
@@ -74,6 +84,31 @@ class PostprocessingFilter(ABC):
|
||||
"""
|
||||
...
|
||||
|
||||
def process_strip(self, strip: np.ndarray) -> Optional[np.ndarray]:
|
||||
"""Process a 1D LED strip array (N, 3) uint8.
|
||||
|
||||
Default implementation reshapes to (1, N, 3), calls process_image
|
||||
with a no-op pool, and reshapes back. Override for filters that
|
||||
need strip-specific behaviour or use ImagePool.
|
||||
|
||||
Returns:
|
||||
None if modified in-place.
|
||||
New np.ndarray if a new array was created.
|
||||
"""
|
||||
from wled_controller.core.filters.image_pool import ImagePool
|
||||
|
||||
img = strip[np.newaxis, :, :] # (1, N, 3)
|
||||
pool = ImagePool(max_size=2)
|
||||
result = self.process_image(img, pool)
|
||||
if result is not None:
|
||||
out = result[0] # (N, 3)
|
||||
pool.release_all()
|
||||
return out
|
||||
# Modified in-place — extract back
|
||||
np.copyto(strip, img[0])
|
||||
pool.release_all()
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def validate_options(cls, options: dict) -> dict:
|
||||
"""Validate and clamp options against the schema. Returns cleaned dict."""
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
"""CSS Filter Template meta-filter — references a color strip processing template.
|
||||
|
||||
This filter exists in the registry for UI discovery only. It is never
|
||||
instantiated at runtime: the store expands it into the referenced
|
||||
template's filters when building the processing pipeline.
|
||||
"""
|
||||
|
||||
from typing import List, Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
from wled_controller.core.filters.base import FilterOptionDef, PostprocessingFilter
|
||||
from wled_controller.core.filters.image_pool import ImagePool
|
||||
from wled_controller.core.filters.registry import FilterRegistry
|
||||
|
||||
|
||||
@FilterRegistry.register
|
||||
class CSSFilterTemplateFilter(PostprocessingFilter):
|
||||
"""Include another color strip processing template's chain at this position."""
|
||||
|
||||
filter_id = "css_filter_template"
|
||||
filter_name = "Strip Filter Template"
|
||||
supports_strip = True
|
||||
|
||||
@classmethod
|
||||
def get_options_schema(cls) -> List[FilterOptionDef]:
|
||||
return [
|
||||
FilterOptionDef(
|
||||
key="template_id",
|
||||
label="Template",
|
||||
option_type="select",
|
||||
default="",
|
||||
min_value=None,
|
||||
max_value=None,
|
||||
step=None,
|
||||
choices=[], # populated dynamically by GET /api/v1/strip-filters
|
||||
),
|
||||
]
|
||||
|
||||
def process_image(self, image: np.ndarray, image_pool: ImagePool) -> Optional[np.ndarray]:
|
||||
# Never called — expanded at pipeline build time.
|
||||
return None
|
||||
|
||||
def process_strip(self, strip: np.ndarray) -> Optional[np.ndarray]:
|
||||
# Never called — expanded at pipeline build time.
|
||||
return None
|
||||
@@ -16,6 +16,7 @@ class DownscalerFilter(PostprocessingFilter):
|
||||
|
||||
filter_id = "downscaler"
|
||||
filter_name = "Downscaler"
|
||||
supports_strip = False
|
||||
|
||||
@classmethod
|
||||
def get_options_schema(cls) -> List[FilterOptionDef]:
|
||||
|
||||
@@ -20,6 +20,7 @@ class FilterTemplateFilter(PostprocessingFilter):
|
||||
|
||||
filter_id = "filter_template"
|
||||
filter_name = "Filter Template"
|
||||
supports_strip = False
|
||||
|
||||
@classmethod
|
||||
def get_options_schema(cls) -> List[FilterOptionDef]:
|
||||
|
||||
@@ -15,6 +15,7 @@ class FlipFilter(PostprocessingFilter):
|
||||
|
||||
filter_id = "flip"
|
||||
filter_name = "Flip"
|
||||
supports_strip = False
|
||||
|
||||
@classmethod
|
||||
def get_options_schema(cls) -> List[FilterOptionDef]:
|
||||
|
||||
@@ -59,15 +59,23 @@ class FrameInterpolationFilter(PostprocessingFilter):
|
||||
None — image passes through unchanged (no blend needed).
|
||||
ndarray — blended output acquired from image_pool.
|
||||
"""
|
||||
return self._blend(image, lambda shape: image_pool.acquire(*shape))
|
||||
|
||||
def process_strip(self, strip: np.ndarray) -> Optional[np.ndarray]:
|
||||
"""Frame interpolation for 1D LED strips — allocates directly."""
|
||||
return self._blend(strip, lambda shape: np.empty(shape, dtype=np.uint8))
|
||||
|
||||
def _blend(self, data: np.ndarray, alloc_fn) -> Optional[np.ndarray]:
|
||||
"""Shared blend logic for both images and strips."""
|
||||
now = time.perf_counter()
|
||||
|
||||
# Detect new vs idle frame via cheap 64-byte signature
|
||||
sig = bytes(image.ravel()[:64])
|
||||
sig = bytes(data.ravel()[:64])
|
||||
if sig != self._sig_b:
|
||||
# New source frame — shift A ← B, B ← current
|
||||
self._frame_a = self._frame_b
|
||||
self._time_a = self._time_b
|
||||
self._frame_b = image.copy()
|
||||
self._frame_b = data.copy()
|
||||
self._time_b = now
|
||||
self._sig_b = sig
|
||||
|
||||
@@ -83,8 +91,7 @@ class FrameInterpolationFilter(PostprocessingFilter):
|
||||
|
||||
# Blend: output = (1 - alpha)*A + alpha*B (integer fast path)
|
||||
alpha_i = int(alpha * 256)
|
||||
h, w, c = image.shape
|
||||
shape = (h, w, c)
|
||||
shape = data.shape
|
||||
|
||||
# Resize scratch buffers on shape change
|
||||
if self._blend_shape != shape:
|
||||
@@ -92,9 +99,9 @@ class FrameInterpolationFilter(PostprocessingFilter):
|
||||
self._u16_b = np.empty(shape, dtype=np.uint16)
|
||||
self._blend_shape = shape
|
||||
|
||||
out = image_pool.acquire(h, w, c)
|
||||
out = alloc_fn(shape)
|
||||
np.copyto(self._u16_a, self._frame_a, casting='unsafe')
|
||||
np.copyto(self._u16_b, image, casting='unsafe')
|
||||
np.copyto(self._u16_b, data, casting='unsafe')
|
||||
self._u16_a *= (256 - alpha_i)
|
||||
self._u16_b *= alpha_i
|
||||
self._u16_a += self._u16_b
|
||||
|
||||
@@ -83,7 +83,7 @@ class PaletteQuantizationFilter(PostprocessingFilter):
|
||||
min_value=None,
|
||||
max_value=None,
|
||||
step=None,
|
||||
choices=[{"value": k, "label": k.capitalize()} for k in _PRESETS],
|
||||
choices=[{"value": k, "label": k.capitalize(), "colors": v} for k, v in _PRESETS.items()],
|
||||
),
|
||||
FilterOptionDef(
|
||||
key="colors",
|
||||
|
||||
30
server/src/wled_controller/core/filters/reverse.py
Normal file
30
server/src/wled_controller/core/filters/reverse.py
Normal file
@@ -0,0 +1,30 @@
|
||||
"""Reverse filter — reverses the LED order in a 1D strip."""
|
||||
|
||||
from typing import List, Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
from wled_controller.core.filters.base import FilterOptionDef, PostprocessingFilter
|
||||
from wled_controller.core.filters.image_pool import ImagePool
|
||||
from wled_controller.core.filters.registry import FilterRegistry
|
||||
|
||||
|
||||
@FilterRegistry.register
|
||||
class ReverseFilter(PostprocessingFilter):
|
||||
"""Reverses the order of LEDs in a color strip."""
|
||||
|
||||
filter_id = "reverse"
|
||||
filter_name = "Reverse"
|
||||
supports_strip = True
|
||||
|
||||
@classmethod
|
||||
def get_options_schema(cls) -> List[FilterOptionDef]:
|
||||
return []
|
||||
|
||||
def process_image(self, image: np.ndarray, image_pool: ImagePool) -> Optional[np.ndarray]:
|
||||
"""Reverse image horizontally (for 2D fallback)."""
|
||||
return image[:, ::-1].copy()
|
||||
|
||||
def process_strip(self, strip: np.ndarray) -> Optional[np.ndarray]:
|
||||
"""Reverse the LED array order."""
|
||||
return strip[::-1].copy()
|
||||
@@ -251,6 +251,7 @@ class AudioColorStripStream(ColorStripStream):
|
||||
_full_amp = np.empty(n, dtype=np.float32)
|
||||
_vu_gradient = np.linspace(0, 1, n, dtype=np.float32)
|
||||
_indices_buf = np.empty(n, dtype=np.int32)
|
||||
_f32_rgb = np.empty((n, 3), dtype=np.float32)
|
||||
self._prev_spectrum = None # reset smoothing on resize
|
||||
|
||||
# Make pre-computed arrays available to render methods
|
||||
@@ -260,6 +261,7 @@ class AudioColorStripStream(ColorStripStream):
|
||||
self._full_amp = _full_amp
|
||||
self._vu_gradient = _vu_gradient
|
||||
self._indices_buf = _indices_buf
|
||||
self._f32_rgb = _f32_rgb
|
||||
|
||||
buf = _buf_a if _use_a else _buf_b
|
||||
_use_a = not _use_a
|
||||
@@ -352,8 +354,11 @@ class AudioColorStripStream(ColorStripStream):
|
||||
|
||||
# Scale brightness by amplitude — restore full_amp to [0, 1]
|
||||
full_amp *= (1.0 / 255.0)
|
||||
for ch in range(3):
|
||||
buf[:, ch] = (colors[:, ch].astype(np.float32) * full_amp).astype(np.uint8)
|
||||
f32_rgb = self._f32_rgb
|
||||
np.copyto(f32_rgb, colors, casting='unsafe')
|
||||
f32_rgb *= full_amp[:, np.newaxis]
|
||||
np.clip(f32_rgb, 0, 255, out=f32_rgb)
|
||||
np.copyto(buf, f32_rgb, casting='unsafe')
|
||||
|
||||
# ── VU Meter ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ by processing frames from a LiveStream.
|
||||
|
||||
Multiple WledTargetProcessors may read from the same ColorStripStream instance
|
||||
(shared via ColorStripStreamManager reference counting), meaning the CPU-bound
|
||||
processing — border extraction, pixel mapping, color correction — runs only once
|
||||
processing — border extraction, pixel mapping, smoothing — runs only once
|
||||
even when multiple devices share the same source configuration.
|
||||
"""
|
||||
|
||||
@@ -28,54 +28,6 @@ from wled_controller.utils.timer import high_resolution_timer
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def _apply_saturation(colors: np.ndarray, saturation: float,
|
||||
_i32: np.ndarray = None, _i32_gray: np.ndarray = None,
|
||||
_out: np.ndarray = None) -> np.ndarray:
|
||||
"""Adjust saturation via luminance mixing (Rec.601 weights, integer math).
|
||||
|
||||
saturation=1.0: no change
|
||||
saturation=0.0: grayscale
|
||||
saturation=2.0: double saturation (clipped to 0-255)
|
||||
|
||||
Optional pre-allocated scratch buffers (_i32, _i32_gray, _out) avoid
|
||||
per-frame allocations when called from a hot loop.
|
||||
"""
|
||||
n = len(colors)
|
||||
if _i32 is None:
|
||||
_i32 = np.empty((n, 3), dtype=np.int32)
|
||||
if _i32_gray is None:
|
||||
_i32_gray = np.empty((n, 1), dtype=np.int32)
|
||||
if _out is None:
|
||||
_out = np.empty((n, 3), dtype=np.uint8)
|
||||
|
||||
sat_int = int(saturation * 256)
|
||||
np.copyto(_i32, colors, casting='unsafe')
|
||||
_i32_gray[:, 0] = (_i32[:, 0] * 299 + _i32[:, 1] * 587 + _i32[:, 2] * 114) // 1000
|
||||
_i32 *= sat_int
|
||||
_i32_gray *= (256 - sat_int)
|
||||
_i32 += _i32_gray
|
||||
_i32 >>= 8
|
||||
np.clip(_i32, 0, 255, out=_i32)
|
||||
np.copyto(_out, _i32, casting='unsafe')
|
||||
return _out
|
||||
|
||||
|
||||
def _build_gamma_lut(gamma: float) -> np.ndarray:
|
||||
"""Build a 256-entry uint8 LUT for gamma correction.
|
||||
|
||||
gamma=1.0: identity (no correction)
|
||||
gamma<1.0: brighter midtones (gamma < 1 lifts shadows)
|
||||
gamma>1.0: darker midtones (standard LED gamma, e.g. 2.2–2.8)
|
||||
"""
|
||||
if gamma == 1.0:
|
||||
return np.arange(256, dtype=np.uint8)
|
||||
lut = np.array(
|
||||
[min(255, int(((i / 255.0) ** gamma) * 255 + 0.5)) for i in range(256)],
|
||||
dtype=np.uint8,
|
||||
)
|
||||
return lut
|
||||
|
||||
|
||||
class ColorStripStream(ABC):
|
||||
"""Abstract base: a runtime source of LED color arrays.
|
||||
|
||||
@@ -142,10 +94,7 @@ class PictureColorStripStream(ColorStripStream):
|
||||
2. Extracts border pixels using the calibration's border_width
|
||||
3. Maps border pixels to LED colors via PixelMapper
|
||||
4. Applies temporal smoothing
|
||||
5. Applies saturation correction
|
||||
6. Applies gamma correction (LUT-based, O(1) per pixel)
|
||||
7. Applies brightness scaling
|
||||
8. Caches the result for lock-free consumer reads
|
||||
5. Caches the result for lock-free consumer reads
|
||||
|
||||
Processing parameters can be hot-updated via update_source() without
|
||||
restarting the thread (except when the underlying LiveStream changes).
|
||||
@@ -167,11 +116,10 @@ class PictureColorStripStream(ColorStripStream):
|
||||
else:
|
||||
self._live_streams = {}
|
||||
self._live_stream = live_stream
|
||||
|
||||
self._fps: int = 30 # internal capture rate (send FPS is on the target)
|
||||
self._frame_time: float = 1.0 / 30
|
||||
self._smoothing: float = source.smoothing
|
||||
self._brightness: float = source.brightness
|
||||
self._saturation: float = source.saturation
|
||||
self._gamma: float = source.gamma
|
||||
self._interpolation_mode: str = source.interpolation_mode
|
||||
self._calibration: CalibrationConfig = source.calibration
|
||||
self._pixel_mapper = create_pixel_mapper(
|
||||
@@ -179,25 +127,21 @@ class PictureColorStripStream(ColorStripStream):
|
||||
)
|
||||
cal_leds = self._calibration.get_total_leds()
|
||||
self._led_count: int = source.led_count if source.led_count > 0 else cal_leds
|
||||
self._gamma_lut: np.ndarray = _build_gamma_lut(self._gamma)
|
||||
|
||||
# Thread-safe color cache
|
||||
self._latest_colors: Optional[np.ndarray] = None
|
||||
self._colors_lock = threading.Lock()
|
||||
self._previous_colors: Optional[np.ndarray] = None
|
||||
|
||||
# Frame interpolation state
|
||||
self._frame_interpolation: bool = source.frame_interpolation
|
||||
self._interp_from: Optional[np.ndarray] = None
|
||||
self._interp_to: Optional[np.ndarray] = None
|
||||
self._interp_start: float = 0.0
|
||||
self._interp_duration: float = 1.0 / self._fps if self._fps > 0 else 1.0
|
||||
self._last_capture_time: float = 0.0
|
||||
|
||||
self._running = False
|
||||
self._thread: Optional[threading.Thread] = None
|
||||
self._last_timing: dict = {}
|
||||
|
||||
@property
|
||||
def live_stream(self):
|
||||
"""Public accessor for the underlying LiveStream (used by preview WebSocket)."""
|
||||
return self._live_stream
|
||||
|
||||
@property
|
||||
def target_fps(self) -> int:
|
||||
return self._fps
|
||||
@@ -239,9 +183,6 @@ class PictureColorStripStream(ColorStripStream):
|
||||
self._thread = None
|
||||
self._latest_colors = None
|
||||
self._previous_colors = None
|
||||
self._interp_from = None
|
||||
self._interp_to = None
|
||||
self._last_capture_time = 0.0
|
||||
logger.info("PictureColorStripStream stopped")
|
||||
|
||||
def get_latest_colors(self) -> Optional[np.ndarray]:
|
||||
@@ -256,7 +197,7 @@ class PictureColorStripStream(ColorStripStream):
|
||||
fps = max(1, min(90, fps))
|
||||
if fps != self._fps:
|
||||
self._fps = fps
|
||||
self._interp_duration = 1.0 / fps
|
||||
self._frame_time = 1.0 / fps
|
||||
logger.info(f"PictureColorStripStream capture FPS set to {fps}")
|
||||
|
||||
def update_source(self, source) -> None:
|
||||
@@ -270,12 +211,6 @@ class PictureColorStripStream(ColorStripStream):
|
||||
return
|
||||
|
||||
self._smoothing = source.smoothing
|
||||
self._brightness = source.brightness
|
||||
self._saturation = source.saturation
|
||||
|
||||
if source.gamma != self._gamma:
|
||||
self._gamma = source.gamma
|
||||
self._gamma_lut = _build_gamma_lut(source.gamma)
|
||||
|
||||
if (
|
||||
source.interpolation_mode != self._interpolation_mode
|
||||
@@ -291,11 +226,6 @@ class PictureColorStripStream(ColorStripStream):
|
||||
)
|
||||
self._previous_colors = None # Reset smoothing history on calibration change
|
||||
|
||||
if source.frame_interpolation != self._frame_interpolation:
|
||||
self._frame_interpolation = source.frame_interpolation
|
||||
self._interp_from = None
|
||||
self._interp_to = None
|
||||
|
||||
logger.info("PictureColorStripStream params updated in-place")
|
||||
|
||||
def _processing_loop(self) -> None:
|
||||
@@ -306,8 +236,7 @@ class PictureColorStripStream(ColorStripStream):
|
||||
_pool_n = 0
|
||||
_frame_a = _frame_b = None # double-buffered uint8 output
|
||||
_use_a = True
|
||||
_u16_a = _u16_b = None # uint16 scratch for smoothing / interp blending
|
||||
_i32 = _i32_gray = None # int32 scratch for saturation + brightness
|
||||
_u16_a = _u16_b = None # uint16 scratch for smoothing blending
|
||||
|
||||
def _blend_u16(a, b, alpha_b, out):
|
||||
"""Blend two uint8 arrays: out = ((256-alpha_b)*a + alpha_b*b) >> 8.
|
||||
@@ -323,62 +252,20 @@ class PictureColorStripStream(ColorStripStream):
|
||||
_u16_a >>= 8
|
||||
np.copyto(out, _u16_a, casting='unsafe')
|
||||
|
||||
def _apply_corrections(led_colors, frame_buf):
|
||||
"""Apply saturation, gamma, brightness using pre-allocated scratch.
|
||||
|
||||
Returns the (possibly reassigned) led_colors array.
|
||||
"""
|
||||
nonlocal _i32
|
||||
if self._saturation != 1.0:
|
||||
_apply_saturation(led_colors, self._saturation, _i32, _i32_gray, led_colors)
|
||||
if self._gamma != 1.0:
|
||||
led_colors = self._gamma_lut[led_colors]
|
||||
if self._brightness != 1.0:
|
||||
bright_int = int(self._brightness * 256)
|
||||
np.copyto(_i32, led_colors, casting='unsafe')
|
||||
_i32 *= bright_int
|
||||
_i32 >>= 8
|
||||
np.clip(_i32, 0, 255, out=_i32)
|
||||
np.copyto(frame_buf, _i32, casting='unsafe')
|
||||
led_colors = frame_buf
|
||||
return led_colors
|
||||
|
||||
try:
|
||||
with high_resolution_timer():
|
||||
while self._running:
|
||||
loop_start = time.perf_counter()
|
||||
fps = self._fps
|
||||
frame_time = 1.0 / fps if fps > 0 else 1.0
|
||||
frame_time = self._frame_time
|
||||
|
||||
try:
|
||||
frame = self._live_stream.get_latest_frame()
|
||||
|
||||
if frame is None or frame is cached_frame:
|
||||
if (
|
||||
frame is not None
|
||||
and self._frame_interpolation
|
||||
and self._interp_from is not None
|
||||
and self._interp_to is not None
|
||||
and _u16_a is not None
|
||||
):
|
||||
# Interpolate between previous and current capture
|
||||
t = min(1.0, (loop_start - self._interp_start) / self._interp_duration)
|
||||
frame_buf = _frame_a if _use_a else _frame_b
|
||||
_use_a = not _use_a
|
||||
_blend_u16(self._interp_from, self._interp_to, int(t * 256), frame_buf)
|
||||
led_colors = _apply_corrections(frame_buf, frame_buf)
|
||||
with self._colors_lock:
|
||||
self._latest_colors = led_colors
|
||||
elapsed = time.perf_counter() - loop_start
|
||||
time.sleep(max(frame_time - elapsed, 0.001))
|
||||
continue
|
||||
|
||||
interval = (
|
||||
loop_start - self._last_capture_time
|
||||
if self._last_capture_time > 0
|
||||
else frame_time
|
||||
)
|
||||
self._last_capture_time = loop_start
|
||||
cached_frame = frame
|
||||
|
||||
t0 = time.perf_counter()
|
||||
@@ -410,8 +297,6 @@ class PictureColorStripStream(ColorStripStream):
|
||||
_frame_b = np.empty((_n, 3), dtype=np.uint8)
|
||||
_u16_a = np.empty((_n, 3), dtype=np.uint16)
|
||||
_u16_b = np.empty((_n, 3), dtype=np.uint16)
|
||||
_i32 = np.empty((_n, 3), dtype=np.int32)
|
||||
_i32_gray = np.empty((_n, 1), dtype=np.int32)
|
||||
self._previous_colors = None
|
||||
|
||||
# Copy/pad into double-buffered frame (avoids per-frame allocations)
|
||||
@@ -440,38 +325,6 @@ class PictureColorStripStream(ColorStripStream):
|
||||
int(smoothing * 256), led_colors)
|
||||
t3 = time.perf_counter()
|
||||
|
||||
# Update interpolation buffers (smoothed colors, before corrections)
|
||||
# Must be AFTER smoothing so idle-tick interpolation produces
|
||||
# output consistent with new-frame ticks (both smoothed).
|
||||
if self._frame_interpolation:
|
||||
self._interp_from = self._interp_to
|
||||
self._interp_to = led_colors.copy()
|
||||
self._interp_start = loop_start
|
||||
self._interp_duration = max(interval, 0.001)
|
||||
|
||||
# Saturation (pre-allocated int32 scratch)
|
||||
saturation = self._saturation
|
||||
if saturation != 1.0:
|
||||
_apply_saturation(led_colors, saturation, _i32, _i32_gray, led_colors)
|
||||
t4 = time.perf_counter()
|
||||
|
||||
# Gamma (LUT lookup — O(1) per pixel)
|
||||
if self._gamma != 1.0:
|
||||
led_colors = self._gamma_lut[led_colors]
|
||||
t5 = time.perf_counter()
|
||||
|
||||
# Brightness (integer math with pre-allocated int32 scratch)
|
||||
brightness = self._brightness
|
||||
if brightness != 1.0:
|
||||
bright_int = int(brightness * 256)
|
||||
np.copyto(_i32, led_colors, casting='unsafe')
|
||||
_i32 *= bright_int
|
||||
_i32 >>= 8
|
||||
np.clip(_i32, 0, 255, out=_i32)
|
||||
np.copyto(frame_buf, _i32, casting='unsafe')
|
||||
led_colors = frame_buf
|
||||
t6 = time.perf_counter()
|
||||
|
||||
self._previous_colors = led_colors
|
||||
|
||||
with self._colors_lock:
|
||||
@@ -481,10 +334,7 @@ class PictureColorStripStream(ColorStripStream):
|
||||
"extract_ms": (t1 - t0) * 1000,
|
||||
"map_leds_ms": (t2 - t1) * 1000,
|
||||
"smooth_ms": (t3 - t2) * 1000,
|
||||
"saturation_ms": (t4 - t3) * 1000,
|
||||
"gamma_ms": (t5 - t4) * 1000,
|
||||
"brightness_ms": (t6 - t5) * 1000,
|
||||
"total_ms": (t6 - t0) * 1000,
|
||||
"total_ms": (t3 - t0) * 1000,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
@@ -1201,12 +1051,52 @@ class GradientColorStripStream(ColorStripStream):
|
||||
|
||||
elif atype == "rainbow_fade":
|
||||
h_shift = (speed * t * 0.1) % 1.0
|
||||
for i in range(n):
|
||||
r, g, b = base[i]
|
||||
h, s, v = colorsys.rgb_to_hsv(r / 255.0, g / 255.0, b / 255.0)
|
||||
new_h = (h + h_shift) % 1.0
|
||||
nr, ng, nb = colorsys.hsv_to_rgb(new_h, max(s, 0.5), max(v, 0.3))
|
||||
buf[i] = (int(nr * 255), int(ng * 255), int(nb * 255))
|
||||
# Vectorized RGB->HSV shift->RGB (no per-LED colorsys)
|
||||
rgb_f = base.astype(np.float32) * (1.0 / 255.0)
|
||||
r_f = rgb_f[:, 0]
|
||||
g_f = rgb_f[:, 1]
|
||||
b_f = rgb_f[:, 2]
|
||||
cmax = np.maximum(np.maximum(r_f, g_f), b_f)
|
||||
cmin = np.minimum(np.minimum(r_f, g_f), b_f)
|
||||
delta = cmax - cmin
|
||||
# Hue
|
||||
h_arr = np.zeros(n, dtype=np.float32)
|
||||
mask_r = (delta > 0) & (cmax == r_f)
|
||||
mask_g = (delta > 0) & (cmax == g_f) & ~mask_r
|
||||
mask_b = (delta > 0) & ~mask_r & ~mask_g
|
||||
h_arr[mask_r] = ((g_f[mask_r] - b_f[mask_r]) / delta[mask_r]) % 6.0
|
||||
h_arr[mask_g] = ((b_f[mask_g] - r_f[mask_g]) / delta[mask_g]) + 2.0
|
||||
h_arr[mask_b] = ((r_f[mask_b] - g_f[mask_b]) / delta[mask_b]) + 4.0
|
||||
h_arr *= (1.0 / 6.0)
|
||||
h_arr %= 1.0
|
||||
# Saturation & Value with clamping
|
||||
s_arr = np.where(cmax > 0, delta / cmax, np.float32(0))
|
||||
np.maximum(s_arr, 0.5, out=s_arr)
|
||||
v_arr = cmax.copy()
|
||||
np.maximum(v_arr, 0.3, out=v_arr)
|
||||
# Shift hue
|
||||
h_arr += h_shift
|
||||
h_arr %= 1.0
|
||||
# Vectorized HSV->RGB
|
||||
h6 = h_arr * 6.0
|
||||
hi = h6.astype(np.int32) % 6
|
||||
f_arr = h6 - np.floor(h6)
|
||||
p = v_arr * (1.0 - s_arr)
|
||||
q = v_arr * (1.0 - s_arr * f_arr)
|
||||
tt = v_arr * (1.0 - s_arr * (1.0 - f_arr))
|
||||
ro = np.empty(n, dtype=np.float32)
|
||||
go = np.empty(n, dtype=np.float32)
|
||||
bo = np.empty(n, dtype=np.float32)
|
||||
for sxt, rv, gv, bv in (
|
||||
(0, v_arr, tt, p), (1, q, v_arr, p),
|
||||
(2, p, v_arr, tt), (3, p, q, v_arr),
|
||||
(4, tt, p, v_arr), (5, v_arr, p, q),
|
||||
):
|
||||
m = hi == sxt
|
||||
ro[m] = rv[m]; go[m] = gv[m]; bo[m] = bv[m]
|
||||
buf[:, 0] = np.clip(ro * 255.0, 0, 255).astype(np.uint8)
|
||||
buf[:, 1] = np.clip(go * 255.0, 0, 255).astype(np.uint8)
|
||||
buf[:, 2] = np.clip(bo * 255.0, 0, 255).astype(np.uint8)
|
||||
colors = buf
|
||||
|
||||
if colors is not None:
|
||||
|
||||
@@ -18,6 +18,7 @@ from wled_controller.core.processing.color_strip_stream import (
|
||||
PictureColorStripStream,
|
||||
StaticColorStripStream,
|
||||
)
|
||||
from wled_controller.core.processing.processed_stream import ProcessedColorStripStream
|
||||
from wled_controller.core.processing.effect_stream import EffectColorStripStream
|
||||
from wled_controller.core.processing.api_input_stream import ApiInputColorStripStream
|
||||
from wled_controller.core.processing.notification_stream import NotificationColorStripStream
|
||||
@@ -68,7 +69,7 @@ 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, sync_clock_manager=None, value_stream_manager=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, value_stream_manager=None, cspt_store=None):
|
||||
"""
|
||||
Args:
|
||||
color_strip_store: ColorStripStore for resolving source configs
|
||||
@@ -77,6 +78,7 @@ class ColorStripStreamManager:
|
||||
audio_source_store: AudioSourceStore for resolving audio source chains
|
||||
sync_clock_manager: SyncClockManager for acquiring clock runtimes
|
||||
value_stream_manager: ValueStreamManager for per-layer brightness sources
|
||||
cspt_store: ColorStripProcessingTemplateStore for per-layer filter chains
|
||||
"""
|
||||
self._color_strip_store = color_strip_store
|
||||
self._live_stream_manager = live_stream_manager
|
||||
@@ -85,6 +87,7 @@ class ColorStripStreamManager:
|
||||
self._audio_template_store = audio_template_store
|
||||
self._sync_clock_manager = sync_clock_manager
|
||||
self._value_stream_manager = value_stream_manager
|
||||
self._cspt_store = cspt_store
|
||||
self._streams: Dict[str, _ColorStripEntry] = {}
|
||||
|
||||
def _inject_clock(self, css_stream, source) -> Optional[str]:
|
||||
@@ -161,10 +164,12 @@ class ColorStripStreamManager:
|
||||
css_stream = AudioColorStripStream(source, self._audio_capture_manager, self._audio_source_store, self._audio_template_store)
|
||||
elif source.source_type == "composite":
|
||||
from wled_controller.core.processing.composite_stream import CompositeColorStripStream
|
||||
css_stream = CompositeColorStripStream(source, self, self._value_stream_manager)
|
||||
css_stream = CompositeColorStripStream(source, self, self._value_stream_manager, self._cspt_store)
|
||||
elif source.source_type == "mapped":
|
||||
from wled_controller.core.processing.mapped_stream import MappedColorStripStream
|
||||
css_stream = MappedColorStripStream(source, self)
|
||||
elif source.source_type == "processed":
|
||||
css_stream = ProcessedColorStripStream(source, self, self._cspt_store)
|
||||
else:
|
||||
stream_cls = _SIMPLE_STREAM_MAP.get(source.source_type)
|
||||
if not stream_cls:
|
||||
|
||||
@@ -29,7 +29,7 @@ class CompositeColorStripStream(ColorStripStream):
|
||||
sub-stream's latest colors and blending bottom-to-top.
|
||||
"""
|
||||
|
||||
def __init__(self, source, css_manager, value_stream_manager=None):
|
||||
def __init__(self, source, css_manager, value_stream_manager=None, cspt_store=None):
|
||||
import uuid as _uuid
|
||||
self._source_id: str = source.id
|
||||
self._instance_id: str = _uuid.uuid4().hex[:8] # unique per instance to avoid release races
|
||||
@@ -38,6 +38,7 @@ class CompositeColorStripStream(ColorStripStream):
|
||||
self._auto_size: bool = source.led_count == 0
|
||||
self._css_manager = css_manager
|
||||
self._value_stream_manager = value_stream_manager
|
||||
self._cspt_store = cspt_store
|
||||
self._fps: int = 30
|
||||
self._frame_time: float = 1.0 / 30
|
||||
|
||||
@@ -309,6 +310,9 @@ class CompositeColorStripStream(ColorStripStream):
|
||||
# ── Processing loop ─────────────────────────────────────────
|
||||
|
||||
def _processing_loop(self) -> None:
|
||||
# Per-layer CSPT filter cache: layer_index -> (template_id, [PostprocessingFilter, ...])
|
||||
_layer_cspt_cache: Dict[int, tuple] = {}
|
||||
|
||||
try:
|
||||
while self._running:
|
||||
loop_start = time.perf_counter()
|
||||
@@ -341,6 +345,37 @@ class CompositeColorStripStream(ColorStripStream):
|
||||
if colors is None:
|
||||
continue
|
||||
|
||||
# Apply per-layer CSPT filters
|
||||
_layer_tmpl_id = layer.get("processing_template_id") or ""
|
||||
if _layer_tmpl_id and self._cspt_store:
|
||||
cached = _layer_cspt_cache.get(i)
|
||||
if cached is None or cached[0] != _layer_tmpl_id:
|
||||
# Resolve and cache filters for this layer
|
||||
try:
|
||||
from wled_controller.core.filters.registry import FilterRegistry
|
||||
_resolved = self._cspt_store.resolve_filter_instances(
|
||||
self._cspt_store.get_template(_layer_tmpl_id).filters
|
||||
)
|
||||
_filters = [
|
||||
FilterRegistry.create_instance(fi.filter_id, fi.options)
|
||||
for fi in _resolved
|
||||
if getattr(FilterRegistry.get(fi.filter_id), "supports_strip", True)
|
||||
]
|
||||
_layer_cspt_cache[i] = (_layer_tmpl_id, _filters)
|
||||
logger.info(
|
||||
f"Composite layer {i} CSPT resolved {len(_filters)} filters "
|
||||
f"from template {_layer_tmpl_id}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to resolve layer {i} CSPT {_layer_tmpl_id}: {e}")
|
||||
_layer_cspt_cache[i] = (_layer_tmpl_id, [])
|
||||
_layer_filters = _layer_cspt_cache[i][1]
|
||||
if _layer_filters:
|
||||
for _flt in _layer_filters:
|
||||
_result = _flt.process_strip(colors)
|
||||
if _result is not None:
|
||||
colors = _result
|
||||
|
||||
# Resize to target LED count if needed
|
||||
if len(colors) != target_n:
|
||||
colors = self._resize_to_target(colors, target_n)
|
||||
|
||||
@@ -444,21 +444,26 @@ class EffectColorStripStream(ColorStripStream):
|
||||
np.clip(self._s_f32_c, 0, 255, out=self._s_f32_c)
|
||||
np.copyto(buf[:, 2], self._s_f32_c, casting='unsafe')
|
||||
|
||||
# Bright white-ish head (2-3 LEDs — small, leave allocating)
|
||||
head_mask = np.abs(indices - pos) < 1.5
|
||||
head_brightness = np.clip(1.0 - np.abs(indices - pos), 0, 1)
|
||||
buf[head_mask, 0] = np.clip(
|
||||
buf[head_mask, 0].astype(np.int16) + (head_brightness[head_mask] * (255 - r)).astype(np.int16),
|
||||
0, 255,
|
||||
).astype(np.uint8)
|
||||
buf[head_mask, 1] = np.clip(
|
||||
buf[head_mask, 1].astype(np.int16) + (head_brightness[head_mask] * (255 - g)).astype(np.int16),
|
||||
0, 255,
|
||||
).astype(np.uint8)
|
||||
buf[head_mask, 2] = np.clip(
|
||||
buf[head_mask, 2].astype(np.int16) + (head_brightness[head_mask] * (255 - b)).astype(np.int16),
|
||||
0, 255,
|
||||
).astype(np.uint8)
|
||||
# Bright white-ish head (2-3 LEDs) — direct index range to avoid
|
||||
# boolean mask allocations and fancy indexing temporaries.
|
||||
head_lo = max(0, int(pos - 1.5) + 1)
|
||||
head_hi = min(n, int(pos + 1.5) + 1)
|
||||
if head_hi > head_lo:
|
||||
head_sl = slice(head_lo, head_hi)
|
||||
head_dist = self._s_f32_a[head_sl]
|
||||
np.subtract(indices[head_sl], pos, out=head_dist)
|
||||
np.abs(head_dist, out=head_dist)
|
||||
# head_brightness = clip(1 - abs_dist, 0, 1)
|
||||
head_br = self._s_f32_b[head_sl]
|
||||
np.subtract(1.0, head_dist, out=head_br)
|
||||
np.clip(head_br, 0, 1, out=head_br)
|
||||
# Additive blend towards white using scratch _s_f32_c slice
|
||||
tmp = self._s_f32_c[head_sl]
|
||||
for ch_idx, ch_base in enumerate((r, g, b)):
|
||||
np.multiply(head_br, 255 - ch_base, out=tmp)
|
||||
tmp += buf[head_sl, ch_idx]
|
||||
np.clip(tmp, 0, 255, out=tmp)
|
||||
np.copyto(buf[head_sl, ch_idx], tmp, casting='unsafe')
|
||||
|
||||
# ── Plasma ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -266,7 +266,12 @@ class LiveStreamManager:
|
||||
|
||||
@staticmethod
|
||||
def _load_static_image(image_source: str) -> np.ndarray:
|
||||
"""Load a static image from URL or file path, return as RGB numpy array."""
|
||||
"""Load a static image from URL or file path, return as RGB numpy array.
|
||||
|
||||
Note: Uses synchronous httpx.get() for URLs, which blocks up to 15s.
|
||||
This is acceptable because acquire() (the only caller chain) is always
|
||||
invoked from background worker threads, never from the async event loop.
|
||||
"""
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@@ -163,6 +163,9 @@ class MappedColorStripStream(ColorStripStream):
|
||||
|
||||
def _processing_loop(self) -> None:
|
||||
frame_time = self._frame_time
|
||||
_pool_n = 0
|
||||
_buf_a = _buf_b = None
|
||||
_use_a = True
|
||||
try:
|
||||
while self._running:
|
||||
loop_start = time.perf_counter()
|
||||
@@ -173,7 +176,14 @@ class MappedColorStripStream(ColorStripStream):
|
||||
time.sleep(frame_time)
|
||||
continue
|
||||
|
||||
result = np.zeros((target_n, 3), dtype=np.uint8)
|
||||
if target_n != _pool_n:
|
||||
_pool_n = target_n
|
||||
_buf_a = np.zeros((target_n, 3), dtype=np.uint8)
|
||||
_buf_b = np.zeros((target_n, 3), dtype=np.uint8)
|
||||
|
||||
result = _buf_a if _use_a else _buf_b
|
||||
_use_a = not _use_a
|
||||
result[:] = 0
|
||||
|
||||
with self._sub_lock:
|
||||
sub_snapshot = dict(self._sub_streams)
|
||||
|
||||
@@ -5,7 +5,10 @@ from collections import deque
|
||||
from datetime import datetime, timezone
|
||||
from typing import Dict, Optional
|
||||
|
||||
import psutil
|
||||
|
||||
from wled_controller.utils import get_logger
|
||||
from wled_controller.utils.gpu import nvml_available as _nvml_available, nvml as _nvml, nvml_handle as _nvml_handle
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -18,8 +21,6 @@ def _collect_system_snapshot() -> dict:
|
||||
|
||||
Returns a dict suitable for direct JSON serialization.
|
||||
"""
|
||||
import psutil
|
||||
|
||||
mem = psutil.virtual_memory()
|
||||
snapshot = {
|
||||
"t": datetime.now(timezone.utc).isoformat(),
|
||||
@@ -32,8 +33,6 @@ def _collect_system_snapshot() -> dict:
|
||||
}
|
||||
|
||||
try:
|
||||
from wled_controller.api.routes.system import _nvml_available, _nvml, _nvml_handle
|
||||
|
||||
if _nvml_available:
|
||||
util = _nvml.nvmlDeviceGetUtilizationRates(_nvml_handle)
|
||||
temp = _nvml.nvmlDeviceGetTemperature(_nvml_handle, _nvml.NVML_TEMPERATURE_GPU)
|
||||
|
||||
@@ -258,6 +258,11 @@ class OsNotificationListener:
|
||||
# Recent notification history (thread-safe deque, newest first)
|
||||
self._history: collections.deque = collections.deque(maxlen=50)
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Whether a platform backend is active and listening."""
|
||||
return self._available
|
||||
|
||||
def start(self) -> None:
|
||||
global _instance
|
||||
_instance = self
|
||||
|
||||
159
server/src/wled_controller/core/processing/processed_stream.py
Normal file
159
server/src/wled_controller/core/processing/processed_stream.py
Normal file
@@ -0,0 +1,159 @@
|
||||
"""Processed color strip stream — wraps another CSS and applies a CSPT filter chain."""
|
||||
|
||||
import threading
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
from wled_controller.core.processing.color_strip_stream import ColorStripStream
|
||||
from wled_controller.core.filters import FilterRegistry
|
||||
from wled_controller.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class ProcessedColorStripStream(ColorStripStream):
|
||||
"""Color strip stream that wraps an input CSS and applies CSPT filters.
|
||||
|
||||
Acquires the input stream from the manager, reads its colors, applies
|
||||
the filter chain from the referenced processing template, and caches
|
||||
the result.
|
||||
"""
|
||||
|
||||
def __init__(self, source, css_manager, cspt_store=None):
|
||||
self._source = source
|
||||
self._css_manager = css_manager
|
||||
self._cspt_store = cspt_store
|
||||
self._input_stream: Optional[ColorStripStream] = None
|
||||
self._consumer_id = f"__processed_{source.id}__"
|
||||
self._filters = []
|
||||
self._cached_template_id = None
|
||||
self._running = False
|
||||
self._thread: Optional[threading.Thread] = None
|
||||
self._colors: Optional[np.ndarray] = None
|
||||
self._colors_lock = threading.Lock()
|
||||
self._led_count = 0
|
||||
self._auto_size = True
|
||||
self._fps = 30
|
||||
self._frame_time = 1.0 / 30
|
||||
self._resolve_count = 0
|
||||
|
||||
@property
|
||||
def target_fps(self) -> int:
|
||||
return self._fps
|
||||
|
||||
@property
|
||||
def led_count(self) -> int:
|
||||
if self._input_stream:
|
||||
return self._input_stream.led_count
|
||||
return self._led_count or 1
|
||||
|
||||
@property
|
||||
def is_animated(self) -> bool:
|
||||
if self._input_stream:
|
||||
return self._input_stream.is_animated
|
||||
return True
|
||||
|
||||
def configure(self, device_led_count: int) -> None:
|
||||
self._led_count = device_led_count
|
||||
self._auto_size = True
|
||||
if self._input_stream and hasattr(self._input_stream, 'configure'):
|
||||
self._input_stream.configure(device_led_count)
|
||||
|
||||
def start(self) -> None:
|
||||
if self._running:
|
||||
return
|
||||
# Acquire input stream
|
||||
input_id = self._source.input_source_id
|
||||
if not input_id:
|
||||
raise ValueError(f"Processed source {self._source.id} has no input_source_id")
|
||||
self._input_stream = self._css_manager.acquire(input_id, self._consumer_id)
|
||||
# Resolve initial filter chain
|
||||
self._resolve_filters()
|
||||
self._running = True
|
||||
self._thread = threading.Thread(
|
||||
target=self._processing_loop,
|
||||
name=f"css-processed-{self._source.id[:8]}",
|
||||
daemon=True,
|
||||
)
|
||||
self._thread.start()
|
||||
logger.info(f"ProcessedColorStripStream started for {self._source.id}")
|
||||
|
||||
def stop(self) -> None:
|
||||
self._running = False
|
||||
if self._thread:
|
||||
self._thread.join(timeout=5.0)
|
||||
self._thread = None
|
||||
# Release input stream
|
||||
if self._input_stream:
|
||||
input_id = self._source.input_source_id
|
||||
self._css_manager.release(input_id, self._consumer_id)
|
||||
self._input_stream = None
|
||||
logger.info(f"ProcessedColorStripStream stopped for {self._source.id}")
|
||||
|
||||
def get_latest_colors(self) -> Optional[np.ndarray]:
|
||||
with self._colors_lock:
|
||||
return self._colors
|
||||
|
||||
def update_source(self, source) -> None:
|
||||
self._source = source
|
||||
# Force re-resolve filters on next iteration
|
||||
self._cached_template_id = None
|
||||
|
||||
def set_clock(self, clock_runtime) -> None:
|
||||
if self._input_stream and hasattr(self._input_stream, 'set_clock'):
|
||||
self._input_stream.set_clock(clock_runtime)
|
||||
|
||||
def _resolve_filters(self) -> None:
|
||||
"""Resolve the CSPT filter chain from the template."""
|
||||
template_id = self._source.processing_template_id
|
||||
if template_id == self._cached_template_id:
|
||||
return
|
||||
self._cached_template_id = template_id
|
||||
self._filters = []
|
||||
if not template_id or not self._cspt_store:
|
||||
return
|
||||
try:
|
||||
template = self._cspt_store.get_template(template_id)
|
||||
resolved = self._cspt_store.resolve_filter_instances(template.filters)
|
||||
self._filters = [
|
||||
FilterRegistry.create_instance(fi.filter_id, fi.options)
|
||||
for fi in resolved
|
||||
]
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to resolve CSPT {template_id}: {e}")
|
||||
self._filters = []
|
||||
|
||||
def _processing_loop(self) -> None:
|
||||
"""Main loop: read input, apply filters, cache result."""
|
||||
while self._running:
|
||||
t0 = time.monotonic()
|
||||
|
||||
# Periodically re-resolve filters (every 30 iterations)
|
||||
self._resolve_count += 1
|
||||
if self._resolve_count >= 30:
|
||||
self._resolve_count = 0
|
||||
self._resolve_filters()
|
||||
|
||||
colors = None
|
||||
if self._input_stream:
|
||||
colors = self._input_stream.get_latest_colors()
|
||||
|
||||
if colors is not None and self._filters:
|
||||
for flt in self._filters:
|
||||
try:
|
||||
result = flt.process_strip(colors)
|
||||
if result is not None:
|
||||
colors = result
|
||||
except Exception as e:
|
||||
logger.warning(f"Filter error in processed stream: {e}")
|
||||
|
||||
if colors is not None:
|
||||
with self._colors_lock:
|
||||
self._colors = colors
|
||||
|
||||
elapsed = time.monotonic() - t0
|
||||
sleep_time = self._frame_time - elapsed
|
||||
if sleep_time > 0:
|
||||
time.sleep(sleep_time)
|
||||
@@ -86,7 +86,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, sync_clock_manager=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, cspt_store=None):
|
||||
"""Initialize processor manager."""
|
||||
self._devices: Dict[str, DeviceState] = {}
|
||||
self._processors: Dict[str, TargetProcessor] = {}
|
||||
@@ -102,6 +102,7 @@ class ProcessorManager:
|
||||
self._audio_source_store = audio_source_store
|
||||
self._audio_template_store = audio_template_store
|
||||
self._value_source_store = value_source_store
|
||||
self._cspt_store = cspt_store
|
||||
self._live_stream_manager = LiveStreamManager(
|
||||
picture_source_store, capture_template_store, pp_template_store
|
||||
)
|
||||
@@ -114,6 +115,7 @@ class ProcessorManager:
|
||||
audio_source_store=audio_source_store,
|
||||
audio_template_store=audio_template_store,
|
||||
sync_clock_manager=sync_clock_manager,
|
||||
cspt_store=cspt_store,
|
||||
)
|
||||
self._value_stream_manager = ValueStreamManager(
|
||||
value_source_store=value_source_store,
|
||||
@@ -160,6 +162,7 @@ class ProcessorManager:
|
||||
device_store=self._device_store,
|
||||
color_strip_stream_manager=self._color_strip_stream_manager,
|
||||
value_stream_manager=self._value_stream_manager,
|
||||
cspt_store=self._cspt_store,
|
||||
fire_event=self.fire_event,
|
||||
get_device_info=self._get_device_info,
|
||||
)
|
||||
@@ -185,8 +188,8 @@ class ProcessorManager:
|
||||
chroma_device_type = "chromalink"
|
||||
gamesense_device_type = "keyboard"
|
||||
if self._device_store:
|
||||
dev = self._device_store.get_device(ds.device_id)
|
||||
if dev:
|
||||
try:
|
||||
dev = self._device_store.get_device(ds.device_id)
|
||||
send_latency_ms = getattr(dev, "send_latency_ms", 0)
|
||||
rgbw = getattr(dev, "rgbw", False)
|
||||
dmx_protocol = getattr(dev, "dmx_protocol", "artnet")
|
||||
@@ -201,6 +204,8 @@ class ProcessorManager:
|
||||
spi_led_type = getattr(dev, "spi_led_type", "WS2812B")
|
||||
chroma_device_type = getattr(dev, "chroma_device_type", "chromalink")
|
||||
gamesense_device_type = getattr(dev, "gamesense_device_type", "keyboard")
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
return DeviceInfo(
|
||||
device_id=ds.device_id,
|
||||
@@ -356,9 +361,11 @@ class ProcessorManager:
|
||||
# (e.g. mock devices have no real hardware to query)
|
||||
rgbw = h.device_rgbw
|
||||
if rgbw is None and self._device_store:
|
||||
dev = self._device_store.get_device(device_id)
|
||||
if dev:
|
||||
try:
|
||||
dev = self._device_store.get_device(device_id)
|
||||
rgbw = getattr(dev, "rgbw", False)
|
||||
except ValueError:
|
||||
pass
|
||||
return {
|
||||
"device_id": device_id,
|
||||
"device_online": h.online,
|
||||
@@ -529,9 +536,11 @@ class ProcessorManager:
|
||||
dev_name = proc.device_id
|
||||
tgt_name = other_id
|
||||
if self._device_store:
|
||||
dev = self._device_store.get_device(proc.device_id)
|
||||
if dev:
|
||||
try:
|
||||
dev = self._device_store.get_device(proc.device_id)
|
||||
dev_name = dev.name
|
||||
except ValueError:
|
||||
pass
|
||||
raise RuntimeError(
|
||||
f"Device '{dev_name}' is already being processed by target {tgt_name}"
|
||||
)
|
||||
|
||||
@@ -26,6 +26,7 @@ if TYPE_CHECKING:
|
||||
from wled_controller.storage.template_store import TemplateStore
|
||||
from wled_controller.storage.postprocessing_template_store import PostprocessingTemplateStore
|
||||
from wled_controller.storage.pattern_template_store import PatternTemplateStore
|
||||
from wled_controller.storage.color_strip_processing_template_store import ColorStripProcessingTemplateStore
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -112,6 +113,7 @@ class TargetContext:
|
||||
device_store: Optional["DeviceStore"] = None
|
||||
color_strip_stream_manager: Optional["ColorStripStreamManager"] = None
|
||||
value_stream_manager: Optional["ValueStreamManager"] = None
|
||||
cspt_store: Optional["ColorStripProcessingTemplateStore"] = None
|
||||
fire_event: Callable[[dict], None] = lambda e: None
|
||||
get_device_info: Callable[[str], Optional[DeviceInfo]] = lambda _: None
|
||||
|
||||
|
||||
@@ -644,6 +644,12 @@ class WledTargetProcessor(TargetProcessor):
|
||||
self._effective_fps = self._target_fps
|
||||
self._device_reachable = None
|
||||
|
||||
# --- CSPT (Color Strip Processing Template) filter cache ---
|
||||
_cspt_cached_template_id: Optional[str] = None # last resolved template ID
|
||||
_cspt_filters: list = [] # list of PostprocessingFilter instances
|
||||
_cspt_check_interval = 30 # re-check device template ID every N iterations
|
||||
_cspt_check_counter = 0
|
||||
|
||||
logger.info(
|
||||
f"Processing loop started for target {self._target_id} "
|
||||
f"(css={self._css_id}, {_total_leds} LEDs, fps={self._target_fps}"
|
||||
@@ -746,6 +752,45 @@ class WledTargetProcessor(TargetProcessor):
|
||||
await asyncio.sleep(frame_time)
|
||||
continue
|
||||
|
||||
# --- Apply device default CSPT filters ---
|
||||
_cspt_check_counter += 1
|
||||
if _cspt_check_counter >= _cspt_check_interval:
|
||||
_cspt_check_counter = 0
|
||||
# Re-read the device's default template ID
|
||||
_cur_cspt_id = ""
|
||||
if self._ctx.device_store:
|
||||
try:
|
||||
_dev = self._ctx.device_store.get(self._device_id)
|
||||
_cur_cspt_id = getattr(_dev, "default_css_processing_template_id", "") or ""
|
||||
except Exception:
|
||||
_cur_cspt_id = ""
|
||||
if _cur_cspt_id != _cspt_cached_template_id:
|
||||
_cspt_cached_template_id = _cur_cspt_id
|
||||
_cspt_filters = []
|
||||
if _cur_cspt_id and self._ctx.cspt_store:
|
||||
try:
|
||||
from wled_controller.core.filters.registry import FilterRegistry
|
||||
_resolved = self._ctx.cspt_store.resolve_filter_instances(
|
||||
self._ctx.cspt_store.get_template(_cur_cspt_id).filters
|
||||
)
|
||||
_cspt_filters = [
|
||||
FilterRegistry.create_instance(fi.filter_id, fi.options)
|
||||
for fi in _resolved
|
||||
if getattr(FilterRegistry.get(fi.filter_id), "supports_strip", True)
|
||||
]
|
||||
logger.info(
|
||||
f"CSPT resolved {len(_cspt_filters)} filters for "
|
||||
f"device {self._device_id} template {_cur_cspt_id}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to resolve CSPT {_cur_cspt_id}: {e}")
|
||||
_cspt_filters = []
|
||||
if _cspt_filters:
|
||||
for _flt in _cspt_filters:
|
||||
_result = _flt.process_strip(frame)
|
||||
if _result is not None:
|
||||
frame = _result
|
||||
|
||||
cur_brightness = _effective_brightness(device_info)
|
||||
|
||||
# Min brightness threshold: combine brightness source
|
||||
|
||||
@@ -31,6 +31,7 @@ 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.storage.color_strip_processing_template_store import ColorStripProcessingTemplateStore
|
||||
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
|
||||
@@ -61,6 +62,7 @@ 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)
|
||||
cspt_store = ColorStripProcessingTemplateStore(config.storage.color_strip_processing_templates_file)
|
||||
sync_clock_manager = SyncClockManager(sync_clock_store)
|
||||
|
||||
processor_manager = ProcessorManager(
|
||||
@@ -74,6 +76,7 @@ processor_manager = ProcessorManager(
|
||||
value_source_store=value_source_store,
|
||||
audio_template_store=audio_template_store,
|
||||
sync_clock_manager=sync_clock_manager,
|
||||
cspt_store=cspt_store,
|
||||
)
|
||||
|
||||
|
||||
@@ -144,6 +147,7 @@ async def lifespan(app: FastAPI):
|
||||
auto_backup_engine=auto_backup_engine,
|
||||
sync_clock_store=sync_clock_store,
|
||||
sync_clock_manager=sync_clock_manager,
|
||||
cspt_store=cspt_store,
|
||||
)
|
||||
|
||||
# Register devices in processor manager for health monitoring
|
||||
@@ -281,10 +285,12 @@ async def pwa_service_worker():
|
||||
# Middleware: no-cache for static JS/CSS (development convenience)
|
||||
@app.middleware("http")
|
||||
async def _no_cache_static(request: Request, call_next):
|
||||
response = await call_next(request)
|
||||
if request.url.path.startswith("/static/") and request.url.path.endswith((".js", ".css", ".json")):
|
||||
path = request.url.path
|
||||
if path.startswith("/static/") and path.endswith((".js", ".css", ".json")):
|
||||
response = await call_next(request)
|
||||
response.headers["Cache-Control"] = "no-cache, must-revalidate"
|
||||
return response
|
||||
return response
|
||||
return await call_next(request)
|
||||
|
||||
# Mount static files
|
||||
static_path = Path(__file__).parent / "static"
|
||||
|
||||
@@ -636,6 +636,7 @@ textarea:focus-visible {
|
||||
|
||||
.icon-select-grid {
|
||||
display: grid;
|
||||
grid-auto-rows: 1fr;
|
||||
gap: 6px;
|
||||
padding: 6px;
|
||||
border: 1px solid var(--border-color);
|
||||
|
||||
@@ -65,6 +65,9 @@ import {
|
||||
addFilterFromSelect, toggleFilterExpand, removeFilter, moveFilter, updateFilterOption,
|
||||
renderModalFilterList, updateCaptureDuration,
|
||||
cloneStream, cloneCaptureTemplate, clonePPTemplate,
|
||||
showAddCSPTModal, editCSPT, closeCSPTModal, saveCSPT, deleteCSPT, cloneCSPT,
|
||||
csptAddFilterFromSelect, csptToggleFilterExpand, csptRemoveFilter, csptUpdateFilterOption,
|
||||
renderCSPTModalFilterList,
|
||||
expandAllStreamSections, collapseAllStreamSections,
|
||||
} from './features/streams.js';
|
||||
import {
|
||||
@@ -125,7 +128,7 @@ import {
|
||||
onNotificationFilterModeChange,
|
||||
notificationAddAppColor, notificationRemoveAppColor,
|
||||
testNotification,
|
||||
testColorStrip, closeTestCssSourceModal, applyCssTestSettings, fireCssTestNotification, fireCssTestNotificationLayer,
|
||||
testColorStrip, testCSPT, closeTestCssSourceModal, applyCssTestSettings, fireCssTestNotification, fireCssTestNotificationLayer,
|
||||
} from './features/color-strips.js';
|
||||
|
||||
// Layer 5: audio sources
|
||||
@@ -295,6 +298,17 @@ Object.assign(window, {
|
||||
cloneStream,
|
||||
cloneCaptureTemplate,
|
||||
clonePPTemplate,
|
||||
showAddCSPTModal,
|
||||
editCSPT,
|
||||
closeCSPTModal,
|
||||
saveCSPT,
|
||||
deleteCSPT,
|
||||
cloneCSPT,
|
||||
csptAddFilterFromSelect,
|
||||
csptToggleFilterExpand,
|
||||
csptRemoveFilter,
|
||||
csptUpdateFilterOption,
|
||||
renderCSPTModalFilterList,
|
||||
showAddAudioTemplateModal,
|
||||
editAudioTemplate,
|
||||
closeAudioTemplateModal,
|
||||
@@ -413,7 +427,7 @@ Object.assign(window, {
|
||||
onNotificationFilterModeChange,
|
||||
notificationAddAppColor, notificationRemoveAppColor,
|
||||
testNotification,
|
||||
testColorStrip, closeTestCssSourceModal, applyCssTestSettings, fireCssTestNotification, fireCssTestNotificationLayer,
|
||||
testColorStrip, testCSPT, closeTestCssSourceModal, applyCssTestSettings, fireCssTestNotification, fireCssTestNotificationLayer,
|
||||
|
||||
// audio sources
|
||||
showAudioSourceModal,
|
||||
|
||||
@@ -116,6 +116,7 @@ export const ENTITY_COLORS = {
|
||||
scene_preset: '#CE93D8',
|
||||
automation: '#A5D6A7',
|
||||
pattern_template: '#BCAAA4',
|
||||
cspt: '#7E57C2',
|
||||
};
|
||||
|
||||
export const ENTITY_LABELS = {
|
||||
@@ -132,6 +133,7 @@ export const ENTITY_LABELS = {
|
||||
scene_preset: 'Scene Preset',
|
||||
automation: 'Automation',
|
||||
pattern_template: 'Pattern Template',
|
||||
cspt: 'Strip Processing',
|
||||
};
|
||||
|
||||
/* ── Edge type (for CSS class) ── */
|
||||
@@ -145,6 +147,7 @@ function edgeType(fromKind, toKind, field) {
|
||||
if (fromKind === 'audio_source' || fromKind === 'audio_template') return 'audio';
|
||||
if (fromKind === 'capture_template' || fromKind === 'pp_template' || fromKind === 'pattern_template') return 'template';
|
||||
if (fromKind === 'scene_preset') return 'scene';
|
||||
if (fromKind === 'cspt') return 'template';
|
||||
return 'default';
|
||||
}
|
||||
|
||||
@@ -238,6 +241,11 @@ function buildGraph(e) {
|
||||
addNode(a.id, 'automation', a.name, '', { running: a.enabled || false });
|
||||
}
|
||||
|
||||
// 14. Color strip processing templates (CSPT)
|
||||
for (const t of e.csptTemplates || []) {
|
||||
addNode(t.id, 'cspt', t.name, '');
|
||||
}
|
||||
|
||||
// ── Edges ──
|
||||
|
||||
// Picture source edges
|
||||
@@ -265,6 +273,10 @@ function buildGraph(e) {
|
||||
if (s.audio_source_id) addEdge(s.audio_source_id, s.id, 'audio_source_id');
|
||||
if (s.clock_id) addEdge(s.clock_id, s.id, 'clock_id');
|
||||
|
||||
// Processed type: input source and processing template edges
|
||||
if (s.input_source_id) addEdge(s.input_source_id, s.id, 'input_source_id');
|
||||
if (s.processing_template_id) addEdge(s.processing_template_id, s.id, 'processing_template_id');
|
||||
|
||||
// Composite layers
|
||||
if (s.layers) {
|
||||
for (const layer of s.layers) {
|
||||
@@ -316,6 +328,20 @@ function buildGraph(e) {
|
||||
if (a.deactivation_scene_preset_id) addEdge(a.deactivation_scene_preset_id, a.id, 'deactivation_scene_preset_id');
|
||||
}
|
||||
|
||||
// Composite layer → CSPT edges
|
||||
for (const s of e.colorStripSources || []) {
|
||||
if (s.layers) {
|
||||
for (const layer of s.layers) {
|
||||
if (layer.processing_template_id) addEdge(layer.processing_template_id, s.id, 'layer.processing_template_id');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Device → CSPT default template edges
|
||||
for (const d of e.devices || []) {
|
||||
if (d.default_css_processing_template_id) addEdge(d.default_css_processing_template_id, d.id, 'default_css_processing_template_id');
|
||||
}
|
||||
|
||||
return { nodes, edges };
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ const KIND_ICONS = {
|
||||
output_target: P.zap,
|
||||
scene_preset: P.sparkles,
|
||||
automation: P.clipboardList,
|
||||
cspt: P.wrench,
|
||||
};
|
||||
|
||||
// ── Subtype-specific icon overrides ──
|
||||
@@ -34,6 +35,7 @@ const SUBTYPE_ICONS = {
|
||||
mapped: P.mapPin, mapped_zones: P.mapPin,
|
||||
audio: P.music, audio_visualization: P.music,
|
||||
api_input: P.send, notification: P.bellRing, daylight: P.sun, candlelight: P.flame,
|
||||
processed: P.sparkles,
|
||||
},
|
||||
picture_source: { raw: P.monitor, processed: P.palette, static_image: P.image },
|
||||
value_source: {
|
||||
|
||||
@@ -26,6 +26,7 @@ const _colorStripTypeIcons = {
|
||||
notification: _svg(P.bellRing),
|
||||
daylight: _svg(P.sun),
|
||||
candlelight: _svg(P.flame),
|
||||
processed: _svg(P.sparkles),
|
||||
};
|
||||
const _valueSourceTypeIcons = {
|
||||
static: _svg(P.layoutDashboard), animated: _svg(P.refreshCw), audio: _svg(P.music),
|
||||
@@ -102,6 +103,7 @@ export const ICON_CAPTURE_TEMPLATE = _svg(P.camera);
|
||||
export const ICON_PP_TEMPLATE = _svg(P.wrench);
|
||||
export const ICON_PATTERN_TEMPLATE = _svg(P.fileText);
|
||||
export const ICON_AUDIO_TEMPLATE = _svg(P.music);
|
||||
export const ICON_CSPT = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/></svg>';
|
||||
|
||||
// ── Action constants ────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -83,6 +83,17 @@ export function set_modalFilters(v) { _modalFilters = v; }
|
||||
export let _ppTemplateNameManuallyEdited = false;
|
||||
export function set_ppTemplateNameManuallyEdited(v) { _ppTemplateNameManuallyEdited = v; }
|
||||
|
||||
// CSPT (Color Strip Processing Template) state
|
||||
export let _csptModalFilters = [];
|
||||
export function set_csptModalFilters(v) { _csptModalFilters = v; }
|
||||
|
||||
export let _csptNameManuallyEdited = false;
|
||||
export function set_csptNameManuallyEdited(v) { _csptNameManuallyEdited = v; }
|
||||
|
||||
export let _stripFilters = [];
|
||||
|
||||
export let _cachedCSPTemplates = [];
|
||||
|
||||
// Stream test state
|
||||
export let _currentTestStreamId = null;
|
||||
export function set_currentTestStreamId(v) { _currentTestStreamId = v; }
|
||||
@@ -260,6 +271,18 @@ export const colorStripSourcesCache = new DataCache({
|
||||
extractData: json => json.sources || [],
|
||||
});
|
||||
|
||||
export const csptCache = new DataCache({
|
||||
endpoint: '/color-strip-processing-templates',
|
||||
extractData: json => json.templates || [],
|
||||
});
|
||||
csptCache.subscribe(v => { _cachedCSPTemplates = v; });
|
||||
|
||||
export const stripFiltersCache = new DataCache({
|
||||
endpoint: '/strip-filters',
|
||||
extractData: json => json.filters || [],
|
||||
});
|
||||
stripFiltersCache.subscribe(v => { _stripFilters = v; });
|
||||
|
||||
export const devicesCache = new DataCache({
|
||||
endpoint: '/devices',
|
||||
extractData: json => json.devices || [],
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*/
|
||||
|
||||
import { fetchWithAuth, escapeHtml } from '../core/api.js';
|
||||
import { _cachedSyncClocks, _cachedValueSources, audioSourcesCache, streamsCache, colorStripSourcesCache, valueSourcesCache } from '../core/state.js';
|
||||
import { _cachedSyncClocks, _cachedValueSources, _cachedCSPTemplates, audioSourcesCache, streamsCache, colorStripSourcesCache, valueSourcesCache, csptCache } from '../core/state.js';
|
||||
import { t } from '../core/i18n.js';
|
||||
import { showToast, showConfirm, desktopFocus } from '../core/ui.js';
|
||||
import { Modal } from '../core/modal.js';
|
||||
@@ -48,11 +48,7 @@ class CSSEditorModal extends Modal {
|
||||
picture_source: document.getElementById('css-editor-picture-source').value,
|
||||
interpolation: document.getElementById('css-editor-interpolation').value,
|
||||
smoothing: document.getElementById('css-editor-smoothing').value,
|
||||
brightness: document.getElementById('css-editor-brightness').value,
|
||||
saturation: document.getElementById('css-editor-saturation').value,
|
||||
gamma: document.getElementById('css-editor-gamma').value,
|
||||
color: document.getElementById('css-editor-color').value,
|
||||
frame_interpolation: document.getElementById('css-editor-frame-interpolation').checked,
|
||||
led_count: document.getElementById('css-editor-led-count').value,
|
||||
gradient_stops: type === 'gradient' ? JSON.stringify(getGradientStops()) : '[]',
|
||||
animation_type: document.getElementById('css-editor-animation-type').value,
|
||||
@@ -89,6 +85,8 @@ class CSSEditorModal extends Modal {
|
||||
candlelight_intensity: document.getElementById('css-editor-candlelight-intensity').value,
|
||||
candlelight_num_candles: document.getElementById('css-editor-candlelight-num-candles').value,
|
||||
candlelight_speed: document.getElementById('css-editor-candlelight-speed').value,
|
||||
processed_input: document.getElementById('css-editor-processed-input').value,
|
||||
processed_template: document.getElementById('css-editor-processed-template').value,
|
||||
tags: JSON.stringify(_cssTagsInput ? _cssTagsInput.getValue() : []),
|
||||
};
|
||||
}
|
||||
@@ -102,13 +100,15 @@ let _cssTagsInput = null;
|
||||
let _cssPictureSourceEntitySelect = null;
|
||||
let _cssAudioSourceEntitySelect = null;
|
||||
let _cssClockEntitySelect = null;
|
||||
let _processedInputEntitySelect = null;
|
||||
let _processedTemplateEntitySelect = null;
|
||||
|
||||
/* ── Icon-grid type selector ──────────────────────────────────── */
|
||||
|
||||
const CSS_TYPE_KEYS = [
|
||||
'picture', 'picture_advanced', 'static', 'gradient', 'color_cycle',
|
||||
'effect', 'composite', 'mapped', 'audio',
|
||||
'api_input', 'notification', 'daylight', 'candlelight',
|
||||
'api_input', 'notification', 'daylight', 'candlelight', 'processed',
|
||||
];
|
||||
|
||||
function _buildCSSTypeItems() {
|
||||
@@ -159,8 +159,10 @@ export function onCSSTypeChange() {
|
||||
document.getElementById('css-editor-notification-section').style.display = type === 'notification' ? '' : 'none';
|
||||
document.getElementById('css-editor-daylight-section').style.display = type === 'daylight' ? '' : 'none';
|
||||
document.getElementById('css-editor-candlelight-section').style.display = type === 'candlelight' ? '' : 'none';
|
||||
document.getElementById('css-editor-processed-section').style.display = type === 'processed' ? '' : 'none';
|
||||
|
||||
if (isPictureType) _ensureInterpolationIconSelect();
|
||||
if (type === 'processed') _populateProcessedSelectors();
|
||||
if (type === 'effect') {
|
||||
_ensureEffectTypeIconSelect();
|
||||
_ensureEffectPaletteIconSelect();
|
||||
@@ -244,6 +246,45 @@ export function onCSSClockChange() {
|
||||
// No-op: speed sliders removed; speed is now clock-only
|
||||
}
|
||||
|
||||
function _populateProcessedSelectors() {
|
||||
const editingId = document.getElementById('css-editor-id').value;
|
||||
const allSources = colorStripSourcesCache.data || [];
|
||||
// Exclude self and other processed sources to prevent cycles
|
||||
const inputSources = allSources.filter(s => s.id !== editingId && s.source_type !== 'processed');
|
||||
const inputSel = document.getElementById('css-editor-processed-input');
|
||||
const prevInput = inputSel.value;
|
||||
inputSel.innerHTML = '<option value="">—</option>' +
|
||||
inputSources.map(s => `<option value="${s.id}">${escapeHtml(s.name)}</option>`).join('');
|
||||
inputSel.value = prevInput || '';
|
||||
if (_processedInputEntitySelect) _processedInputEntitySelect.destroy();
|
||||
if (inputSources.length > 0) {
|
||||
_processedInputEntitySelect = new EntitySelect({
|
||||
target: inputSel,
|
||||
getItems: () => (colorStripSourcesCache.data || [])
|
||||
.filter(s => s.id !== editingId && s.source_type !== 'processed')
|
||||
.map(s => ({ value: s.id, label: s.name, icon: getColorStripIcon(s.source_type) })),
|
||||
placeholder: t('palette.search'),
|
||||
});
|
||||
}
|
||||
|
||||
const templates = csptCache.data || [];
|
||||
const tplSel = document.getElementById('css-editor-processed-template');
|
||||
const prevTpl = tplSel.value;
|
||||
tplSel.innerHTML = '<option value="">—</option>' +
|
||||
templates.map(tp => `<option value="${tp.id}">${escapeHtml(tp.name)}</option>`).join('');
|
||||
tplSel.value = prevTpl || '';
|
||||
if (_processedTemplateEntitySelect) _processedTemplateEntitySelect.destroy();
|
||||
if (templates.length > 0) {
|
||||
_processedTemplateEntitySelect = new EntitySelect({
|
||||
target: tplSel,
|
||||
getItems: () => (csptCache.data || []).map(tp => ({
|
||||
value: tp.id, label: tp.name, icon: ICON_SPARKLES,
|
||||
})),
|
||||
placeholder: t('palette.search'),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function _getAnimationPayload() {
|
||||
const type = document.getElementById('css-editor-animation-type').value;
|
||||
return {
|
||||
@@ -543,6 +584,7 @@ let _compositeAvailableSources = []; // non-composite sources for layer dropdow
|
||||
let _compositeSourceEntitySelects = [];
|
||||
let _compositeBrightnessEntitySelects = [];
|
||||
let _compositeBlendIconSelects = [];
|
||||
let _compositeCSPTEntitySelects = [];
|
||||
|
||||
function _compositeDestroyEntitySelects() {
|
||||
_compositeSourceEntitySelects.forEach(es => es.destroy());
|
||||
@@ -551,6 +593,8 @@ function _compositeDestroyEntitySelects() {
|
||||
_compositeBrightnessEntitySelects = [];
|
||||
_compositeBlendIconSelects.forEach(is => is.destroy());
|
||||
_compositeBlendIconSelects = [];
|
||||
_compositeCSPTEntitySelects.forEach(es => es.destroy());
|
||||
_compositeCSPTEntitySelects = [];
|
||||
}
|
||||
|
||||
function _getCompositeBlendItems() {
|
||||
@@ -578,6 +622,14 @@ function _getCompositeBrightnessItems() {
|
||||
}));
|
||||
}
|
||||
|
||||
function _getCompositeCSPTItems() {
|
||||
return (_cachedCSPTemplates || []).map(tmpl => ({
|
||||
value: tmpl.id,
|
||||
label: tmpl.name,
|
||||
icon: ICON_SPARKLES,
|
||||
}));
|
||||
}
|
||||
|
||||
function _compositeRenderList() {
|
||||
const list = document.getElementById('composite-layers-list');
|
||||
if (!list) return;
|
||||
@@ -591,6 +643,11 @@ function _compositeRenderList() {
|
||||
vsList.map(v =>
|
||||
`<option value="${v.id}"${layer.brightness_source_id === v.id ? ' selected' : ''}>${escapeHtml(v.name)}</option>`
|
||||
).join('');
|
||||
const csptList = _cachedCSPTemplates || [];
|
||||
const csptOptions = `<option value="">${t('common.none')}</option>` +
|
||||
csptList.map(tmpl =>
|
||||
`<option value="${tmpl.id}"${layer.processing_template_id === tmpl.id ? ' selected' : ''}>${escapeHtml(tmpl.name)}</option>`
|
||||
).join('');
|
||||
const canRemove = _compositeLayers.length > 1;
|
||||
return `
|
||||
<div class="composite-layer-item">
|
||||
@@ -625,6 +682,12 @@ function _compositeRenderList() {
|
||||
</label>
|
||||
<select class="composite-layer-brightness" data-idx="${i}">${vsOptions}</select>
|
||||
</div>
|
||||
<div class="composite-layer-row">
|
||||
<label class="composite-layer-brightness-label">
|
||||
<span>${t('color_strip.composite.processing')}:</span>
|
||||
</label>
|
||||
<select class="composite-layer-cspt" data-idx="${i}">${csptOptions}</select>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
@@ -663,6 +726,17 @@ function _compositeRenderList() {
|
||||
noneLabel: t('color_strip.composite.brightness.none'),
|
||||
}));
|
||||
});
|
||||
|
||||
// Attach EntitySelect to each layer's CSPT dropdown
|
||||
list.querySelectorAll('.composite-layer-cspt').forEach(sel => {
|
||||
_compositeCSPTEntitySelects.push(new EntitySelect({
|
||||
target: sel,
|
||||
getItems: _getCompositeCSPTItems,
|
||||
placeholder: t('palette.search'),
|
||||
allowNone: true,
|
||||
noneLabel: t('common.none'),
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
export function compositeAddLayer() {
|
||||
@@ -673,6 +747,7 @@ export function compositeAddLayer() {
|
||||
opacity: 1.0,
|
||||
enabled: true,
|
||||
brightness_source_id: null,
|
||||
processing_template_id: null,
|
||||
});
|
||||
_compositeRenderList();
|
||||
}
|
||||
@@ -692,6 +767,7 @@ function _compositeLayersSyncFromDom() {
|
||||
const opacities = list.querySelectorAll('.composite-layer-opacity');
|
||||
const enableds = list.querySelectorAll('.composite-layer-enabled');
|
||||
const briSrcs = list.querySelectorAll('.composite-layer-brightness');
|
||||
const csptSels = list.querySelectorAll('.composite-layer-cspt');
|
||||
if (srcs.length === _compositeLayers.length) {
|
||||
for (let i = 0; i < srcs.length; i++) {
|
||||
_compositeLayers[i].source_id = srcs[i].value;
|
||||
@@ -699,6 +775,7 @@ function _compositeLayersSyncFromDom() {
|
||||
_compositeLayers[i].opacity = parseFloat(opacities[i].value);
|
||||
_compositeLayers[i].enabled = enableds[i].checked;
|
||||
_compositeLayers[i].brightness_source_id = briSrcs[i] ? (briSrcs[i].value || null) : null;
|
||||
_compositeLayers[i].processing_template_id = csptSels[i] ? (csptSels[i].value || null) : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -713,6 +790,7 @@ function _compositeGetLayers() {
|
||||
enabled: l.enabled,
|
||||
};
|
||||
if (l.brightness_source_id) layer.brightness_source_id = l.brightness_source_id;
|
||||
if (l.processing_template_id) layer.processing_template_id = l.processing_template_id;
|
||||
return layer;
|
||||
});
|
||||
}
|
||||
@@ -726,8 +804,9 @@ function _loadCompositeState(css) {
|
||||
opacity: l.opacity != null ? l.opacity : 1.0,
|
||||
enabled: l.enabled != null ? l.enabled : true,
|
||||
brightness_source_id: l.brightness_source_id || null,
|
||||
processing_template_id: l.processing_template_id || null,
|
||||
}))
|
||||
: [{ source_id: '', blend_mode: 'normal', opacity: 1.0, enabled: true, brightness_source_id: null }];
|
||||
: [{ source_id: '', blend_mode: 'normal', opacity: 1.0, enabled: true, brightness_source_id: null, processing_template_id: null }];
|
||||
_compositeRenderList();
|
||||
}
|
||||
|
||||
@@ -1217,6 +1296,16 @@ export function createColorStripCard(source, pictureSourceMap, audioSourceMap) {
|
||||
<span class="stream-card-prop">${numCandles} ${t('color_strip.candlelight.num_candles')}</span>
|
||||
${clockBadge}
|
||||
`;
|
||||
} else if (source.source_type === 'processed') {
|
||||
const inputSrc = (colorStripSourcesCache.data || []).find(s => s.id === source.input_source_id);
|
||||
const inputName = inputSrc?.name || source.input_source_id || '—';
|
||||
const tplName = source.processing_template_id
|
||||
? (_cachedCSPTemplates.find(t => t.id === source.processing_template_id)?.name || source.processing_template_id)
|
||||
: '—';
|
||||
propsHtml = `
|
||||
<span class="stream-card-prop" title="${t('color_strip.processed.input')}">${ICON_LINK_SOURCE} ${escapeHtml(inputName)}</span>
|
||||
<span class="stream-card-prop" title="${t('color_strip.processed.template')}">${ICON_SPARKLES} ${escapeHtml(tplName)}</span>
|
||||
`;
|
||||
} else if (isPictureAdvanced) {
|
||||
const cal = source.calibration || {};
|
||||
const lines = cal.lines || [];
|
||||
@@ -1253,7 +1342,8 @@ export function createColorStripCard(source, pictureSourceMap, audioSourceMap) {
|
||||
const icon = getColorStripIcon(source.source_type);
|
||||
const isDaylight = source.source_type === 'daylight';
|
||||
const isCandlelight = source.source_type === 'candlelight';
|
||||
const isPictureKind = (!isStatic && !isGradient && !isColorCycle && !isEffect && !isComposite && !isMapped && !isAudio && !isApiInput && !isNotification && !isDaylight && !isCandlelight);
|
||||
const isProcessed = source.source_type === 'processed';
|
||||
const isPictureKind = (!isStatic && !isGradient && !isColorCycle && !isEffect && !isComposite && !isMapped && !isAudio && !isApiInput && !isNotification && !isDaylight && !isCandlelight && !isProcessed);
|
||||
const calibrationBtn = isPictureKind
|
||||
? `<button class="btn btn-icon btn-secondary" onclick="${isPictureAdvanced ? `showAdvancedCalibration('${source.id}')` : `showCSSCalibration('${source.id}')`}" title="${t('calibration.title')}">${ICON_CALIBRATION}</button>`
|
||||
: '';
|
||||
@@ -1427,6 +1517,12 @@ export async function showCSSEditor(cssId = null, cloneData = null, presetType =
|
||||
document.getElementById('css-editor-candlelight-num-candles').value = css.num_candles ?? 3;
|
||||
document.getElementById('css-editor-candlelight-speed').value = css.speed ?? 1.0;
|
||||
document.getElementById('css-editor-candlelight-speed-val').textContent = parseFloat(css.speed ?? 1.0).toFixed(1);
|
||||
} else if (sourceType === 'processed') {
|
||||
await csptCache.fetch();
|
||||
await colorStripSourcesCache.fetch();
|
||||
_populateProcessedSelectors();
|
||||
document.getElementById('css-editor-processed-input').value = css.input_source_id || '';
|
||||
document.getElementById('css-editor-processed-template').value = css.processing_template_id || '';
|
||||
} else {
|
||||
if (sourceType === 'picture') sourceSelect.value = css.picture_source_id || '';
|
||||
|
||||
@@ -1437,19 +1533,6 @@ export async function showCSSEditor(cssId = null, cloneData = null, presetType =
|
||||
document.getElementById('css-editor-smoothing').value = smoothing;
|
||||
document.getElementById('css-editor-smoothing-value').textContent = parseFloat(smoothing).toFixed(2);
|
||||
|
||||
const brightness = css.brightness ?? 1.0;
|
||||
document.getElementById('css-editor-brightness').value = brightness;
|
||||
document.getElementById('css-editor-brightness-value').textContent = parseFloat(brightness).toFixed(2);
|
||||
|
||||
const saturation = css.saturation ?? 1.0;
|
||||
document.getElementById('css-editor-saturation').value = saturation;
|
||||
document.getElementById('css-editor-saturation-value').textContent = parseFloat(saturation).toFixed(2);
|
||||
|
||||
const gamma = css.gamma ?? 1.0;
|
||||
document.getElementById('css-editor-gamma').value = gamma;
|
||||
document.getElementById('css-editor-gamma-value').textContent = parseFloat(gamma).toFixed(2);
|
||||
|
||||
document.getElementById('css-editor-frame-interpolation').checked = css.frame_interpolation || false;
|
||||
}
|
||||
|
||||
document.getElementById('css-editor-led-count').value = css.led_count ?? 0;
|
||||
@@ -1496,13 +1579,6 @@ export async function showCSSEditor(cssId = null, cloneData = null, presetType =
|
||||
if (_interpolationIconSelect) _interpolationIconSelect.setValue('average');
|
||||
document.getElementById('css-editor-smoothing').value = 0.3;
|
||||
document.getElementById('css-editor-smoothing-value').textContent = '0.30';
|
||||
document.getElementById('css-editor-brightness').value = 1.0;
|
||||
document.getElementById('css-editor-brightness-value').textContent = '1.00';
|
||||
document.getElementById('css-editor-saturation').value = 1.0;
|
||||
document.getElementById('css-editor-saturation-value').textContent = '1.00';
|
||||
document.getElementById('css-editor-gamma').value = 1.0;
|
||||
document.getElementById('css-editor-gamma-value').textContent = '1.00';
|
||||
document.getElementById('css-editor-frame-interpolation').checked = false;
|
||||
document.getElementById('css-editor-color').value = '#ffffff';
|
||||
document.getElementById('css-editor-led-count').value = 0;
|
||||
_loadAnimationState(null);
|
||||
@@ -1536,6 +1612,12 @@ export async function showCSSEditor(cssId = null, cloneData = null, presetType =
|
||||
document.getElementById('css-editor-candlelight-num-candles').value = 3;
|
||||
document.getElementById('css-editor-candlelight-speed').value = 1.0;
|
||||
document.getElementById('css-editor-candlelight-speed-val').textContent = '1.0';
|
||||
// Processed defaults
|
||||
if (presetType === 'processed') {
|
||||
await csptCache.fetch();
|
||||
await colorStripSourcesCache.fetch();
|
||||
_populateProcessedSelectors();
|
||||
}
|
||||
const typeIcon = getColorStripIcon(presetType || 'picture');
|
||||
document.getElementById('css-editor-title').innerHTML = `${typeIcon} ${t('color_strip.add')}: ${t(`color_strip.type.${presetType || 'picture'}`)}`;
|
||||
document.getElementById('css-editor-gradient-preset').value = '';
|
||||
@@ -1714,15 +1796,24 @@ export async function saveCSSEditor() {
|
||||
speed: parseFloat(document.getElementById('css-editor-candlelight-speed').value),
|
||||
};
|
||||
if (!cssId) payload.source_type = 'candlelight';
|
||||
} else if (sourceType === 'processed') {
|
||||
const inputId = document.getElementById('css-editor-processed-input').value;
|
||||
const templateId = document.getElementById('css-editor-processed-template').value;
|
||||
if (!inputId) {
|
||||
cssEditorModal.showError(t('color_strip.processed.error.no_input'));
|
||||
return;
|
||||
}
|
||||
payload = {
|
||||
name,
|
||||
input_source_id: inputId,
|
||||
processing_template_id: templateId || null,
|
||||
};
|
||||
if (!cssId) payload.source_type = 'processed';
|
||||
} else if (sourceType === 'picture_advanced') {
|
||||
payload = {
|
||||
name,
|
||||
interpolation_mode: document.getElementById('css-editor-interpolation').value,
|
||||
smoothing: parseFloat(document.getElementById('css-editor-smoothing').value),
|
||||
brightness: parseFloat(document.getElementById('css-editor-brightness').value),
|
||||
saturation: parseFloat(document.getElementById('css-editor-saturation').value),
|
||||
gamma: parseFloat(document.getElementById('css-editor-gamma').value),
|
||||
frame_interpolation: document.getElementById('css-editor-frame-interpolation').checked,
|
||||
led_count: parseInt(document.getElementById('css-editor-led-count').value) || 0,
|
||||
};
|
||||
if (!cssId) payload.source_type = 'picture_advanced';
|
||||
@@ -1732,10 +1823,6 @@ export async function saveCSSEditor() {
|
||||
picture_source_id: document.getElementById('css-editor-picture-source').value,
|
||||
interpolation_mode: document.getElementById('css-editor-interpolation').value,
|
||||
smoothing: parseFloat(document.getElementById('css-editor-smoothing').value),
|
||||
brightness: parseFloat(document.getElementById('css-editor-brightness').value),
|
||||
saturation: parseFloat(document.getElementById('css-editor-saturation').value),
|
||||
gamma: parseFloat(document.getElementById('css-editor-gamma').value),
|
||||
frame_interpolation: document.getElementById('css-editor-frame-interpolation').checked,
|
||||
led_count: parseInt(document.getElementById('css-editor-led-count').value) || 0,
|
||||
};
|
||||
if (!cssId) payload.source_type = 'picture';
|
||||
@@ -1906,6 +1993,9 @@ let _cssTestIsComposite = false;
|
||||
let _cssTestLayerData = null; // { layerCount, ledCount, layers: [Uint8Array], composite: Uint8Array }
|
||||
let _cssTestGeneration = 0; // bumped on each connect to ignore stale WS messages
|
||||
let _cssTestNotificationIds = []; // notification source IDs to fire (self or composite layers)
|
||||
let _cssTestCSPTMode = false; // true when testing a CSPT template
|
||||
let _cssTestCSPTId = null; // CSPT template ID when in CSPT mode
|
||||
let _csptTestInputEntitySelect = null;
|
||||
|
||||
function _getCssTestLedCount() {
|
||||
const stored = parseInt(localStorage.getItem(_CSS_TEST_LED_KEY), 10);
|
||||
@@ -1918,6 +2008,48 @@ function _getCssTestFps() {
|
||||
}
|
||||
|
||||
export function testColorStrip(sourceId) {
|
||||
_cssTestCSPTMode = false;
|
||||
_cssTestCSPTId = null;
|
||||
// Hide CSPT input selector
|
||||
const csptGroup = document.getElementById('css-test-cspt-input-group');
|
||||
if (csptGroup) csptGroup.style.display = 'none';
|
||||
_openTestModal(sourceId);
|
||||
}
|
||||
|
||||
export async function testCSPT(templateId) {
|
||||
_cssTestCSPTMode = true;
|
||||
_cssTestCSPTId = templateId;
|
||||
|
||||
// Populate input source selector
|
||||
await colorStripSourcesCache.fetch();
|
||||
const sources = colorStripSourcesCache.data || [];
|
||||
const nonProcessed = sources.filter(s => s.source_type !== 'processed');
|
||||
const sel = document.getElementById('css-test-cspt-input-select');
|
||||
sel.innerHTML = nonProcessed.map(s =>
|
||||
`<option value="${s.id}">${escapeHtml(s.name)}</option>`
|
||||
).join('');
|
||||
// EntitySelect for input source picker
|
||||
if (_csptTestInputEntitySelect) _csptTestInputEntitySelect.destroy();
|
||||
_csptTestInputEntitySelect = new EntitySelect({
|
||||
target: sel,
|
||||
getItems: () => (colorStripSourcesCache.data || [])
|
||||
.filter(s => s.source_type !== 'processed')
|
||||
.map(s => ({ value: s.id, label: s.name, icon: getColorStripIcon(s.source_type) })),
|
||||
placeholder: t('palette.search'),
|
||||
});
|
||||
// Show CSPT input selector
|
||||
const csptGroup = document.getElementById('css-test-cspt-input-group');
|
||||
if (csptGroup) csptGroup.style.display = '';
|
||||
|
||||
const inputId = sel.value;
|
||||
if (!inputId) {
|
||||
showToast(t('color_strip.processed.error.no_input'), 'error');
|
||||
return;
|
||||
}
|
||||
_openTestModal(inputId);
|
||||
}
|
||||
|
||||
function _openTestModal(sourceId) {
|
||||
// Clean up any previous session fully
|
||||
if (_cssTestWs) { _cssTestWs.close(); _cssTestWs = null; }
|
||||
if (_cssTestRaf) { cancelAnimationFrame(_cssTestRaf); _cssTestRaf = null; }
|
||||
@@ -1966,7 +2098,12 @@ function _cssTestConnect(sourceId, ledCount, fps) {
|
||||
if (!fps) fps = _getCssTestFps();
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const apiKey = localStorage.getItem('wled_api_key') || '';
|
||||
const wsUrl = `${protocol}//${window.location.host}/api/v1/color-strip-sources/${sourceId}/test/ws?token=${encodeURIComponent(apiKey)}&led_count=${ledCount}&fps=${fps}`;
|
||||
let wsUrl;
|
||||
if (_cssTestCSPTMode && _cssTestCSPTId) {
|
||||
wsUrl = `${protocol}//${window.location.host}/api/v1/color-strip-processing-templates/${_cssTestCSPTId}/test/ws?token=${encodeURIComponent(apiKey)}&input_source_id=${encodeURIComponent(sourceId)}&led_count=${ledCount}&fps=${fps}`;
|
||||
} else {
|
||||
wsUrl = `${protocol}//${window.location.host}/api/v1/color-strip-sources/${sourceId}/test/ws?token=${encodeURIComponent(apiKey)}&led_count=${ledCount}&fps=${fps}`;
|
||||
}
|
||||
|
||||
_cssTestWs = new WebSocket(wsUrl);
|
||||
_cssTestWs.binaryType = 'arraybuffer';
|
||||
@@ -2171,6 +2308,14 @@ export function applyCssTestSettings() {
|
||||
_cssTestMeta = null;
|
||||
_cssTestLayerData = null;
|
||||
|
||||
// In CSPT mode, read selected input source
|
||||
if (_cssTestCSPTMode) {
|
||||
const inputSel = document.getElementById('css-test-cspt-input-select');
|
||||
if (inputSel && inputSel.value) {
|
||||
_cssTestSourceId = inputSel.value;
|
||||
}
|
||||
}
|
||||
|
||||
// Reconnect (generation counter ignores stale frames from old WS)
|
||||
_cssTestConnect(_cssTestSourceId, leds, fps);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import {
|
||||
_discoveryScanRunning, set_discoveryScanRunning,
|
||||
_discoveryCache, set_discoveryCache,
|
||||
csptCache,
|
||||
} from '../core/state.js';
|
||||
import { API_BASE, fetchWithAuth, isSerialDevice, isMockDevice, isMqttDevice, isWsDevice, isOpenrgbDevice, isDmxDevice, isEspnowDevice, isHueDevice, isUsbhidDevice, isSpiDevice, isChromaDevice, isGameSenseDevice, escapeHtml } from '../core/api.js';
|
||||
import { devicesCache } from '../core/state.js';
|
||||
@@ -12,7 +13,8 @@ import { t } from '../core/i18n.js';
|
||||
import { showToast, desktopFocus } from '../core/ui.js';
|
||||
import { Modal } from '../core/modal.js';
|
||||
import { _computeMaxFps, _renderFpsHint } from './devices.js';
|
||||
import { getDeviceTypeIcon, ICON_RADIO, ICON_GLOBE, ICON_CPU, ICON_KEYBOARD, ICON_MOUSE, ICON_HEADPHONES, ICON_PLUG, ICON_TARGET_ICON, ICON_ACTIVITY } from '../core/icons.js';
|
||||
import { getDeviceTypeIcon, ICON_RADIO, ICON_GLOBE, ICON_CPU, ICON_KEYBOARD, ICON_MOUSE, ICON_HEADPHONES, ICON_PLUG, ICON_TARGET_ICON, ICON_ACTIVITY, ICON_TEMPLATE } from '../core/icons.js';
|
||||
import { EntitySelect } from '../core/entity-palette.js';
|
||||
import { IconSelect, showTypePicker } from '../core/icon-select.js';
|
||||
|
||||
class AddDeviceModal extends Modal {
|
||||
@@ -30,6 +32,7 @@ class AddDeviceModal extends Modal {
|
||||
sendLatency: document.getElementById('device-send-latency')?.value || '0',
|
||||
zones: JSON.stringify(_getCheckedZones('device-zone-list')),
|
||||
zoneMode: _getZoneMode(),
|
||||
csptId: document.getElementById('device-css-processing-template')?.value || '',
|
||||
dmxProtocol: document.getElementById('device-dmx-protocol')?.value || 'artnet',
|
||||
dmxStartUniverse: document.getElementById('device-dmx-start-universe')?.value || '0',
|
||||
dmxStartChannel: document.getElementById('device-dmx-start-channel')?.value || '1',
|
||||
@@ -53,6 +56,7 @@ function _buildDeviceTypeItems() {
|
||||
}
|
||||
|
||||
let _deviceTypeIconSelect = null;
|
||||
let _csptEntitySelect = null;
|
||||
|
||||
function _ensureDeviceTypeIconSelect() {
|
||||
const sel = document.getElementById('device-type');
|
||||
@@ -61,6 +65,30 @@ function _ensureDeviceTypeIconSelect() {
|
||||
_deviceTypeIconSelect = new IconSelect({ target: sel, items: _buildDeviceTypeItems(), columns: 3 });
|
||||
}
|
||||
|
||||
function _ensureCsptEntitySelect() {
|
||||
const sel = document.getElementById('device-css-processing-template');
|
||||
if (!sel) return;
|
||||
const templates = csptCache.data || [];
|
||||
// Populate native <select> options
|
||||
sel.innerHTML = `<option value="">—</option>` +
|
||||
templates.map(tp => `<option value="${tp.id}">${tp.name}</option>`).join('');
|
||||
if (_csptEntitySelect) _csptEntitySelect.destroy();
|
||||
if (templates.length > 0) {
|
||||
_csptEntitySelect = new EntitySelect({
|
||||
target: sel,
|
||||
getItems: () => (csptCache.data || []).map(tp => ({
|
||||
value: tp.id,
|
||||
label: tp.name,
|
||||
icon: ICON_TEMPLATE,
|
||||
desc: '',
|
||||
})),
|
||||
placeholder: t('palette.search'),
|
||||
allowNone: true,
|
||||
noneLabel: '—',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Icon-grid DMX protocol selector ─────────────────────────── */
|
||||
|
||||
function _buildDmxProtocolItems() {
|
||||
@@ -583,6 +611,8 @@ export function showAddDevice(presetType = null) {
|
||||
const scanBtn = document.getElementById('scan-network-btn');
|
||||
if (scanBtn) scanBtn.disabled = false;
|
||||
_ensureDeviceTypeIconSelect();
|
||||
// Populate CSPT template selector
|
||||
csptCache.fetch().then(() => _ensureCsptEntitySelect());
|
||||
|
||||
// Pre-select type and hide the type selector (already chosen)
|
||||
document.getElementById('device-type').value = presetType;
|
||||
@@ -775,6 +805,8 @@ export async function handleAddDevice(event) {
|
||||
if (isGameSenseDevice(deviceType)) {
|
||||
body.gamesense_device_type = document.getElementById('device-gamesense-device-type')?.value || 'keyboard';
|
||||
}
|
||||
const csptId = document.getElementById('device-css-processing-template')?.value;
|
||||
if (csptId) body.default_css_processing_template_id = csptId;
|
||||
if (lastTemplateId) body.capture_template_id = lastTemplateId;
|
||||
|
||||
const response = await fetchWithAuth('/devices', {
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
import {
|
||||
_deviceBrightnessCache, updateDeviceBrightness,
|
||||
csptCache,
|
||||
} from '../core/state.js';
|
||||
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, isSerialDevice, isMockDevice, isMqttDevice, isWsDevice, isOpenrgbDevice, isDmxDevice } from '../core/api.js';
|
||||
import { devicesCache } from '../core/state.js';
|
||||
@@ -11,11 +12,36 @@ import { _fetchOpenrgbZones, _getCheckedZones, _splitOpenrgbZone, _getZoneMode,
|
||||
import { t } from '../core/i18n.js';
|
||||
import { showToast, showConfirm, desktopFocus } from '../core/ui.js';
|
||||
import { Modal } from '../core/modal.js';
|
||||
import { ICON_SETTINGS, ICON_STOP_PLAIN, ICON_LED, ICON_WEB, ICON_PLUG, ICON_REFRESH } from '../core/icons.js';
|
||||
import { ICON_SETTINGS, ICON_STOP_PLAIN, ICON_LED, ICON_WEB, ICON_PLUG, ICON_REFRESH, ICON_TEMPLATE } from '../core/icons.js';
|
||||
import { wrapCard } from '../core/card-colors.js';
|
||||
import { TagInput, renderTagChips } from '../core/tag-input.js';
|
||||
import { EntitySelect } from '../core/entity-palette.js';
|
||||
|
||||
let _deviceTagsInput = null;
|
||||
let _settingsCsptEntitySelect = null;
|
||||
|
||||
function _ensureSettingsCsptSelect() {
|
||||
const sel = document.getElementById('settings-css-processing-template');
|
||||
if (!sel) return;
|
||||
const templates = csptCache.data || [];
|
||||
sel.innerHTML = `<option value="">—</option>` +
|
||||
templates.map(tp => `<option value="${tp.id}">${tp.name}</option>`).join('');
|
||||
if (_settingsCsptEntitySelect) _settingsCsptEntitySelect.destroy();
|
||||
if (templates.length > 0) {
|
||||
_settingsCsptEntitySelect = new EntitySelect({
|
||||
target: sel,
|
||||
getItems: () => (csptCache.data || []).map(tp => ({
|
||||
value: tp.id,
|
||||
label: tp.name,
|
||||
icon: ICON_TEMPLATE,
|
||||
desc: '',
|
||||
})),
|
||||
placeholder: window.t ? t('palette.search') : 'Search...',
|
||||
allowNone: true,
|
||||
noneLabel: '—',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class DeviceSettingsModal extends Modal {
|
||||
constructor() { super('device-settings-modal'); }
|
||||
@@ -38,6 +64,7 @@ class DeviceSettingsModal extends Modal {
|
||||
dmxProtocol: document.getElementById('settings-dmx-protocol')?.value || 'artnet',
|
||||
dmxStartUniverse: document.getElementById('settings-dmx-start-universe')?.value || '0',
|
||||
dmxStartChannel: document.getElementById('settings-dmx-start-channel')?.value || '1',
|
||||
csptId: document.getElementById('settings-css-processing-template')?.value || '',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -394,6 +421,12 @@ export async function showSettings(deviceId) {
|
||||
});
|
||||
_deviceTagsInput.setValue(device.tags || []);
|
||||
|
||||
// CSPT template selector
|
||||
await csptCache.fetch();
|
||||
_ensureSettingsCsptSelect();
|
||||
const csptSel = document.getElementById('settings-css-processing-template');
|
||||
if (csptSel) csptSel.value = device.default_css_processing_template_id || '';
|
||||
|
||||
settingsModal.snapshot();
|
||||
settingsModal.open();
|
||||
|
||||
@@ -407,7 +440,7 @@ export async function showSettings(deviceId) {
|
||||
}
|
||||
|
||||
export function isSettingsDirty() { return settingsModal.isDirty(); }
|
||||
export function forceCloseDeviceSettingsModal() { if (_deviceTagsInput) { _deviceTagsInput.destroy(); _deviceTagsInput = null; } settingsModal.forceClose(); }
|
||||
export function forceCloseDeviceSettingsModal() { if (_deviceTagsInput) { _deviceTagsInput.destroy(); _deviceTagsInput = null; } if (_settingsCsptEntitySelect) { _settingsCsptEntitySelect.destroy(); _settingsCsptEntitySelect = null; } settingsModal.forceClose(); }
|
||||
export function closeDeviceSettingsModal() { settingsModal.close(); }
|
||||
|
||||
export async function saveDeviceSettings() {
|
||||
@@ -449,6 +482,8 @@ export async function saveDeviceSettings() {
|
||||
body.dmx_start_universe = parseInt(document.getElementById('settings-dmx-start-universe')?.value || '0', 10);
|
||||
body.dmx_start_channel = parseInt(document.getElementById('settings-dmx-start-channel')?.value || '1', 10);
|
||||
}
|
||||
const csptId = document.getElementById('settings-css-processing-template')?.value || '';
|
||||
body.default_css_processing_template_id = csptId;
|
||||
const deviceResponse = await fetchWithAuth(`/devices/${deviceId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(body)
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
streamsCache, audioSourcesCache, audioTemplatesCache,
|
||||
valueSourcesCache, colorStripSourcesCache, syncClocksCache,
|
||||
outputTargetsCache, patternTemplatesCache, scenePresetsCache,
|
||||
automationsCacheObj,
|
||||
automationsCacheObj, csptCache,
|
||||
} from '../core/state.js';
|
||||
import { fetchWithAuth } from '../core/api.js';
|
||||
import { showToast, showConfirm } from '../core/ui.js';
|
||||
@@ -515,13 +515,13 @@ async function _fetchAllEntities() {
|
||||
devices, captureTemplates, ppTemplates, pictureSources,
|
||||
audioSources, audioTemplates, valueSources, colorStripSources,
|
||||
syncClocks, outputTargets, patternTemplates, scenePresets, automations,
|
||||
batchStatesResp,
|
||||
csptTemplates, batchStatesResp,
|
||||
] = await Promise.all([
|
||||
devicesCache.fetch(), captureTemplatesCache.fetch(), ppTemplatesCache.fetch(),
|
||||
streamsCache.fetch(), audioSourcesCache.fetch(), audioTemplatesCache.fetch(),
|
||||
valueSourcesCache.fetch(), colorStripSourcesCache.fetch(), syncClocksCache.fetch(),
|
||||
outputTargetsCache.fetch(), patternTemplatesCache.fetch(), scenePresetsCache.fetch(),
|
||||
automationsCacheObj.fetch(),
|
||||
automationsCacheObj.fetch(), csptCache.fetch(),
|
||||
fetchWithAuth('/output-targets/batch/states').catch(() => null),
|
||||
]);
|
||||
|
||||
@@ -540,6 +540,7 @@ async function _fetchAllEntities() {
|
||||
devices, captureTemplates, ppTemplates, pictureSources,
|
||||
audioSources, audioTemplates, valueSources, colorStripSources,
|
||||
syncClocks, outputTargets: enrichedTargets, patternTemplates, scenePresets, automations,
|
||||
csptTemplates,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,10 @@ import {
|
||||
_cachedValueSources,
|
||||
_cachedSyncClocks,
|
||||
_cachedAudioTemplates,
|
||||
_cachedCSPTemplates,
|
||||
_csptModalFilters, set_csptModalFilters,
|
||||
_csptNameManuallyEdited, set_csptNameManuallyEdited,
|
||||
_stripFilters,
|
||||
availableAudioEngines, setAvailableAudioEngines,
|
||||
currentEditingAudioTemplateId, setCurrentEditingAudioTemplateId,
|
||||
_audioTemplateNameManuallyEdited, set_audioTemplateNameManuallyEdited,
|
||||
@@ -31,6 +35,7 @@ import {
|
||||
streamsCache, ppTemplatesCache, captureTemplatesCache,
|
||||
audioSourcesCache, audioTemplatesCache, valueSourcesCache, syncClocksCache, filtersCache,
|
||||
colorStripSourcesCache,
|
||||
csptCache, stripFiltersCache,
|
||||
} from '../core/state.js';
|
||||
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.js';
|
||||
import { t } from '../core/i18n.js';
|
||||
@@ -48,7 +53,7 @@ import {
|
||||
ICON_TEMPLATE, ICON_CLONE, ICON_EDIT, ICON_TEST, ICON_LINK_SOURCE,
|
||||
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,
|
||||
ICON_CAPTURE_TEMPLATE, ICON_PP_TEMPLATE, ICON_CSPT, ICON_HELP,
|
||||
} from '../core/icons.js';
|
||||
import { wrapCard } from '../core/card-colors.js';
|
||||
import { TagInput, renderTagChips } from '../core/tag-input.js';
|
||||
@@ -61,6 +66,7 @@ let _captureTemplateTagsInput = null;
|
||||
let _streamTagsInput = null;
|
||||
let _ppTemplateTagsInput = null;
|
||||
let _audioTemplateTagsInput = null;
|
||||
let _csptTagsInput = null;
|
||||
|
||||
// ── Card section instances ──
|
||||
const csRawStreams = new CardSection('raw-streams', { titleKey: 'streams.section.streams', gridClass: 'templates-grid', addCardOnclick: "showAddStreamModal('raw')", keyAttr: 'data-stream-id' });
|
||||
@@ -74,6 +80,7 @@ const csAudioTemplates = new CardSection('audio-templates', { titleKey: 'audio_t
|
||||
const csColorStrips = new CardSection('color-strips', { titleKey: 'targets.section.color_strips', gridClass: 'templates-grid', addCardOnclick: "showCSSEditor()", keyAttr: 'data-css-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' });
|
||||
const csCSPTemplates = new CardSection('css-proc-templates', { titleKey: 'css_processing.title', gridClass: 'templates-grid', addCardOnclick: "showAddCSPTModal()", keyAttr: 'data-cspt-id' });
|
||||
|
||||
// Re-render picture sources when language changes
|
||||
document.addEventListener('languageChanged', () => { if (apiKey) loadPictureSources(); });
|
||||
@@ -170,13 +177,34 @@ class AudioTemplateModal extends Modal {
|
||||
}
|
||||
}
|
||||
|
||||
class CSPTEditorModal extends Modal {
|
||||
constructor() { super('cspt-modal'); }
|
||||
|
||||
snapshotValues() {
|
||||
return {
|
||||
name: document.getElementById('cspt-name').value,
|
||||
description: document.getElementById('cspt-description').value,
|
||||
filters: JSON.stringify(_csptModalFilters.map(fi => ({ filter_id: fi.filter_id, options: fi.options }))),
|
||||
tags: JSON.stringify(_csptTagsInput ? _csptTagsInput.getValue() : []),
|
||||
};
|
||||
}
|
||||
|
||||
onForceClose() {
|
||||
if (_csptTagsInput) { _csptTagsInput.destroy(); _csptTagsInput = null; }
|
||||
set_csptModalFilters([]);
|
||||
set_csptNameManuallyEdited(false);
|
||||
}
|
||||
}
|
||||
|
||||
const templateModal = new CaptureTemplateModal();
|
||||
const testTemplateModal = new Modal('test-template-modal');
|
||||
const streamModal = new StreamEditorModal();
|
||||
const testStreamModal = new Modal('test-stream-modal');
|
||||
const ppTemplateModal = new PPTemplateEditorModal();
|
||||
const testPPTemplateModal = new Modal('test-pp-template-modal');
|
||||
let _ppTestSourceEntitySelect = null;
|
||||
const audioTemplateModal = new AudioTemplateModal();
|
||||
const csptModal = new CSPTEditorModal();
|
||||
|
||||
// ===== Capture Templates =====
|
||||
|
||||
@@ -1179,6 +1207,7 @@ export async function loadPictureSources() {
|
||||
syncClocksCache.fetch(),
|
||||
audioTemplatesCache.fetch(),
|
||||
colorStripSourcesCache.fetch(),
|
||||
csptCache.fetch(),
|
||||
filtersCache.data.length === 0 ? filtersCache.fetch() : Promise.resolve(filtersCache.data),
|
||||
]);
|
||||
renderPictureSourcesList(streams);
|
||||
@@ -1220,6 +1249,7 @@ const _streamSectionMap = {
|
||||
raw: [csRawStreams, csRawTemplates],
|
||||
static_image: [csStaticStreams],
|
||||
processed: [csProcStreams, csProcTemplates],
|
||||
css_processing: [csCSPTemplates],
|
||||
color_strip: [csColorStrips],
|
||||
audio: [csAudioMulti, csAudioMono, csAudioTemplates],
|
||||
value: [csValueSources],
|
||||
@@ -1365,6 +1395,32 @@ function renderPictureSourcesList(streams) {
|
||||
});
|
||||
};
|
||||
|
||||
const renderCSPTCard = (tmpl) => {
|
||||
let filterChainHtml = '';
|
||||
if (tmpl.filters && tmpl.filters.length > 0) {
|
||||
const filterNames = tmpl.filters.map(fi => `<span class="filter-chain-item">${escapeHtml(_getStripFilterName(fi.filter_id))}</span>`);
|
||||
filterChainHtml = `<div class="filter-chain">${filterNames.join('<span class="filter-chain-arrow">\u2192</span>')}</div>`;
|
||||
}
|
||||
return wrapCard({
|
||||
type: 'template-card',
|
||||
dataAttr: 'data-cspt-id',
|
||||
id: tmpl.id,
|
||||
removeOnclick: `deleteCSPT('${tmpl.id}')`,
|
||||
removeTitle: t('common.delete'),
|
||||
content: `
|
||||
<div class="template-card-header">
|
||||
<div class="template-name">${ICON_CSPT} ${escapeHtml(tmpl.name)}</div>
|
||||
</div>
|
||||
${tmpl.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(tmpl.description)}</div>` : ''}
|
||||
${filterChainHtml}
|
||||
${renderTagChips(tmpl.tags)}`,
|
||||
actions: `
|
||||
<button class="btn btn-icon btn-secondary" onclick="event.stopPropagation(); testCSPT('${tmpl.id}')" title="${t('color_strip.test.title')}">${ICON_TEST}</button>
|
||||
<button class="btn btn-icon btn-secondary" onclick="cloneCSPT('${tmpl.id}')" title="${t('common.clone')}">${ICON_CLONE}</button>
|
||||
<button class="btn btn-icon btn-secondary" onclick="editCSPT('${tmpl.id}')" title="${t('common.edit')}">${ICON_EDIT}</button>`,
|
||||
});
|
||||
};
|
||||
|
||||
const rawStreams = streams.filter(s => s.stream_type === 'raw');
|
||||
const processedStreams = streams.filter(s => s.stream_type === 'processed');
|
||||
const staticImageStreams = streams.filter(s => s.stream_type === 'static_image');
|
||||
@@ -1372,6 +1428,9 @@ function renderPictureSourcesList(streams) {
|
||||
const multichannelSources = _cachedAudioSources.filter(s => s.source_type === 'multichannel');
|
||||
const monoSources = _cachedAudioSources.filter(s => s.source_type === 'mono');
|
||||
|
||||
// CSPT templates
|
||||
const csptTemplates = csptCache.data;
|
||||
|
||||
// Color strip sources (maps needed for card rendering)
|
||||
const colorStrips = colorStripSourcesCache.data;
|
||||
const pictureSourceMap = {};
|
||||
@@ -1383,6 +1442,7 @@ function renderPictureSourcesList(streams) {
|
||||
{ key: 'raw', icon: getPictureSourceIcon('raw'), titleKey: 'streams.group.raw', count: rawStreams.length },
|
||||
{ key: 'static_image', icon: getPictureSourceIcon('static_image'), titleKey: 'streams.group.static_image', count: staticImageStreams.length },
|
||||
{ key: 'processed', icon: getPictureSourceIcon('processed'), titleKey: 'streams.group.processed', count: processedStreams.length },
|
||||
{ key: 'css_processing', icon: ICON_CSPT, titleKey: 'streams.group.css_processing', count: csptTemplates.length },
|
||||
{ key: 'color_strip', icon: getColorStripIcon('static'), titleKey: 'streams.group.color_strip', count: colorStrips.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 },
|
||||
@@ -1400,8 +1460,11 @@ function renderPictureSourcesList(streams) {
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'color_strip', icon: getColorStripIcon('static'), titleKey: 'streams.group.color_strip',
|
||||
count: colorStrips.length,
|
||||
key: 'strip_group', icon: getColorStripIcon('static'), titleKey: 'tree.group.strip',
|
||||
children: [
|
||||
{ key: 'color_strip', titleKey: 'streams.group.color_strip', icon: getColorStripIcon('static'), count: colorStrips.length },
|
||||
{ key: 'css_processing', titleKey: 'streams.group.css_processing', icon: ICON_CSPT, count: csptTemplates.length },
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'audio', icon: getAudioSourceIcon('multichannel'), titleKey: 'streams.group.audio',
|
||||
@@ -1515,6 +1578,7 @@ function renderPictureSourcesList(streams) {
|
||||
const colorStripItems = csColorStrips.applySortOrder(colorStrips.map(s => ({ key: s.id, html: createColorStripCard(s, pictureSourceMap, audioSourceMap) })));
|
||||
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) })));
|
||||
const csptItems = csCSPTemplates.applySortOrder(csptTemplates.map(t => ({ key: t.id, html: renderCSPTCard(t) })));
|
||||
|
||||
if (csRawStreams.isMounted()) {
|
||||
// Incremental update: reconcile cards in-place
|
||||
@@ -1522,6 +1586,7 @@ function renderPictureSourcesList(streams) {
|
||||
raw: rawStreams.length,
|
||||
static_image: staticImageStreams.length,
|
||||
processed: processedStreams.length,
|
||||
css_processing: csptTemplates.length,
|
||||
color_strip: colorStrips.length,
|
||||
audio: _cachedAudioSources.length + _cachedAudioTemplates.length,
|
||||
value: _cachedValueSources.length,
|
||||
@@ -1531,6 +1596,7 @@ function renderPictureSourcesList(streams) {
|
||||
csRawTemplates.reconcile(rawTemplateItems);
|
||||
csProcStreams.reconcile(procStreamItems);
|
||||
csProcTemplates.reconcile(procTemplateItems);
|
||||
csCSPTemplates.reconcile(csptItems);
|
||||
csColorStrips.reconcile(colorStripItems);
|
||||
csAudioMulti.reconcile(multiItems);
|
||||
csAudioMono.reconcile(monoItems);
|
||||
@@ -1544,6 +1610,7 @@ function renderPictureSourcesList(streams) {
|
||||
let panelContent = '';
|
||||
if (tab.key === 'raw') panelContent = csRawStreams.render(rawStreamItems) + csRawTemplates.render(rawTemplateItems);
|
||||
else if (tab.key === 'processed') panelContent = csProcStreams.render(procStreamItems) + csProcTemplates.render(procTemplateItems);
|
||||
else if (tab.key === 'css_processing') panelContent = csCSPTemplates.render(csptItems);
|
||||
else if (tab.key === 'color_strip') panelContent = csColorStrips.render(colorStripItems);
|
||||
else if (tab.key === 'audio') panelContent = csAudioMulti.render(multiItems) + csAudioMono.render(monoItems) + csAudioTemplates.render(audioTemplateItems);
|
||||
else if (tab.key === 'value') panelContent = csValueSources.render(valueItems);
|
||||
@@ -1553,7 +1620,7 @@ function renderPictureSourcesList(streams) {
|
||||
}).join('');
|
||||
|
||||
container.innerHTML = panels;
|
||||
CardSection.bindAll([csRawStreams, csRawTemplates, csProcStreams, csProcTemplates, csColorStrips, csAudioMulti, csAudioMono, csAudioTemplates, csStaticStreams, csValueSources, csSyncClocks]);
|
||||
CardSection.bindAll([csRawStreams, csRawTemplates, csProcStreams, csProcTemplates, csCSPTemplates, csColorStrips, csAudioMulti, csAudioMono, csAudioTemplates, csStaticStreams, csValueSources, csSyncClocks]);
|
||||
|
||||
// Render tree sidebar with expand/collapse buttons
|
||||
_streamsTree.setExtraHtml(`<button class="btn-expand-collapse" onclick="expandAllStreamSections()" data-i18n-title="section.expand_all" title="${t('section.expand_all')}">⊞</button><button class="btn-expand-collapse" onclick="collapseAllStreamSections()" data-i18n-title="section.collapse_all" title="${t('section.collapse_all')}">⊟</button><button class="tutorial-trigger-btn" onclick="startSourcesTutorial()" data-i18n-title="tour.restart" title="${t('tour.restart')}">${ICON_HELP}</button>`);
|
||||
@@ -1562,6 +1629,7 @@ function renderPictureSourcesList(streams) {
|
||||
'raw-streams': 'raw', 'raw-templates': 'raw',
|
||||
'static-streams': 'static_image',
|
||||
'proc-streams': 'processed', 'proc-templates': 'processed',
|
||||
'css-proc-templates': 'css_processing',
|
||||
'color-strips': 'color_strip',
|
||||
'audio-multi': 'audio', 'audio-mono': 'audio', 'audio-templates': 'audio',
|
||||
'value-sources': 'value',
|
||||
@@ -2073,6 +2141,18 @@ export async function showTestPPTemplateModal(templateId) {
|
||||
select.value = lastStream;
|
||||
}
|
||||
|
||||
// EntitySelect for source stream picker
|
||||
if (_ppTestSourceEntitySelect) _ppTestSourceEntitySelect.destroy();
|
||||
_ppTestSourceEntitySelect = new EntitySelect({
|
||||
target: select,
|
||||
getItems: () => _cachedStreams.map(s => ({
|
||||
value: s.id,
|
||||
label: s.name,
|
||||
icon: getPictureSourceIcon(s.stream_type),
|
||||
})),
|
||||
placeholder: t('palette.search'),
|
||||
});
|
||||
|
||||
testPPTemplateModal.open();
|
||||
}
|
||||
|
||||
@@ -2134,6 +2214,16 @@ function _getFilterName(filterId) {
|
||||
return translated;
|
||||
}
|
||||
|
||||
function _getStripFilterName(filterId) {
|
||||
const key = 'filters.' + filterId;
|
||||
const translated = t(key);
|
||||
if (translated === key) {
|
||||
const def = _stripFilters.find(f => f.filter_id === filterId);
|
||||
return def ? def.filter_name : filterId;
|
||||
}
|
||||
return translated;
|
||||
}
|
||||
|
||||
let _filterIconSelect = null;
|
||||
|
||||
const _FILTER_ICONS = {
|
||||
@@ -2149,6 +2239,7 @@ const _FILTER_ICONS = {
|
||||
frame_interpolation: P.fastForward,
|
||||
noise_gate: P.volume2,
|
||||
palette_quantization: P.sparkles,
|
||||
css_filter_template: P.fileText,
|
||||
};
|
||||
|
||||
function _populateFilterSelect() {
|
||||
@@ -2179,17 +2270,32 @@ function _populateFilterSelect() {
|
||||
}
|
||||
}
|
||||
|
||||
export function renderModalFilterList() {
|
||||
const container = document.getElementById('pp-filter-list');
|
||||
if (_modalFilters.length === 0) {
|
||||
/**
|
||||
* Generic filter list renderer — shared by PP template and CSPT modals.
|
||||
* @param {string} containerId - DOM container ID for filter cards
|
||||
* @param {Array} filtersArr - mutable array of {filter_id, options, _expanded}
|
||||
* @param {Array} filterDefs - available filter definitions (with options_schema)
|
||||
* @param {string} prefix - handler prefix: '' for PP, 'cspt' for CSPT
|
||||
* @param {string} editingIdInputId - ID of hidden input holding the editing template ID
|
||||
* @param {string} selfRefFilterId - filter_id that should exclude self ('filter_template' or 'css_filter_template')
|
||||
*/
|
||||
function _renderFilterListGeneric(containerId, filtersArr, filterDefs, prefix, editingIdInputId, selfRefFilterId) {
|
||||
const container = document.getElementById(containerId);
|
||||
if (filtersArr.length === 0) {
|
||||
container.innerHTML = `<div class="pp-filter-empty">${t('filters.empty')}</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const toggleFn = prefix ? `${prefix}ToggleFilterExpand` : 'toggleFilterExpand';
|
||||
const removeFn = prefix ? `${prefix}RemoveFilter` : 'removeFilter';
|
||||
const updateFn = prefix ? `${prefix}UpdateFilterOption` : 'updateFilterOption';
|
||||
const inputPrefix = prefix ? `${prefix}-filter` : 'filter';
|
||||
const nameFn = prefix ? _getStripFilterName : _getFilterName;
|
||||
|
||||
let html = '';
|
||||
_modalFilters.forEach((fi, index) => {
|
||||
const filterDef = _availableFilters.find(f => f.filter_id === fi.filter_id);
|
||||
const filterName = _getFilterName(fi.filter_id);
|
||||
filtersArr.forEach((fi, index) => {
|
||||
const filterDef = filterDefs.find(f => f.filter_id === fi.filter_id);
|
||||
const filterName = nameFn(fi.filter_id);
|
||||
const isExpanded = fi._expanded === true;
|
||||
|
||||
let summary = '';
|
||||
@@ -2201,13 +2307,13 @@ export function renderModalFilterList() {
|
||||
}
|
||||
|
||||
html += `<div class="pp-filter-card${isExpanded ? ' expanded' : ''}" data-filter-index="${index}">
|
||||
<div class="pp-filter-card-header" onclick="toggleFilterExpand(${index})">
|
||||
<div class="pp-filter-card-header" onclick="${toggleFn}(${index})">
|
||||
<span class="pp-filter-drag-handle" title="${t('filters.drag_to_reorder')}">⠇</span>
|
||||
<span class="pp-filter-card-chevron">${isExpanded ? '▼' : '▶'}</span>
|
||||
<span class="pp-filter-card-name">${escapeHtml(filterName)}</span>
|
||||
${summary ? `<span class="pp-filter-card-summary">${escapeHtml(summary)}</span>` : ''}
|
||||
<div class="pp-filter-card-actions" onclick="event.stopPropagation()">
|
||||
<button type="button" class="btn-filter-action btn-filter-remove" onclick="removeFilter(${index})" title="${t('filters.remove')}">✕</button>
|
||||
<button type="button" class="btn-filter-action btn-filter-remove" onclick="${removeFn}(${index})" title="${t('filters.remove')}">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pp-filter-card-options"${isExpanded ? '' : ' style="display:none"'}>`;
|
||||
@@ -2215,35 +2321,37 @@ export function renderModalFilterList() {
|
||||
if (filterDef) {
|
||||
for (const opt of filterDef.options_schema) {
|
||||
const currentVal = fi.options[opt.key] !== undefined ? fi.options[opt.key] : opt.default;
|
||||
const inputId = `filter-${index}-${opt.key}`;
|
||||
const inputId = `${inputPrefix}-${index}-${opt.key}`;
|
||||
if (opt.type === 'bool') {
|
||||
const checked = currentVal === true || currentVal === 'true';
|
||||
html += `<div class="pp-filter-option pp-filter-option-bool">
|
||||
<label for="${inputId}">
|
||||
<span>${escapeHtml(opt.label)}</span>
|
||||
<input type="checkbox" id="${inputId}" ${checked ? 'checked' : ''}
|
||||
onchange="updateFilterOption(${index}, '${opt.key}', this.checked)">
|
||||
onchange="${updateFn}(${index}, '${opt.key}', this.checked)">
|
||||
</label>
|
||||
</div>`;
|
||||
} else if (opt.type === 'select' && Array.isArray(opt.choices)) {
|
||||
// Exclude the template being edited from filter_template choices (prevent self-reference)
|
||||
const editingId = document.getElementById('pp-template-id')?.value || '';
|
||||
const filteredChoices = (fi.filter_id === 'filter_template' && opt.key === 'template_id' && editingId)
|
||||
const editingId = document.getElementById(editingIdInputId)?.value || '';
|
||||
const filteredChoices = (fi.filter_id === selfRefFilterId && opt.key === 'template_id' && editingId)
|
||||
? opt.choices.filter(c => c.value !== editingId)
|
||||
: opt.choices;
|
||||
// Auto-correct if current value doesn't match any choice
|
||||
let selectVal = currentVal;
|
||||
if (filteredChoices.length > 0 && !filteredChoices.some(c => c.value === selectVal)) {
|
||||
selectVal = filteredChoices[0].value;
|
||||
fi.options[opt.key] = selectVal;
|
||||
}
|
||||
const hasPaletteColors = filteredChoices.some(c => c.colors);
|
||||
const options = filteredChoices.map(c =>
|
||||
`<option value="${escapeHtml(c.value)}"${c.value === selectVal ? ' selected' : ''}>${escapeHtml(c.label)}</option>`
|
||||
).join('');
|
||||
const gridAttr = hasPaletteColors ? ` data-palette-grid="${escapeHtml(JSON.stringify(filteredChoices))}"` : '';
|
||||
const isTemplateRef = opt.key === 'template_id';
|
||||
const entityAttr = isTemplateRef ? ' data-entity-select="template"' : '';
|
||||
html += `<div class="pp-filter-option">
|
||||
<label for="${inputId}"><span>${escapeHtml(opt.label)}:</span></label>
|
||||
<select id="${inputId}"
|
||||
onchange="updateFilterOption(${index}, '${opt.key}', this.value)">
|
||||
<select id="${inputId}"${gridAttr}${entityAttr}
|
||||
onchange="${updateFn}(${index}, '${opt.key}', this.value)">
|
||||
${options}
|
||||
</select>
|
||||
</div>`;
|
||||
@@ -2253,7 +2361,7 @@ export function renderModalFilterList() {
|
||||
<label for="${inputId}"><span>${escapeHtml(opt.label)}:</span></label>
|
||||
<input type="text" id="${inputId}" value="${escapeHtml(String(currentVal))}"
|
||||
maxlength="${maxLen}" class="pp-filter-text-input"
|
||||
onchange="updateFilterOption(${index}, '${opt.key}', this.value)">
|
||||
onchange="${updateFn}(${index}, '${opt.key}', this.value)">
|
||||
</div>`;
|
||||
} else {
|
||||
html += `<div class="pp-filter-option">
|
||||
@@ -2263,7 +2371,7 @@ export function renderModalFilterList() {
|
||||
</label>
|
||||
<input type="range" id="${inputId}"
|
||||
min="${opt.min_value}" max="${opt.max_value}" step="${opt.step}" value="${currentVal}"
|
||||
oninput="updateFilterOption(${index}, '${opt.key}', this.value); document.getElementById('${inputId}-display').textContent = this.value;">
|
||||
oninput="${updateFn}(${index}, '${opt.key}', this.value); document.getElementById('${inputId}-display').textContent = this.value;">
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
@@ -2273,18 +2381,72 @@ export function renderModalFilterList() {
|
||||
});
|
||||
|
||||
container.innerHTML = html;
|
||||
_initFilterDrag();
|
||||
_initFilterDragForContainer(containerId, filtersArr, () => {
|
||||
_renderFilterListGeneric(containerId, filtersArr, filterDefs, prefix, editingIdInputId, selfRefFilterId);
|
||||
});
|
||||
// Initialize palette icon grids on select elements
|
||||
_initFilterPaletteGrids(container);
|
||||
}
|
||||
|
||||
/* ── PP filter drag-and-drop reordering ── */
|
||||
/** Stored IconSelect instances for filter option selects (keyed by select element id). */
|
||||
const _filterOptionIconSelects = {};
|
||||
|
||||
function _paletteSwatchHTML(hexStr) {
|
||||
const hexColors = hexStr.split(',').map(s => s.trim());
|
||||
if (hexColors.length === 1) {
|
||||
return `<span style="display:inline-block;width:60px;height:14px;border-radius:3px;background:${hexColors[0]}"></span>`;
|
||||
}
|
||||
const stops = hexColors.map((c, i) => `${c} ${(i / (hexColors.length - 1) * 100).toFixed(0)}%`).join(', ');
|
||||
return `<span style="display:inline-block;width:60px;height:14px;border-radius:3px;background:linear-gradient(to right,${stops})"></span>`;
|
||||
}
|
||||
|
||||
function _initFilterPaletteGrids(container) {
|
||||
// Palette-colored grids (e.g. palette quantization preset)
|
||||
container.querySelectorAll('select[data-palette-grid]').forEach(sel => {
|
||||
if (_filterOptionIconSelects[sel.id]) _filterOptionIconSelects[sel.id].destroy();
|
||||
try {
|
||||
const choices = JSON.parse(sel.dataset.paletteGrid);
|
||||
const items = choices.map(c => ({
|
||||
value: c.value,
|
||||
label: c.label,
|
||||
icon: _paletteSwatchHTML(c.colors || ''),
|
||||
}));
|
||||
_filterOptionIconSelects[sel.id] = new IconSelect({ target: sel, items, columns: 2 });
|
||||
} catch { /* ignore parse errors */ }
|
||||
});
|
||||
// Template reference selects → EntitySelect (searchable palette)
|
||||
container.querySelectorAll('select[data-entity-select]').forEach(sel => {
|
||||
if (_filterOptionIconSelects[sel.id]) _filterOptionIconSelects[sel.id].destroy();
|
||||
const icon = sel.dataset.entitySelect === 'template' ? ICON_PP_TEMPLATE : ICON_CSPT;
|
||||
_filterOptionIconSelects[sel.id] = new EntitySelect({
|
||||
target: sel,
|
||||
getItems: () => Array.from(sel.options).map(opt => ({
|
||||
value: opt.value,
|
||||
label: opt.textContent,
|
||||
icon,
|
||||
})),
|
||||
placeholder: t('palette.search'),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function renderModalFilterList() {
|
||||
_renderFilterListGeneric('pp-filter-list', _modalFilters, _availableFilters, '', 'pp-template-id', 'filter_template');
|
||||
}
|
||||
|
||||
export function renderCSPTModalFilterList() {
|
||||
_renderFilterListGeneric('cspt-filter-list', _csptModalFilters, _stripFilters, 'cspt', 'cspt-id', 'css_filter_template');
|
||||
}
|
||||
|
||||
/* ── Generic filter drag-and-drop reordering ── */
|
||||
|
||||
const _FILTER_DRAG_THRESHOLD = 5;
|
||||
const _FILTER_SCROLL_EDGE = 60;
|
||||
const _FILTER_SCROLL_SPEED = 12;
|
||||
let _filterDragState = null;
|
||||
|
||||
function _initFilterDrag() {
|
||||
const container = document.getElementById('pp-filter-list');
|
||||
function _initFilterDragForContainer(containerId, filtersArr, rerenderFn) {
|
||||
const container = document.getElementById(containerId);
|
||||
if (!container) return;
|
||||
|
||||
container.addEventListener('pointerdown', (e) => {
|
||||
@@ -2306,6 +2468,8 @@ function _initFilterDrag() {
|
||||
offsetY: 0,
|
||||
fromIndex,
|
||||
scrollRaf: null,
|
||||
filtersArr,
|
||||
rerenderFn,
|
||||
};
|
||||
|
||||
const onMove = (ev) => _onFilterDragMove(ev);
|
||||
@@ -2402,12 +2566,11 @@ function _onFilterDragEnd() {
|
||||
ds.clone.remove();
|
||||
document.body.classList.remove('pp-filter-dragging');
|
||||
|
||||
// Reorder _modalFilters array
|
||||
// Reorder filters array
|
||||
if (toIndex !== ds.fromIndex) {
|
||||
const [item] = _modalFilters.splice(ds.fromIndex, 1);
|
||||
_modalFilters.splice(toIndex, 0, item);
|
||||
renderModalFilterList();
|
||||
_autoGeneratePPTemplateName();
|
||||
const [item] = ds.filtersArr.splice(ds.fromIndex, 1);
|
||||
ds.filtersArr.splice(toIndex, 0, item);
|
||||
ds.rerenderFn();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2430,17 +2593,19 @@ function _filterAutoScroll(clientY, ds) {
|
||||
ds.scrollRaf = requestAnimationFrame(scroll);
|
||||
}
|
||||
|
||||
export function addFilterFromSelect() {
|
||||
const select = document.getElementById('pp-add-filter-select');
|
||||
/**
|
||||
* Generic: add a filter from a select element into a filters array.
|
||||
*/
|
||||
function _addFilterGeneric(selectId, filtersArr, filterDefs, iconSelect, renderFn, autoNameFn) {
|
||||
const select = document.getElementById(selectId);
|
||||
const filterId = select.value;
|
||||
if (!filterId) return;
|
||||
|
||||
const filterDef = _availableFilters.find(f => f.filter_id === filterId);
|
||||
const filterDef = filterDefs.find(f => f.filter_id === filterId);
|
||||
if (!filterDef) return;
|
||||
|
||||
const options = {};
|
||||
for (const opt of filterDef.options_schema) {
|
||||
// For select options with empty default, use the first choice's value
|
||||
if (opt.type === 'select' && !opt.default && Array.isArray(opt.choices) && opt.choices.length > 0) {
|
||||
options[opt.key] = opt.choices[0].value;
|
||||
} else {
|
||||
@@ -2448,40 +2613,17 @@ export function addFilterFromSelect() {
|
||||
}
|
||||
}
|
||||
|
||||
_modalFilters.push({ filter_id: filterId, options, _expanded: true });
|
||||
filtersArr.push({ filter_id: filterId, options, _expanded: true });
|
||||
select.value = '';
|
||||
if (_filterIconSelect) _filterIconSelect.setValue('');
|
||||
renderModalFilterList();
|
||||
_autoGeneratePPTemplateName();
|
||||
if (iconSelect) iconSelect.setValue('');
|
||||
renderFn();
|
||||
if (autoNameFn) autoNameFn();
|
||||
}
|
||||
|
||||
export function toggleFilterExpand(index) {
|
||||
if (_modalFilters[index]) {
|
||||
_modalFilters[index]._expanded = !_modalFilters[index]._expanded;
|
||||
renderModalFilterList();
|
||||
}
|
||||
}
|
||||
|
||||
export function removeFilter(index) {
|
||||
_modalFilters.splice(index, 1);
|
||||
renderModalFilterList();
|
||||
_autoGeneratePPTemplateName();
|
||||
}
|
||||
|
||||
export function moveFilter(index, direction) {
|
||||
const newIndex = index + direction;
|
||||
if (newIndex < 0 || newIndex >= _modalFilters.length) return;
|
||||
const tmp = _modalFilters[index];
|
||||
_modalFilters[index] = _modalFilters[newIndex];
|
||||
_modalFilters[newIndex] = tmp;
|
||||
renderModalFilterList();
|
||||
_autoGeneratePPTemplateName();
|
||||
}
|
||||
|
||||
export function updateFilterOption(filterIndex, optionKey, value) {
|
||||
if (_modalFilters[filterIndex]) {
|
||||
const fi = _modalFilters[filterIndex];
|
||||
const filterDef = _availableFilters.find(f => f.filter_id === fi.filter_id);
|
||||
function _updateFilterOptionGeneric(filterIndex, optionKey, value, filtersArr, filterDefs) {
|
||||
if (filtersArr[filterIndex]) {
|
||||
const fi = filtersArr[filterIndex];
|
||||
const filterDef = filterDefs.find(f => f.filter_id === fi.filter_id);
|
||||
if (filterDef) {
|
||||
const optDef = filterDef.options_schema.find(o => o.key === optionKey);
|
||||
if (optDef && optDef.type === 'bool') {
|
||||
@@ -2501,6 +2643,49 @@ export function updateFilterOption(filterIndex, optionKey, value) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── PP filter actions ──
|
||||
export function addFilterFromSelect() {
|
||||
_addFilterGeneric('pp-add-filter-select', _modalFilters, _availableFilters, _filterIconSelect, renderModalFilterList, _autoGeneratePPTemplateName);
|
||||
}
|
||||
|
||||
export function toggleFilterExpand(index) {
|
||||
if (_modalFilters[index]) { _modalFilters[index]._expanded = !_modalFilters[index]._expanded; renderModalFilterList(); }
|
||||
}
|
||||
|
||||
export function removeFilter(index) {
|
||||
_modalFilters.splice(index, 1); renderModalFilterList(); _autoGeneratePPTemplateName();
|
||||
}
|
||||
|
||||
export function moveFilter(index, direction) {
|
||||
const newIndex = index + direction;
|
||||
if (newIndex < 0 || newIndex >= _modalFilters.length) return;
|
||||
const tmp = _modalFilters[index];
|
||||
_modalFilters[index] = _modalFilters[newIndex];
|
||||
_modalFilters[newIndex] = tmp;
|
||||
renderModalFilterList(); _autoGeneratePPTemplateName();
|
||||
}
|
||||
|
||||
export function updateFilterOption(filterIndex, optionKey, value) {
|
||||
_updateFilterOptionGeneric(filterIndex, optionKey, value, _modalFilters, _availableFilters);
|
||||
}
|
||||
|
||||
// ── CSPT filter actions ──
|
||||
export function csptAddFilterFromSelect() {
|
||||
_addFilterGeneric('cspt-add-filter-select', _csptModalFilters, _stripFilters, _csptFilterIconSelect, renderCSPTModalFilterList, _autoGenerateCSPTName);
|
||||
}
|
||||
|
||||
export function csptToggleFilterExpand(index) {
|
||||
if (_csptModalFilters[index]) { _csptModalFilters[index]._expanded = !_csptModalFilters[index]._expanded; renderCSPTModalFilterList(); }
|
||||
}
|
||||
|
||||
export function csptRemoveFilter(index) {
|
||||
_csptModalFilters.splice(index, 1); renderCSPTModalFilterList(); _autoGenerateCSPTName();
|
||||
}
|
||||
|
||||
export function csptUpdateFilterOption(filterIndex, optionKey, value) {
|
||||
_updateFilterOptionGeneric(filterIndex, optionKey, value, _csptModalFilters, _stripFilters);
|
||||
}
|
||||
|
||||
function collectFilters() {
|
||||
return _modalFilters.map(fi => ({
|
||||
filter_id: fi.filter_id,
|
||||
@@ -2689,5 +2874,208 @@ export async function closePPTemplateModal() {
|
||||
await ppTemplateModal.close();
|
||||
}
|
||||
|
||||
// ===== Color Strip Processing Templates (CSPT) =====
|
||||
|
||||
let _csptFilterIconSelect = null;
|
||||
|
||||
async function loadStripFilters() {
|
||||
await stripFiltersCache.fetch();
|
||||
}
|
||||
|
||||
async function loadCSPTemplates() {
|
||||
try {
|
||||
if (_stripFilters.length === 0) await stripFiltersCache.fetch();
|
||||
await csptCache.fetch();
|
||||
renderPictureSourcesList(_cachedStreams);
|
||||
} catch (error) {
|
||||
console.error('Error loading CSPT:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function _populateCSPTFilterSelect() {
|
||||
const select = document.getElementById('cspt-add-filter-select');
|
||||
select.innerHTML = `<option value="">${t('filters.select_type')}</option>`;
|
||||
const items = [];
|
||||
for (const f of _stripFilters) {
|
||||
const name = _getStripFilterName(f.filter_id);
|
||||
select.innerHTML += `<option value="${f.filter_id}">${name}</option>`;
|
||||
const pathData = _FILTER_ICONS[f.filter_id] || P.wrench;
|
||||
items.push({
|
||||
value: f.filter_id,
|
||||
icon: `<svg class="icon" viewBox="0 0 24 24">${pathData}</svg>`,
|
||||
label: name,
|
||||
desc: t(`filters.${f.filter_id}.desc`),
|
||||
});
|
||||
}
|
||||
if (_csptFilterIconSelect) {
|
||||
_csptFilterIconSelect.updateItems(items);
|
||||
} else if (items.length > 0) {
|
||||
_csptFilterIconSelect = new IconSelect({
|
||||
target: select,
|
||||
items,
|
||||
columns: 3,
|
||||
placeholder: t('filters.select_type'),
|
||||
onChange: () => csptAddFilterFromSelect(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function _autoGenerateCSPTName() {
|
||||
if (_csptNameManuallyEdited) return;
|
||||
if (document.getElementById('cspt-id').value) return;
|
||||
const nameInput = document.getElementById('cspt-name');
|
||||
if (_csptModalFilters.length > 0) {
|
||||
const filterNames = _csptModalFilters.map(f => _getStripFilterName(f.filter_id)).join(' + ');
|
||||
nameInput.value = filterNames;
|
||||
} else {
|
||||
nameInput.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
function collectCSPTFilters() {
|
||||
return _csptModalFilters.map(fi => ({
|
||||
filter_id: fi.filter_id,
|
||||
options: { ...fi.options },
|
||||
}));
|
||||
}
|
||||
|
||||
export async function showAddCSPTModal(cloneData = null) {
|
||||
if (_stripFilters.length === 0) await loadStripFilters();
|
||||
|
||||
document.getElementById('cspt-modal-title').innerHTML = `${ICON_CSPT} ${t('css_processing.add')}`;
|
||||
document.getElementById('cspt-form').reset();
|
||||
document.getElementById('cspt-id').value = '';
|
||||
document.getElementById('cspt-error').style.display = 'none';
|
||||
|
||||
if (cloneData) {
|
||||
set_csptModalFilters((cloneData.filters || []).map(fi => ({
|
||||
filter_id: fi.filter_id,
|
||||
options: { ...fi.options },
|
||||
})));
|
||||
set_csptNameManuallyEdited(true);
|
||||
} else {
|
||||
set_csptModalFilters([]);
|
||||
set_csptNameManuallyEdited(false);
|
||||
}
|
||||
document.getElementById('cspt-name').oninput = () => { set_csptNameManuallyEdited(true); };
|
||||
|
||||
_populateCSPTFilterSelect();
|
||||
renderCSPTModalFilterList();
|
||||
|
||||
if (cloneData) {
|
||||
document.getElementById('cspt-name').value = (cloneData.name || '') + ' (Copy)';
|
||||
document.getElementById('cspt-description').value = cloneData.description || '';
|
||||
}
|
||||
|
||||
if (_csptTagsInput) { _csptTagsInput.destroy(); _csptTagsInput = null; }
|
||||
_csptTagsInput = new TagInput(document.getElementById('cspt-tags-container'), { placeholder: t('tags.placeholder') });
|
||||
_csptTagsInput.setValue(cloneData ? (cloneData.tags || []) : []);
|
||||
|
||||
csptModal.open();
|
||||
csptModal.snapshot();
|
||||
}
|
||||
|
||||
export async function editCSPT(templateId) {
|
||||
try {
|
||||
if (_stripFilters.length === 0) await loadStripFilters();
|
||||
|
||||
const response = await fetchWithAuth(`/color-strip-processing-templates/${templateId}`);
|
||||
if (!response.ok) throw new Error(`Failed to load template: ${response.status}`);
|
||||
const tmpl = await response.json();
|
||||
|
||||
document.getElementById('cspt-modal-title').innerHTML = `${ICON_CSPT} ${t('css_processing.edit')}`;
|
||||
document.getElementById('cspt-id').value = templateId;
|
||||
document.getElementById('cspt-name').value = tmpl.name;
|
||||
document.getElementById('cspt-description').value = tmpl.description || '';
|
||||
document.getElementById('cspt-error').style.display = 'none';
|
||||
|
||||
set_csptModalFilters((tmpl.filters || []).map(fi => ({
|
||||
filter_id: fi.filter_id,
|
||||
options: { ...fi.options },
|
||||
})));
|
||||
|
||||
_populateCSPTFilterSelect();
|
||||
renderCSPTModalFilterList();
|
||||
|
||||
if (_csptTagsInput) { _csptTagsInput.destroy(); _csptTagsInput = null; }
|
||||
_csptTagsInput = new TagInput(document.getElementById('cspt-tags-container'), { placeholder: t('tags.placeholder') });
|
||||
_csptTagsInput.setValue(tmpl.tags || []);
|
||||
|
||||
csptModal.open();
|
||||
csptModal.snapshot();
|
||||
} catch (error) {
|
||||
console.error('Error loading CSPT:', error);
|
||||
showToast(t('css_processing.error.load') + ': ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveCSPT() {
|
||||
const templateId = document.getElementById('cspt-id').value;
|
||||
const name = document.getElementById('cspt-name').value.trim();
|
||||
const description = document.getElementById('cspt-description').value.trim();
|
||||
const errorEl = document.getElementById('cspt-error');
|
||||
|
||||
if (!name) { showToast(t('css_processing.error.required'), 'error'); return; }
|
||||
|
||||
const payload = { name, filters: collectCSPTFilters(), description: description || null, tags: _csptTagsInput ? _csptTagsInput.getValue() : [] };
|
||||
|
||||
try {
|
||||
let response;
|
||||
if (templateId) {
|
||||
response = await fetchWithAuth(`/color-strip-processing-templates/${templateId}`, { method: 'PUT', body: JSON.stringify(payload) });
|
||||
} else {
|
||||
response = await fetchWithAuth('/color-strip-processing-templates', { method: 'POST', body: JSON.stringify(payload) });
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || error.message || 'Failed to save template');
|
||||
}
|
||||
|
||||
showToast(templateId ? t('css_processing.updated') : t('css_processing.created'), 'success');
|
||||
csptModal.forceClose();
|
||||
await loadCSPTemplates();
|
||||
} catch (error) {
|
||||
console.error('Error saving CSPT:', error);
|
||||
errorEl.textContent = error.message;
|
||||
errorEl.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
export async function cloneCSPT(templateId) {
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/color-strip-processing-templates/${templateId}`);
|
||||
if (!resp.ok) throw new Error('Failed to load template');
|
||||
const tmpl = await resp.json();
|
||||
showAddCSPTModal(tmpl);
|
||||
} catch (error) {
|
||||
if (error.isAuth) return;
|
||||
console.error('Failed to clone CSPT:', error);
|
||||
showToast(t('css_processing.error.clone_failed') + ': ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteCSPT(templateId) {
|
||||
const confirmed = await showConfirm(t('css_processing.delete.confirm'));
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
const response = await fetchWithAuth(`/color-strip-processing-templates/${templateId}`, { method: 'DELETE' });
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || error.message || 'Failed to delete template');
|
||||
}
|
||||
showToast(t('css_processing.deleted'), 'success');
|
||||
await loadCSPTemplates();
|
||||
} catch (error) {
|
||||
console.error('Error deleting CSPT:', error);
|
||||
showToast(t('css_processing.error.delete') + ': ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
export async function closeCSPTModal() {
|
||||
await csptModal.close();
|
||||
}
|
||||
|
||||
// Exported helpers used by other modules
|
||||
export { updateCaptureDuration, buildTestStatsHtml };
|
||||
|
||||
@@ -198,6 +198,8 @@
|
||||
"device.gamesense.peripheral.mousepad.desc": "Mousepad edge lighting zones",
|
||||
"device.gamesense.peripheral.indicator": "Indicator",
|
||||
"device.gamesense.peripheral.indicator.desc": "OLED/LED status indicator",
|
||||
"device.css_processing_template": "Strip Processing Template:",
|
||||
"device.css_processing_template.hint": "Default processing template applied to all color strip outputs on this device",
|
||||
"device.dmx_protocol": "DMX Protocol:",
|
||||
"device.dmx_protocol.hint": "Art-Net uses UDP port 6454, sACN (E1.31) uses UDP port 5568",
|
||||
"device.dmx_protocol.artnet.desc": "UDP unicast, port 6454",
|
||||
@@ -417,6 +419,7 @@
|
||||
"streams.description": "Sources define the capture pipeline. A raw source captures from a display using a capture template. A processed source applies postprocessing to another source. Assign sources to devices.",
|
||||
"streams.group.raw": "Screen Capture",
|
||||
"streams.group.processed": "Processed",
|
||||
"streams.group.css_processing": "Processing Templates",
|
||||
"streams.group.color_strip": "Color Strips",
|
||||
"streams.group.audio": "Audio",
|
||||
"streams.section.streams": "Sources",
|
||||
@@ -485,12 +488,16 @@
|
||||
"filters.color_correction.desc": "White balance and color temperature",
|
||||
"filters.filter_template": "Filter Template",
|
||||
"filters.filter_template.desc": "Embed another processing template",
|
||||
"filters.css_filter_template": "Strip Filter Template",
|
||||
"filters.css_filter_template.desc": "Embed another strip processing template",
|
||||
"filters.frame_interpolation": "Frame Interpolation",
|
||||
"filters.frame_interpolation.desc": "Blend between frames for smoother output",
|
||||
"filters.noise_gate": "Noise Gate",
|
||||
"filters.noise_gate.desc": "Suppress small color changes below threshold",
|
||||
"filters.palette_quantization": "Palette Quantization",
|
||||
"filters.palette_quantization.desc": "Reduce colors to a limited palette",
|
||||
"filters.reverse": "Reverse",
|
||||
"filters.reverse.desc": "Reverse the LED order in the strip",
|
||||
"postprocessing.description_label": "Description (optional):",
|
||||
"postprocessing.description_placeholder": "Describe this template...",
|
||||
"postprocessing.created": "Template created successfully",
|
||||
@@ -506,6 +513,21 @@
|
||||
"postprocessing.test.running": "Testing processing template...",
|
||||
"postprocessing.test.error.no_stream": "Please select a source",
|
||||
"postprocessing.test.error.failed": "Processing template test failed",
|
||||
"css_processing.title": "Strip Processing Templates",
|
||||
"css_processing.add": "Add Strip Processing Template",
|
||||
"css_processing.edit": "Edit Strip Processing Template",
|
||||
"css_processing.name": "Template Name:",
|
||||
"css_processing.name_placeholder": "My Strip Processing Template",
|
||||
"css_processing.description_label": "Description (optional):",
|
||||
"css_processing.description_placeholder": "Describe this template...",
|
||||
"css_processing.created": "Strip processing template created",
|
||||
"css_processing.updated": "Strip processing template updated",
|
||||
"css_processing.deleted": "Strip processing template deleted",
|
||||
"css_processing.delete.confirm": "Are you sure you want to delete this strip processing template?",
|
||||
"css_processing.error.required": "Please fill in all required fields",
|
||||
"css_processing.error.load": "Error loading strip processing template",
|
||||
"css_processing.error.delete": "Error deleting strip processing template",
|
||||
"css_processing.error.clone_failed": "Failed to clone strip processing template",
|
||||
"device.button.stream_selector": "Source Settings",
|
||||
"device.stream_settings.title": "Source Settings",
|
||||
"device.stream_selector.label": "Source:",
|
||||
@@ -1027,6 +1049,14 @@
|
||||
"color_strip.candlelight.num_candles.hint": "How many independent candle sources along the strip. Each flickers with its own pattern.",
|
||||
"color_strip.candlelight.speed": "Flicker Speed:",
|
||||
"color_strip.candlelight.speed.hint": "Speed of the flicker animation. Higher values produce faster, more restless flames.",
|
||||
"color_strip.type.processed": "Processed",
|
||||
"color_strip.type.processed.desc": "Apply a processing template to another source",
|
||||
"color_strip.type.processed.hint": "Wraps an existing color strip source and pipes its output through a filter chain.",
|
||||
"color_strip.processed.input": "Input Source:",
|
||||
"color_strip.processed.input.hint": "The color strip source whose output will be processed",
|
||||
"color_strip.processed.template": "Processing Template:",
|
||||
"color_strip.processed.template.hint": "Filter chain to apply to the input source output",
|
||||
"color_strip.processed.error.no_input": "Please select an input source",
|
||||
"color_strip.composite.layers": "Layers:",
|
||||
"color_strip.composite.layers.hint": "Stack multiple color strip sources. First layer is the bottom, last is the top. Each layer can have its own blend mode and opacity.",
|
||||
"color_strip.composite.add_layer": "+ Add Layer",
|
||||
@@ -1043,6 +1073,7 @@
|
||||
"color_strip.composite.opacity": "Opacity",
|
||||
"color_strip.composite.brightness": "Brightness",
|
||||
"color_strip.composite.brightness.none": "— None —",
|
||||
"color_strip.composite.processing": "Processing",
|
||||
"color_strip.composite.enabled": "Enabled",
|
||||
"color_strip.composite.error.min_layers": "At least 1 layer is required",
|
||||
"color_strip.composite.error.no_source": "Each layer must have a source selected",
|
||||
@@ -1182,6 +1213,7 @@
|
||||
"streams.group.value": "Value Sources",
|
||||
"streams.group.sync": "Sync Clocks",
|
||||
"tree.group.picture": "Picture",
|
||||
"tree.group.strip": "Color Strip",
|
||||
"tree.group.utility": "Utility",
|
||||
"value_source.group.title": "Value Sources",
|
||||
"value_source.select_type": "Select Value Source Type",
|
||||
|
||||
@@ -169,6 +169,8 @@
|
||||
"device.led_type.hint": "RGB (3 канала) или RGBW (4 канала с выделенным белым)",
|
||||
"device.send_latency": "Задержка отправки (мс):",
|
||||
"device.send_latency.hint": "Имитация сетевой/серийной задержки на кадр в миллисекундах",
|
||||
"device.css_processing_template": "Шаблон Обработки Полос:",
|
||||
"device.css_processing_template.hint": "Шаблон обработки по умолчанию, применяемый ко всем цветовым полосам на этом устройстве",
|
||||
"device.mqtt_topic": "MQTT Топик:",
|
||||
"device.mqtt_topic.hint": "MQTT топик для публикации пиксельных данных (напр. mqtt://ledgrab/device/name)",
|
||||
"device.mqtt_topic.placeholder": "mqtt://ledgrab/device/гостиная",
|
||||
@@ -366,6 +368,7 @@
|
||||
"streams.description": "Источники определяют конвейер захвата. Сырой источник захватывает экран с помощью шаблона захвата. Обработанный источник применяет постобработку к другому источнику. Назначайте источники устройствам.",
|
||||
"streams.group.raw": "Захват Экрана",
|
||||
"streams.group.processed": "Обработанные",
|
||||
"streams.group.css_processing": "Шаблоны Обработки",
|
||||
"streams.group.color_strip": "Цветовые Полосы",
|
||||
"streams.group.audio": "Аудио",
|
||||
"streams.section.streams": "Источники",
|
||||
@@ -434,12 +437,16 @@
|
||||
"filters.color_correction.desc": "Баланс белого и цветовая температура",
|
||||
"filters.filter_template": "Шаблон фильтров",
|
||||
"filters.filter_template.desc": "Встроить другой шаблон обработки",
|
||||
"filters.css_filter_template": "Шаблон Фильтра Полос",
|
||||
"filters.css_filter_template.desc": "Встроить другой шаблон обработки полос",
|
||||
"filters.frame_interpolation": "Интерполяция кадров",
|
||||
"filters.frame_interpolation.desc": "Сглаживание между кадрами",
|
||||
"filters.noise_gate": "Шумоподавление",
|
||||
"filters.noise_gate.desc": "Подавление малых изменений цвета ниже порога",
|
||||
"filters.palette_quantization": "Квантизация палитры",
|
||||
"filters.palette_quantization.desc": "Сокращение цветов до ограниченной палитры",
|
||||
"filters.reverse": "Реверс",
|
||||
"filters.reverse.desc": "Изменить порядок светодиодов на обратный",
|
||||
"postprocessing.description_label": "Описание (необязательно):",
|
||||
"postprocessing.description_placeholder": "Опишите этот шаблон...",
|
||||
"postprocessing.created": "Шаблон успешно создан",
|
||||
@@ -455,6 +462,21 @@
|
||||
"postprocessing.test.running": "Тестирование шаблона фильтра...",
|
||||
"postprocessing.test.error.no_stream": "Пожалуйста, выберите источник",
|
||||
"postprocessing.test.error.failed": "Тест шаблона фильтра не удался",
|
||||
"css_processing.title": "Шаблоны Обработки Полос",
|
||||
"css_processing.add": "Добавить Шаблон Обработки Полос",
|
||||
"css_processing.edit": "Редактировать Шаблон Обработки Полос",
|
||||
"css_processing.name": "Имя Шаблона:",
|
||||
"css_processing.name_placeholder": "Мой Шаблон Обработки Полос",
|
||||
"css_processing.description_label": "Описание (необязательно):",
|
||||
"css_processing.description_placeholder": "Опишите этот шаблон...",
|
||||
"css_processing.created": "Шаблон обработки полос создан",
|
||||
"css_processing.updated": "Шаблон обработки полос обновлён",
|
||||
"css_processing.deleted": "Шаблон обработки полос удалён",
|
||||
"css_processing.delete.confirm": "Вы уверены, что хотите удалить этот шаблон обработки полос?",
|
||||
"css_processing.error.required": "Заполните все обязательные поля",
|
||||
"css_processing.error.load": "Ошибка загрузки шаблона обработки полос",
|
||||
"css_processing.error.delete": "Ошибка удаления шаблона обработки полос",
|
||||
"css_processing.error.clone_failed": "Не удалось клонировать шаблон обработки полос",
|
||||
"device.button.stream_selector": "Настройки источника",
|
||||
"device.stream_settings.title": "Настройки источника",
|
||||
"device.stream_selector.label": "Источник:",
|
||||
@@ -976,6 +998,14 @@
|
||||
"color_strip.candlelight.num_candles.hint": "Сколько независимых источников свечей вдоль ленты. Каждый мерцает по-своему.",
|
||||
"color_strip.candlelight.speed": "Скорость мерцания:",
|
||||
"color_strip.candlelight.speed.hint": "Скорость анимации мерцания. Большие значения — более быстрое, беспокойное пламя.",
|
||||
"color_strip.type.processed": "Обработанный",
|
||||
"color_strip.type.processed.desc": "Применить шаблон обработки к другому источнику",
|
||||
"color_strip.type.processed.hint": "Оборачивает существующий источник цветовой полосы и пропускает его вывод через цепочку фильтров.",
|
||||
"color_strip.processed.input": "Входной источник:",
|
||||
"color_strip.processed.input.hint": "Источник цветовой полосы, вывод которого будет обработан",
|
||||
"color_strip.processed.template": "Шаблон обработки:",
|
||||
"color_strip.processed.template.hint": "Цепочка фильтров для применения к выводу входного источника",
|
||||
"color_strip.processed.error.no_input": "Выберите входной источник",
|
||||
"color_strip.composite.layers": "Слои:",
|
||||
"color_strip.composite.layers.hint": "Наложение нескольких источников. Первый слой — нижний, последний — верхний. Каждый слой может иметь свой режим смешивания и прозрачность.",
|
||||
"color_strip.composite.add_layer": "+ Добавить слой",
|
||||
@@ -992,6 +1022,7 @@
|
||||
"color_strip.composite.opacity": "Непрозрачность",
|
||||
"color_strip.composite.brightness": "Яркость",
|
||||
"color_strip.composite.brightness.none": "— Нет —",
|
||||
"color_strip.composite.processing": "Обработка",
|
||||
"color_strip.composite.enabled": "Включён",
|
||||
"color_strip.composite.error.min_layers": "Необходим хотя бы 1 слой",
|
||||
"color_strip.composite.error.no_source": "Для каждого слоя должен быть выбран источник",
|
||||
@@ -1131,6 +1162,7 @@
|
||||
"streams.group.value": "Источники значений",
|
||||
"streams.group.sync": "Часы синхронизации",
|
||||
"tree.group.picture": "Изображения",
|
||||
"tree.group.strip": "Цветовые Полосы",
|
||||
"tree.group.utility": "Утилиты",
|
||||
"value_source.group.title": "Источники значений",
|
||||
"value_source.select_type": "Выберите тип источника значений",
|
||||
|
||||
@@ -169,6 +169,8 @@
|
||||
"device.led_type.hint": "RGB(3通道)或 RGBW(4通道,带独立白色)",
|
||||
"device.send_latency": "发送延迟(毫秒):",
|
||||
"device.send_latency.hint": "每帧模拟网络/串口延迟(毫秒)",
|
||||
"device.css_processing_template": "色带处理模板:",
|
||||
"device.css_processing_template.hint": "应用于此设备所有色带输出的默认处理模板",
|
||||
"device.mqtt_topic": "MQTT 主题:",
|
||||
"device.mqtt_topic.hint": "用于发布像素数据的 MQTT 主题路径(例如 mqtt://ledgrab/device/name)",
|
||||
"device.mqtt_topic.placeholder": "mqtt://ledgrab/device/客厅",
|
||||
@@ -366,6 +368,7 @@
|
||||
"streams.description": "源定义采集管线。原始源使用采集模板从显示器采集。处理源对另一个源应用后处理。将源分配给设备。",
|
||||
"streams.group.raw": "屏幕采集",
|
||||
"streams.group.processed": "已处理",
|
||||
"streams.group.css_processing": "处理模板",
|
||||
"streams.group.color_strip": "色带源",
|
||||
"streams.group.audio": "音频",
|
||||
"streams.section.streams": "源",
|
||||
@@ -434,12 +437,16 @@
|
||||
"filters.color_correction.desc": "白平衡和色温调整",
|
||||
"filters.filter_template": "滤镜模板",
|
||||
"filters.filter_template.desc": "嵌入另一个处理模板",
|
||||
"filters.css_filter_template": "色带滤镜模板",
|
||||
"filters.css_filter_template.desc": "嵌入另一个色带处理模板",
|
||||
"filters.frame_interpolation": "帧插值",
|
||||
"filters.frame_interpolation.desc": "帧间混合以获得更平滑的输出",
|
||||
"filters.noise_gate": "噪声门",
|
||||
"filters.noise_gate.desc": "抑制低于阈值的细微色彩变化",
|
||||
"filters.palette_quantization": "调色板量化",
|
||||
"filters.palette_quantization.desc": "将颜色减少到有限调色板",
|
||||
"filters.reverse": "反转",
|
||||
"filters.reverse.desc": "反转色带中的LED顺序",
|
||||
"postprocessing.description_label": "描述(可选):",
|
||||
"postprocessing.description_placeholder": "描述此模板...",
|
||||
"postprocessing.created": "模板创建成功",
|
||||
@@ -455,6 +462,21 @@
|
||||
"postprocessing.test.running": "正在测试处理模板...",
|
||||
"postprocessing.test.error.no_stream": "请选择一个源",
|
||||
"postprocessing.test.error.failed": "处理模板测试失败",
|
||||
"css_processing.title": "色带处理模板",
|
||||
"css_processing.add": "添加色带处理模板",
|
||||
"css_processing.edit": "编辑色带处理模板",
|
||||
"css_processing.name": "模板名称:",
|
||||
"css_processing.name_placeholder": "我的色带处理模板",
|
||||
"css_processing.description_label": "描述(可选):",
|
||||
"css_processing.description_placeholder": "描述此模板...",
|
||||
"css_processing.created": "色带处理模板已创建",
|
||||
"css_processing.updated": "色带处理模板已更新",
|
||||
"css_processing.deleted": "色带处理模板已删除",
|
||||
"css_processing.delete.confirm": "确定要删除此色带处理模板吗?",
|
||||
"css_processing.error.required": "请填写所有必填字段",
|
||||
"css_processing.error.load": "加载色带处理模板出错",
|
||||
"css_processing.error.delete": "删除色带处理模板出错",
|
||||
"css_processing.error.clone_failed": "克隆色带处理模板失败",
|
||||
"device.button.stream_selector": "源设置",
|
||||
"device.stream_settings.title": "源设置",
|
||||
"device.stream_selector.label": "源:",
|
||||
@@ -976,6 +998,14 @@
|
||||
"color_strip.candlelight.num_candles.hint": "灯带上独立蜡烛光源的数量。每支蜡烛有自己的闪烁模式。",
|
||||
"color_strip.candlelight.speed": "闪烁速度:",
|
||||
"color_strip.candlelight.speed.hint": "闪烁动画的速度。较高的值产生更快、更不安定的火焰。",
|
||||
"color_strip.type.processed": "已处理",
|
||||
"color_strip.type.processed.desc": "将处理模板应用于另一个源",
|
||||
"color_strip.type.processed.hint": "包装现有色带源并通过滤镜链处理其输出。",
|
||||
"color_strip.processed.input": "输入源:",
|
||||
"color_strip.processed.input.hint": "将被处理的色带源",
|
||||
"color_strip.processed.template": "处理模板:",
|
||||
"color_strip.processed.template.hint": "应用于输入源输出的滤镜链",
|
||||
"color_strip.processed.error.no_input": "请选择输入源",
|
||||
"color_strip.composite.layers": "图层:",
|
||||
"color_strip.composite.layers.hint": "叠加多个色带源。第一个图层在底部,最后一个在顶部。每个图层可以有自己的混合模式和不透明度。",
|
||||
"color_strip.composite.add_layer": "+ 添加图层",
|
||||
@@ -992,6 +1022,7 @@
|
||||
"color_strip.composite.opacity": "不透明度",
|
||||
"color_strip.composite.brightness": "亮度",
|
||||
"color_strip.composite.brightness.none": "— 无 —",
|
||||
"color_strip.composite.processing": "处理",
|
||||
"color_strip.composite.enabled": "启用",
|
||||
"color_strip.composite.error.min_layers": "至少需要 1 个图层",
|
||||
"color_strip.composite.error.no_source": "每个图层必须选择一个源",
|
||||
@@ -1131,6 +1162,7 @@
|
||||
"streams.group.value": "值源",
|
||||
"streams.group.sync": "同步时钟",
|
||||
"tree.group.picture": "图片",
|
||||
"tree.group.strip": "色带",
|
||||
"tree.group.utility": "工具",
|
||||
"value_source.group.title": "值源",
|
||||
"value_source.select_type": "选择值源类型",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Base class for JSON entity stores — eliminates boilerplate across 12+ stores."""
|
||||
|
||||
import json
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Callable, Dict, Generic, List, TypeVar
|
||||
|
||||
@@ -32,6 +33,7 @@ class BaseJsonStore(Generic[T]):
|
||||
self.file_path = Path(file_path)
|
||||
self._items: Dict[str, T] = {}
|
||||
self._deserializer = deserializer
|
||||
self._lock = threading.Lock()
|
||||
self._load()
|
||||
|
||||
# ── I/O ────────────────────────────────────────────────────────
|
||||
@@ -69,6 +71,12 @@ class BaseJsonStore(Generic[T]):
|
||||
)
|
||||
|
||||
def _save(self) -> None:
|
||||
"""Persist all items to disk atomically.
|
||||
|
||||
Note: This is synchronous blocking I/O. When called from async route
|
||||
handlers, it briefly blocks the event loop (typically < 5ms for small
|
||||
stores). Acceptable for user-initiated CRUD; not suitable for hot loops.
|
||||
"""
|
||||
try:
|
||||
data = {
|
||||
"version": self._version,
|
||||
@@ -105,7 +113,11 @@ class BaseJsonStore(Generic[T]):
|
||||
# ── Helpers ────────────────────────────────────────────────────
|
||||
|
||||
def _check_name_unique(self, name: str, exclude_id: str = None) -> None:
|
||||
"""Raise ValueError if *name* is empty or already taken."""
|
||||
"""Raise ValueError if *name* is empty or already taken.
|
||||
|
||||
Callers should hold ``self._lock`` when calling this + mutating
|
||||
``_items`` to prevent race conditions between concurrent requests.
|
||||
"""
|
||||
if not name or not name.strip():
|
||||
raise ValueError("Name is required")
|
||||
for item_id, item in self._items.items():
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Color strip processing template data model."""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import List, Optional
|
||||
|
||||
from wled_controller.core.filters.filter_instance import FilterInstance
|
||||
|
||||
|
||||
@dataclass
|
||||
class ColorStripProcessingTemplate:
|
||||
"""Processing template for color strip sources — ordered list of filters
|
||||
applied to 1D LED arrays (N, 3) uint8.
|
||||
"""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
filters: List[FilterInstance]
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
description: Optional[str] = None
|
||||
tags: List[str] = field(default_factory=list)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert template to dictionary."""
|
||||
return {
|
||||
"id": self.id,
|
||||
"name": self.name,
|
||||
"filters": [f.to_dict() for f in self.filters],
|
||||
"created_at": self.created_at.isoformat(),
|
||||
"updated_at": self.updated_at.isoformat(),
|
||||
"description": self.description,
|
||||
"tags": self.tags,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "ColorStripProcessingTemplate":
|
||||
"""Create template from dictionary."""
|
||||
filters = [FilterInstance.from_dict(f) for f in data.get("filters", [])]
|
||||
|
||||
return cls(
|
||||
id=data["id"],
|
||||
name=data["name"],
|
||||
filters=filters,
|
||||
created_at=datetime.fromisoformat(data["created_at"])
|
||||
if isinstance(data.get("created_at"), str)
|
||||
else data.get("created_at", datetime.now(timezone.utc)),
|
||||
updated_at=datetime.fromisoformat(data["updated_at"])
|
||||
if isinstance(data.get("updated_at"), str)
|
||||
else data.get("updated_at", datetime.now(timezone.utc)),
|
||||
description=data.get("description"),
|
||||
tags=data.get("tags", []),
|
||||
)
|
||||
@@ -0,0 +1,170 @@
|
||||
"""Color strip processing template storage using JSON files."""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import List, Optional
|
||||
|
||||
from wled_controller.core.filters.filter_instance import FilterInstance
|
||||
from wled_controller.core.filters.registry import FilterRegistry
|
||||
from wled_controller.storage.base_store import BaseJsonStore
|
||||
from wled_controller.storage.color_strip_processing_template import ColorStripProcessingTemplate
|
||||
from wled_controller.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class ColorStripProcessingTemplateStore(BaseJsonStore[ColorStripProcessingTemplate]):
|
||||
"""Storage for color strip processing templates.
|
||||
|
||||
All templates are persisted to the JSON file.
|
||||
On startup, if no templates exist, a default one is auto-created.
|
||||
"""
|
||||
|
||||
_json_key = "color_strip_processing_templates"
|
||||
_entity_name = "Color strip processing template"
|
||||
_version = "1.0.0"
|
||||
|
||||
def __init__(self, file_path: str):
|
||||
super().__init__(file_path, ColorStripProcessingTemplate.from_dict)
|
||||
self._ensure_initial_template()
|
||||
|
||||
# Backward-compatible aliases
|
||||
get_all_templates = BaseJsonStore.get_all
|
||||
get_template = BaseJsonStore.get
|
||||
delete_template = BaseJsonStore.delete
|
||||
|
||||
def _ensure_initial_template(self) -> None:
|
||||
"""Auto-create a default color strip processing template if none exist."""
|
||||
if self._items:
|
||||
return
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
template_id = f"cspt_{uuid.uuid4().hex[:8]}"
|
||||
|
||||
template = ColorStripProcessingTemplate(
|
||||
id=template_id,
|
||||
name="Default",
|
||||
filters=[
|
||||
FilterInstance("brightness", {"value": 1.0}),
|
||||
FilterInstance("gamma", {"value": 2.2}),
|
||||
],
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
description="Default color strip processing template",
|
||||
)
|
||||
|
||||
self._items[template_id] = template
|
||||
self._save()
|
||||
logger.info(f"Auto-created initial color strip processing template: {template.name} ({template_id})")
|
||||
|
||||
def _validate_strip_filters(self, filters: List[FilterInstance]) -> None:
|
||||
"""Validate that all filters support strip processing."""
|
||||
for fi in filters:
|
||||
if not FilterRegistry.is_registered(fi.filter_id):
|
||||
raise ValueError(f"Unknown filter type: '{fi.filter_id}'")
|
||||
filter_cls = FilterRegistry.get(fi.filter_id)
|
||||
if not getattr(filter_cls, "supports_strip", True):
|
||||
raise ValueError(
|
||||
f"Filter '{fi.filter_id}' does not support strip processing"
|
||||
)
|
||||
|
||||
def create_template(
|
||||
self,
|
||||
name: str,
|
||||
filters: Optional[List[FilterInstance]] = None,
|
||||
description: Optional[str] = None,
|
||||
tags: Optional[List[str]] = None,
|
||||
) -> ColorStripProcessingTemplate:
|
||||
self._check_name_unique(name)
|
||||
|
||||
if filters is None:
|
||||
filters = []
|
||||
|
||||
self._validate_strip_filters(filters)
|
||||
|
||||
template_id = f"cspt_{uuid.uuid4().hex[:8]}"
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
template = ColorStripProcessingTemplate(
|
||||
id=template_id,
|
||||
name=name,
|
||||
filters=filters,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
description=description,
|
||||
tags=tags or [],
|
||||
)
|
||||
|
||||
self._items[template_id] = template
|
||||
self._save()
|
||||
|
||||
logger.info(f"Created color strip processing template: {name} ({template_id})")
|
||||
return template
|
||||
|
||||
def update_template(
|
||||
self,
|
||||
template_id: str,
|
||||
name: Optional[str] = None,
|
||||
filters: Optional[List[FilterInstance]] = None,
|
||||
description: Optional[str] = None,
|
||||
tags: Optional[List[str]] = None,
|
||||
) -> ColorStripProcessingTemplate:
|
||||
template = self.get(template_id)
|
||||
|
||||
if name is not None:
|
||||
self._check_name_unique(name, exclude_id=template_id)
|
||||
template.name = name
|
||||
if filters is not None:
|
||||
self._validate_strip_filters(filters)
|
||||
template.filters = filters
|
||||
if description is not None:
|
||||
template.description = description
|
||||
if tags is not None:
|
||||
template.tags = tags
|
||||
|
||||
template.updated_at = datetime.now(timezone.utc)
|
||||
self._save()
|
||||
|
||||
logger.info(f"Updated color strip processing template: {template_id}")
|
||||
return template
|
||||
|
||||
def resolve_filter_instances(self, filter_instances, _visited=None):
|
||||
"""Recursively resolve filter instances, expanding css_filter_template references.
|
||||
|
||||
Returns a flat list of FilterInstance objects with no css_filter_template entries.
|
||||
"""
|
||||
if _visited is None:
|
||||
_visited = set()
|
||||
resolved = []
|
||||
for fi in filter_instances:
|
||||
if fi.filter_id == "css_filter_template":
|
||||
template_id = fi.options.get("template_id", "")
|
||||
if not template_id or template_id in _visited:
|
||||
continue
|
||||
try:
|
||||
ref_template = self.get_template(template_id)
|
||||
_visited.add(template_id)
|
||||
resolved.extend(self.resolve_filter_instances(ref_template.filters, _visited))
|
||||
_visited.discard(template_id)
|
||||
except ValueError:
|
||||
logger.warning(f"Referenced CSS filter template '{template_id}' not found, skipping")
|
||||
else:
|
||||
resolved.append(fi)
|
||||
return resolved
|
||||
|
||||
def get_references(self, template_id: str, device_store=None, css_store=None) -> List[str]:
|
||||
"""Return names of entities that reference this template."""
|
||||
refs = []
|
||||
if device_store:
|
||||
for dev in device_store.get_all():
|
||||
if getattr(dev, "default_css_processing_template_id", "") == template_id:
|
||||
refs.append(f"Device: {dev.name}")
|
||||
if css_store:
|
||||
for src in css_store.get_all():
|
||||
layers = getattr(src, "layers", None)
|
||||
if layers:
|
||||
for layer in layers:
|
||||
if isinstance(layer, dict) and layer.get("processing_template_id") == template_id:
|
||||
refs.append(f"Composite layer in: {src.name}")
|
||||
break
|
||||
return refs
|
||||
@@ -19,13 +19,19 @@ Current types:
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import List, Optional
|
||||
from typing import Dict, List, Optional, Type
|
||||
|
||||
from wled_controller.core.capture.calibration import (
|
||||
CalibrationConfig,
|
||||
calibration_from_dict,
|
||||
calibration_to_dict,
|
||||
)
|
||||
from wled_controller.storage.utils import resolve_ref
|
||||
|
||||
|
||||
def _validate_rgb(value, default: list) -> list:
|
||||
"""Return value if it's a 3-element list, otherwise return default."""
|
||||
return value if isinstance(value, list) and len(value) == 3 else list(default)
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -51,7 +57,7 @@ class ColorStripSource:
|
||||
return False
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert source to dictionary. Subclasses extend this."""
|
||||
"""Convert source to dictionary. Subclasses extend this with their own fields."""
|
||||
return {
|
||||
"id": self.id,
|
||||
"name": self.name,
|
||||
@@ -61,48 +67,40 @@ class ColorStripSource:
|
||||
"description": self.description,
|
||||
"clock_id": self.clock_id,
|
||||
"tags": self.tags,
|
||||
# Subclass fields default to None for forward compat
|
||||
"picture_source_id": None,
|
||||
"fps": None,
|
||||
"brightness": None,
|
||||
"saturation": None,
|
||||
"gamma": None,
|
||||
"smoothing": None,
|
||||
"interpolation_mode": None,
|
||||
"calibration": None,
|
||||
"led_count": None,
|
||||
"color": None,
|
||||
"stops": None,
|
||||
"animation": None,
|
||||
"colors": None,
|
||||
"effect_type": None,
|
||||
"palette": None,
|
||||
"intensity": None,
|
||||
"scale": None,
|
||||
"mirror": None,
|
||||
"layers": None,
|
||||
"zones": None,
|
||||
"visualization_mode": None,
|
||||
"audio_source_id": None,
|
||||
"sensitivity": None,
|
||||
"color_peak": None,
|
||||
"fallback_color": None,
|
||||
"timeout": None,
|
||||
"notification_effect": None,
|
||||
"duration_ms": None,
|
||||
"default_color": None,
|
||||
"app_colors": None,
|
||||
"app_filter_mode": None,
|
||||
"app_filter_list": None,
|
||||
"os_listener": None,
|
||||
# daylight-type fields
|
||||
"speed": None,
|
||||
"use_real_time": None,
|
||||
"latitude": None,
|
||||
# candlelight-type fields
|
||||
"num_candles": None,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def create_from_kwargs(cls, *, id: str, name: str, source_type: str,
|
||||
created_at: datetime, updated_at: datetime,
|
||||
description: Optional[str] = None,
|
||||
clock_id: Optional[str] = None,
|
||||
tags: Optional[List[str]] = None,
|
||||
**kwargs) -> "ColorStripSource":
|
||||
"""Create an instance from keyword arguments.
|
||||
|
||||
Base implementation — subclasses override to extract type-specific fields.
|
||||
This should not be called directly; use create_instance() instead.
|
||||
"""
|
||||
raise NotImplementedError(f"create_from_kwargs not implemented on {cls.__name__}")
|
||||
|
||||
def apply_update(self, **kwargs) -> None:
|
||||
"""Apply type-specific field updates. Subclasses override this.
|
||||
|
||||
Only fields present in kwargs (and not None) are updated.
|
||||
Base class handles common fields (name, description, clock_id, tags)
|
||||
in the store; this method handles subclass-specific fields only.
|
||||
"""
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def create_instance(source_type: str, **kwargs) -> "ColorStripSource":
|
||||
"""Factory: create the correct subclass based on source_type."""
|
||||
cls = _SOURCE_TYPE_MAP.get(source_type)
|
||||
if cls is None:
|
||||
# Default to PictureColorStripSource for unknown types
|
||||
cls = PictureColorStripSource
|
||||
return cls.create_from_kwargs(source_type=source_type, **kwargs)
|
||||
|
||||
@staticmethod
|
||||
def from_dict(data: dict) -> "ColorStripSource":
|
||||
"""Factory: dispatch to the correct subclass based on source_type."""
|
||||
@@ -262,6 +260,15 @@ class ColorStripSource:
|
||||
latitude=float(data.get("latitude") or 50.0),
|
||||
)
|
||||
|
||||
if source_type == "processed":
|
||||
return ProcessedColorStripSource(
|
||||
id=sid, name=name, source_type="processed",
|
||||
created_at=created_at, updated_at=updated_at, description=description,
|
||||
clock_id=clock_id, tags=tags,
|
||||
input_source_id=data.get("input_source_id") or "",
|
||||
processing_template_id=data.get("processing_template_id") or "",
|
||||
)
|
||||
|
||||
if source_type == "candlelight":
|
||||
raw_color = data.get("color")
|
||||
color = (
|
||||
@@ -282,14 +289,10 @@ class ColorStripSource:
|
||||
_picture_kwargs = dict(
|
||||
tags=tags,
|
||||
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,
|
||||
gamma=data["gamma"] if data.get("gamma") is not None else 1.0,
|
||||
smoothing=data["smoothing"] if data.get("smoothing") is not None else 0.3,
|
||||
interpolation_mode=data.get("interpolation_mode") or "average",
|
||||
calibration=calibration,
|
||||
led_count=data.get("led_count") or 0,
|
||||
frame_interpolation=bool(data.get("frame_interpolation", False)),
|
||||
)
|
||||
|
||||
if source_type == "picture_advanced":
|
||||
@@ -311,17 +314,27 @@ class ColorStripSource:
|
||||
def _picture_base_to_dict(source, d: dict) -> dict:
|
||||
"""Populate dict with fields common to both picture source types."""
|
||||
d["fps"] = source.fps
|
||||
d["brightness"] = source.brightness
|
||||
d["saturation"] = source.saturation
|
||||
d["gamma"] = source.gamma
|
||||
d["smoothing"] = source.smoothing
|
||||
d["interpolation_mode"] = source.interpolation_mode
|
||||
d["calibration"] = calibration_to_dict(source.calibration)
|
||||
d["led_count"] = source.led_count
|
||||
d["frame_interpolation"] = source.frame_interpolation
|
||||
return d
|
||||
|
||||
|
||||
def _apply_picture_update(source, **kwargs) -> None:
|
||||
"""Apply update fields common to both picture source types."""
|
||||
if kwargs.get("fps") is not None:
|
||||
source.fps = kwargs["fps"]
|
||||
if kwargs.get("smoothing") is not None:
|
||||
source.smoothing = kwargs["smoothing"]
|
||||
if kwargs.get("interpolation_mode") is not None:
|
||||
source.interpolation_mode = kwargs["interpolation_mode"]
|
||||
if kwargs.get("calibration") is not None:
|
||||
source.calibration = kwargs["calibration"]
|
||||
if kwargs.get("led_count") is not None:
|
||||
source.led_count = kwargs["led_count"]
|
||||
|
||||
|
||||
@dataclass
|
||||
class PictureColorStripSource(ColorStripSource):
|
||||
"""Color strip source driven by a single PictureSource (simple 4-edge calibration).
|
||||
@@ -337,29 +350,50 @@ class PictureColorStripSource(ColorStripSource):
|
||||
|
||||
picture_source_id: str = ""
|
||||
fps: int = 30
|
||||
brightness: float = 1.0 # color correction multiplier (0.0–2.0; 1.0 = unchanged)
|
||||
saturation: float = 1.0 # 1.0 = unchanged, 0.0 = grayscale, 2.0 = double saturation
|
||||
gamma: float = 1.0 # 1.0 = no correction; <1 = brighter, >1 = darker mids
|
||||
smoothing: float = 0.3 # temporal smoothing (0.0 = none, 1.0 = full)
|
||||
interpolation_mode: str = "average" # "average" | "median" | "dominant"
|
||||
calibration: CalibrationConfig = field(
|
||||
default_factory=lambda: CalibrationConfig(layout="clockwise", start_position="bottom_left")
|
||||
)
|
||||
led_count: int = 0 # explicit LED count; 0 = auto (derived from calibration)
|
||||
frame_interpolation: bool = False # blend between consecutive captured frames
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
d = super().to_dict()
|
||||
d["picture_source_id"] = self.picture_source_id
|
||||
return _picture_base_to_dict(self, d)
|
||||
|
||||
@classmethod
|
||||
def create_from_kwargs(cls, *, id: str, name: str, source_type: str,
|
||||
created_at: datetime, updated_at: datetime,
|
||||
description=None, clock_id=None, tags=None,
|
||||
picture_source_id="", fps=30,
|
||||
smoothing=0.3,
|
||||
interpolation_mode="average", calibration=None,
|
||||
led_count=0, **_kwargs):
|
||||
if calibration is None:
|
||||
calibration = CalibrationConfig(layout="clockwise", start_position="bottom_left")
|
||||
return cls(
|
||||
id=id, name=name, source_type=source_type,
|
||||
created_at=created_at, updated_at=updated_at,
|
||||
description=description, clock_id=clock_id, tags=tags or [],
|
||||
picture_source_id=picture_source_id, fps=fps,
|
||||
smoothing=smoothing, interpolation_mode=interpolation_mode,
|
||||
calibration=calibration, led_count=led_count,
|
||||
)
|
||||
|
||||
def apply_update(self, **kwargs) -> None:
|
||||
picture_source_id = kwargs.get("picture_source_id")
|
||||
if picture_source_id is not None:
|
||||
self.picture_source_id = resolve_ref(picture_source_id, self.picture_source_id)
|
||||
_apply_picture_update(self, **kwargs)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdvancedPictureColorStripSource(ColorStripSource):
|
||||
"""Color strip source with line-based calibration across multiple picture sources.
|
||||
|
||||
Each calibration line references its own picture source and edge, enabling
|
||||
LED strips that span multiple monitors. No single picture_source_id — the
|
||||
LED strips that span multiple monitors. No single picture_source_id -- the
|
||||
picture sources are defined per-line in the calibration config.
|
||||
"""
|
||||
|
||||
@@ -369,27 +403,44 @@ class AdvancedPictureColorStripSource(ColorStripSource):
|
||||
return True
|
||||
|
||||
fps: int = 30
|
||||
brightness: float = 1.0
|
||||
saturation: float = 1.0
|
||||
gamma: float = 1.0
|
||||
smoothing: float = 0.3
|
||||
interpolation_mode: str = "average"
|
||||
calibration: CalibrationConfig = field(
|
||||
default_factory=lambda: CalibrationConfig(mode="advanced")
|
||||
)
|
||||
led_count: int = 0
|
||||
frame_interpolation: bool = False
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
d = super().to_dict()
|
||||
return _picture_base_to_dict(self, d)
|
||||
|
||||
@classmethod
|
||||
def create_from_kwargs(cls, *, id: str, name: str, source_type: str,
|
||||
created_at: datetime, updated_at: datetime,
|
||||
description=None, clock_id=None, tags=None,
|
||||
fps=30,
|
||||
smoothing=0.3, interpolation_mode="average",
|
||||
calibration=None, led_count=0, **_kwargs):
|
||||
if calibration is None:
|
||||
calibration = CalibrationConfig(mode="advanced")
|
||||
return cls(
|
||||
id=id, name=name, source_type="picture_advanced",
|
||||
created_at=created_at, updated_at=updated_at,
|
||||
description=description, clock_id=clock_id, tags=tags or [],
|
||||
fps=fps,
|
||||
smoothing=smoothing, interpolation_mode=interpolation_mode,
|
||||
calibration=calibration, led_count=led_count,
|
||||
)
|
||||
|
||||
def apply_update(self, **kwargs) -> None:
|
||||
_apply_picture_update(self, **kwargs)
|
||||
|
||||
|
||||
@dataclass
|
||||
class StaticColorStripSource(ColorStripSource):
|
||||
"""Color strip source that fills all LEDs with a single static color.
|
||||
|
||||
No capture or processing — the entire LED strip is set to one constant
|
||||
No capture or processing -- the entire LED strip is set to one constant
|
||||
RGB color. Useful for solid-color accents or as a placeholder while
|
||||
a PictureColorStripSource is being configured.
|
||||
"""
|
||||
@@ -403,12 +454,33 @@ class StaticColorStripSource(ColorStripSource):
|
||||
d["animation"] = self.animation
|
||||
return d
|
||||
|
||||
@classmethod
|
||||
def create_from_kwargs(cls, *, id: str, name: str, source_type: str,
|
||||
created_at: datetime, updated_at: datetime,
|
||||
description=None, clock_id=None, tags=None,
|
||||
color=None, animation=None, **_kwargs):
|
||||
rgb = _validate_rgb(color, [255, 255, 255])
|
||||
return cls(
|
||||
id=id, name=name, source_type="static",
|
||||
created_at=created_at, updated_at=updated_at,
|
||||
description=description, clock_id=clock_id, tags=tags or [],
|
||||
color=rgb, animation=animation,
|
||||
)
|
||||
|
||||
def apply_update(self, **kwargs) -> None:
|
||||
color = kwargs.get("color")
|
||||
if color is not None:
|
||||
if isinstance(color, list) and len(color) == 3:
|
||||
self.color = color
|
||||
if kwargs.get("animation") is not None:
|
||||
self.animation = kwargs["animation"]
|
||||
|
||||
|
||||
@dataclass
|
||||
class GradientColorStripSource(ColorStripSource):
|
||||
"""Color strip source that produces a linear gradient across all LEDs.
|
||||
|
||||
The gradient is defined by color stops at relative positions (0.0–1.0).
|
||||
The gradient is defined by color stops at relative positions (0.0-1.0).
|
||||
Each stop has a primary color; optionally a second "right" color to create
|
||||
a hard discontinuity (bidirectional stop) at that position.
|
||||
|
||||
@@ -428,6 +500,29 @@ class GradientColorStripSource(ColorStripSource):
|
||||
d["animation"] = self.animation
|
||||
return d
|
||||
|
||||
@classmethod
|
||||
def create_from_kwargs(cls, *, id: str, name: str, source_type: str,
|
||||
created_at: datetime, updated_at: datetime,
|
||||
description=None, clock_id=None, tags=None,
|
||||
stops=None, animation=None, **_kwargs):
|
||||
return cls(
|
||||
id=id, name=name, source_type="gradient",
|
||||
created_at=created_at, updated_at=updated_at,
|
||||
description=description, clock_id=clock_id, tags=tags or [],
|
||||
stops=stops if isinstance(stops, list) else [
|
||||
{"position": 0.0, "color": [255, 0, 0]},
|
||||
{"position": 1.0, "color": [0, 0, 255]},
|
||||
],
|
||||
animation=animation,
|
||||
)
|
||||
|
||||
def apply_update(self, **kwargs) -> None:
|
||||
stops = kwargs.get("stops")
|
||||
if stops is not None and isinstance(stops, list):
|
||||
self.stops = stops
|
||||
if kwargs.get("animation") is not None:
|
||||
self.animation = kwargs["animation"]
|
||||
|
||||
|
||||
@dataclass
|
||||
class ColorCycleColorStripSource(ColorStripSource):
|
||||
@@ -448,6 +543,27 @@ class ColorCycleColorStripSource(ColorStripSource):
|
||||
d["colors"] = [list(c) for c in self.colors]
|
||||
return d
|
||||
|
||||
@classmethod
|
||||
def create_from_kwargs(cls, *, id: str, name: str, source_type: str,
|
||||
created_at: datetime, updated_at: datetime,
|
||||
description=None, clock_id=None, tags=None,
|
||||
colors=None, **_kwargs):
|
||||
default_colors = [
|
||||
[255, 0, 0], [255, 255, 0], [0, 255, 0],
|
||||
[0, 255, 255], [0, 0, 255], [255, 0, 255],
|
||||
]
|
||||
return cls(
|
||||
id=id, name=name, source_type="color_cycle",
|
||||
created_at=created_at, updated_at=updated_at,
|
||||
description=description, clock_id=clock_id, tags=tags or [],
|
||||
colors=colors if isinstance(colors, list) and len(colors) >= 2 else default_colors,
|
||||
)
|
||||
|
||||
def apply_update(self, **kwargs) -> None:
|
||||
colors = kwargs.get("colors")
|
||||
if colors is not None and isinstance(colors, list) and len(colors) >= 2:
|
||||
self.colors = colors
|
||||
|
||||
|
||||
@dataclass
|
||||
class EffectColorStripSource(ColorStripSource):
|
||||
@@ -461,8 +577,8 @@ class EffectColorStripSource(ColorStripSource):
|
||||
effect_type: str = "fire" # fire | meteor | plasma | noise | aurora
|
||||
palette: str = "fire" # named color palette
|
||||
color: list = field(default_factory=lambda: [255, 80, 0]) # [R,G,B] for meteor head
|
||||
intensity: float = 1.0 # effect-specific intensity (0.1–2.0)
|
||||
scale: float = 1.0 # spatial scale / zoom (0.5–5.0)
|
||||
intensity: float = 1.0 # effect-specific intensity (0.1-2.0)
|
||||
scale: float = 1.0 # spatial scale / zoom (0.5-5.0)
|
||||
mirror: bool = False # bounce mode (meteor)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
@@ -475,6 +591,39 @@ class EffectColorStripSource(ColorStripSource):
|
||||
d["mirror"] = self.mirror
|
||||
return d
|
||||
|
||||
@classmethod
|
||||
def create_from_kwargs(cls, *, id: str, name: str, source_type: str,
|
||||
created_at: datetime, updated_at: datetime,
|
||||
description=None, clock_id=None, tags=None,
|
||||
effect_type="fire", palette="fire", color=None,
|
||||
intensity=1.0, scale=1.0, mirror=False, **_kwargs):
|
||||
rgb = _validate_rgb(color, [255, 80, 0])
|
||||
return cls(
|
||||
id=id, name=name, source_type="effect",
|
||||
created_at=created_at, updated_at=updated_at,
|
||||
description=description, clock_id=clock_id, tags=tags or [],
|
||||
effect_type=effect_type or "fire", palette=palette or "fire",
|
||||
color=rgb,
|
||||
intensity=float(intensity) if intensity else 1.0,
|
||||
scale=float(scale) if scale else 1.0,
|
||||
mirror=bool(mirror),
|
||||
)
|
||||
|
||||
def apply_update(self, **kwargs) -> None:
|
||||
if kwargs.get("effect_type") is not None:
|
||||
self.effect_type = kwargs["effect_type"]
|
||||
if kwargs.get("palette") is not None:
|
||||
self.palette = kwargs["palette"]
|
||||
color = kwargs.get("color")
|
||||
if color is not None and isinstance(color, list) and len(color) == 3:
|
||||
self.color = color
|
||||
if kwargs.get("intensity") is not None:
|
||||
self.intensity = float(kwargs["intensity"])
|
||||
if kwargs.get("scale") is not None:
|
||||
self.scale = float(kwargs["scale"])
|
||||
if kwargs.get("mirror") is not None:
|
||||
self.mirror = bool(kwargs["mirror"])
|
||||
|
||||
|
||||
@dataclass
|
||||
class AudioColorStripSource(ColorStripSource):
|
||||
@@ -487,8 +636,8 @@ class AudioColorStripSource(ColorStripSource):
|
||||
|
||||
visualization_mode: str = "spectrum" # spectrum | beat_pulse | vu_meter
|
||||
audio_source_id: str = "" # references a MonoAudioSource
|
||||
sensitivity: float = 1.0 # gain multiplier (0.1–5.0)
|
||||
smoothing: float = 0.3 # temporal smoothing (0.0–1.0)
|
||||
sensitivity: float = 1.0 # gain multiplier (0.1-5.0)
|
||||
smoothing: float = 0.3 # temporal smoothing (0.0-1.0)
|
||||
palette: str = "rainbow" # named color palette
|
||||
color: list = field(default_factory=lambda: [0, 255, 0]) # base RGB for VU meter
|
||||
color_peak: list = field(default_factory=lambda: [255, 0, 0]) # peak RGB for VU meter
|
||||
@@ -508,6 +657,52 @@ class AudioColorStripSource(ColorStripSource):
|
||||
d["mirror"] = self.mirror
|
||||
return d
|
||||
|
||||
@classmethod
|
||||
def create_from_kwargs(cls, *, id: str, name: str, source_type: str,
|
||||
created_at: datetime, updated_at: datetime,
|
||||
description=None, clock_id=None, tags=None,
|
||||
visualization_mode="spectrum", audio_source_id="",
|
||||
sensitivity=1.0, smoothing=0.3, palette="rainbow",
|
||||
color=None, color_peak=None, led_count=0,
|
||||
mirror=False, **_kwargs):
|
||||
rgb = _validate_rgb(color, [0, 255, 0])
|
||||
peak = _validate_rgb(color_peak, [255, 0, 0])
|
||||
return cls(
|
||||
id=id, name=name, source_type="audio",
|
||||
created_at=created_at, updated_at=updated_at,
|
||||
description=description, clock_id=clock_id, tags=tags or [],
|
||||
visualization_mode=visualization_mode or "spectrum",
|
||||
audio_source_id=audio_source_id or "",
|
||||
sensitivity=float(sensitivity) if sensitivity else 1.0,
|
||||
smoothing=float(smoothing) if smoothing else 0.3,
|
||||
palette=palette or "rainbow",
|
||||
color=rgb, color_peak=peak, led_count=led_count,
|
||||
mirror=bool(mirror),
|
||||
)
|
||||
|
||||
def apply_update(self, **kwargs) -> None:
|
||||
if kwargs.get("visualization_mode") is not None:
|
||||
self.visualization_mode = kwargs["visualization_mode"]
|
||||
audio_source_id = kwargs.get("audio_source_id")
|
||||
if audio_source_id is not None:
|
||||
self.audio_source_id = resolve_ref(audio_source_id, self.audio_source_id)
|
||||
if kwargs.get("sensitivity") is not None:
|
||||
self.sensitivity = float(kwargs["sensitivity"])
|
||||
if kwargs.get("smoothing") is not None:
|
||||
self.smoothing = float(kwargs["smoothing"])
|
||||
if kwargs.get("palette") is not None:
|
||||
self.palette = kwargs["palette"]
|
||||
color = kwargs.get("color")
|
||||
if color is not None and isinstance(color, list) and len(color) == 3:
|
||||
self.color = color
|
||||
color_peak = kwargs.get("color_peak")
|
||||
if color_peak is not None and isinstance(color_peak, list) and len(color_peak) == 3:
|
||||
self.color_peak = color_peak
|
||||
if kwargs.get("led_count") is not None:
|
||||
self.led_count = kwargs["led_count"]
|
||||
if kwargs.get("mirror") is not None:
|
||||
self.mirror = bool(kwargs["mirror"])
|
||||
|
||||
|
||||
@dataclass
|
||||
class CompositeColorStripSource(ColorStripSource):
|
||||
@@ -528,6 +723,26 @@ class CompositeColorStripSource(ColorStripSource):
|
||||
d["led_count"] = self.led_count
|
||||
return d
|
||||
|
||||
@classmethod
|
||||
def create_from_kwargs(cls, *, id: str, name: str, source_type: str,
|
||||
created_at: datetime, updated_at: datetime,
|
||||
description=None, clock_id=None, tags=None,
|
||||
layers=None, led_count=0, **_kwargs):
|
||||
return cls(
|
||||
id=id, name=name, source_type="composite",
|
||||
created_at=created_at, updated_at=updated_at,
|
||||
description=description, clock_id=clock_id, tags=tags or [],
|
||||
layers=layers if isinstance(layers, list) else [],
|
||||
led_count=led_count,
|
||||
)
|
||||
|
||||
def apply_update(self, **kwargs) -> None:
|
||||
layers = kwargs.get("layers")
|
||||
if layers is not None and isinstance(layers, list):
|
||||
self.layers = layers
|
||||
if kwargs.get("led_count") is not None:
|
||||
self.led_count = kwargs["led_count"]
|
||||
|
||||
|
||||
@dataclass
|
||||
class MappedColorStripSource(ColorStripSource):
|
||||
@@ -549,6 +764,26 @@ class MappedColorStripSource(ColorStripSource):
|
||||
d["led_count"] = self.led_count
|
||||
return d
|
||||
|
||||
@classmethod
|
||||
def create_from_kwargs(cls, *, id: str, name: str, source_type: str,
|
||||
created_at: datetime, updated_at: datetime,
|
||||
description=None, clock_id=None, tags=None,
|
||||
zones=None, led_count=0, **_kwargs):
|
||||
return cls(
|
||||
id=id, name=name, source_type="mapped",
|
||||
created_at=created_at, updated_at=updated_at,
|
||||
description=description, clock_id=clock_id, tags=tags or [],
|
||||
zones=zones if isinstance(zones, list) else [],
|
||||
led_count=led_count,
|
||||
)
|
||||
|
||||
def apply_update(self, **kwargs) -> None:
|
||||
zones = kwargs.get("zones")
|
||||
if zones is not None and isinstance(zones, list):
|
||||
self.zones = zones
|
||||
if kwargs.get("led_count") is not None:
|
||||
self.led_count = kwargs["led_count"]
|
||||
|
||||
|
||||
@dataclass
|
||||
class ApiInputColorStripSource(ColorStripSource):
|
||||
@@ -571,6 +806,30 @@ class ApiInputColorStripSource(ColorStripSource):
|
||||
d["timeout"] = self.timeout
|
||||
return d
|
||||
|
||||
@classmethod
|
||||
def create_from_kwargs(cls, *, id: str, name: str, source_type: str,
|
||||
created_at: datetime, updated_at: datetime,
|
||||
description=None, clock_id=None, tags=None,
|
||||
led_count=0, fallback_color=None, timeout=None,
|
||||
**_kwargs):
|
||||
fb = _validate_rgb(fallback_color, [0, 0, 0])
|
||||
return cls(
|
||||
id=id, name=name, source_type="api_input",
|
||||
created_at=created_at, updated_at=updated_at,
|
||||
description=description, clock_id=clock_id, tags=tags or [],
|
||||
led_count=led_count, fallback_color=fb,
|
||||
timeout=float(timeout) if timeout is not None else 5.0,
|
||||
)
|
||||
|
||||
def apply_update(self, **kwargs) -> None:
|
||||
fallback_color = kwargs.get("fallback_color")
|
||||
if fallback_color is not None and isinstance(fallback_color, list) and len(fallback_color) == 3:
|
||||
self.fallback_color = fallback_color
|
||||
if kwargs.get("timeout") is not None:
|
||||
self.timeout = float(kwargs["timeout"])
|
||||
if kwargs.get("led_count") is not None:
|
||||
self.led_count = kwargs["led_count"]
|
||||
|
||||
|
||||
@dataclass
|
||||
class NotificationColorStripSource(ColorStripSource):
|
||||
@@ -586,7 +845,7 @@ class NotificationColorStripSource(ColorStripSource):
|
||||
notification_effect: str = "flash" # flash | pulse | sweep
|
||||
duration_ms: int = 1500 # effect duration in milliseconds
|
||||
default_color: str = "#FFFFFF" # hex color for notifications without app match
|
||||
app_colors: dict = field(default_factory=dict) # app name → hex color
|
||||
app_colors: dict = field(default_factory=dict) # app name -> hex color
|
||||
app_filter_mode: str = "off" # off | whitelist | blacklist
|
||||
app_filter_list: list = field(default_factory=list) # app names for filter
|
||||
os_listener: bool = False # whether to listen for OS notifications
|
||||
@@ -602,6 +861,45 @@ class NotificationColorStripSource(ColorStripSource):
|
||||
d["os_listener"] = self.os_listener
|
||||
return d
|
||||
|
||||
@classmethod
|
||||
def create_from_kwargs(cls, *, id: str, name: str, source_type: str,
|
||||
created_at: datetime, updated_at: datetime,
|
||||
description=None, clock_id=None, tags=None,
|
||||
notification_effect=None, duration_ms=None,
|
||||
default_color=None, app_colors=None,
|
||||
app_filter_mode=None, app_filter_list=None,
|
||||
os_listener=None, **_kwargs):
|
||||
return cls(
|
||||
id=id, name=name, source_type="notification",
|
||||
created_at=created_at, updated_at=updated_at,
|
||||
description=description, clock_id=clock_id, tags=tags or [],
|
||||
notification_effect=notification_effect or "flash",
|
||||
duration_ms=int(duration_ms) if duration_ms is not None else 1500,
|
||||
default_color=default_color or "#FFFFFF",
|
||||
app_colors=app_colors if isinstance(app_colors, dict) else {},
|
||||
app_filter_mode=app_filter_mode or "off",
|
||||
app_filter_list=app_filter_list if isinstance(app_filter_list, list) else [],
|
||||
os_listener=bool(os_listener) if os_listener is not None else False,
|
||||
)
|
||||
|
||||
def apply_update(self, **kwargs) -> None:
|
||||
if kwargs.get("notification_effect") is not None:
|
||||
self.notification_effect = kwargs["notification_effect"]
|
||||
if kwargs.get("duration_ms") is not None:
|
||||
self.duration_ms = int(kwargs["duration_ms"])
|
||||
if kwargs.get("default_color") is not None:
|
||||
self.default_color = kwargs["default_color"]
|
||||
app_colors = kwargs.get("app_colors")
|
||||
if app_colors is not None and isinstance(app_colors, dict):
|
||||
self.app_colors = app_colors
|
||||
if kwargs.get("app_filter_mode") is not None:
|
||||
self.app_filter_mode = kwargs["app_filter_mode"]
|
||||
app_filter_list = kwargs.get("app_filter_list")
|
||||
if app_filter_list is not None and isinstance(app_filter_list, list):
|
||||
self.app_filter_list = app_filter_list
|
||||
if kwargs.get("os_listener") is not None:
|
||||
self.os_listener = bool(kwargs["os_listener"])
|
||||
|
||||
|
||||
@dataclass
|
||||
class DaylightColorStripSource(ColorStripSource):
|
||||
@@ -614,7 +912,7 @@ class DaylightColorStripSource(ColorStripSource):
|
||||
|
||||
When use_real_time is True, the current wall-clock hour determines
|
||||
the color; speed is ignored. When False, speed controls how fast
|
||||
a full 24-hour cycle plays (1.0 ≈ 4 minutes per full cycle).
|
||||
a full 24-hour cycle plays (1.0 = 4 minutes per full cycle).
|
||||
"""
|
||||
|
||||
speed: float = 1.0 # cycle speed (ignored when use_real_time)
|
||||
@@ -628,6 +926,29 @@ class DaylightColorStripSource(ColorStripSource):
|
||||
d["latitude"] = self.latitude
|
||||
return d
|
||||
|
||||
@classmethod
|
||||
def create_from_kwargs(cls, *, id: str, name: str, source_type: str,
|
||||
created_at: datetime, updated_at: datetime,
|
||||
description=None, clock_id=None, tags=None,
|
||||
speed=None, use_real_time=None, latitude=None,
|
||||
**_kwargs):
|
||||
return cls(
|
||||
id=id, name=name, source_type="daylight",
|
||||
created_at=created_at, updated_at=updated_at,
|
||||
description=description, clock_id=clock_id, tags=tags or [],
|
||||
speed=float(speed) if speed is not None else 1.0,
|
||||
use_real_time=bool(use_real_time) if use_real_time is not None else False,
|
||||
latitude=float(latitude) if latitude is not None else 50.0,
|
||||
)
|
||||
|
||||
def apply_update(self, **kwargs) -> None:
|
||||
if kwargs.get("speed") is not None:
|
||||
self.speed = float(kwargs["speed"])
|
||||
if kwargs.get("use_real_time") is not None:
|
||||
self.use_real_time = bool(kwargs["use_real_time"])
|
||||
if kwargs.get("latitude") is not None:
|
||||
self.latitude = float(kwargs["latitude"])
|
||||
|
||||
|
||||
@dataclass
|
||||
class CandlelightColorStripSource(ColorStripSource):
|
||||
@@ -639,7 +960,7 @@ class CandlelightColorStripSource(ColorStripSource):
|
||||
"""
|
||||
|
||||
color: list = field(default_factory=lambda: [255, 147, 41]) # warm candle base [R,G,B]
|
||||
intensity: float = 1.0 # flicker intensity (0.1–2.0)
|
||||
intensity: float = 1.0 # flicker intensity (0.1-2.0)
|
||||
num_candles: int = 3 # number of independent candle sources
|
||||
speed: float = 1.0 # flicker speed multiplier
|
||||
|
||||
@@ -650,3 +971,93 @@ class CandlelightColorStripSource(ColorStripSource):
|
||||
d["num_candles"] = self.num_candles
|
||||
d["speed"] = self.speed
|
||||
return d
|
||||
|
||||
@classmethod
|
||||
def create_from_kwargs(cls, *, id: str, name: str, source_type: str,
|
||||
created_at: datetime, updated_at: datetime,
|
||||
description=None, clock_id=None, tags=None,
|
||||
color=None, intensity=1.0, num_candles=None,
|
||||
speed=None, **_kwargs):
|
||||
rgb = _validate_rgb(color, [255, 147, 41])
|
||||
return cls(
|
||||
id=id, name=name, source_type="candlelight",
|
||||
created_at=created_at, updated_at=updated_at,
|
||||
description=description, clock_id=clock_id, tags=tags or [],
|
||||
color=rgb,
|
||||
intensity=float(intensity) if intensity else 1.0,
|
||||
num_candles=int(num_candles) if num_candles is not None else 3,
|
||||
speed=float(speed) if speed is not None else 1.0,
|
||||
)
|
||||
|
||||
def apply_update(self, **kwargs) -> None:
|
||||
color = kwargs.get("color")
|
||||
if color is not None and isinstance(color, list) and len(color) == 3:
|
||||
self.color = color
|
||||
if kwargs.get("intensity") is not None:
|
||||
self.intensity = float(kwargs["intensity"])
|
||||
if kwargs.get("num_candles") is not None:
|
||||
self.num_candles = int(kwargs["num_candles"])
|
||||
if kwargs.get("speed") is not None:
|
||||
self.speed = float(kwargs["speed"])
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProcessedColorStripSource(ColorStripSource):
|
||||
"""Color strip source that takes another CSS and applies a processing template.
|
||||
|
||||
Wraps an existing color strip source and pipes its output through a
|
||||
ColorStripProcessingTemplate filter chain. Useful for applying brightness,
|
||||
gamma, palette quantization, etc. to any source without modifying the
|
||||
original.
|
||||
"""
|
||||
|
||||
input_source_id: str = "" # ID of the input color strip source
|
||||
processing_template_id: str = "" # ID of the CSPT to apply
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
d = super().to_dict()
|
||||
d["input_source_id"] = self.input_source_id
|
||||
d["processing_template_id"] = self.processing_template_id
|
||||
return d
|
||||
|
||||
@classmethod
|
||||
def create_from_kwargs(cls, *, id: str, name: str, source_type: str,
|
||||
created_at: datetime, updated_at: datetime,
|
||||
description=None, clock_id=None, tags=None,
|
||||
input_source_id="", processing_template_id="",
|
||||
**_kwargs):
|
||||
return cls(
|
||||
id=id, name=name, source_type="processed",
|
||||
created_at=created_at, updated_at=updated_at,
|
||||
description=description, clock_id=clock_id, tags=tags or [],
|
||||
input_source_id=input_source_id,
|
||||
processing_template_id=processing_template_id,
|
||||
)
|
||||
|
||||
def apply_update(self, **kwargs) -> None:
|
||||
input_source_id = kwargs.get("input_source_id")
|
||||
if input_source_id is not None:
|
||||
self.input_source_id = resolve_ref(input_source_id, self.input_source_id)
|
||||
processing_template_id = kwargs.get("processing_template_id")
|
||||
if processing_template_id is not None:
|
||||
self.processing_template_id = resolve_ref(processing_template_id, self.processing_template_id)
|
||||
|
||||
|
||||
# -- Source type registry --
|
||||
# Maps source_type string to its subclass for factory dispatch.
|
||||
_SOURCE_TYPE_MAP: Dict[str, Type[ColorStripSource]] = {
|
||||
"picture": PictureColorStripSource,
|
||||
"picture_advanced": AdvancedPictureColorStripSource,
|
||||
"static": StaticColorStripSource,
|
||||
"gradient": GradientColorStripSource,
|
||||
"color_cycle": ColorCycleColorStripSource,
|
||||
"effect": EffectColorStripSource,
|
||||
"audio": AudioColorStripSource,
|
||||
"composite": CompositeColorStripSource,
|
||||
"mapped": MappedColorStripSource,
|
||||
"api_input": ApiInputColorStripSource,
|
||||
"notification": NotificationColorStripSource,
|
||||
"daylight": DaylightColorStripSource,
|
||||
"candlelight": CandlelightColorStripSource,
|
||||
"processed": ProcessedColorStripSource,
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import List, Optional
|
||||
|
||||
from wled_controller.core.capture.calibration import CalibrationConfig, calibration_to_dict
|
||||
from wled_controller.storage.base_store import BaseJsonStore
|
||||
from wled_controller.storage.utils import resolve_ref
|
||||
from wled_controller.storage.color_strip_source import (
|
||||
@@ -21,6 +20,7 @@ from wled_controller.storage.color_strip_source import (
|
||||
MappedColorStripSource,
|
||||
NotificationColorStripSource,
|
||||
PictureColorStripSource,
|
||||
ProcessedColorStripSource,
|
||||
StaticColorStripSource,
|
||||
)
|
||||
from wled_controller.utils import get_logger
|
||||
@@ -45,56 +45,13 @@ class ColorStripStore(BaseJsonStore[ColorStripSource]):
|
||||
"""Get a color strip source by ID (alias for get())."""
|
||||
return self.get(source_id)
|
||||
|
||||
def create_source(
|
||||
self,
|
||||
name: str,
|
||||
source_type: str = "picture",
|
||||
picture_source_id: str = "",
|
||||
fps: int = 30,
|
||||
brightness: float = 1.0,
|
||||
saturation: float = 1.0,
|
||||
gamma: float = 1.0,
|
||||
smoothing: float = 0.3,
|
||||
interpolation_mode: str = "average",
|
||||
calibration=None,
|
||||
led_count: int = 0,
|
||||
color: Optional[list] = None,
|
||||
stops: Optional[list] = None,
|
||||
description: Optional[str] = None,
|
||||
frame_interpolation: bool = False,
|
||||
animation: Optional[dict] = None,
|
||||
colors: Optional[list] = None,
|
||||
effect_type: str = "fire",
|
||||
palette: str = "fire",
|
||||
intensity: float = 1.0,
|
||||
scale: float = 1.0,
|
||||
mirror: bool = False,
|
||||
layers: Optional[list] = None,
|
||||
zones: Optional[list] = None,
|
||||
visualization_mode: str = "spectrum",
|
||||
audio_source_id: str = "",
|
||||
sensitivity: float = 1.0,
|
||||
color_peak: Optional[list] = None,
|
||||
fallback_color: Optional[list] = None,
|
||||
timeout: Optional[float] = None,
|
||||
clock_id: Optional[str] = None,
|
||||
notification_effect: Optional[str] = None,
|
||||
duration_ms: Optional[int] = None,
|
||||
default_color: Optional[str] = None,
|
||||
app_colors: Optional[dict] = None,
|
||||
app_filter_mode: Optional[str] = None,
|
||||
app_filter_list: Optional[list] = None,
|
||||
os_listener: Optional[bool] = None,
|
||||
# daylight-type fields
|
||||
speed: Optional[float] = None,
|
||||
use_real_time: Optional[bool] = None,
|
||||
latitude: Optional[float] = None,
|
||||
# candlelight-type fields
|
||||
num_candles: Optional[int] = None,
|
||||
tags: Optional[List[str]] = None,
|
||||
) -> ColorStripSource:
|
||||
def create_source(self, name: str, source_type: str = "picture",
|
||||
**kwargs) -> ColorStripSource:
|
||||
"""Create a new color strip source.
|
||||
|
||||
All type-specific parameters are passed as keyword arguments and
|
||||
forwarded to the subclass ``create_from_kwargs`` factory method.
|
||||
|
||||
Raises:
|
||||
ValueError: If validation fails
|
||||
"""
|
||||
@@ -108,271 +65,30 @@ class ColorStripStore(BaseJsonStore[ColorStripSource]):
|
||||
source_id = f"css_{uuid.uuid4().hex[:8]}"
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
if source_type == "static":
|
||||
rgb = color if isinstance(color, list) and len(color) == 3 else [255, 255, 255]
|
||||
source = StaticColorStripSource(
|
||||
id=source_id,
|
||||
name=name,
|
||||
source_type="static",
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
description=description,
|
||||
clock_id=clock_id,
|
||||
color=rgb,
|
||||
animation=animation,
|
||||
)
|
||||
elif source_type == "gradient":
|
||||
source = GradientColorStripSource(
|
||||
id=source_id,
|
||||
name=name,
|
||||
source_type="gradient",
|
||||
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]},
|
||||
],
|
||||
animation=animation,
|
||||
)
|
||||
elif source_type == "color_cycle":
|
||||
default_colors = [
|
||||
[255, 0, 0], [255, 255, 0], [0, 255, 0],
|
||||
[0, 255, 255], [0, 0, 255], [255, 0, 255],
|
||||
]
|
||||
source = ColorCycleColorStripSource(
|
||||
id=source_id,
|
||||
name=name,
|
||||
source_type="color_cycle",
|
||||
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,
|
||||
)
|
||||
elif source_type == "effect":
|
||||
rgb = color if isinstance(color, list) and len(color) == 3 else [255, 80, 0]
|
||||
source = EffectColorStripSource(
|
||||
id=source_id,
|
||||
name=name,
|
||||
source_type="effect",
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
description=description,
|
||||
clock_id=clock_id,
|
||||
effect_type=effect_type or "fire",
|
||||
palette=palette or "fire",
|
||||
color=rgb,
|
||||
intensity=float(intensity) if intensity else 1.0,
|
||||
scale=float(scale) if scale else 1.0,
|
||||
mirror=bool(mirror),
|
||||
)
|
||||
elif source_type == "audio":
|
||||
rgb = color if isinstance(color, list) and len(color) == 3 else [0, 255, 0]
|
||||
peak = color_peak if isinstance(color_peak, list) and len(color_peak) == 3 else [255, 0, 0]
|
||||
source = AudioColorStripSource(
|
||||
id=source_id,
|
||||
name=name,
|
||||
source_type="audio",
|
||||
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,
|
||||
smoothing=float(smoothing) if smoothing else 0.3,
|
||||
palette=palette or "rainbow",
|
||||
color=rgb,
|
||||
color_peak=peak,
|
||||
led_count=led_count,
|
||||
mirror=bool(mirror),
|
||||
)
|
||||
elif source_type == "composite":
|
||||
source = CompositeColorStripSource(
|
||||
id=source_id,
|
||||
name=name,
|
||||
source_type="composite",
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
description=description,
|
||||
clock_id=clock_id,
|
||||
layers=layers if isinstance(layers, list) else [],
|
||||
led_count=led_count,
|
||||
)
|
||||
elif source_type == "mapped":
|
||||
source = MappedColorStripSource(
|
||||
id=source_id,
|
||||
name=name,
|
||||
source_type="mapped",
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
description=description,
|
||||
clock_id=clock_id,
|
||||
zones=zones if isinstance(zones, list) else [],
|
||||
led_count=led_count,
|
||||
)
|
||||
elif source_type == "api_input":
|
||||
fb = fallback_color if isinstance(fallback_color, list) and len(fallback_color) == 3 else [0, 0, 0]
|
||||
source = ApiInputColorStripSource(
|
||||
id=source_id,
|
||||
name=name,
|
||||
source_type="api_input",
|
||||
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,
|
||||
)
|
||||
elif source_type == "notification":
|
||||
source = NotificationColorStripSource(
|
||||
id=source_id,
|
||||
name=name,
|
||||
source_type="notification",
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
description=description,
|
||||
clock_id=clock_id,
|
||||
notification_effect=notification_effect or "flash",
|
||||
duration_ms=int(duration_ms) if duration_ms is not None else 1500,
|
||||
default_color=default_color or "#FFFFFF",
|
||||
app_colors=app_colors if isinstance(app_colors, dict) else {},
|
||||
app_filter_mode=app_filter_mode or "off",
|
||||
app_filter_list=app_filter_list if isinstance(app_filter_list, list) else [],
|
||||
os_listener=bool(os_listener) if os_listener is not None else False,
|
||||
)
|
||||
elif source_type == "daylight":
|
||||
source = DaylightColorStripSource(
|
||||
id=source_id,
|
||||
name=name,
|
||||
source_type="daylight",
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
description=description,
|
||||
clock_id=clock_id,
|
||||
speed=float(speed) if speed is not None else 1.0,
|
||||
use_real_time=bool(use_real_time) if use_real_time is not None else False,
|
||||
latitude=float(latitude) if latitude is not None else 50.0,
|
||||
)
|
||||
elif source_type == "candlelight":
|
||||
rgb = color if isinstance(color, list) and len(color) == 3 else [255, 147, 41]
|
||||
source = CandlelightColorStripSource(
|
||||
id=source_id,
|
||||
name=name,
|
||||
source_type="candlelight",
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
description=description,
|
||||
clock_id=clock_id,
|
||||
color=rgb,
|
||||
intensity=float(intensity) if intensity else 1.0,
|
||||
num_candles=int(num_candles) if num_candles is not None else 3,
|
||||
speed=float(speed) if speed is not None else 1.0,
|
||||
)
|
||||
elif source_type == "picture_advanced":
|
||||
if calibration is None:
|
||||
calibration = CalibrationConfig(mode="advanced")
|
||||
source = AdvancedPictureColorStripSource(
|
||||
id=source_id,
|
||||
name=name,
|
||||
source_type="picture_advanced",
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
description=description,
|
||||
clock_id=clock_id,
|
||||
fps=fps,
|
||||
brightness=brightness,
|
||||
saturation=saturation,
|
||||
gamma=gamma,
|
||||
smoothing=smoothing,
|
||||
interpolation_mode=interpolation_mode,
|
||||
calibration=calibration,
|
||||
led_count=led_count,
|
||||
frame_interpolation=frame_interpolation,
|
||||
)
|
||||
else:
|
||||
if calibration is None:
|
||||
calibration = CalibrationConfig(layout="clockwise", start_position="bottom_left")
|
||||
source = PictureColorStripSource(
|
||||
id=source_id,
|
||||
name=name,
|
||||
source_type=source_type,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
description=description,
|
||||
clock_id=clock_id,
|
||||
picture_source_id=picture_source_id,
|
||||
fps=fps,
|
||||
brightness=brightness,
|
||||
saturation=saturation,
|
||||
gamma=gamma,
|
||||
smoothing=smoothing,
|
||||
interpolation_mode=interpolation_mode,
|
||||
calibration=calibration,
|
||||
led_count=led_count,
|
||||
frame_interpolation=frame_interpolation,
|
||||
)
|
||||
tags = kwargs.pop("tags", None) or []
|
||||
|
||||
source = ColorStripSource.create_instance(
|
||||
source_type,
|
||||
id=source_id,
|
||||
name=name,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
tags=tags,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
source.tags = tags or []
|
||||
self._items[source_id] = source
|
||||
self._save()
|
||||
|
||||
logger.info(f"Created color strip source: {name} ({source_id}, type={source_type})")
|
||||
return source
|
||||
|
||||
def update_source(
|
||||
self,
|
||||
source_id: str,
|
||||
name: Optional[str] = None,
|
||||
picture_source_id: Optional[str] = None,
|
||||
fps: Optional[int] = None,
|
||||
brightness: Optional[float] = None,
|
||||
saturation: Optional[float] = None,
|
||||
gamma: Optional[float] = None,
|
||||
smoothing: Optional[float] = None,
|
||||
interpolation_mode: Optional[str] = None,
|
||||
calibration=None,
|
||||
led_count: Optional[int] = None,
|
||||
color: Optional[list] = None,
|
||||
stops: Optional[list] = None,
|
||||
description: Optional[str] = None,
|
||||
frame_interpolation: Optional[bool] = None,
|
||||
animation: Optional[dict] = None,
|
||||
colors: Optional[list] = None,
|
||||
effect_type: Optional[str] = None,
|
||||
palette: Optional[str] = None,
|
||||
intensity: Optional[float] = None,
|
||||
scale: Optional[float] = None,
|
||||
mirror: Optional[bool] = None,
|
||||
layers: Optional[list] = None,
|
||||
zones: Optional[list] = None,
|
||||
visualization_mode: Optional[str] = None,
|
||||
audio_source_id: Optional[str] = None,
|
||||
sensitivity: Optional[float] = None,
|
||||
color_peak: Optional[list] = None,
|
||||
fallback_color: Optional[list] = None,
|
||||
timeout: Optional[float] = None,
|
||||
clock_id: Optional[str] = None,
|
||||
notification_effect: Optional[str] = None,
|
||||
duration_ms: Optional[int] = None,
|
||||
default_color: Optional[str] = None,
|
||||
app_colors: Optional[dict] = None,
|
||||
app_filter_mode: Optional[str] = None,
|
||||
app_filter_list: Optional[list] = None,
|
||||
os_listener: Optional[bool] = None,
|
||||
# daylight-type fields
|
||||
speed: Optional[float] = None,
|
||||
use_real_time: Optional[bool] = None,
|
||||
latitude: Optional[float] = None,
|
||||
# candlelight-type fields
|
||||
num_candles: Optional[int] = None,
|
||||
tags: Optional[List[str]] = None,
|
||||
) -> ColorStripSource:
|
||||
def update_source(self, source_id: str, **kwargs) -> ColorStripSource:
|
||||
"""Update an existing color strip source.
|
||||
|
||||
All type-specific parameters are passed as keyword arguments and
|
||||
forwarded to the subclass ``apply_update`` method.
|
||||
|
||||
Raises:
|
||||
ValueError: If source not found
|
||||
"""
|
||||
@@ -381,136 +97,28 @@ class ColorStripStore(BaseJsonStore[ColorStripSource]):
|
||||
|
||||
source = self._items[source_id]
|
||||
|
||||
# -- Common fields handled here (before dispatching to subclass) --
|
||||
name = kwargs.pop("name", None)
|
||||
if name is not None:
|
||||
for other in self._items.values():
|
||||
if other.id != source_id and other.name == name:
|
||||
raise ValueError(f"Color strip source with name '{name}' already exists")
|
||||
source.name = name
|
||||
|
||||
description = kwargs.pop("description", None)
|
||||
if description is not None:
|
||||
source.description = description
|
||||
|
||||
clock_id = kwargs.pop("clock_id", None)
|
||||
if clock_id is not None:
|
||||
source.clock_id = resolve_ref(clock_id, source.clock_id)
|
||||
|
||||
tags = kwargs.pop("tags", None)
|
||||
if tags is not None:
|
||||
source.tags = tags
|
||||
|
||||
if isinstance(source, (PictureColorStripSource, AdvancedPictureColorStripSource)):
|
||||
if picture_source_id is not None and isinstance(source, PictureColorStripSource):
|
||||
source.picture_source_id = resolve_ref(picture_source_id, source.picture_source_id)
|
||||
if fps is not None:
|
||||
source.fps = fps
|
||||
if brightness is not None:
|
||||
source.brightness = brightness
|
||||
if saturation is not None:
|
||||
source.saturation = saturation
|
||||
if gamma is not None:
|
||||
source.gamma = gamma
|
||||
if smoothing is not None:
|
||||
source.smoothing = smoothing
|
||||
if interpolation_mode is not None:
|
||||
source.interpolation_mode = interpolation_mode
|
||||
if calibration is not None:
|
||||
source.calibration = calibration
|
||||
if led_count is not None:
|
||||
source.led_count = led_count
|
||||
if frame_interpolation is not None:
|
||||
source.frame_interpolation = frame_interpolation
|
||||
elif isinstance(source, StaticColorStripSource):
|
||||
if color is not None:
|
||||
if isinstance(color, list) and len(color) == 3:
|
||||
source.color = color
|
||||
if animation is not None:
|
||||
source.animation = animation
|
||||
elif isinstance(source, GradientColorStripSource):
|
||||
if stops is not None and isinstance(stops, list):
|
||||
source.stops = stops
|
||||
if animation is not None:
|
||||
source.animation = animation
|
||||
elif isinstance(source, ColorCycleColorStripSource):
|
||||
if colors is not None and isinstance(colors, list) and len(colors) >= 2:
|
||||
source.colors = colors
|
||||
elif isinstance(source, EffectColorStripSource):
|
||||
if effect_type is not None:
|
||||
source.effect_type = effect_type
|
||||
if palette is not None:
|
||||
source.palette = palette
|
||||
if color is not None and isinstance(color, list) and len(color) == 3:
|
||||
source.color = color
|
||||
if intensity is not None:
|
||||
source.intensity = float(intensity)
|
||||
if scale is not None:
|
||||
source.scale = float(scale)
|
||||
if mirror is not None:
|
||||
source.mirror = bool(mirror)
|
||||
elif isinstance(source, AudioColorStripSource):
|
||||
if visualization_mode is not None:
|
||||
source.visualization_mode = visualization_mode
|
||||
if audio_source_id is not None:
|
||||
source.audio_source_id = resolve_ref(audio_source_id, source.audio_source_id)
|
||||
if sensitivity is not None:
|
||||
source.sensitivity = float(sensitivity)
|
||||
if smoothing is not None:
|
||||
source.smoothing = float(smoothing)
|
||||
if palette is not None:
|
||||
source.palette = palette
|
||||
if color is not None and isinstance(color, list) and len(color) == 3:
|
||||
source.color = color
|
||||
if color_peak is not None and isinstance(color_peak, list) and len(color_peak) == 3:
|
||||
source.color_peak = color_peak
|
||||
if led_count is not None:
|
||||
source.led_count = led_count
|
||||
if mirror is not None:
|
||||
source.mirror = bool(mirror)
|
||||
elif isinstance(source, CompositeColorStripSource):
|
||||
if layers is not None and isinstance(layers, list):
|
||||
source.layers = layers
|
||||
if led_count is not None:
|
||||
source.led_count = led_count
|
||||
elif isinstance(source, MappedColorStripSource):
|
||||
if zones is not None and isinstance(zones, list):
|
||||
source.zones = zones
|
||||
if led_count is not None:
|
||||
source.led_count = led_count
|
||||
elif isinstance(source, ApiInputColorStripSource):
|
||||
if fallback_color is not None and isinstance(fallback_color, list) and len(fallback_color) == 3:
|
||||
source.fallback_color = fallback_color
|
||||
if timeout is not None:
|
||||
source.timeout = float(timeout)
|
||||
if led_count is not None:
|
||||
source.led_count = led_count
|
||||
elif isinstance(source, NotificationColorStripSource):
|
||||
if notification_effect is not None:
|
||||
source.notification_effect = notification_effect
|
||||
if duration_ms is not None:
|
||||
source.duration_ms = int(duration_ms)
|
||||
if default_color is not None:
|
||||
source.default_color = default_color
|
||||
if app_colors is not None and isinstance(app_colors, dict):
|
||||
source.app_colors = app_colors
|
||||
if app_filter_mode is not None:
|
||||
source.app_filter_mode = app_filter_mode
|
||||
if app_filter_list is not None and isinstance(app_filter_list, list):
|
||||
source.app_filter_list = app_filter_list
|
||||
if os_listener is not None:
|
||||
source.os_listener = bool(os_listener)
|
||||
elif isinstance(source, DaylightColorStripSource):
|
||||
if speed is not None:
|
||||
source.speed = float(speed)
|
||||
if use_real_time is not None:
|
||||
source.use_real_time = bool(use_real_time)
|
||||
if latitude is not None:
|
||||
source.latitude = float(latitude)
|
||||
elif isinstance(source, CandlelightColorStripSource):
|
||||
if color is not None and isinstance(color, list) and len(color) == 3:
|
||||
source.color = color
|
||||
if intensity is not None:
|
||||
source.intensity = float(intensity)
|
||||
if num_candles is not None:
|
||||
source.num_candles = int(num_candles)
|
||||
if speed is not None:
|
||||
source.speed = float(speed)
|
||||
# -- Type-specific fields --
|
||||
source.apply_update(**kwargs)
|
||||
|
||||
source.updated_at = datetime.now(timezone.utc)
|
||||
self._save()
|
||||
@@ -539,3 +147,12 @@ class ColorStripStore(BaseJsonStore[ColorStripSource]):
|
||||
names.append(source.name)
|
||||
break
|
||||
return names
|
||||
|
||||
def get_processed_referencing(self, source_id: str) -> List[str]:
|
||||
"""Return names of processed sources that reference a given source as input."""
|
||||
names = []
|
||||
for source in self._items.values():
|
||||
if isinstance(source, ProcessedColorStripSource):
|
||||
if source.input_source_id == source_id:
|
||||
names.append(source.name)
|
||||
return names
|
||||
|
||||
@@ -6,7 +6,8 @@ from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from wled_controller.utils import atomic_write_json, get_logger
|
||||
from wled_controller.storage.base_store import BaseJsonStore
|
||||
from wled_controller.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -52,6 +53,8 @@ class Device:
|
||||
chroma_device_type: str = "chromalink",
|
||||
# SteelSeries GameSense fields
|
||||
gamesense_device_type: str = "keyboard",
|
||||
# Default color strip processing template
|
||||
default_css_processing_template_id: str = "",
|
||||
created_at: Optional[datetime] = None,
|
||||
updated_at: Optional[datetime] = None,
|
||||
):
|
||||
@@ -80,6 +83,7 @@ class Device:
|
||||
self.spi_led_type = spi_led_type
|
||||
self.chroma_device_type = chroma_device_type
|
||||
self.gamesense_device_type = gamesense_device_type
|
||||
self.default_css_processing_template_id = default_css_processing_template_id
|
||||
self.created_at = created_at or datetime.now(timezone.utc)
|
||||
self.updated_at = updated_at or datetime.now(timezone.utc)
|
||||
|
||||
@@ -133,6 +137,8 @@ class Device:
|
||||
d["chroma_device_type"] = self.chroma_device_type
|
||||
if self.gamesense_device_type != "keyboard":
|
||||
d["gamesense_device_type"] = self.gamesense_device_type
|
||||
if self.default_css_processing_template_id:
|
||||
d["default_css_processing_template_id"] = self.default_css_processing_template_id
|
||||
return d
|
||||
|
||||
@classmethod
|
||||
@@ -164,78 +170,44 @@ class Device:
|
||||
spi_led_type=data.get("spi_led_type", "WS2812B"),
|
||||
chroma_device_type=data.get("chroma_device_type", "chromalink"),
|
||||
gamesense_device_type=data.get("gamesense_device_type", "keyboard"),
|
||||
default_css_processing_template_id=data.get("default_css_processing_template_id", ""),
|
||||
created_at=datetime.fromisoformat(data.get("created_at", datetime.now(timezone.utc).isoformat())),
|
||||
updated_at=datetime.fromisoformat(data.get("updated_at", datetime.now(timezone.utc).isoformat())),
|
||||
)
|
||||
|
||||
|
||||
class DeviceStore:
|
||||
# Fields that can be updated (all Device.__init__ params except identity/timestamps)
|
||||
_UPDATABLE_FIELDS = {
|
||||
k for k in Device.__init__.__code__.co_varnames
|
||||
if k not in ('self', 'device_id', 'created_at', 'updated_at')
|
||||
}
|
||||
|
||||
|
||||
class DeviceStore(BaseJsonStore[Device]):
|
||||
"""Persistent storage for WLED devices."""
|
||||
|
||||
_json_key = "devices"
|
||||
_entity_name = "Device"
|
||||
|
||||
def __init__(self, storage_file: str | Path):
|
||||
self.storage_file = Path(storage_file)
|
||||
self._devices: Dict[str, Device] = {}
|
||||
super().__init__(file_path=str(storage_file), deserializer=Device.from_dict)
|
||||
logger.info(f"Device store initialized with {len(self._items)} devices")
|
||||
|
||||
# Ensure directory exists
|
||||
self.storage_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
# ── Backward-compat aliases ──────────────────────────────────
|
||||
|
||||
# Load existing devices
|
||||
self.load()
|
||||
def get_device(self, device_id: str) -> Device:
|
||||
"""Get device by ID. Raises ValueError if not found."""
|
||||
return self.get(device_id)
|
||||
|
||||
logger.info(f"Device store initialized with {len(self._devices)} devices")
|
||||
def get_all_devices(self) -> List[Device]:
|
||||
"""Get all devices."""
|
||||
return self.get_all()
|
||||
|
||||
def load(self):
|
||||
"""Load devices from storage file."""
|
||||
if not self.storage_file.exists():
|
||||
logger.info("Storage file does not exist, starting with empty store")
|
||||
return
|
||||
def delete_device(self, device_id: str) -> None:
|
||||
"""Delete device. Raises ValueError if not found."""
|
||||
self.delete(device_id)
|
||||
|
||||
try:
|
||||
with open(self.storage_file, "r") as f:
|
||||
data = json.load(f)
|
||||
|
||||
devices_data = data.get("devices", {})
|
||||
self._devices = {
|
||||
device_id: Device.from_dict(device_data)
|
||||
for device_id, device_data in devices_data.items()
|
||||
}
|
||||
|
||||
logger.info(f"Loaded {len(self._devices)} devices from storage")
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"Failed to parse storage file: {e}")
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load devices: {e}")
|
||||
raise
|
||||
|
||||
def load_raw(self) -> dict:
|
||||
"""Load raw JSON data from storage (for migration)."""
|
||||
if not self.storage_file.exists():
|
||||
return {}
|
||||
try:
|
||||
with open(self.storage_file, "r") as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
def save(self):
|
||||
"""Save devices to storage file."""
|
||||
try:
|
||||
data = {
|
||||
"devices": {
|
||||
device_id: device.to_dict()
|
||||
for device_id, device in self._devices.items()
|
||||
}
|
||||
}
|
||||
|
||||
atomic_write_json(self.storage_file, data)
|
||||
|
||||
logger.debug(f"Saved {len(self._devices)} devices to storage")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save devices: {e}")
|
||||
raise
|
||||
# ── Create / Update ──────────────────────────────────────────
|
||||
|
||||
def create_device(
|
||||
self,
|
||||
@@ -295,122 +267,48 @@ class DeviceStore:
|
||||
gamesense_device_type=gamesense_device_type,
|
||||
)
|
||||
|
||||
self._devices[device_id] = device
|
||||
self.save()
|
||||
self._items[device_id] = device
|
||||
self._save()
|
||||
|
||||
logger.info(f"Created device {device_id}: {name}")
|
||||
return device
|
||||
|
||||
def get_device(self, device_id: str) -> Optional[Device]:
|
||||
"""Get device by ID."""
|
||||
return self._devices.get(device_id)
|
||||
def update_device(self, device_id: str, **kwargs) -> Device:
|
||||
"""Update device fields.
|
||||
|
||||
def get_all_devices(self) -> List[Device]:
|
||||
"""Get all devices."""
|
||||
return list(self._devices.values())
|
||||
Pass any updatable Device field as a keyword argument.
|
||||
``None`` values are ignored (no change).
|
||||
"""
|
||||
device = self.get(device_id) # raises ValueError if not found
|
||||
|
||||
def update_device(
|
||||
self,
|
||||
device_id: str,
|
||||
name: Optional[str] = None,
|
||||
url: Optional[str] = None,
|
||||
led_count: Optional[int] = None,
|
||||
enabled: Optional[bool] = None,
|
||||
baud_rate: Optional[int] = None,
|
||||
auto_shutdown: Optional[bool] = None,
|
||||
send_latency_ms: Optional[int] = None,
|
||||
rgbw: Optional[bool] = None,
|
||||
zone_mode: Optional[str] = None,
|
||||
tags: Optional[List[str]] = None,
|
||||
dmx_protocol: Optional[str] = None,
|
||||
dmx_start_universe: Optional[int] = None,
|
||||
dmx_start_channel: Optional[int] = None,
|
||||
espnow_peer_mac: Optional[str] = None,
|
||||
espnow_channel: Optional[int] = None,
|
||||
hue_username: Optional[str] = None,
|
||||
hue_client_key: Optional[str] = None,
|
||||
hue_entertainment_group_id: Optional[str] = None,
|
||||
spi_speed_hz: Optional[int] = None,
|
||||
spi_led_type: Optional[str] = None,
|
||||
chroma_device_type: Optional[str] = None,
|
||||
gamesense_device_type: Optional[str] = None,
|
||||
) -> Device:
|
||||
"""Update device."""
|
||||
device = self._devices.get(device_id)
|
||||
if not device:
|
||||
raise ValueError(f"Device {device_id} not found")
|
||||
|
||||
if name is not None:
|
||||
device.name = name
|
||||
if url is not None:
|
||||
device.url = url
|
||||
if led_count is not None:
|
||||
device.led_count = led_count
|
||||
if enabled is not None:
|
||||
device.enabled = enabled
|
||||
if baud_rate is not None:
|
||||
device.baud_rate = baud_rate
|
||||
if auto_shutdown is not None:
|
||||
device.auto_shutdown = auto_shutdown
|
||||
if send_latency_ms is not None:
|
||||
device.send_latency_ms = send_latency_ms
|
||||
if rgbw is not None:
|
||||
device.rgbw = rgbw
|
||||
if zone_mode is not None:
|
||||
device.zone_mode = zone_mode
|
||||
if tags is not None:
|
||||
device.tags = tags
|
||||
if dmx_protocol is not None:
|
||||
device.dmx_protocol = dmx_protocol
|
||||
if dmx_start_universe is not None:
|
||||
device.dmx_start_universe = dmx_start_universe
|
||||
if dmx_start_channel is not None:
|
||||
device.dmx_start_channel = dmx_start_channel
|
||||
if espnow_peer_mac is not None:
|
||||
device.espnow_peer_mac = espnow_peer_mac
|
||||
if espnow_channel is not None:
|
||||
device.espnow_channel = espnow_channel
|
||||
if hue_username is not None:
|
||||
device.hue_username = hue_username
|
||||
if hue_client_key is not None:
|
||||
device.hue_client_key = hue_client_key
|
||||
if hue_entertainment_group_id is not None:
|
||||
device.hue_entertainment_group_id = hue_entertainment_group_id
|
||||
if spi_speed_hz is not None:
|
||||
device.spi_speed_hz = spi_speed_hz
|
||||
if spi_led_type is not None:
|
||||
device.spi_led_type = spi_led_type
|
||||
if chroma_device_type is not None:
|
||||
device.chroma_device_type = chroma_device_type
|
||||
if gamesense_device_type is not None:
|
||||
device.gamesense_device_type = gamesense_device_type
|
||||
for key, value in kwargs.items():
|
||||
if value is not None and key in _UPDATABLE_FIELDS:
|
||||
setattr(device, key, value)
|
||||
|
||||
device.updated_at = datetime.now(timezone.utc)
|
||||
self.save()
|
||||
self._save()
|
||||
|
||||
logger.info(f"Updated device {device_id}")
|
||||
return device
|
||||
|
||||
def delete_device(self, device_id: str):
|
||||
"""Delete device."""
|
||||
if device_id not in self._devices:
|
||||
raise ValueError(f"Device {device_id} not found")
|
||||
|
||||
del self._devices[device_id]
|
||||
self.save()
|
||||
|
||||
logger.info(f"Deleted device {device_id}")
|
||||
# ── Unique helpers ───────────────────────────────────────────
|
||||
|
||||
def device_exists(self, device_id: str) -> bool:
|
||||
"""Check if device exists."""
|
||||
return device_id in self._devices
|
||||
|
||||
def count(self) -> int:
|
||||
"""Get number of devices."""
|
||||
return len(self._devices)
|
||||
return device_id in self._items
|
||||
|
||||
def clear(self):
|
||||
"""Clear all devices (for testing)."""
|
||||
self._devices.clear()
|
||||
self.save()
|
||||
self._items.clear()
|
||||
self._save()
|
||||
logger.warning("Cleared all devices from storage")
|
||||
|
||||
def load_raw(self) -> dict:
|
||||
"""Load raw JSON data from storage (for migration)."""
|
||||
if not self.file_path.exists():
|
||||
return {}
|
||||
try:
|
||||
with open(self.file_path, "r") as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
@@ -178,6 +178,7 @@
|
||||
{% include 'modals/test-pp-template.html' %}
|
||||
{% include 'modals/stream.html' %}
|
||||
{% include 'modals/pp-template.html' %}
|
||||
{% include 'modals/cspt-modal.html' %}
|
||||
{% include 'modals/automation-editor.html' %}
|
||||
{% include 'modals/scene-preset-editor.html' %}
|
||||
{% include 'modals/audio-source-editor.html' %}
|
||||
|
||||
@@ -256,6 +256,16 @@
|
||||
<option value="indicator">Indicator</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group" id="device-cspt-group">
|
||||
<div class="label-row">
|
||||
<label for="device-css-processing-template" data-i18n="device.css_processing_template">Strip Processing Template:</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="device.css_processing_template.hint">Default processing template applied to all color strip outputs on this device</small>
|
||||
<select id="device-css-processing-template">
|
||||
<option value="">—</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="add-device-error" class="error-message" style="display: none;"></div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
40
server/src/wled_controller/templates/modals/cspt-modal.html
Normal file
40
server/src/wled_controller/templates/modals/cspt-modal.html
Normal file
@@ -0,0 +1,40 @@
|
||||
<!-- Color Strip Processing Template Modal -->
|
||||
<div id="cspt-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="cspt-modal-title">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2 id="cspt-modal-title" data-i18n="css_processing.add">Add Strip Processing Template</h2>
|
||||
<button class="modal-close-btn" onclick="closeCSPTModal()" title="Close" data-i18n-aria-label="aria.close">✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="hidden" id="cspt-id">
|
||||
<form id="cspt-form">
|
||||
<div class="form-group">
|
||||
<label for="cspt-name" data-i18n="css_processing.name">Template Name:</label>
|
||||
<input type="text" id="cspt-name" data-i18n-placeholder="css_processing.name_placeholder" placeholder="My Strip Processing Template" required>
|
||||
<div id="cspt-tags-container"></div>
|
||||
</div>
|
||||
|
||||
<!-- Dynamic filter list -->
|
||||
<div id="cspt-filter-list" class="pp-filter-list"></div>
|
||||
|
||||
<!-- Add filter control -->
|
||||
<div class="pp-add-filter-row">
|
||||
<select id="cspt-add-filter-select" class="pp-add-filter-select">
|
||||
<option value="" data-i18n="filters.select_type">Select filter type...</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="cspt-description" data-i18n="css_processing.description_label">Description (optional):</label>
|
||||
<input type="text" id="cspt-description" data-i18n-placeholder="css_processing.description_placeholder" placeholder="Describe this template...">
|
||||
</div>
|
||||
|
||||
<div id="cspt-error" class="error-message" style="display: none;"></div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-icon btn-secondary" onclick="closeCSPTModal()" title="Cancel" data-i18n-aria-label="aria.cancel">✕</button>
|
||||
<button class="btn btn-icon btn-primary" onclick="saveCSPT()" title="Save" data-i18n-aria-label="aria.save">✓</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -35,6 +35,7 @@
|
||||
<option value="notification" data-i18n="color_strip.type.notification">Notification</option>
|
||||
<option value="daylight" data-i18n="color_strip.type.daylight">Daylight Cycle</option>
|
||||
<option value="candlelight" data-i18n="color_strip.type.candlelight">Candlelight</option>
|
||||
<option value="processed" data-i18n="color_strip.type.processed">Processed</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -74,58 +75,6 @@
|
||||
<input type="range" id="css-editor-smoothing" min="0.0" max="1.0" step="0.05" value="0.3" oninput="document.getElementById('css-editor-smoothing-value').textContent = parseFloat(this.value).toFixed(2)">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label for="css-editor-frame-interpolation" data-i18n="color_strip.frame_interpolation">Frame Interpolation:</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="color_strip.frame_interpolation.hint">Blends between consecutive captured frames to produce output at the full target FPS even when capture rate is lower. Reduces visible stepping on slow ambient transitions.</small>
|
||||
<label class="settings-toggle">
|
||||
<input type="checkbox" id="css-editor-frame-interpolation">
|
||||
<span class="settings-toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<details class="form-collapse">
|
||||
<summary data-i18n="color_strip.color_corrections">Color Corrections</summary>
|
||||
<div class="form-collapse-body">
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label for="css-editor-brightness">
|
||||
<span data-i18n="color_strip.brightness">Brightness:</span>
|
||||
<span id="css-editor-brightness-value">1.00</span>
|
||||
</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="color_strip.brightness.hint">Output brightness multiplier (0=off, 1=unchanged, 2=double). Applied after color extraction.</small>
|
||||
<input type="range" id="css-editor-brightness" min="0.0" max="2.0" step="0.05" value="1.0" oninput="document.getElementById('css-editor-brightness-value').textContent = parseFloat(this.value).toFixed(2)">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label for="css-editor-saturation">
|
||||
<span data-i18n="color_strip.saturation">Saturation:</span>
|
||||
<span id="css-editor-saturation-value">1.00</span>
|
||||
</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="color_strip.saturation.hint">Color saturation (0=grayscale, 1=unchanged, 2=double saturation)</small>
|
||||
<input type="range" id="css-editor-saturation" min="0.0" max="2.0" step="0.05" value="1.0" oninput="document.getElementById('css-editor-saturation-value').textContent = parseFloat(this.value).toFixed(2)">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label for="css-editor-gamma">
|
||||
<span data-i18n="color_strip.gamma">Gamma:</span>
|
||||
<span id="css-editor-gamma-value">1.00</span>
|
||||
</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="color_strip.gamma.hint">Gamma correction (1=none, <1=brighter midtones, >1=darker midtones)</small>
|
||||
<input type="range" id="css-editor-gamma" min="0.1" max="3.0" step="0.05" value="1.0" oninput="document.getElementById('css-editor-gamma-value').textContent = parseFloat(this.value).toFixed(2)">
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<!-- Static-color-specific fields -->
|
||||
@@ -608,6 +557,30 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Processed type fields -->
|
||||
<div id="css-editor-processed-section" style="display:none">
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label for="css-editor-processed-input" data-i18n="color_strip.processed.input">Input Source:</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="color_strip.processed.input.hint">The color strip source whose output will be processed</small>
|
||||
<select id="css-editor-processed-input">
|
||||
<option value="">—</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label for="css-editor-processed-template" data-i18n="color_strip.processed.template">Processing Template:</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="color_strip.processed.template.hint">Filter chain to apply to the input source output</small>
|
||||
<select id="css-editor-processed-template">
|
||||
<option value="">—</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Shared LED count field -->
|
||||
<div id="css-editor-led-count-group" class="form-group">
|
||||
<div class="label-row">
|
||||
|
||||
@@ -165,6 +165,17 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" id="settings-cspt-group">
|
||||
<div class="label-row">
|
||||
<label for="settings-css-processing-template" data-i18n="device.css_processing_template">Strip Processing Template:</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="device.css_processing_template.hint">Default processing template applied to all color strip outputs on this device</small>
|
||||
<select id="settings-css-processing-template">
|
||||
<option value="">—</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div id="device-settings-error" class="error-message" style="display: none;"></div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -44,6 +44,12 @@
|
||||
<canvas id="css-test-layers-axis" class="css-test-strip-axis"></canvas>
|
||||
</div>
|
||||
|
||||
<!-- CSPT test: input source selector (hidden by default) -->
|
||||
<div id="css-test-cspt-input-group" style="display:none" class="css-test-led-control">
|
||||
<label for="css-test-cspt-input-select" data-i18n="color_strip.processed.input">Input Source:</label>
|
||||
<select id="css-test-cspt-input-select" class="css-test-cspt-select" onchange="applyCssTestSettings()"></select>
|
||||
</div>
|
||||
|
||||
<!-- LED count & FPS controls -->
|
||||
<div class="css-test-led-control">
|
||||
<span id="css-test-led-group">
|
||||
|
||||
24
server/src/wled_controller/utils/gpu.py
Normal file
24
server/src/wled_controller/utils/gpu.py
Normal file
@@ -0,0 +1,24 @@
|
||||
"""GPU monitoring state (NVIDIA via pynvml).
|
||||
|
||||
Extracted from system routes so core modules can access GPU metrics
|
||||
without importing from the API layer.
|
||||
"""
|
||||
|
||||
from wled_controller.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
nvml_available = False
|
||||
nvml = None
|
||||
nvml_handle = None
|
||||
|
||||
try:
|
||||
import pynvml as _pynvml_mod
|
||||
|
||||
_pynvml_mod.nvmlInit()
|
||||
nvml_handle = _pynvml_mod.nvmlDeviceGetHandleByIndex(0)
|
||||
nvml_available = True
|
||||
nvml = _pynvml_mod
|
||||
logger.info(f"NVIDIA GPU monitoring enabled: {nvml.nvmlDeviceGetName(nvml_handle)}")
|
||||
except Exception:
|
||||
logger.info("NVIDIA GPU monitoring unavailable (pynvml not installed or no NVIDIA GPU)")
|
||||
@@ -4,8 +4,6 @@ import pytest
|
||||
from pathlib import Path
|
||||
|
||||
from wled_controller.storage.device_store import Device, DeviceStore
|
||||
from wled_controller.core.processing.processing_settings import ProcessingSettings
|
||||
from wled_controller.core.capture.calibration import create_default_calibration
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -51,8 +49,6 @@ def test_device_to_dict():
|
||||
assert data["name"] == "Test Device"
|
||||
assert data["url"] == "http://192.168.1.100"
|
||||
assert data["led_count"] == 150
|
||||
assert "settings" in data
|
||||
assert "calibration" in data
|
||||
|
||||
|
||||
def test_device_from_dict():
|
||||
@@ -63,11 +59,6 @@ def test_device_from_dict():
|
||||
"url": "http://192.168.1.100",
|
||||
"led_count": 150,
|
||||
"enabled": True,
|
||||
"settings": {
|
||||
"display_index": 0,
|
||||
"fps": 30,
|
||||
"border_width": 10,
|
||||
},
|
||||
}
|
||||
|
||||
device = Device.from_dict(data)
|
||||
@@ -130,9 +121,9 @@ def test_get_device(device_store):
|
||||
|
||||
|
||||
def test_get_device_not_found(device_store):
|
||||
"""Test retrieving non-existent device."""
|
||||
device = device_store.get_device("nonexistent")
|
||||
assert device is None
|
||||
"""Test retrieving non-existent device raises ValueError."""
|
||||
with pytest.raises(ValueError, match="not found"):
|
||||
device_store.get_device("nonexistent")
|
||||
|
||||
|
||||
def test_get_all_devices(device_store):
|
||||
@@ -165,41 +156,17 @@ def test_update_device(device_store):
|
||||
assert updated.enabled is False
|
||||
|
||||
|
||||
def test_update_device_settings(device_store):
|
||||
"""Test updating device settings."""
|
||||
def test_update_device_led_count(device_store):
|
||||
"""Test updating device LED count."""
|
||||
device = device_store.create_device(
|
||||
name="Test WLED",
|
||||
url="http://192.168.1.100",
|
||||
led_count=150,
|
||||
)
|
||||
|
||||
new_settings = ProcessingSettings(fps=60, border_width=20)
|
||||
updated = device_store.update_device(device.id, led_count=200)
|
||||
|
||||
updated = device_store.update_device(
|
||||
device.id,
|
||||
settings=new_settings,
|
||||
)
|
||||
|
||||
assert updated.settings.fps == 60
|
||||
assert updated.settings.border_width == 20
|
||||
|
||||
|
||||
def test_update_device_calibration(device_store):
|
||||
"""Test updating device calibration."""
|
||||
device = device_store.create_device(
|
||||
name="Test WLED",
|
||||
url="http://192.168.1.100",
|
||||
led_count=150,
|
||||
)
|
||||
|
||||
new_calibration = create_default_calibration(150)
|
||||
|
||||
updated = device_store.update_device(
|
||||
device.id,
|
||||
calibration=new_calibration,
|
||||
)
|
||||
|
||||
assert updated.calibration is not None
|
||||
assert updated.led_count == 200
|
||||
|
||||
|
||||
def test_update_device_not_found(device_store):
|
||||
@@ -219,7 +186,8 @@ def test_delete_device(device_store):
|
||||
device_store.delete_device(device.id)
|
||||
|
||||
assert device_store.count() == 0
|
||||
assert device_store.get_device(device.id) is None
|
||||
with pytest.raises(ValueError, match="not found"):
|
||||
device_store.get_device(device.id)
|
||||
|
||||
|
||||
def test_delete_device_not_found(device_store):
|
||||
@@ -273,33 +241,28 @@ def test_clear(device_store):
|
||||
assert device_store.count() == 0
|
||||
|
||||
|
||||
def test_update_led_count_resets_calibration(device_store):
|
||||
"""Test that updating LED count resets calibration."""
|
||||
def test_update_device_ignores_none_values(device_store):
|
||||
"""Test that update_device ignores None kwargs."""
|
||||
device = device_store.create_device(
|
||||
name="Test WLED",
|
||||
url="http://192.168.1.100",
|
||||
led_count=150,
|
||||
)
|
||||
|
||||
original_calibration = device.calibration
|
||||
updated = device_store.update_device(device.id, name=None, led_count=200)
|
||||
|
||||
# Update LED count
|
||||
updated = device_store.update_device(device.id, led_count=200)
|
||||
|
||||
# Calibration should be reset for new LED count
|
||||
assert updated.calibration.get_total_leds() == 200
|
||||
assert updated.calibration != original_calibration
|
||||
assert updated.name == "Test WLED" # unchanged
|
||||
assert updated.led_count == 200
|
||||
|
||||
|
||||
def test_update_calibration_led_count_mismatch(device_store):
|
||||
"""Test updating calibration with mismatched LED count fails."""
|
||||
def test_update_device_ignores_unknown_fields(device_store):
|
||||
"""Test that update_device silently ignores unknown kwargs."""
|
||||
device = device_store.create_device(
|
||||
name="Test WLED",
|
||||
url="http://192.168.1.100",
|
||||
led_count=150,
|
||||
)
|
||||
|
||||
wrong_calibration = create_default_calibration(100)
|
||||
|
||||
with pytest.raises(ValueError, match="does not match"):
|
||||
device_store.update_device(device.id, calibration=wrong_calibration)
|
||||
# Should not raise even with an unknown kwarg
|
||||
updated = device_store.update_device(device.id, nonexistent_field="foo")
|
||||
assert updated.name == "Test WLED"
|
||||
|
||||
Reference in New Issue
Block a user