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:
2026-02-24 12:19:40 +03:00
parent 27720e51aa
commit ef474fe275
26 changed files with 1704 additions and 14 deletions

View File

@@ -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() {