Add value sources for dynamic brightness control on LED targets
Introduces a new Value Source entity that produces a scalar float (0.0-1.0) for dynamic brightness modulation. Three subtypes: Static (constant), Animated (sine/triangle/square/sawtooth waveform), and Audio-reactive (RMS/peak/beat from mono audio source). Value sources can be optionally attached to LED targets to control brightness each frame. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -108,6 +108,12 @@ import {
|
||||
editAudioSource, deleteAudioSource, onAudioSourceTypeChange,
|
||||
} from './features/audio-sources.js';
|
||||
|
||||
// Layer 5: value sources
|
||||
import {
|
||||
showValueSourceModal, closeValueSourceModal, saveValueSource,
|
||||
editValueSource, deleteValueSource, onValueSourceTypeChange,
|
||||
} from './features/value-sources.js';
|
||||
|
||||
// Layer 5: calibration
|
||||
import {
|
||||
showCalibration, closeCalibrationModal, forceCloseCalibrationModal, saveCalibration,
|
||||
@@ -317,6 +323,14 @@ Object.assign(window, {
|
||||
deleteAudioSource,
|
||||
onAudioSourceTypeChange,
|
||||
|
||||
// value sources
|
||||
showValueSourceModal,
|
||||
closeValueSourceModal,
|
||||
saveValueSource,
|
||||
editValueSource,
|
||||
deleteValueSource,
|
||||
onValueSourceTypeChange,
|
||||
|
||||
// calibration
|
||||
showCalibration,
|
||||
closeCalibrationModal,
|
||||
|
||||
@@ -169,6 +169,10 @@ export const PATTERN_RECT_BORDERS = [
|
||||
export let _cachedAudioSources = [];
|
||||
export function set_cachedAudioSources(v) { _cachedAudioSources = v; }
|
||||
|
||||
// Value sources
|
||||
export let _cachedValueSources = [];
|
||||
export function set_cachedValueSources(v) { _cachedValueSources = v; }
|
||||
|
||||
// Profiles
|
||||
export let _profilesCache = null;
|
||||
export function set_profilesCache(v) { _profilesCache = v; }
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
_currentTestPPTemplateId, set_currentTestPPTemplateId,
|
||||
_lastValidatedImageSource, set_lastValidatedImageSource,
|
||||
_cachedAudioSources, set_cachedAudioSources,
|
||||
_cachedValueSources, set_cachedValueSources,
|
||||
apiKey,
|
||||
} from '../core/state.js';
|
||||
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.js';
|
||||
@@ -27,6 +28,7 @@ import { Modal } from '../core/modal.js';
|
||||
import { showToast, showConfirm, openLightbox, openFullImageLightbox, showOverlaySpinner, hideOverlaySpinner } from '../core/ui.js';
|
||||
import { openDisplayPicker, formatDisplayLabel } from './displays.js';
|
||||
import { CardSection } from '../core/card-sections.js';
|
||||
import { createValueSourceCard } from './value-sources.js';
|
||||
|
||||
// ── Card section instances ──
|
||||
const csRawStreams = new CardSection('raw-streams', { titleKey: 'streams.section.streams', gridClass: 'templates-grid', addCardOnclick: "showAddStreamModal('raw')" });
|
||||
@@ -36,6 +38,7 @@ const csProcTemplates = new CardSection('proc-templates', { titleKey: 'postproce
|
||||
const csAudioMulti = new CardSection('audio-multi', { titleKey: 'audio_source.group.multichannel', gridClass: 'templates-grid', addCardOnclick: "showAudioSourceModal('multichannel')" });
|
||||
const csAudioMono = new CardSection('audio-mono', { titleKey: 'audio_source.group.mono', gridClass: 'templates-grid', addCardOnclick: "showAudioSourceModal('mono')" });
|
||||
const csStaticStreams = new CardSection('static-streams', { titleKey: 'streams.group.static_image', gridClass: 'templates-grid', addCardOnclick: "showAddStreamModal('static_image')" });
|
||||
const csValueSources = new CardSection('value-sources', { titleKey: 'value_source.group.title', gridClass: 'templates-grid', addCardOnclick: "showValueSourceModal()" });
|
||||
|
||||
// Re-render picture sources when language changes
|
||||
document.addEventListener('languageChanged', () => { if (apiKey) loadPictureSources(); });
|
||||
@@ -447,12 +450,13 @@ export async function deleteTemplate(templateId) {
|
||||
|
||||
export async function loadPictureSources() {
|
||||
try {
|
||||
const [filtersResp, ppResp, captResp, streamsResp, audioResp] = await Promise.all([
|
||||
const [filtersResp, ppResp, captResp, streamsResp, audioResp, valueResp] = await Promise.all([
|
||||
_availableFilters.length === 0 ? fetchWithAuth('/filters') : Promise.resolve(null),
|
||||
fetchWithAuth('/postprocessing-templates'),
|
||||
fetchWithAuth('/capture-templates'),
|
||||
fetchWithAuth('/picture-sources'),
|
||||
fetchWithAuth('/audio-sources'),
|
||||
fetchWithAuth('/value-sources'),
|
||||
]);
|
||||
|
||||
if (filtersResp && filtersResp.ok) {
|
||||
@@ -471,6 +475,10 @@ export async function loadPictureSources() {
|
||||
const ad = await audioResp.json();
|
||||
set_cachedAudioSources(ad.sources || []);
|
||||
}
|
||||
if (valueResp && valueResp.ok) {
|
||||
const vd = await valueResp.json();
|
||||
set_cachedValueSources(vd.sources || []);
|
||||
}
|
||||
if (!streamsResp.ok) throw new Error(`Failed to load streams: ${streamsResp.status}`);
|
||||
const data = await streamsResp.json();
|
||||
set_cachedStreams(data.streams || []);
|
||||
@@ -621,6 +629,7 @@ function renderPictureSourcesList(streams) {
|
||||
{ key: 'static_image', icon: '🖼️', titleKey: 'streams.group.static_image', count: staticImageStreams.length },
|
||||
{ key: 'processed', icon: '🎨', titleKey: 'streams.group.processed', count: processedStreams.length },
|
||||
{ key: 'audio', icon: '🔊', titleKey: 'streams.group.audio', count: _cachedAudioSources.length },
|
||||
{ key: 'value', icon: '🎚️', titleKey: 'streams.group.value', count: _cachedValueSources.length },
|
||||
];
|
||||
|
||||
const tabBar = `<div class="stream-tab-bar">${tabs.map(tab =>
|
||||
@@ -677,6 +686,8 @@ function renderPictureSourcesList(streams) {
|
||||
panelContent =
|
||||
csAudioMulti.render(multichannelSources.map(s => ({ key: s.id, html: renderAudioSourceCard(s) }))) +
|
||||
csAudioMono.render(monoSources.map(s => ({ key: s.id, html: renderAudioSourceCard(s) })));
|
||||
} else if (tab.key === 'value') {
|
||||
panelContent = csValueSources.render(_cachedValueSources.map(s => ({ key: s.id, html: createValueSourceCard(s) })));
|
||||
} else {
|
||||
panelContent = csStaticStreams.render(staticImageStreams.map(s => ({ key: s.id, html: renderStreamCard(s) })));
|
||||
}
|
||||
@@ -685,7 +696,7 @@ function renderPictureSourcesList(streams) {
|
||||
}).join('');
|
||||
|
||||
container.innerHTML = tabBar + panels;
|
||||
CardSection.bindAll([csRawStreams, csRawTemplates, csProcStreams, csProcTemplates, csAudioMulti, csAudioMono, csStaticStreams]);
|
||||
CardSection.bindAll([csRawStreams, csRawTemplates, csProcStreams, csProcTemplates, csAudioMulti, csAudioMono, csStaticStreams, csValueSources]);
|
||||
}
|
||||
|
||||
export function onStreamTypeChange() {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
_targetEditorDevices, set_targetEditorDevices,
|
||||
_deviceBrightnessCache,
|
||||
kcWebSockets,
|
||||
_cachedValueSources, set_cachedValueSources,
|
||||
} from '../core/state.js';
|
||||
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.js';
|
||||
import { t } from '../core/i18n.js';
|
||||
@@ -112,6 +113,7 @@ class TargetEditorModal extends Modal {
|
||||
name: document.getElementById('target-editor-name').value,
|
||||
device: document.getElementById('target-editor-device').value,
|
||||
css_source: document.getElementById('target-editor-css-source').value,
|
||||
brightness_vs: document.getElementById('target-editor-brightness-vs').value,
|
||||
fps: document.getElementById('target-editor-fps').value,
|
||||
keepalive_interval: document.getElementById('target-editor-keepalive-interval').value,
|
||||
};
|
||||
@@ -177,16 +179,32 @@ function _populateCssDropdown(selectedId = '') {
|
||||
).join('');
|
||||
}
|
||||
|
||||
function _populateBrightnessVsDropdown(selectedId = '') {
|
||||
const select = document.getElementById('target-editor-brightness-vs');
|
||||
let html = `<option value="">${t('targets.brightness_vs.none')}</option>`;
|
||||
_cachedValueSources.forEach(vs => {
|
||||
const typeIcons = { static: '📊', animated: '🔄', audio: '🎵' };
|
||||
const icon = typeIcons[vs.source_type] || '🎚️';
|
||||
html += `<option value="${vs.id}"${vs.id === selectedId ? ' selected' : ''}>${icon} ${escapeHtml(vs.name)}</option>`;
|
||||
});
|
||||
select.innerHTML = html;
|
||||
}
|
||||
|
||||
export async function showTargetEditor(targetId = null, cloneData = null) {
|
||||
try {
|
||||
// Load devices and CSS sources for dropdowns
|
||||
const [devicesResp, cssResp] = await Promise.all([
|
||||
// Load devices, CSS sources, and value sources for dropdowns
|
||||
const [devicesResp, cssResp, vsResp] = await Promise.all([
|
||||
fetch(`${API_BASE}/devices`, { headers: getHeaders() }),
|
||||
fetchWithAuth('/color-strip-sources'),
|
||||
fetchWithAuth('/value-sources'),
|
||||
]);
|
||||
|
||||
const devices = devicesResp.ok ? (await devicesResp.json()).devices || [] : [];
|
||||
const cssSources = cssResp.ok ? (await cssResp.json()).sources || [] : [];
|
||||
if (vsResp.ok) {
|
||||
const vsData = await vsResp.json();
|
||||
set_cachedValueSources(vsData.sources || []);
|
||||
}
|
||||
set_targetEditorDevices(devices);
|
||||
_editorCssSources = cssSources;
|
||||
|
||||
@@ -220,6 +238,7 @@ export async function showTargetEditor(targetId = null, cloneData = null) {
|
||||
document.getElementById('target-editor-title').textContent = t('targets.edit');
|
||||
|
||||
_populateCssDropdown(target.color_strip_source_id || '');
|
||||
_populateBrightnessVsDropdown(target.brightness_value_source_id || '');
|
||||
} else if (cloneData) {
|
||||
// Cloning — create mode but pre-filled from clone data
|
||||
document.getElementById('target-editor-id').value = '';
|
||||
@@ -233,6 +252,7 @@ export async function showTargetEditor(targetId = null, cloneData = null) {
|
||||
document.getElementById('target-editor-title').textContent = t('targets.add');
|
||||
|
||||
_populateCssDropdown(cloneData.color_strip_source_id || '');
|
||||
_populateBrightnessVsDropdown(cloneData.brightness_value_source_id || '');
|
||||
} else {
|
||||
// Creating new target
|
||||
document.getElementById('target-editor-id').value = '';
|
||||
@@ -244,6 +264,7 @@ export async function showTargetEditor(targetId = null, cloneData = null) {
|
||||
document.getElementById('target-editor-title').textContent = t('targets.add');
|
||||
|
||||
_populateCssDropdown('');
|
||||
_populateBrightnessVsDropdown('');
|
||||
}
|
||||
|
||||
// Auto-name generation
|
||||
@@ -296,10 +317,13 @@ export async function saveTargetEditor() {
|
||||
const fps = parseInt(document.getElementById('target-editor-fps').value) || 30;
|
||||
const colorStripSourceId = document.getElementById('target-editor-css-source').value;
|
||||
|
||||
const brightnessVsId = document.getElementById('target-editor-brightness-vs').value;
|
||||
|
||||
const payload = {
|
||||
name,
|
||||
device_id: deviceId,
|
||||
color_strip_source_id: colorStripSourceId,
|
||||
brightness_value_source_id: brightnessVsId,
|
||||
fps,
|
||||
keepalive_interval: standbyInterval,
|
||||
};
|
||||
|
||||
234
server/src/wled_controller/static/js/features/value-sources.js
Normal file
234
server/src/wled_controller/static/js/features/value-sources.js
Normal file
@@ -0,0 +1,234 @@
|
||||
/**
|
||||
* Value Sources — CRUD for scalar value sources (static, animated, audio).
|
||||
*
|
||||
* Value sources produce a float 0.0-1.0 used for dynamic brightness control
|
||||
* on LED targets. Three subtypes: static (constant), animated (waveform),
|
||||
* audio (audio-reactive).
|
||||
*
|
||||
* Card rendering is handled by streams.js (Value tab).
|
||||
* This module manages the editor modal and API operations.
|
||||
*/
|
||||
|
||||
import { _cachedValueSources, set_cachedValueSources, _cachedAudioSources } from '../core/state.js';
|
||||
import { fetchWithAuth, escapeHtml } from '../core/api.js';
|
||||
import { t } from '../core/i18n.js';
|
||||
import { showToast, showConfirm } from '../core/ui.js';
|
||||
import { Modal } from '../core/modal.js';
|
||||
import { loadPictureSources } from './streams.js';
|
||||
|
||||
const valueSourceModal = new Modal('value-source-modal');
|
||||
|
||||
// ── Modal ─────────────────────────────────────────────────────
|
||||
|
||||
export async function showValueSourceModal(editData) {
|
||||
const isEdit = !!editData;
|
||||
const titleKey = isEdit ? 'value_source.edit' : 'value_source.add';
|
||||
|
||||
document.getElementById('value-source-modal-title').textContent = t(titleKey);
|
||||
document.getElementById('value-source-id').value = isEdit ? editData.id : '';
|
||||
document.getElementById('value-source-error').style.display = 'none';
|
||||
|
||||
const typeSelect = document.getElementById('value-source-type');
|
||||
typeSelect.disabled = isEdit;
|
||||
|
||||
if (isEdit) {
|
||||
document.getElementById('value-source-name').value = editData.name || '';
|
||||
document.getElementById('value-source-description').value = editData.description || '';
|
||||
typeSelect.value = editData.source_type || 'static';
|
||||
onValueSourceTypeChange();
|
||||
|
||||
if (editData.source_type === 'static') {
|
||||
_setSlider('value-source-value', editData.value ?? 1.0);
|
||||
} else if (editData.source_type === 'animated') {
|
||||
document.getElementById('value-source-waveform').value = editData.waveform || 'sine';
|
||||
_setSlider('value-source-speed', editData.speed ?? 10);
|
||||
_setSlider('value-source-min-value', editData.min_value ?? 0);
|
||||
_setSlider('value-source-max-value', editData.max_value ?? 1);
|
||||
} else if (editData.source_type === 'audio') {
|
||||
_populateAudioSourceDropdown(editData.audio_source_id || '');
|
||||
document.getElementById('value-source-mode').value = editData.mode || 'rms';
|
||||
_setSlider('value-source-sensitivity', editData.sensitivity ?? 1.0);
|
||||
_setSlider('value-source-smoothing', editData.smoothing ?? 0.3);
|
||||
}
|
||||
} else {
|
||||
document.getElementById('value-source-name').value = '';
|
||||
document.getElementById('value-source-description').value = '';
|
||||
typeSelect.value = 'static';
|
||||
onValueSourceTypeChange();
|
||||
_setSlider('value-source-value', 1.0);
|
||||
_setSlider('value-source-speed', 10);
|
||||
_setSlider('value-source-min-value', 0);
|
||||
_setSlider('value-source-max-value', 1);
|
||||
document.getElementById('value-source-waveform').value = 'sine';
|
||||
_populateAudioSourceDropdown('');
|
||||
document.getElementById('value-source-mode').value = 'rms';
|
||||
_setSlider('value-source-sensitivity', 1.0);
|
||||
_setSlider('value-source-smoothing', 0.3);
|
||||
}
|
||||
|
||||
valueSourceModal.open();
|
||||
}
|
||||
|
||||
export function closeValueSourceModal() {
|
||||
valueSourceModal.forceClose();
|
||||
}
|
||||
|
||||
export function onValueSourceTypeChange() {
|
||||
const type = document.getElementById('value-source-type').value;
|
||||
document.getElementById('value-source-static-section').style.display = type === 'static' ? '' : 'none';
|
||||
document.getElementById('value-source-animated-section').style.display = type === 'animated' ? '' : 'none';
|
||||
document.getElementById('value-source-audio-section').style.display = type === 'audio' ? '' : 'none';
|
||||
|
||||
// Populate audio dropdown when switching to audio type
|
||||
if (type === 'audio') {
|
||||
const select = document.getElementById('value-source-audio-source');
|
||||
if (select && select.options.length === 0) {
|
||||
_populateAudioSourceDropdown('');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Save ──────────────────────────────────────────────────────
|
||||
|
||||
export async function saveValueSource() {
|
||||
const id = document.getElementById('value-source-id').value;
|
||||
const name = document.getElementById('value-source-name').value.trim();
|
||||
const sourceType = document.getElementById('value-source-type').value;
|
||||
const description = document.getElementById('value-source-description').value.trim() || null;
|
||||
const errorEl = document.getElementById('value-source-error');
|
||||
|
||||
if (!name) {
|
||||
errorEl.textContent = t('value_source.error.name_required');
|
||||
errorEl.style.display = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = { name, source_type: sourceType, description };
|
||||
|
||||
if (sourceType === 'static') {
|
||||
payload.value = parseFloat(document.getElementById('value-source-value').value);
|
||||
} else if (sourceType === 'animated') {
|
||||
payload.waveform = document.getElementById('value-source-waveform').value;
|
||||
payload.speed = parseFloat(document.getElementById('value-source-speed').value);
|
||||
payload.min_value = parseFloat(document.getElementById('value-source-min-value').value);
|
||||
payload.max_value = parseFloat(document.getElementById('value-source-max-value').value);
|
||||
} else if (sourceType === 'audio') {
|
||||
payload.audio_source_id = document.getElementById('value-source-audio-source').value;
|
||||
payload.mode = document.getElementById('value-source-mode').value;
|
||||
payload.sensitivity = parseFloat(document.getElementById('value-source-sensitivity').value);
|
||||
payload.smoothing = parseFloat(document.getElementById('value-source-smoothing').value);
|
||||
}
|
||||
|
||||
try {
|
||||
const method = id ? 'PUT' : 'POST';
|
||||
const url = id ? `/value-sources/${id}` : '/value-sources';
|
||||
const resp = await fetchWithAuth(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.detail || `HTTP ${resp.status}`);
|
||||
}
|
||||
showToast(t(id ? 'value_source.updated' : 'value_source.created'), 'success');
|
||||
valueSourceModal.forceClose();
|
||||
await loadPictureSources();
|
||||
} catch (e) {
|
||||
errorEl.textContent = e.message;
|
||||
errorEl.style.display = '';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Edit ──────────────────────────────────────────────────────
|
||||
|
||||
export async function editValueSource(sourceId) {
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/value-sources/${sourceId}`);
|
||||
if (!resp.ok) throw new Error('fetch failed');
|
||||
const data = await resp.json();
|
||||
await showValueSourceModal(data);
|
||||
} catch (e) {
|
||||
showToast(e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Delete ────────────────────────────────────────────────────
|
||||
|
||||
export async function deleteValueSource(sourceId) {
|
||||
const confirmed = await showConfirm(t('value_source.delete.confirm'));
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/value-sources/${sourceId}`, { method: 'DELETE' });
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.detail || `HTTP ${resp.status}`);
|
||||
}
|
||||
showToast(t('value_source.deleted'), 'success');
|
||||
await loadPictureSources();
|
||||
} catch (e) {
|
||||
showToast(e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Card rendering (used by streams.js) ───────────────────────
|
||||
|
||||
export function createValueSourceCard(src) {
|
||||
const typeIcons = { static: '📊', animated: '🔄', audio: '🎵' };
|
||||
const icon = typeIcons[src.source_type] || '🎚️';
|
||||
|
||||
let propsHtml = '';
|
||||
if (src.source_type === 'static') {
|
||||
propsHtml = `<span class="stream-card-prop">${t('value_source.type.static')}: ${src.value ?? 1.0}</span>`;
|
||||
} else if (src.source_type === 'animated') {
|
||||
const waveLabel = src.waveform || 'sine';
|
||||
propsHtml = `
|
||||
<span class="stream-card-prop">${escapeHtml(waveLabel)}</span>
|
||||
<span class="stream-card-prop">${src.speed ?? 10} cpm</span>
|
||||
<span class="stream-card-prop">${src.min_value ?? 0}–${src.max_value ?? 1}</span>
|
||||
`;
|
||||
} else if (src.source_type === 'audio') {
|
||||
const audioSrc = _cachedAudioSources.find(a => a.id === src.audio_source_id);
|
||||
const audioName = audioSrc ? audioSrc.name : (src.audio_source_id || '-');
|
||||
const modeLabel = src.mode || 'rms';
|
||||
propsHtml = `
|
||||
<span class="stream-card-prop" title="${escapeHtml(t('value_source.audio_source'))}">${escapeHtml(audioName)}</span>
|
||||
<span class="stream-card-prop">${modeLabel.toUpperCase()}</span>
|
||||
`;
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="template-card" data-id="${src.id}">
|
||||
<button class="card-remove-btn" onclick="deleteValueSource('${src.id}')" title="${t('common.delete')}">✕</button>
|
||||
<div class="template-card-header">
|
||||
<div class="template-name">${icon} ${escapeHtml(src.name)}</div>
|
||||
</div>
|
||||
<div class="stream-card-props">${propsHtml}</div>
|
||||
${src.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(src.description)}</div>` : ''}
|
||||
<div class="template-card-actions">
|
||||
<button class="btn btn-icon btn-secondary" onclick="editValueSource('${src.id}')" title="${t('common.edit')}">✏️</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────
|
||||
|
||||
function _setSlider(id, value) {
|
||||
const slider = document.getElementById(id);
|
||||
if (slider) {
|
||||
slider.value = value;
|
||||
const display = document.getElementById(id + '-display');
|
||||
if (display) display.textContent = value;
|
||||
}
|
||||
}
|
||||
|
||||
function _populateAudioSourceDropdown(selectedId) {
|
||||
const select = document.getElementById('value-source-audio-source');
|
||||
if (!select) return;
|
||||
const mono = _cachedAudioSources.filter(s => s.source_type === 'mono');
|
||||
select.innerHTML = mono.map(s =>
|
||||
`<option value="${s.id}"${s.id === selectedId ? ' selected' : ''}>${escapeHtml(s.name)}</option>`
|
||||
).join('');
|
||||
}
|
||||
Reference in New Issue
Block a user