Add adaptive brightness value source with time-of-day and scene modes
New "adaptive" value source type that automatically adjusts brightness based on external conditions. Two sub-modes: time-of-day (schedule-based interpolation with midnight wrap) and scene brightness (frame luminance analysis via numpy BT.601 subsampling with EMA smoothing). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,15 +1,15 @@
|
||||
/**
|
||||
* Value Sources — CRUD for scalar value sources (static, animated, audio).
|
||||
* Value Sources — CRUD for scalar value sources (static, animated, audio, adaptive).
|
||||
*
|
||||
* 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).
|
||||
* on LED targets. Four subtypes: static (constant), animated (waveform),
|
||||
* audio (audio-reactive), adaptive (time-of-day schedule or scene brightness).
|
||||
*
|
||||
* 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 { _cachedValueSources, set_cachedValueSources, _cachedAudioSources, _cachedStreams } from '../core/state.js';
|
||||
import { fetchWithAuth, escapeHtml } from '../core/api.js';
|
||||
import { t } from '../core/i18n.js';
|
||||
import { showToast, showConfirm } from '../core/ui.js';
|
||||
@@ -49,6 +49,16 @@ export async function showValueSourceModal(editData) {
|
||||
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 if (editData.source_type === 'adaptive') {
|
||||
document.getElementById('value-source-adaptive-mode').value = editData.adaptive_mode || 'time_of_day';
|
||||
onAdaptiveModeChange();
|
||||
_populateScheduleUI(editData.schedule);
|
||||
_populatePictureSourceDropdown(editData.picture_source_id || '');
|
||||
document.getElementById('value-source-scene-behavior').value = editData.scene_behavior || 'complement';
|
||||
_setSlider('value-source-scene-sensitivity', editData.sensitivity ?? 1.0);
|
||||
_setSlider('value-source-scene-smoothing', editData.smoothing ?? 0.3);
|
||||
_setSlider('value-source-adaptive-min-value', editData.min_value ?? 0);
|
||||
_setSlider('value-source-adaptive-max-value', editData.max_value ?? 1);
|
||||
}
|
||||
} else {
|
||||
document.getElementById('value-source-name').value = '';
|
||||
@@ -64,6 +74,15 @@ export async function showValueSourceModal(editData) {
|
||||
document.getElementById('value-source-mode').value = 'rms';
|
||||
_setSlider('value-source-sensitivity', 1.0);
|
||||
_setSlider('value-source-smoothing', 0.3);
|
||||
// Adaptive defaults
|
||||
document.getElementById('value-source-adaptive-mode').value = 'time_of_day';
|
||||
_populateScheduleUI([]);
|
||||
_populatePictureSourceDropdown('');
|
||||
document.getElementById('value-source-scene-behavior').value = 'complement';
|
||||
_setSlider('value-source-scene-sensitivity', 1.0);
|
||||
_setSlider('value-source-scene-smoothing', 0.3);
|
||||
_setSlider('value-source-adaptive-min-value', 0);
|
||||
_setSlider('value-source-adaptive-max-value', 1);
|
||||
}
|
||||
|
||||
valueSourceModal.open();
|
||||
@@ -78,6 +97,7 @@ export function onValueSourceTypeChange() {
|
||||
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';
|
||||
document.getElementById('value-source-adaptive-section').style.display = type === 'adaptive' ? '' : 'none';
|
||||
|
||||
// Populate audio dropdown when switching to audio type
|
||||
if (type === 'audio') {
|
||||
@@ -86,6 +106,18 @@ export function onValueSourceTypeChange() {
|
||||
_populateAudioSourceDropdown('');
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize adaptive sub-sections
|
||||
if (type === 'adaptive') {
|
||||
onAdaptiveModeChange();
|
||||
_populatePictureSourceDropdown('');
|
||||
}
|
||||
}
|
||||
|
||||
export function onAdaptiveModeChange() {
|
||||
const mode = document.getElementById('value-source-adaptive-mode').value;
|
||||
document.getElementById('value-source-tod-section').style.display = mode === 'time_of_day' ? '' : 'none';
|
||||
document.getElementById('value-source-scene-section').style.display = mode === 'scene' ? '' : 'none';
|
||||
}
|
||||
|
||||
// ── Save ──────────────────────────────────────────────────────
|
||||
@@ -117,6 +149,23 @@ export async function saveValueSource() {
|
||||
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);
|
||||
} else if (sourceType === 'adaptive') {
|
||||
payload.adaptive_mode = document.getElementById('value-source-adaptive-mode').value;
|
||||
payload.min_value = parseFloat(document.getElementById('value-source-adaptive-min-value').value);
|
||||
payload.max_value = parseFloat(document.getElementById('value-source-adaptive-max-value').value);
|
||||
if (payload.adaptive_mode === 'time_of_day') {
|
||||
payload.schedule = _getScheduleFromUI();
|
||||
if (payload.schedule.length < 2) {
|
||||
errorEl.textContent = t('value_source.error.schedule_min');
|
||||
errorEl.style.display = '';
|
||||
return;
|
||||
}
|
||||
} else if (payload.adaptive_mode === 'scene') {
|
||||
payload.picture_source_id = document.getElementById('value-source-picture-source').value;
|
||||
payload.scene_behavior = document.getElementById('value-source-scene-behavior').value;
|
||||
payload.sensitivity = parseFloat(document.getElementById('value-source-scene-sensitivity').value);
|
||||
payload.smoothing = parseFloat(document.getElementById('value-source-scene-smoothing').value);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -175,7 +224,7 @@ export async function deleteValueSource(sourceId) {
|
||||
// ── Card rendering (used by streams.js) ───────────────────────
|
||||
|
||||
export function createValueSourceCard(src) {
|
||||
const typeIcons = { static: '📊', animated: '🔄', audio: '🎵' };
|
||||
const typeIcons = { static: '📊', animated: '🔄', audio: '🎵', adaptive: '🌤️' };
|
||||
const icon = typeIcons[src.source_type] || '🎚️';
|
||||
|
||||
let propsHtml = '';
|
||||
@@ -196,6 +245,23 @@ export function createValueSourceCard(src) {
|
||||
<span class="stream-card-prop" title="${escapeHtml(t('value_source.audio_source'))}">${escapeHtml(audioName)}</span>
|
||||
<span class="stream-card-prop">${modeLabel.toUpperCase()}</span>
|
||||
`;
|
||||
} else if (src.source_type === 'adaptive') {
|
||||
if (src.adaptive_mode === 'scene') {
|
||||
const ps = _cachedStreams.find(s => s.id === src.picture_source_id);
|
||||
const psName = ps ? ps.name : (src.picture_source_id || '-');
|
||||
propsHtml = `
|
||||
<span class="stream-card-prop">${t('value_source.adaptive_mode.scene')}</span>
|
||||
<span class="stream-card-prop">${escapeHtml(psName)}</span>
|
||||
<span class="stream-card-prop">${src.scene_behavior || 'complement'}</span>
|
||||
`;
|
||||
} else {
|
||||
const pts = (src.schedule || []).length;
|
||||
propsHtml = `
|
||||
<span class="stream-card-prop">${t('value_source.adaptive_mode.time_of_day')}</span>
|
||||
<span class="stream-card-prop">${pts} ${t('value_source.schedule.points')}</span>
|
||||
<span class="stream-card-prop">${src.min_value ?? 0}–${src.max_value ?? 1}</span>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
return `
|
||||
@@ -232,3 +298,52 @@ function _populateAudioSourceDropdown(selectedId) {
|
||||
`<option value="${s.id}"${s.id === selectedId ? ' selected' : ''}>${escapeHtml(s.name)}</option>`
|
||||
).join('');
|
||||
}
|
||||
|
||||
// ── Adaptive helpers ──────────────────────────────────────────
|
||||
|
||||
function _populatePictureSourceDropdown(selectedId) {
|
||||
const select = document.getElementById('value-source-picture-source');
|
||||
if (!select) return;
|
||||
select.innerHTML = _cachedStreams.map(s =>
|
||||
`<option value="${s.id}"${s.id === selectedId ? ' selected' : ''}>${escapeHtml(s.name)}</option>`
|
||||
).join('');
|
||||
}
|
||||
|
||||
export function addSchedulePoint(time = '', value = 1.0) {
|
||||
const list = document.getElementById('value-source-schedule-list');
|
||||
if (!list) return;
|
||||
const row = document.createElement('div');
|
||||
row.className = 'schedule-row';
|
||||
row.innerHTML = `
|
||||
<input type="time" class="schedule-time" value="${time || '12:00'}">
|
||||
<input type="range" class="schedule-value" min="0" max="1" step="0.01" value="${value}"
|
||||
oninput="this.nextElementSibling.textContent = this.value">
|
||||
<span class="schedule-value-display">${value}</span>
|
||||
<button type="button" class="btn btn-icon btn-danger btn-sm" onclick="this.parentElement.remove()">✕</button>
|
||||
`;
|
||||
list.appendChild(row);
|
||||
}
|
||||
|
||||
function _getScheduleFromUI() {
|
||||
const rows = document.querySelectorAll('#value-source-schedule-list .schedule-row');
|
||||
const schedule = [];
|
||||
rows.forEach(row => {
|
||||
const time = row.querySelector('.schedule-time').value;
|
||||
const value = parseFloat(row.querySelector('.schedule-value').value);
|
||||
if (time) schedule.push({ time, value });
|
||||
});
|
||||
return schedule;
|
||||
}
|
||||
|
||||
function _populateScheduleUI(schedule) {
|
||||
const list = document.getElementById('value-source-schedule-list');
|
||||
if (!list) return;
|
||||
list.innerHTML = '';
|
||||
if (!schedule || schedule.length === 0) {
|
||||
// Default: morning bright, night dim
|
||||
addSchedulePoint('08:00', 1.0);
|
||||
addSchedulePoint('22:00', 0.3);
|
||||
} else {
|
||||
schedule.forEach(p => addSchedulePoint(p.time, p.value));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user