feat: BindableFloat — universal value source binding for all scalar properties
Lint & Test / test (push) Successful in 1m20s

Introduce BindableFloat abstraction that allows any numeric property to be
either a static value or dynamically driven by a ValueSource. Backward-compatible
serialization: plain float when unbound, {value, source_id} dict when bound.

Backend:
- storage/bindable.py — BindableFloat dataclass + bfloat() helper
- 25+ scalar properties converted across all entity types
- Runtime VS acquisition in ColorStripStreamManager for CSS bindings
- All stream hot loops use self.resolve() for live values
- KeyColorsColorStripStream now inherits ColorStripStream

Frontend:
- BindableScalarWidget (slider + VS picker toggle) for all editors
- TypeScript BindableFloat type + helpers
- Graph editor edges for all bindable properties
- Audio source channel IconSelect grid

Fixes: daylight longitude, candlelight wind_strength/candle_type from_dict
This commit is contained in:
2026-03-29 00:33:24 +03:00
parent 5f70302263
commit 8a17bb5caa
48 changed files with 2512 additions and 887 deletions
@@ -30,6 +30,8 @@ class AudioSourceModal extends Modal {
onForceClose() {
if (_audioSourceTagsInput) { _audioSourceTagsInput.destroy(); _audioSourceTagsInput = null; }
if (_asChannelIconSelect) { _asChannelIconSelect.destroy(); _asChannelIconSelect = null; }
if (_asBandIconSelect) { _asBandIconSelect.destroy(); _asBandIconSelect = null; }
}
snapshotValues() {
@@ -58,6 +60,7 @@ let _asDeviceEntitySelect: EntitySelect | null = null;
let _asParentEntitySelect: EntitySelect | null = null;
let _asBandParentEntitySelect: EntitySelect | null = null;
let _asBandIconSelect: IconSelect | null = null;
let _asChannelIconSelect: IconSelect | null = null;
const _svg = (d: string): string => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
@@ -136,7 +139,7 @@ export async function showAudioSourceModal(sourceType: any, editData?: any) {
} else if (editData.source_type === 'mono') {
_loadMultichannelSources(editData.audio_source_id);
(document.getElementById('audio-source-channel') as HTMLSelectElement).value = editData.channel || 'mono';
(document.getElementById('audio-source-channel') as HTMLSelectElement).onchange = () => _autoGenerateAudioSourceName();
_ensureChannelIconSelect();
} else if (editData.source_type === 'band_extract') {
_loadBandParentSources(editData.audio_source_id);
(document.getElementById('audio-source-band') as HTMLSelectElement).value = editData.band || 'bass';
@@ -155,7 +158,7 @@ export async function showAudioSourceModal(sourceType: any, editData?: any) {
await _loadAudioDevices();
} else if (sourceType === 'mono') {
_loadMultichannelSources();
(document.getElementById('audio-source-channel') as HTMLSelectElement).onchange = () => _autoGenerateAudioSourceName();
_ensureChannelIconSelect();
} else if (sourceType === 'band_extract') {
_loadBandParentSources();
(document.getElementById('audio-source-band') as HTMLSelectElement).value = 'bass';
@@ -426,6 +429,28 @@ function _ensureBandIconSelect() {
});
}
const _icon = (d: string) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
function _ensureChannelIconSelect() {
const sel = document.getElementById('audio-source-channel') as HTMLSelectElement | null;
if (!sel) return;
const items = [
{ value: 'mono', icon: _icon(P.headphones), label: t('audio_source.channel.mono'), desc: t('audio_source.channel.mono.desc') },
{ value: 'left', icon: _icon(P.volume2), label: t('audio_source.channel.left'), desc: t('audio_source.channel.left.desc') },
{ value: 'right', icon: _icon(P.volume2), label: t('audio_source.channel.right'), desc: t('audio_source.channel.right.desc') },
];
if (_asChannelIconSelect) {
_asChannelIconSelect.updateItems(items);
return;
}
_asChannelIconSelect = new IconSelect({
target: sel,
items,
columns: 3,
onChange: () => _autoGenerateAudioSourceName(),
});
}
function _loadBandParentSources(selectedId?: any) {
const select = document.getElementById('audio-source-band-parent') as HTMLSelectElement | null;
if (!select) return;