Introduce ColorStripSource as first-class entity

Extracts color processing and calibration out of WledPictureTarget into a
new PictureColorStripSource entity, enabling multiple LED targets to share
one capture/processing pipeline.

New entities & processing:
- storage/color_strip_source.py: ColorStripSource + PictureColorStripSource models
- storage/color_strip_store.py: JSON-backed CRUD store (prefix css_)
- core/processing/color_strip_stream.py: ColorStripStream ABC + PictureColorStripStream (runs border-extract → map → smooth → brightness/sat/gamma in background thread)
- core/processing/color_strip_stream_manager.py: ref-counted shared stream manager

Modified storage/processing:
- WledPictureTarget simplified to device_id + color_strip_source_id + standby_interval + state_check_interval
- Device model: calibration field removed
- WledTargetProcessor: acquires ColorStripStream from manager instead of running its own pipeline
- ProcessorManager: wires ColorStripStreamManager into TargetContext

API layer:
- New routes: GET/POST/PUT/DELETE /api/v1/color-strip-sources, PUT calibration/test
- Removed calibration endpoints from /devices
- Updated /picture-targets CRUD for new target structure

Frontend:
- New color-strips.js module with CSS editor modal and card rendering
- Calibration modal extended with CSS mode (css-id hidden field + device picker)
- targets.js: Color Strip Sources section added to LED tab; target editor/card updated
- app.js: imports and window globals for CSS + showCSSCalibration
- en.json / ru.json: color_strip.* and targets.section.color_strips keys added

Data migration runs at startup: existing WledPictureTargets are converted to
reference a new PictureColorStripSource created from their old settings.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-20 15:49:47 +03:00
parent c4e0257389
commit 7de3546b14
33 changed files with 2325 additions and 814 deletions

View File

@@ -14,6 +14,7 @@ import { showToast, showConfirm } from '../core/ui.js';
import { Modal } from '../core/modal.js';
import { createDeviceCard, attachDeviceListeners, fetchDeviceBrightness } from './devices.js';
import { createKCTargetCard, connectKCWebSocket, disconnectKCWebSocket } from './kc-targets.js';
import { createColorStripCard } from './color-strips.js';
// createPatternTemplateCard is imported via window.* to avoid circular deps
// (pattern-templates.js calls window.loadTargetsTab)
@@ -30,10 +31,7 @@ class TargetEditorModal extends Modal {
return {
name: document.getElementById('target-editor-name').value,
device: document.getElementById('target-editor-device').value,
source: document.getElementById('target-editor-source').value,
fps: document.getElementById('target-editor-fps').value,
interpolation: document.getElementById('target-editor-interpolation').value,
smoothing: document.getElementById('target-editor-smoothing').value,
css: document.getElementById('target-editor-css').value,
standby_interval: document.getElementById('target-editor-standby-interval').value,
};
}
@@ -47,11 +45,11 @@ function _autoGenerateTargetName() {
if (_targetNameManuallyEdited) return;
if (document.getElementById('target-editor-id').value) return;
const deviceSelect = document.getElementById('target-editor-device');
const sourceSelect = document.getElementById('target-editor-source');
const cssSelect = document.getElementById('target-editor-css');
const deviceName = deviceSelect.selectedOptions[0]?.dataset?.name || '';
const sourceName = sourceSelect.selectedOptions[0]?.dataset?.name || '';
if (!deviceName || !sourceName) return;
document.getElementById('target-editor-name').value = `${deviceName} \u00b7 ${sourceName}`;
const cssName = cssSelect.selectedOptions[0]?.dataset?.name || '';
if (!deviceName || !cssName) return;
document.getElementById('target-editor-name').value = `${deviceName} \u00b7 ${cssName}`;
}
function _updateStandbyVisibility() {
@@ -64,14 +62,14 @@ function _updateStandbyVisibility() {
export async function showTargetEditor(targetId = null) {
try {
// Load devices and sources for dropdowns
const [devicesResp, sourcesResp] = await Promise.all([
// Load devices and CSS sources for dropdowns
const [devicesResp, cssResp] = await Promise.all([
fetch(`${API_BASE}/devices`, { headers: getHeaders() }),
fetchWithAuth('/picture-sources'),
fetchWithAuth('/color-strip-sources'),
]);
const devices = devicesResp.ok ? (await devicesResp.json()).devices || [] : [];
const sources = sourcesResp.ok ? (await sourcesResp.json()).streams || [] : [];
const cssSources = cssResp.ok ? (await cssResp.json()).sources || [] : [];
set_targetEditorDevices(devices);
// Populate device select
@@ -87,16 +85,15 @@ export async function showTargetEditor(targetId = null) {
deviceSelect.appendChild(opt);
});
// Populate source select
const sourceSelect = document.getElementById('target-editor-source');
sourceSelect.innerHTML = '';
sources.forEach(s => {
// Populate color strip source select
const cssSelect = document.getElementById('target-editor-css');
cssSelect.innerHTML = '';
cssSources.forEach(s => {
const opt = document.createElement('option');
opt.value = s.id;
opt.dataset.name = s.name;
const typeIcon = s.stream_type === 'raw' ? '\uD83D\uDDA5\uFE0F' : s.stream_type === 'static_image' ? '\uD83D\uDDBC\uFE0F' : '\uD83C\uDFA8';
opt.textContent = `${typeIcon} ${s.name}`;
sourceSelect.appendChild(opt);
opt.textContent = `🎞️ ${s.name}`;
cssSelect.appendChild(opt);
});
if (targetId) {
@@ -108,24 +105,14 @@ export async function showTargetEditor(targetId = null) {
document.getElementById('target-editor-id').value = target.id;
document.getElementById('target-editor-name').value = target.name;
deviceSelect.value = target.device_id || '';
sourceSelect.value = target.picture_source_id || '';
document.getElementById('target-editor-fps').value = target.settings?.fps ?? 30;
document.getElementById('target-editor-fps-value').textContent = target.settings?.fps ?? 30;
document.getElementById('target-editor-interpolation').value = target.settings?.interpolation_mode ?? 'average';
document.getElementById('target-editor-smoothing').value = target.settings?.smoothing ?? 0.3;
document.getElementById('target-editor-smoothing-value').textContent = target.settings?.smoothing ?? 0.3;
document.getElementById('target-editor-standby-interval').value = target.settings?.standby_interval ?? 1.0;
document.getElementById('target-editor-standby-interval-value').textContent = target.settings?.standby_interval ?? 1.0;
cssSelect.value = target.color_strip_source_id || '';
document.getElementById('target-editor-standby-interval').value = target.standby_interval ?? 1.0;
document.getElementById('target-editor-standby-interval-value').textContent = target.standby_interval ?? 1.0;
document.getElementById('target-editor-title').textContent = t('targets.edit');
} else {
// Creating new target — first option is selected by default
document.getElementById('target-editor-id').value = '';
document.getElementById('target-editor-name').value = '';
document.getElementById('target-editor-fps').value = 30;
document.getElementById('target-editor-fps-value').textContent = '30';
document.getElementById('target-editor-interpolation').value = 'average';
document.getElementById('target-editor-smoothing').value = 0.3;
document.getElementById('target-editor-smoothing-value').textContent = '0.3';
document.getElementById('target-editor-standby-interval').value = 1.0;
document.getElementById('target-editor-standby-interval-value').textContent = '1.0';
document.getElementById('target-editor-title').textContent = t('targets.add');
@@ -135,7 +122,7 @@ export async function showTargetEditor(targetId = null) {
_targetNameManuallyEdited = !!targetId;
document.getElementById('target-editor-name').oninput = () => { _targetNameManuallyEdited = true; };
deviceSelect.onchange = () => { _updateStandbyVisibility(); _autoGenerateTargetName(); };
sourceSelect.onchange = () => _autoGenerateTargetName();
cssSelect.onchange = () => _autoGenerateTargetName();
if (!targetId) _autoGenerateTargetName();
// Show/hide standby interval based on selected device capabilities
@@ -168,10 +155,7 @@ export async function saveTargetEditor() {
const targetId = document.getElementById('target-editor-id').value;
const name = document.getElementById('target-editor-name').value.trim();
const deviceId = document.getElementById('target-editor-device').value;
const sourceId = document.getElementById('target-editor-source').value;
const fps = parseInt(document.getElementById('target-editor-fps').value) || 30;
const interpolation = document.getElementById('target-editor-interpolation').value;
const smoothing = parseFloat(document.getElementById('target-editor-smoothing').value);
const cssId = document.getElementById('target-editor-css').value;
const standbyInterval = parseFloat(document.getElementById('target-editor-standby-interval').value);
if (!name) {
@@ -182,13 +166,8 @@ export async function saveTargetEditor() {
const payload = {
name,
device_id: deviceId,
picture_source_id: sourceId,
settings: {
fps: fps,
interpolation_mode: interpolation,
smoothing: smoothing,
standby_interval: standbyInterval,
},
color_strip_source_id: cssId,
standby_interval: standbyInterval,
};
try {
@@ -243,10 +222,11 @@ export async function loadTargetsTab() {
if (!container) return;
try {
// Fetch devices, targets, sources, and pattern templates in parallel
const [devicesResp, targetsResp, sourcesResp, patResp] = await Promise.all([
// Fetch devices, targets, CSS sources, picture sources, and pattern templates in parallel
const [devicesResp, targetsResp, cssResp, psResp, patResp] = await Promise.all([
fetchWithAuth('/devices'),
fetchWithAuth('/picture-targets'),
fetchWithAuth('/color-strip-sources').catch(() => null),
fetchWithAuth('/picture-sources').catch(() => null),
fetchWithAuth('/pattern-templates').catch(() => null),
]);
@@ -257,10 +237,16 @@ export async function loadTargetsTab() {
const targetsData = await targetsResp.json();
const targets = targetsData.targets || [];
let sourceMap = {};
if (sourcesResp && sourcesResp.ok) {
const srcData = await sourcesResp.json();
(srcData.streams || []).forEach(s => { sourceMap[s.id] = s; });
let colorStripSourceMap = {};
if (cssResp && cssResp.ok) {
const cssData = await cssResp.json();
(cssData.sources || []).forEach(s => { colorStripSourceMap[s.id] = s; });
}
let pictureSourceMap = {};
if (psResp && psResp.ok) {
const psData = await psResp.json();
(psData.streams || []).forEach(s => { pictureSourceMap[s.id] = s; });
}
let patternTemplates = [];
@@ -320,7 +306,7 @@ export async function loadTargetsTab() {
if (activeSubTab === 'wled') activeSubTab = 'led';
const subTabs = [
{ key: 'led', icon: '\uD83D\uDCA1', titleKey: 'targets.subtab.led', count: ledDevices.length + ledTargets.length },
{ key: 'led', icon: '\uD83D\uDCA1', titleKey: 'targets.subtab.led', count: ledDevices.length + Object.keys(colorStripSourceMap).length + ledTargets.length },
{ key: 'key_colors', icon: '\uD83C\uDFA8', titleKey: 'targets.subtab.key_colors', count: kcTargets.length + patternTemplates.length },
];
@@ -331,7 +317,7 @@ export async function loadTargetsTab() {
// Use window.createPatternTemplateCard to avoid circular import
const createPatternTemplateCard = window.createPatternTemplateCard || (() => '');
// LED panel: devices section + targets section
// LED panel: devices section + color strip sources section + targets section
const ledPanel = `
<div class="target-sub-tab-panel stream-tab-panel${activeSubTab === 'led' ? ' active' : ''}" id="target-sub-tab-led">
<div class="subtab-section">
@@ -343,10 +329,19 @@ export async function loadTargetsTab() {
</div>
</div>
</div>
<div class="subtab-section">
<h3 class="subtab-section-header">${t('targets.section.color_strips')}</h3>
<div class="devices-grid">
${Object.values(colorStripSourceMap).map(s => createColorStripCard(s, pictureSourceMap)).join('')}
<div class="template-card add-template-card" onclick="showCSSEditor()">
<div class="add-template-icon">+</div>
</div>
</div>
</div>
<div class="subtab-section">
<h3 class="subtab-section-header">${t('targets.section.targets')}</h3>
<div class="devices-grid">
${ledTargets.map(target => createTargetCard(target, deviceMap, sourceMap)).join('')}
${ledTargets.map(target => createTargetCard(target, deviceMap, colorStripSourceMap)).join('')}
<div class="template-card add-template-card" onclick="showTargetEditor()">
<div class="add-template-icon">+</div>
</div>
@@ -360,7 +355,7 @@ export async function loadTargetsTab() {
<div class="subtab-section">
<h3 class="subtab-section-header">${t('targets.section.key_colors')}</h3>
<div class="devices-grid">
${kcTargets.map(target => createKCTargetCard(target, sourceMap, patternTemplateMap)).join('')}
${kcTargets.map(target => createKCTargetCard(target, pictureSourceMap, patternTemplateMap)).join('')}
<div class="template-card add-template-card" onclick="showKCEditor()">
<div class="add-template-icon">+</div>
</div>
@@ -422,17 +417,16 @@ export async function loadTargetsTab() {
}
}
export function createTargetCard(target, deviceMap, sourceMap) {
export function createTargetCard(target, deviceMap, colorStripSourceMap) {
const state = target.state || {};
const metrics = target.metrics || {};
const settings = target.settings || {};
const isProcessing = state.processing || false;
const device = deviceMap[target.device_id];
const source = sourceMap[target.picture_source_id];
const css = colorStripSourceMap[target.color_strip_source_id];
const deviceName = device ? device.name : (target.device_id || 'No device');
const sourceName = source ? source.name : (target.picture_source_id || 'No source');
const cssName = css ? css.name : (target.color_strip_source_id || 'No strip source');
// Health info from target state (forwarded from device)
const devOnline = state.device_online || false;
@@ -455,8 +449,7 @@ export function createTargetCard(target, deviceMap, sourceMap) {
</div>
<div class="stream-card-props">
<span class="stream-card-prop" title="${t('targets.device')}">💡 ${escapeHtml(deviceName)}</span>
<span class="stream-card-prop" title="${t('targets.fps')}"> ${settings.fps || 30}</span>
<span class="stream-card-prop stream-card-prop-full" title="${t('targets.source')}">📺 ${escapeHtml(sourceName)}</span>
<span class="stream-card-prop stream-card-prop-full" title="${t('targets.color_strip_source')}">🎞️ ${escapeHtml(cssName)}</span>
</div>
<div class="card-content">
${isProcessing ? `