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:
2026-02-24 15:14:30 +03:00
parent 48651f0a4e
commit d339dd3f90
11 changed files with 643 additions and 19 deletions

View File

@@ -112,6 +112,7 @@ import {
import {
showValueSourceModal, closeValueSourceModal, saveValueSource,
editValueSource, deleteValueSource, onValueSourceTypeChange,
onAdaptiveModeChange, addSchedulePoint,
} from './features/value-sources.js';
// Layer 5: calibration
@@ -330,6 +331,8 @@ Object.assign(window, {
editValueSource,
deleteValueSource,
onValueSourceTypeChange,
onAdaptiveModeChange,
addSchedulePoint,
// calibration
showCalibration,

View File

@@ -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()">&#x2715;</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));
}
}

View File

@@ -774,10 +774,11 @@
"value_source.name.placeholder": "Brightness Pulse",
"value_source.name.hint": "A descriptive name for this value source",
"value_source.type": "Type:",
"value_source.type.hint": "Static outputs a constant value. Animated cycles through a waveform. Audio reacts to sound input.",
"value_source.type.hint": "Static outputs a constant value. Animated cycles through a waveform. Audio reacts to sound input. Adaptive adjusts based on time of day or scene brightness.",
"value_source.type.static": "Static",
"value_source.type.animated": "Animated",
"value_source.type.audio": "Audio",
"value_source.type.adaptive": "Adaptive",
"value_source.value": "Value:",
"value_source.value.hint": "Constant output value (0.0 = off, 1.0 = full brightness)",
"value_source.waveform": "Waveform:",
@@ -803,6 +804,25 @@
"value_source.sensitivity.hint": "Gain multiplier for the audio signal (higher = more reactive)",
"value_source.smoothing": "Smoothing:",
"value_source.smoothing.hint": "Temporal smoothing (0 = instant response, 1 = very smooth/slow)",
"value_source.adaptive_mode": "Adaptive Mode:",
"value_source.adaptive_mode.hint": "Time of Day adjusts brightness on a daily schedule. Scene analyzes picture brightness in real time.",
"value_source.adaptive_mode.time_of_day": "Time of Day",
"value_source.adaptive_mode.scene": "Scene Brightness",
"value_source.schedule": "Schedule:",
"value_source.schedule.hint": "Define at least 2 time points. Brightness interpolates linearly between them, wrapping at midnight.",
"value_source.schedule.add": "+ Add Point",
"value_source.schedule.points": "points",
"value_source.picture_source": "Picture Source:",
"value_source.picture_source.hint": "The picture source whose frames will be analyzed for average brightness.",
"value_source.scene_behavior": "Behavior:",
"value_source.scene_behavior.hint": "Complement: dark scene = high brightness (ideal for ambient backlight). Match: bright scene = high brightness.",
"value_source.scene_behavior.complement": "Complement (dark → bright)",
"value_source.scene_behavior.match": "Match (bright → bright)",
"value_source.adaptive_min_value": "Min Value:",
"value_source.adaptive_min_value.hint": "Minimum output brightness",
"value_source.adaptive_max_value": "Max Value:",
"value_source.adaptive_max_value.hint": "Maximum output brightness",
"value_source.error.schedule_min": "Schedule requires at least 2 time points",
"value_source.description": "Description (optional):",
"value_source.description.placeholder": "Describe this value source...",
"value_source.description.hint": "Optional notes about this value source",

View File

@@ -774,10 +774,11 @@
"value_source.name.placeholder": "Пульс яркости",
"value_source.name.hint": "Описательное имя для этого источника значений",
"value_source.type": "Тип:",
"value_source.type.hint": "Статический выдаёт постоянное значение. Анимированный циклически меняет форму волны. Аудио реагирует на звук.",
"value_source.type.hint": "Статический выдаёт постоянное значение. Анимированный циклически меняет форму волны. Аудио реагирует на звук. Адаптивный подстраивается под время суток или яркость сцены.",
"value_source.type.static": "Статический",
"value_source.type.animated": "Анимированный",
"value_source.type.audio": "Аудио",
"value_source.type.adaptive": "Адаптивный",
"value_source.value": "Значение:",
"value_source.value.hint": "Постоянное выходное значение (0.0 = выкл, 1.0 = полная яркость)",
"value_source.waveform": "Форма волны:",
@@ -803,6 +804,25 @@
"value_source.sensitivity.hint": "Множитель усиления аудиосигнала (выше = более реактивный)",
"value_source.smoothing": "Сглаживание:",
"value_source.smoothing.hint": "Временное сглаживание (0 = мгновенный отклик, 1 = очень плавный/медленный)",
"value_source.adaptive_mode": "Адаптивный режим:",
"value_source.adaptive_mode.hint": "Время суток регулирует яркость по дневному расписанию. Сцена анализирует яркость изображения в реальном времени.",
"value_source.adaptive_mode.time_of_day": "Время суток",
"value_source.adaptive_mode.scene": "Яркость сцены",
"value_source.schedule": "Расписание:",
"value_source.schedule.hint": "Определите минимум 2 временные точки. Яркость линейно интерполируется между ними, с переходом через полночь.",
"value_source.schedule.add": "+ Добавить точку",
"value_source.schedule.points": "точек",
"value_source.picture_source": "Источник изображения:",
"value_source.picture_source.hint": "Источник изображения, кадры которого будут анализироваться на среднюю яркость.",
"value_source.scene_behavior": "Поведение:",
"value_source.scene_behavior.hint": "Дополнение: тёмная сцена = высокая яркость (для фоновой подсветки). Совпадение: яркая сцена = высокая яркость.",
"value_source.scene_behavior.complement": "Дополнение (тёмный → ярко)",
"value_source.scene_behavior.match": "Совпадение (яркий → ярко)",
"value_source.adaptive_min_value": "Мин. значение:",
"value_source.adaptive_min_value.hint": "Минимальная выходная яркость",
"value_source.adaptive_max_value": "Макс. значение:",
"value_source.adaptive_max_value.hint": "Максимальная выходная яркость",
"value_source.error.schedule_min": "Расписание требует минимум 2 временные точки",
"value_source.description": "Описание (необязательно):",
"value_source.description.placeholder": "Опишите этот источник значений...",
"value_source.description.hint": "Необязательные заметки об этом источнике значений",