Add LED axis ticks and calibration labels to color strip test preview
- Add horizontal axis with tick marks and LED index labels below strip and composite preview canvases (first/last labels edge-aligned) - Show actual/calibration LED count label on picture-based composite layers (e.g. "25/934") - Display warning icon in orange when LED counts don't match - Send is_picture and calibration_led_count in composite layer_infos Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -3,7 +3,6 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import io as _io
|
import io as _io
|
||||||
import json as _json
|
import json as _json
|
||||||
import secrets
|
|
||||||
import time as _time
|
import time as _time
|
||||||
import uuid as _uuid
|
import uuid as _uuid
|
||||||
|
|
||||||
@@ -43,22 +42,29 @@ from wled_controller.storage.picture_source import ProcessedPictureSource, Scree
|
|||||||
from wled_controller.storage.picture_source_store import PictureSourceStore
|
from wled_controller.storage.picture_source_store import PictureSourceStore
|
||||||
from wled_controller.storage.output_target_store import OutputTargetStore
|
from wled_controller.storage.output_target_store import OutputTargetStore
|
||||||
from wled_controller.utils import get_logger
|
from wled_controller.utils import get_logger
|
||||||
from wled_controller.config import get_config
|
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
def _css_to_response(source, overlay_active: bool = False) -> ColorStripSourceResponse:
|
def _css_to_response(source, overlay_active: bool = False) -> ColorStripSourceResponse:
|
||||||
"""Convert a ColorStripSource to a ColorStripSourceResponse."""
|
"""Convert a ColorStripSource to a ColorStripSourceResponse.
|
||||||
calibration = None
|
|
||||||
if isinstance(source, (PictureColorStripSource, AdvancedPictureColorStripSource)) and source.calibration:
|
|
||||||
calibration = CalibrationSchema(**calibration_to_dict(source.calibration))
|
|
||||||
|
|
||||||
# Convert raw stop dicts to ColorStop schema objects for gradient sources
|
Uses the source's to_dict() for type-specific fields, then applies
|
||||||
|
schema conversions for calibration and gradient stops.
|
||||||
|
"""
|
||||||
from wled_controller.api.schemas.color_strip_sources import ColorStop as ColorStopSchema
|
from wled_controller.api.schemas.color_strip_sources import ColorStop as ColorStopSchema
|
||||||
raw_stops = getattr(source, "stops", None)
|
|
||||||
|
d = source.to_dict()
|
||||||
|
|
||||||
|
# Convert calibration dict → schema object
|
||||||
|
calibration = None
|
||||||
|
raw_cal = d.pop("calibration", None)
|
||||||
|
if raw_cal and isinstance(raw_cal, dict):
|
||||||
|
calibration = CalibrationSchema(**raw_cal)
|
||||||
|
|
||||||
|
# Convert stop dicts → schema objects
|
||||||
|
raw_stops = d.pop("stops", None)
|
||||||
stops = None
|
stops = None
|
||||||
if raw_stops is not None:
|
if raw_stops is not None:
|
||||||
try:
|
try:
|
||||||
@@ -66,51 +72,20 @@ def _css_to_response(source, overlay_active: bool = False) -> ColorStripSourceRe
|
|||||||
except Exception:
|
except Exception:
|
||||||
stops = None
|
stops = None
|
||||||
|
|
||||||
|
# Remove serialized timestamp strings — use actual datetime objects
|
||||||
|
d.pop("created_at", None)
|
||||||
|
d.pop("updated_at", None)
|
||||||
|
|
||||||
|
# Filter to only keys accepted by the schema (to_dict may include extra
|
||||||
|
# fields like 'fps' that aren't in the response model)
|
||||||
|
valid_fields = ColorStripSourceResponse.model_fields
|
||||||
|
filtered = {k: v for k, v in d.items() if k in valid_fields}
|
||||||
|
|
||||||
return ColorStripSourceResponse(
|
return ColorStripSourceResponse(
|
||||||
id=source.id,
|
**filtered,
|
||||||
name=source.name,
|
|
||||||
source_type=source.source_type,
|
|
||||||
picture_source_id=getattr(source, "picture_source_id", None),
|
|
||||||
brightness=getattr(source, "brightness", None),
|
|
||||||
saturation=getattr(source, "saturation", None),
|
|
||||||
gamma=getattr(source, "gamma", None),
|
|
||||||
smoothing=getattr(source, "smoothing", None),
|
|
||||||
interpolation_mode=getattr(source, "interpolation_mode", None),
|
|
||||||
led_count=getattr(source, "led_count", 0),
|
|
||||||
calibration=calibration,
|
calibration=calibration,
|
||||||
color=getattr(source, "color", None),
|
|
||||||
stops=stops,
|
stops=stops,
|
||||||
colors=getattr(source, "colors", None),
|
|
||||||
effect_type=getattr(source, "effect_type", None),
|
|
||||||
palette=getattr(source, "palette", None),
|
|
||||||
intensity=getattr(source, "intensity", None),
|
|
||||||
scale=getattr(source, "scale", None),
|
|
||||||
mirror=getattr(source, "mirror", None),
|
|
||||||
description=source.description,
|
|
||||||
clock_id=source.clock_id,
|
|
||||||
frame_interpolation=getattr(source, "frame_interpolation", None),
|
|
||||||
animation=getattr(source, "animation", None),
|
|
||||||
layers=getattr(source, "layers", None),
|
|
||||||
zones=getattr(source, "zones", None),
|
|
||||||
visualization_mode=getattr(source, "visualization_mode", None),
|
|
||||||
audio_source_id=getattr(source, "audio_source_id", None),
|
|
||||||
sensitivity=getattr(source, "sensitivity", None),
|
|
||||||
color_peak=getattr(source, "color_peak", None),
|
|
||||||
fallback_color=getattr(source, "fallback_color", None),
|
|
||||||
timeout=getattr(source, "timeout", None),
|
|
||||||
notification_effect=getattr(source, "notification_effect", None),
|
|
||||||
duration_ms=getattr(source, "duration_ms", None),
|
|
||||||
default_color=getattr(source, "default_color", None),
|
|
||||||
app_colors=getattr(source, "app_colors", None),
|
|
||||||
app_filter_mode=getattr(source, "app_filter_mode", None),
|
|
||||||
app_filter_list=getattr(source, "app_filter_list", None),
|
|
||||||
os_listener=getattr(source, "os_listener", None),
|
|
||||||
speed=getattr(source, "speed", None),
|
|
||||||
use_real_time=getattr(source, "use_real_time", None),
|
|
||||||
latitude=getattr(source, "latitude", None),
|
|
||||||
num_candles=getattr(source, "num_candles", None),
|
|
||||||
overlay_active=overlay_active,
|
overlay_active=overlay_active,
|
||||||
tags=getattr(source, 'tags', []),
|
|
||||||
created_at=source.created_at,
|
created_at=source.created_at,
|
||||||
updated_at=source.updated_at,
|
updated_at=source.updated_at,
|
||||||
)
|
)
|
||||||
@@ -145,6 +120,27 @@ async def list_color_strip_sources(
|
|||||||
return ColorStripSourceListResponse(sources=responses, count=len(responses))
|
return ColorStripSourceListResponse(sources=responses, count=len(responses))
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_css_kwargs(data) -> dict:
|
||||||
|
"""Extract store-compatible kwargs from a Pydantic CSS create/update schema.
|
||||||
|
|
||||||
|
Converts nested Pydantic models (calibration, stops, layers, zones,
|
||||||
|
animation) to plain dicts/lists that the store expects.
|
||||||
|
"""
|
||||||
|
kwargs = data.model_dump(exclude_unset=False, exclude={"calibration", "stops", "layers", "zones", "animation"})
|
||||||
|
# Remove fields that don't map to store kwargs
|
||||||
|
kwargs.pop("source_type", None)
|
||||||
|
|
||||||
|
if data.calibration is not None:
|
||||||
|
kwargs["calibration"] = calibration_from_dict(data.calibration.model_dump())
|
||||||
|
else:
|
||||||
|
kwargs["calibration"] = None
|
||||||
|
kwargs["stops"] = [s.model_dump() for s in data.stops] if data.stops is not None else None
|
||||||
|
kwargs["layers"] = [l.model_dump() for l in data.layers] if data.layers is not None else None
|
||||||
|
kwargs["zones"] = [z.model_dump() for z in data.zones] if data.zones is not None else None
|
||||||
|
kwargs["animation"] = data.animation.model_dump() if data.animation else None
|
||||||
|
return kwargs
|
||||||
|
|
||||||
|
|
||||||
@router.post("/api/v1/color-strip-sources", response_model=ColorStripSourceResponse, tags=["Color Strip Sources"], status_code=201)
|
@router.post("/api/v1/color-strip-sources", response_model=ColorStripSourceResponse, tags=["Color Strip Sources"], status_code=201)
|
||||||
async def create_color_strip_source(
|
async def create_color_strip_source(
|
||||||
data: ColorStripSourceCreate,
|
data: ColorStripSourceCreate,
|
||||||
@@ -153,60 +149,8 @@ async def create_color_strip_source(
|
|||||||
):
|
):
|
||||||
"""Create a new color strip source."""
|
"""Create a new color strip source."""
|
||||||
try:
|
try:
|
||||||
calibration = None
|
kwargs = _extract_css_kwargs(data)
|
||||||
if data.calibration is not None:
|
source = store.create_source(source_type=data.source_type, **kwargs)
|
||||||
calibration = calibration_from_dict(data.calibration.model_dump())
|
|
||||||
|
|
||||||
stops = [s.model_dump() for s in data.stops] if data.stops is not None else None
|
|
||||||
|
|
||||||
layers = [l.model_dump() for l in data.layers] if data.layers is not None else None
|
|
||||||
|
|
||||||
zones = [z.model_dump() for z in data.zones] if data.zones is not None else None
|
|
||||||
|
|
||||||
source = store.create_source(
|
|
||||||
name=data.name,
|
|
||||||
source_type=data.source_type,
|
|
||||||
picture_source_id=data.picture_source_id,
|
|
||||||
brightness=data.brightness,
|
|
||||||
saturation=data.saturation,
|
|
||||||
gamma=data.gamma,
|
|
||||||
smoothing=data.smoothing,
|
|
||||||
interpolation_mode=data.interpolation_mode,
|
|
||||||
led_count=data.led_count,
|
|
||||||
calibration=calibration,
|
|
||||||
color=data.color,
|
|
||||||
stops=stops,
|
|
||||||
description=data.description,
|
|
||||||
frame_interpolation=data.frame_interpolation,
|
|
||||||
animation=data.animation.model_dump() if data.animation else None,
|
|
||||||
colors=data.colors,
|
|
||||||
effect_type=data.effect_type,
|
|
||||||
palette=data.palette,
|
|
||||||
intensity=data.intensity,
|
|
||||||
scale=data.scale,
|
|
||||||
mirror=data.mirror,
|
|
||||||
layers=layers,
|
|
||||||
zones=zones,
|
|
||||||
visualization_mode=data.visualization_mode,
|
|
||||||
audio_source_id=data.audio_source_id,
|
|
||||||
sensitivity=data.sensitivity,
|
|
||||||
color_peak=data.color_peak,
|
|
||||||
fallback_color=data.fallback_color,
|
|
||||||
timeout=data.timeout,
|
|
||||||
clock_id=data.clock_id,
|
|
||||||
notification_effect=data.notification_effect,
|
|
||||||
duration_ms=data.duration_ms,
|
|
||||||
default_color=data.default_color,
|
|
||||||
app_colors=data.app_colors,
|
|
||||||
app_filter_mode=data.app_filter_mode,
|
|
||||||
app_filter_list=data.app_filter_list,
|
|
||||||
os_listener=data.os_listener,
|
|
||||||
speed=data.speed,
|
|
||||||
use_real_time=data.use_real_time,
|
|
||||||
latitude=data.latitude,
|
|
||||||
num_candles=data.num_candles,
|
|
||||||
tags=data.tags,
|
|
||||||
)
|
|
||||||
fire_entity_event("color_strip_source", "created", source.id)
|
fire_entity_event("color_strip_source", "created", source.id)
|
||||||
return _css_to_response(source)
|
return _css_to_response(source)
|
||||||
|
|
||||||
@@ -242,60 +186,8 @@ async def update_color_strip_source(
|
|||||||
):
|
):
|
||||||
"""Update a color strip source and hot-reload any running streams."""
|
"""Update a color strip source and hot-reload any running streams."""
|
||||||
try:
|
try:
|
||||||
calibration = None
|
kwargs = _extract_css_kwargs(data)
|
||||||
if data.calibration is not None:
|
source = store.update_source(source_id=source_id, **kwargs)
|
||||||
calibration = calibration_from_dict(data.calibration.model_dump())
|
|
||||||
|
|
||||||
stops = [s.model_dump() for s in data.stops] if data.stops is not None else None
|
|
||||||
|
|
||||||
layers = [l.model_dump() for l in data.layers] if data.layers is not None else None
|
|
||||||
|
|
||||||
zones = [z.model_dump() for z in data.zones] if data.zones is not None else None
|
|
||||||
|
|
||||||
source = store.update_source(
|
|
||||||
source_id=source_id,
|
|
||||||
name=data.name,
|
|
||||||
picture_source_id=data.picture_source_id,
|
|
||||||
brightness=data.brightness,
|
|
||||||
saturation=data.saturation,
|
|
||||||
gamma=data.gamma,
|
|
||||||
smoothing=data.smoothing,
|
|
||||||
interpolation_mode=data.interpolation_mode,
|
|
||||||
led_count=data.led_count,
|
|
||||||
calibration=calibration,
|
|
||||||
color=data.color,
|
|
||||||
stops=stops,
|
|
||||||
description=data.description,
|
|
||||||
frame_interpolation=data.frame_interpolation,
|
|
||||||
animation=data.animation.model_dump() if data.animation else None,
|
|
||||||
colors=data.colors,
|
|
||||||
effect_type=data.effect_type,
|
|
||||||
palette=data.palette,
|
|
||||||
intensity=data.intensity,
|
|
||||||
scale=data.scale,
|
|
||||||
mirror=data.mirror,
|
|
||||||
layers=layers,
|
|
||||||
zones=zones,
|
|
||||||
visualization_mode=data.visualization_mode,
|
|
||||||
audio_source_id=data.audio_source_id,
|
|
||||||
sensitivity=data.sensitivity,
|
|
||||||
color_peak=data.color_peak,
|
|
||||||
fallback_color=data.fallback_color,
|
|
||||||
timeout=data.timeout,
|
|
||||||
clock_id=data.clock_id,
|
|
||||||
notification_effect=data.notification_effect,
|
|
||||||
duration_ms=data.duration_ms,
|
|
||||||
default_color=data.default_color,
|
|
||||||
app_colors=data.app_colors,
|
|
||||||
app_filter_mode=data.app_filter_mode,
|
|
||||||
app_filter_list=data.app_filter_list,
|
|
||||||
os_listener=data.os_listener,
|
|
||||||
speed=data.speed,
|
|
||||||
use_real_time=data.use_real_time,
|
|
||||||
latitude=data.latitude,
|
|
||||||
num_candles=data.num_candles,
|
|
||||||
tags=data.tags,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Hot-reload running stream (no restart needed for in-place param changes)
|
# Hot-reload running stream (no restart needed for in-place param changes)
|
||||||
try:
|
try:
|
||||||
@@ -583,7 +475,7 @@ async def os_notification_history(_auth: AuthRequired):
|
|||||||
if listener is None:
|
if listener is None:
|
||||||
return {"available": False, "history": []}
|
return {"available": False, "history": []}
|
||||||
return {
|
return {
|
||||||
"available": listener._available,
|
"available": listener.available,
|
||||||
"history": listener.recent_history,
|
"history": listener.recent_history,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -599,16 +491,8 @@ async def css_api_input_ws(
|
|||||||
Auth via ?token=<api_key>. Accepts JSON frames ({"colors": [[R,G,B], ...]})
|
Auth via ?token=<api_key>. Accepts JSON frames ({"colors": [[R,G,B], ...]})
|
||||||
or binary frames (raw RGBRGB... bytes, 3 bytes per LED).
|
or binary frames (raw RGBRGB... bytes, 3 bytes per LED).
|
||||||
"""
|
"""
|
||||||
# Authenticate
|
from wled_controller.api.auth import verify_ws_token
|
||||||
authenticated = False
|
if not verify_ws_token(token):
|
||||||
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:
|
|
||||||
await websocket.close(code=4001, reason="Unauthorized")
|
await websocket.close(code=4001, reason="Unauthorized")
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -689,15 +573,8 @@ async def test_color_strip_ws(
|
|||||||
First message is JSON metadata (source_type, led_count, calibration segments).
|
First message is JSON metadata (source_type, led_count, calibration segments).
|
||||||
Subsequent messages are binary RGB frames (``led_count * 3`` bytes).
|
Subsequent messages are binary RGB frames (``led_count * 3`` bytes).
|
||||||
"""
|
"""
|
||||||
# Authenticate
|
from wled_controller.api.auth import verify_ws_token
|
||||||
authenticated = False
|
if not verify_ws_token(token):
|
||||||
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:
|
|
||||||
await websocket.close(code=4001, reason="Unauthorized")
|
await websocket.close(code=4001, reason="Unauthorized")
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -760,7 +637,7 @@ async def test_color_strip_ws(
|
|||||||
if is_composite and hasattr(source, "layers"):
|
if is_composite and hasattr(source, "layers"):
|
||||||
# Send layer info for composite preview
|
# Send layer info for composite preview
|
||||||
enabled_layers = [l for l in source.layers if l.get("enabled", True)]
|
enabled_layers = [l for l in source.layers if l.get("enabled", True)]
|
||||||
layer_infos = [] # [{name, id, is_notification, has_brightness}, ...]
|
layer_infos = [] # [{name, id, is_notification, has_brightness, ...}, ...]
|
||||||
for layer in enabled_layers:
|
for layer in enabled_layers:
|
||||||
info = {"id": layer["source_id"], "name": layer.get("source_id", "?"),
|
info = {"id": layer["source_id"], "name": layer.get("source_id", "?"),
|
||||||
"is_notification": False, "has_brightness": bool(layer.get("brightness_source_id"))}
|
"is_notification": False, "has_brightness": bool(layer.get("brightness_source_id"))}
|
||||||
@@ -768,6 +645,10 @@ async def test_color_strip_ws(
|
|||||||
layer_src = store.get_source(layer["source_id"])
|
layer_src = store.get_source(layer["source_id"])
|
||||||
info["name"] = layer_src.name
|
info["name"] = layer_src.name
|
||||||
info["is_notification"] = isinstance(layer_src, NotificationColorStripSource)
|
info["is_notification"] = isinstance(layer_src, NotificationColorStripSource)
|
||||||
|
if isinstance(layer_src, (PictureColorStripSource, AdvancedPictureColorStripSource)):
|
||||||
|
info["is_picture"] = True
|
||||||
|
if hasattr(layer_src, "calibration") and layer_src.calibration:
|
||||||
|
info["calibration_led_count"] = layer_src.calibration.get_total_leds()
|
||||||
except (ValueError, KeyError):
|
except (ValueError, KeyError):
|
||||||
pass
|
pass
|
||||||
layer_infos.append(info)
|
layer_infos.append(info)
|
||||||
@@ -777,8 +658,8 @@ async def test_color_strip_ws(
|
|||||||
|
|
||||||
# For picture sources, grab the live stream for frame preview
|
# For picture sources, grab the live stream for frame preview
|
||||||
_frame_live = None
|
_frame_live = None
|
||||||
if is_picture and hasattr(stream, '_live_stream'):
|
if is_picture and hasattr(stream, 'live_stream'):
|
||||||
_frame_live = stream._live_stream
|
_frame_live = stream.live_stream
|
||||||
_last_aux_time = 0.0
|
_last_aux_time = 0.0
|
||||||
_AUX_INTERVAL = 0.08 # send JPEG preview / brightness updates ~12 FPS
|
_AUX_INTERVAL = 0.08 # send JPEG preview / brightness updates ~12 FPS
|
||||||
|
|
||||||
|
|||||||
@@ -132,6 +132,12 @@
|
|||||||
image-rendering: pixelated;
|
image-rendering: pixelated;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.css-test-strip-axis {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
.css-test-fire-btn {
|
.css-test-fire-btn {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 6px;
|
right: 6px;
|
||||||
@@ -306,6 +312,27 @@
|
|||||||
}
|
}
|
||||||
.css-test-layer-brightness svg { width: 12px; height: 12px; }
|
.css-test-layer-brightness svg { width: 12px; height: 12px; }
|
||||||
|
|
||||||
|
.css-test-layer-cal {
|
||||||
|
position: absolute;
|
||||||
|
right: 6px;
|
||||||
|
bottom: 4px;
|
||||||
|
font-size: 0.6rem;
|
||||||
|
font-family: var(--font-mono, monospace);
|
||||||
|
color: #fff;
|
||||||
|
text-shadow: 0 0 3px rgba(0,0,0,0.9), 0 0 6px rgba(0,0,0,0.6);
|
||||||
|
pointer-events: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
opacity: 0.7;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
.css-test-layer-cal svg { width: 12px; height: 12px; }
|
||||||
|
.css-test-layer-cal-warn {
|
||||||
|
color: var(--warning-color, #ff9800);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
/* LED count control */
|
/* LED count control */
|
||||||
.css-test-led-control {
|
.css-test-led-control {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
ICON_LED, ICON_PALETTE, ICON_FPS, ICON_MAP_PIN, ICON_MUSIC,
|
ICON_LED, ICON_PALETTE, ICON_FPS, ICON_MAP_PIN, ICON_MUSIC,
|
||||||
ICON_AUDIO_LOOPBACK, ICON_TIMER, ICON_LINK_SOURCE, ICON_FILM,
|
ICON_AUDIO_LOOPBACK, ICON_TIMER, ICON_LINK_SOURCE, ICON_FILM,
|
||||||
ICON_LINK, ICON_SPARKLES, ICON_ACTIVITY, ICON_CLOCK, ICON_BELL, ICON_EYE,
|
ICON_LINK, ICON_SPARKLES, ICON_ACTIVITY, ICON_CLOCK, ICON_BELL, ICON_EYE,
|
||||||
ICON_SUN_DIM,
|
ICON_SUN_DIM, ICON_WARNING,
|
||||||
} from '../core/icons.js';
|
} from '../core/icons.js';
|
||||||
import * as P from '../core/icon-paths.js';
|
import * as P from '../core/icon-paths.js';
|
||||||
import { wrapCard } from '../core/card-colors.js';
|
import { wrapCard } from '../core/card-colors.js';
|
||||||
@@ -2024,6 +2024,12 @@ function _cssTestConnect(sourceId, ledCount, fps) {
|
|||||||
// Build composite layer canvases
|
// Build composite layer canvases
|
||||||
if (_cssTestIsComposite) {
|
if (_cssTestIsComposite) {
|
||||||
_cssTestBuildLayers(_cssTestMeta.layers, _cssTestMeta.source_type, _cssTestMeta.layer_infos);
|
_cssTestBuildLayers(_cssTestMeta.layers, _cssTestMeta.source_type, _cssTestMeta.layer_infos);
|
||||||
|
requestAnimationFrame(() => _cssTestRenderStripAxis('css-test-layers-axis', _cssTestMeta.led_count));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render strip axis for non-picture, non-composite views
|
||||||
|
if (!isPicture && !_cssTestIsComposite) {
|
||||||
|
requestAnimationFrame(() => _cssTestRenderStripAxis('css-test-strip-axis', _cssTestMeta.led_count));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const raw = new Uint8Array(event.data);
|
const raw = new Uint8Array(event.data);
|
||||||
@@ -2110,9 +2116,15 @@ function _cssTestBuildLayers(layerNames, sourceType, layerInfos) {
|
|||||||
const briLabel = hasBri
|
const briLabel = hasBri
|
||||||
? `<span class="css-test-layer-brightness" data-layer-idx="${i}" style="display:none"></span>`
|
? `<span class="css-test-layer-brightness" data-layer-idx="${i}" style="display:none"></span>`
|
||||||
: '';
|
: '';
|
||||||
|
let calLabel = '';
|
||||||
|
if (info && info.is_picture && info.calibration_led_count) {
|
||||||
|
const mismatch = _cssTestMeta.led_count !== info.calibration_led_count;
|
||||||
|
calLabel = `<span class="css-test-layer-cal${mismatch ? ' css-test-layer-cal-warn' : ''}" data-layer-idx="${i}">${mismatch ? ICON_WARNING + ' ' : ''}${_cssTestMeta.led_count}/${info.calibration_led_count}</span>`;
|
||||||
|
}
|
||||||
html += `<div class="css-test-layer css-test-strip-wrap">` +
|
html += `<div class="css-test-layer css-test-strip-wrap">` +
|
||||||
`<canvas class="css-test-layer-canvas" data-layer-idx="${i}"></canvas>` +
|
`<canvas class="css-test-layer-canvas" data-layer-idx="${i}"></canvas>` +
|
||||||
`<span class="css-test-layer-label">${escapeHtml(layerNames[i])}</span>` +
|
`<span class="css-test-layer-label">${escapeHtml(layerNames[i])}</span>` +
|
||||||
|
calLabel +
|
||||||
briLabel +
|
briLabel +
|
||||||
fireBtn +
|
fireBtn +
|
||||||
`</div>`;
|
`</div>`;
|
||||||
@@ -2376,6 +2388,73 @@ function _cssTestRenderTicks(edges) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function _cssTestRenderStripAxis(canvasId, ledCount) {
|
||||||
|
const canvas = document.getElementById(canvasId);
|
||||||
|
if (!canvas || ledCount <= 0) return;
|
||||||
|
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
const w = canvas.clientWidth;
|
||||||
|
const h = canvas.clientHeight;
|
||||||
|
canvas.width = w * dpr;
|
||||||
|
canvas.height = h * dpr;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
ctx.scale(dpr, dpr);
|
||||||
|
ctx.clearRect(0, 0, w, h);
|
||||||
|
|
||||||
|
const isDark = document.documentElement.getAttribute('data-theme') !== 'light';
|
||||||
|
const tickStroke = isDark ? 'rgba(255, 255, 255, 0.5)' : 'rgba(0, 0, 0, 0.35)';
|
||||||
|
const tickFill = isDark ? 'rgba(255, 255, 255, 0.75)' : 'rgba(0, 0, 0, 0.65)';
|
||||||
|
ctx.strokeStyle = tickStroke;
|
||||||
|
ctx.fillStyle = tickFill;
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.font = '10px -apple-system, BlinkMacSystemFont, sans-serif';
|
||||||
|
ctx.textBaseline = 'top';
|
||||||
|
|
||||||
|
const tickLen = 5;
|
||||||
|
|
||||||
|
// Determine which ticks to label
|
||||||
|
const labelsToShow = new Set([0]);
|
||||||
|
if (ledCount > 1) labelsToShow.add(ledCount - 1);
|
||||||
|
|
||||||
|
if (ledCount > 2) {
|
||||||
|
const maxDigits = String(ledCount - 1).length;
|
||||||
|
const minSpacing = maxDigits * 7 + 8;
|
||||||
|
const niceSteps = [5, 10, 25, 50, 100, 250, 500];
|
||||||
|
let step = niceSteps[niceSteps.length - 1];
|
||||||
|
for (const s of niceSteps) {
|
||||||
|
if (Math.floor(ledCount / s) <= Math.floor(w / minSpacing)) { step = s; break; }
|
||||||
|
}
|
||||||
|
|
||||||
|
const tickPx = i => (i / (ledCount - 1)) * w;
|
||||||
|
const placed = [];
|
||||||
|
labelsToShow.forEach(i => placed.push(tickPx(i)));
|
||||||
|
|
||||||
|
for (let i = 1; i < ledCount - 1; i++) {
|
||||||
|
if (i % step === 0) {
|
||||||
|
const px = tickPx(i);
|
||||||
|
if (!placed.some(p => Math.abs(px - p) < minSpacing)) {
|
||||||
|
labelsToShow.add(i);
|
||||||
|
placed.push(px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
labelsToShow.forEach(idx => {
|
||||||
|
const fraction = ledCount > 1 ? idx / (ledCount - 1) : 0.5;
|
||||||
|
const tx = fraction * w;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(tx, 0);
|
||||||
|
ctx.lineTo(tx, tickLen);
|
||||||
|
ctx.stroke();
|
||||||
|
// Align first tick left, last tick right, others center
|
||||||
|
if (idx === 0) ctx.textAlign = 'left';
|
||||||
|
else if (idx === ledCount - 1) ctx.textAlign = 'right';
|
||||||
|
else ctx.textAlign = 'center';
|
||||||
|
ctx.fillText(String(idx), tx, tickLen + 1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function fireCssTestNotification() {
|
export function fireCssTestNotification() {
|
||||||
for (const id of _cssTestNotificationIds) {
|
for (const id of _cssTestNotificationIds) {
|
||||||
testNotification(id);
|
testNotification(id);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
<!-- Strip view (for generic sources) -->
|
<!-- Strip view (for generic sources) -->
|
||||||
<div id="css-test-strip-view" class="css-test-strip-wrap">
|
<div id="css-test-strip-view" class="css-test-strip-wrap">
|
||||||
<canvas id="css-test-strip-canvas" class="css-test-strip-canvas"></canvas>
|
<canvas id="css-test-strip-canvas" class="css-test-strip-canvas"></canvas>
|
||||||
|
<canvas id="css-test-strip-axis" class="css-test-strip-axis"></canvas>
|
||||||
<button id="css-test-fire-btn" class="css-test-fire-btn" style="display:none"
|
<button id="css-test-fire-btn" class="css-test-fire-btn" style="display:none"
|
||||||
onclick="fireCssTestNotification()"
|
onclick="fireCssTestNotification()"
|
||||||
data-i18n-title="color_strip.notification.test"><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"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/><path d="M4 2C2.8 3.7 2 5.7 2 8"/><path d="M22 8c0-2.3-.8-4.3-2-6"/></svg></button>
|
data-i18n-title="color_strip.notification.test"><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"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/><path d="M4 2C2.8 3.7 2 5.7 2 8"/><path d="M22 8c0-2.3-.8-4.3-2-6"/></svg></button>
|
||||||
@@ -40,6 +41,7 @@
|
|||||||
<!-- Composite layers view -->
|
<!-- Composite layers view -->
|
||||||
<div id="css-test-layers-view" style="display:none">
|
<div id="css-test-layers-view" style="display:none">
|
||||||
<div id="css-test-layers" class="css-test-layers"></div>
|
<div id="css-test-layers" class="css-test-layers"></div>
|
||||||
|
<canvas id="css-test-layers-axis" class="css-test-strip-axis"></canvas>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- LED count & FPS controls -->
|
<!-- LED count & FPS controls -->
|
||||||
|
|||||||
Reference in New Issue
Block a user