CSS: add StaticColorStripSource type with auto-sized LED count

Introduces a new 'static' source type that fills all device LEDs with a
single constant RGB color — no screen capture or processing required.

- StaticColorStripSource storage model (color + led_count=0 auto-size)
- StaticColorStripStream: no background thread, configure() sizes to device
  LED count at processor start; hot-updates preserve runtime size
- ColorStripStreamManager dispatches static sources (no LiveStream needed)
- WledTargetProcessor calls stream.configure(device_led_count) on start
- API schemas/routes: source_type Literal["picture","static"]; color field;
  overlay/calibration-test endpoints return 400 for static
- Frontend: type selector modal, color picker, type-aware card rendering
  (🎨 icon + color swatch), LED count field hidden for static type
- Locale keys: color_strip.type, color_strip.static_color (en + ru)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-20 17:49:48 +03:00
parent 0a23cb7043
commit 2a8e2daefc
12 changed files with 430 additions and 155 deletions

View File

@@ -90,6 +90,7 @@ import {
// Layer 5: color-strip sources
import {
showCSSEditor, closeCSSEditorModal, forceCSSEditorClose, saveCSSEditor, deleteColorStrip,
onCSSTypeChange,
} from './features/color-strips.js';
// Layer 5: calibration
@@ -274,6 +275,7 @@ Object.assign(window, {
forceCSSEditorClose,
saveCSSEditor,
deleteColorStrip,
onCSSTypeChange,
// calibration
showCalibration,

View File

@@ -13,8 +13,10 @@ class CSSEditorModal extends Modal {
}
snapshotValues() {
const type = document.getElementById('css-editor-type').value;
return {
name: document.getElementById('css-editor-name').value,
type,
picture_source: document.getElementById('css-editor-picture-source').value,
fps: document.getElementById('css-editor-fps').value,
interpolation: document.getElementById('css-editor-interpolation').value,
@@ -22,39 +24,81 @@ class CSSEditorModal extends Modal {
brightness: document.getElementById('css-editor-brightness').value,
saturation: document.getElementById('css-editor-saturation').value,
gamma: document.getElementById('css-editor-gamma').value,
led_count: document.getElementById('css-editor-led-count').value,
color: document.getElementById('css-editor-color').value,
led_count: type === 'static' ? '0' : document.getElementById('css-editor-led-count').value,
};
}
}
const cssEditorModal = new CSSEditorModal();
/* ── Type-switch helper ───────────────────────────────────────── */
export function onCSSTypeChange() {
const type = document.getElementById('css-editor-type').value;
document.getElementById('css-editor-picture-section').style.display = type === 'picture' ? '' : 'none';
document.getElementById('css-editor-static-section').style.display = type === 'static' ? '' : 'none';
// LED count is only meaningful for picture sources; static uses device LED count automatically
document.getElementById('css-editor-led-count-group').style.display = type === 'static' ? 'none' : '';
}
/** Convert an [R, G, B] array to a CSS hex color string like "#rrggbb". */
function rgbArrayToHex(rgb) {
if (!Array.isArray(rgb) || rgb.length !== 3) return '#ffffff';
return '#' + rgb.map(v => Math.max(0, Math.min(255, v)).toString(16).padStart(2, '0')).join('');
}
/** Convert a CSS hex string like "#rrggbb" to an [R, G, B] array. */
function hexToRgbArray(hex) {
const m = /^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(hex);
return m ? [parseInt(m[1], 16), parseInt(m[2], 16), parseInt(m[3], 16)] : [255, 255, 255];
}
/* ── Card ─────────────────────────────────────────────────────── */
export function createColorStripCard(source, pictureSourceMap) {
const srcName = (pictureSourceMap && pictureSourceMap[source.picture_source_id])
? pictureSourceMap[source.picture_source_id].name
: source.picture_source_id || '—';
const cal = source.calibration || {};
const calLeds = (cal.leds_top || 0) + (cal.leds_right || 0) + (cal.leds_bottom || 0) + (cal.leds_left || 0);
const ledCount = (source.led_count > 0) ? source.led_count : calLeds;
const isStatic = source.source_type === 'static';
let propsHtml;
if (isStatic) {
const hexColor = rgbArrayToHex(source.color);
propsHtml = `
<span class="stream-card-prop" title="${t('color_strip.static_color')}">
<span style="display:inline-block;width:14px;height:14px;background:${hexColor};border:1px solid #888;border-radius:2px;vertical-align:middle;margin-right:4px"></span>${hexColor.toUpperCase()}
</span>
${source.led_count ? `<span class="stream-card-prop" title="${t('color_strip.leds')}">💡 ${source.led_count}</span>` : ''}
`;
} else {
const srcName = (pictureSourceMap && pictureSourceMap[source.picture_source_id])
? pictureSourceMap[source.picture_source_id].name
: source.picture_source_id || '—';
const cal = source.calibration || {};
const calLeds = (cal.leds_top || 0) + (cal.leds_right || 0) + (cal.leds_bottom || 0) + (cal.leds_left || 0);
const ledCount = (source.led_count > 0) ? source.led_count : calLeds;
propsHtml = `
<span class="stream-card-prop" title="${t('color_strip.fps')}">⚡ ${source.fps || 30} fps</span>
${ledCount ? `<span class="stream-card-prop" title="${t('color_strip.leds')}">💡 ${ledCount}</span>` : ''}
<span class="stream-card-prop stream-card-prop-full" title="${t('color_strip.picture_source')}">📺 ${escapeHtml(srcName)}</span>
`;
}
const icon = isStatic ? '🎨' : '🎞️';
const calibrationBtn = isStatic ? '' : `<button class="btn btn-icon btn-secondary" onclick="showCSSCalibration('${source.id}')" title="${t('calibration.title')}">📐</button>`;
return `
<div class="card" data-css-id="${source.id}">
<button class="card-remove-btn" onclick="deleteColorStrip('${source.id}')" title="${t('common.delete')}">&#x2715;</button>
<div class="card-header">
<div class="card-title">
🎞️ ${escapeHtml(source.name)}
${icon} ${escapeHtml(source.name)}
</div>
</div>
<div class="stream-card-props">
<span class="stream-card-prop" title="${t('color_strip.fps')}">⚡ ${source.fps || 30} fps</span>
${ledCount ? `<span class="stream-card-prop" title="${t('color_strip.leds')}">💡 ${ledCount}</span>` : ''}
<span class="stream-card-prop stream-card-prop-full" title="${t('color_strip.picture_source')}">📺 ${escapeHtml(srcName)}</span>
${propsHtml}
</div>
<div class="card-actions">
<button class="btn btn-icon btn-secondary" onclick="showCSSEditor('${source.id}')" title="${t('common.edit')}">✏️</button>
<button class="btn btn-icon btn-secondary" onclick="showCSSCalibration('${source.id}')" title="${t('calibration.title')}">📐</button>
${calibrationBtn}
</div>
</div>
`;
@@ -85,36 +129,46 @@ export async function showCSSEditor(cssId = null) {
document.getElementById('css-editor-id').value = css.id;
document.getElementById('css-editor-name').value = css.name;
sourceSelect.value = css.picture_source_id || '';
const fps = css.fps ?? 30;
document.getElementById('css-editor-fps').value = fps;
document.getElementById('css-editor-fps-value').textContent = fps;
const sourceType = css.source_type || 'picture';
document.getElementById('css-editor-type').value = sourceType;
onCSSTypeChange();
document.getElementById('css-editor-interpolation').value = css.interpolation_mode || 'average';
if (sourceType === 'static') {
document.getElementById('css-editor-color').value = rgbArrayToHex(css.color);
} else {
sourceSelect.value = css.picture_source_id || '';
const smoothing = css.smoothing ?? 0.3;
document.getElementById('css-editor-smoothing').value = smoothing;
document.getElementById('css-editor-smoothing-value').textContent = parseFloat(smoothing).toFixed(2);
const fps = css.fps ?? 30;
document.getElementById('css-editor-fps').value = fps;
document.getElementById('css-editor-fps-value').textContent = fps;
const brightness = css.brightness ?? 1.0;
document.getElementById('css-editor-brightness').value = brightness;
document.getElementById('css-editor-brightness-value').textContent = parseFloat(brightness).toFixed(2);
document.getElementById('css-editor-interpolation').value = css.interpolation_mode || 'average';
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 smoothing = css.smoothing ?? 0.3;
document.getElementById('css-editor-smoothing').value = smoothing;
document.getElementById('css-editor-smoothing-value').textContent = parseFloat(smoothing).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);
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-led-count').value = css.led_count ?? 0;
document.getElementById('css-editor-title').textContent = t('color_strip.edit');
} else {
document.getElementById('css-editor-id').value = '';
document.getElementById('css-editor-name').value = '';
document.getElementById('css-editor-type').value = 'picture';
onCSSTypeChange();
document.getElementById('css-editor-fps').value = 30;
document.getElementById('css-editor-fps-value').textContent = '30';
document.getElementById('css-editor-interpolation').value = 'average';
@@ -126,6 +180,7 @@ export async function showCSSEditor(cssId = null) {
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-color').value = '#ffffff';
document.getElementById('css-editor-led-count').value = 0;
document.getElementById('css-editor-title').textContent = t('color_strip.add');
}
@@ -150,23 +205,38 @@ export function isCSSEditorDirty() { return cssEditorModal.isDirty(); }
export async function saveCSSEditor() {
const cssId = document.getElementById('css-editor-id').value;
const name = document.getElementById('css-editor-name').value.trim();
const sourceType = document.getElementById('css-editor-type').value;
if (!name) {
cssEditorModal.showError(t('color_strip.error.name_required'));
return;
}
const payload = {
name,
picture_source_id: document.getElementById('css-editor-picture-source').value,
fps: parseInt(document.getElementById('css-editor-fps').value) || 30,
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),
led_count: parseInt(document.getElementById('css-editor-led-count').value) || 0,
};
let payload;
if (sourceType === 'static') {
payload = {
name,
color: hexToRgbArray(document.getElementById('css-editor-color').value),
};
if (!cssId) {
payload.source_type = 'static';
}
} else {
payload = {
name,
picture_source_id: document.getElementById('css-editor-picture-source').value,
fps: parseInt(document.getElementById('css-editor-fps').value) || 30,
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),
led_count: parseInt(document.getElementById('css-editor-led-count').value) || 0,
};
if (!cssId) {
payload.source_type = 'picture';
}
}
try {
let response;
@@ -176,7 +246,6 @@ export async function saveCSSEditor() {
body: JSON.stringify(payload),
});
} else {
payload.source_type = 'picture';
response = await fetchWithAuth('/color-strip-sources', {
method: 'POST',
body: JSON.stringify(payload),