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 io as _io
|
||||
import json as _json
|
||||
import secrets
|
||||
import time as _time
|
||||
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.output_target_store import OutputTargetStore
|
||||
from wled_controller.utils import get_logger
|
||||
from wled_controller.config import get_config
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _css_to_response(source, overlay_active: bool = False) -> 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 a ColorStripSource to a ColorStripSourceResponse.
|
||||
|
||||
# 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
|
||||
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
|
||||
if raw_stops is not None:
|
||||
try:
|
||||
@@ -66,51 +72,20 @@ def _css_to_response(source, overlay_active: bool = False) -> ColorStripSourceRe
|
||||
except Exception:
|
||||
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(
|
||||
id=source.id,
|
||||
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),
|
||||
**filtered,
|
||||
calibration=calibration,
|
||||
color=getattr(source, "color", None),
|
||||
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,
|
||||
tags=getattr(source, 'tags', []),
|
||||
created_at=source.created_at,
|
||||
updated_at=source.updated_at,
|
||||
)
|
||||
@@ -145,6 +120,27 @@ async def list_color_strip_sources(
|
||||
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)
|
||||
async def create_color_strip_source(
|
||||
data: ColorStripSourceCreate,
|
||||
@@ -153,60 +149,8 @@ async def create_color_strip_source(
|
||||
):
|
||||
"""Create a new color strip source."""
|
||||
try:
|
||||
calibration = None
|
||||
if data.calibration is not None:
|
||||
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,
|
||||
)
|
||||
kwargs = _extract_css_kwargs(data)
|
||||
source = store.create_source(source_type=data.source_type, **kwargs)
|
||||
fire_entity_event("color_strip_source", "created", source.id)
|
||||
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."""
|
||||
try:
|
||||
calibration = None
|
||||
if data.calibration is not None:
|
||||
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,
|
||||
)
|
||||
kwargs = _extract_css_kwargs(data)
|
||||
source = store.update_source(source_id=source_id, **kwargs)
|
||||
|
||||
# Hot-reload running stream (no restart needed for in-place param changes)
|
||||
try:
|
||||
@@ -583,7 +475,7 @@ async def os_notification_history(_auth: AuthRequired):
|
||||
if listener is None:
|
||||
return {"available": False, "history": []}
|
||||
return {
|
||||
"available": listener._available,
|
||||
"available": listener.available,
|
||||
"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], ...]})
|
||||
or binary frames (raw RGBRGB... bytes, 3 bytes per LED).
|
||||
"""
|
||||
# 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
|
||||
|
||||
@@ -689,15 +573,8 @@ async def test_color_strip_ws(
|
||||
First message is JSON metadata (source_type, led_count, calibration segments).
|
||||
Subsequent messages are binary RGB frames (``led_count * 3`` bytes).
|
||||
"""
|
||||
# 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
|
||||
|
||||
@@ -760,7 +637,7 @@ async def test_color_strip_ws(
|
||||
if is_composite and hasattr(source, "layers"):
|
||||
# Send layer info for composite preview
|
||||
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:
|
||||
info = {"id": layer["source_id"], "name": layer.get("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"])
|
||||
info["name"] = layer_src.name
|
||||
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):
|
||||
pass
|
||||
layer_infos.append(info)
|
||||
@@ -777,8 +658,8 @@ async def test_color_strip_ws(
|
||||
|
||||
# For picture sources, grab the live stream for frame preview
|
||||
_frame_live = None
|
||||
if is_picture and hasattr(stream, '_live_stream'):
|
||||
_frame_live = stream._live_stream
|
||||
if is_picture and hasattr(stream, 'live_stream'):
|
||||
_frame_live = stream.live_stream
|
||||
_last_aux_time = 0.0
|
||||
_AUX_INTERVAL = 0.08 # send JPEG preview / brightness updates ~12 FPS
|
||||
|
||||
|
||||
@@ -132,6 +132,12 @@
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
.css-test-strip-axis {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.css-test-fire-btn {
|
||||
position: absolute;
|
||||
right: 6px;
|
||||
@@ -306,6 +312,27 @@
|
||||
}
|
||||
.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 */
|
||||
.css-test-led-control {
|
||||
display: flex;
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
ICON_LED, ICON_PALETTE, ICON_FPS, ICON_MAP_PIN, ICON_MUSIC,
|
||||
ICON_AUDIO_LOOPBACK, ICON_TIMER, ICON_LINK_SOURCE, ICON_FILM,
|
||||
ICON_LINK, ICON_SPARKLES, ICON_ACTIVITY, ICON_CLOCK, ICON_BELL, ICON_EYE,
|
||||
ICON_SUN_DIM,
|
||||
ICON_SUN_DIM, ICON_WARNING,
|
||||
} from '../core/icons.js';
|
||||
import * as P from '../core/icon-paths.js';
|
||||
import { wrapCard } from '../core/card-colors.js';
|
||||
@@ -2024,6 +2024,12 @@ function _cssTestConnect(sourceId, ledCount, fps) {
|
||||
// Build composite layer canvases
|
||||
if (_cssTestIsComposite) {
|
||||
_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 {
|
||||
const raw = new Uint8Array(event.data);
|
||||
@@ -2110,9 +2116,15 @@ function _cssTestBuildLayers(layerNames, sourceType, layerInfos) {
|
||||
const briLabel = hasBri
|
||||
? `<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">` +
|
||||
`<canvas class="css-test-layer-canvas" data-layer-idx="${i}"></canvas>` +
|
||||
`<span class="css-test-layer-label">${escapeHtml(layerNames[i])}</span>` +
|
||||
calLabel +
|
||||
briLabel +
|
||||
fireBtn +
|
||||
`</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() {
|
||||
for (const id of _cssTestNotificationIds) {
|
||||
testNotification(id);
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
<!-- Strip view (for generic sources) -->
|
||||
<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-axis" class="css-test-strip-axis"></canvas>
|
||||
<button id="css-test-fire-btn" class="css-test-fire-btn" style="display:none"
|
||||
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>
|
||||
@@ -40,6 +41,7 @@
|
||||
<!-- Composite layers view -->
|
||||
<div id="css-test-layers-view" style="display:none">
|
||||
<div id="css-test-layers" class="css-test-layers"></div>
|
||||
<canvas id="css-test-layers-axis" class="css-test-strip-axis"></canvas>
|
||||
</div>
|
||||
|
||||
<!-- LED count & FPS controls -->
|
||||
|
||||
Reference in New Issue
Block a user