Add 5 procedural LED effects, gradient presets, auto-crop min aspect ratio, static source polling optimization

New features:
- Procedural effect source type with fire, meteor, plasma, noise, and aurora algorithms
  using palette LUT system and 1D value noise generator
- 12 predefined gradient presets (rainbow, sunset, ocean, forest, fire, lava, aurora,
  ice, warm, cool, neon, pastel) selectable from a dropdown in the gradient editor
- Auto-crop filter: min aspect ratio parameter to prevent false-positive cropping
  in dark scenes on ultrawide displays

Optimization:
- Static/gradient sources without animation: stream thread sleeps 0.25s instead of
  frame_time; processor repolls at frame_time instead of 5ms (~40x fewer iterations)
- Inverted isinstance checks in routes to test for PictureColorStripSource only

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-23 01:03:16 +03:00
parent 9392741f08
commit a4083764fb
14 changed files with 1033 additions and 19 deletions

View File

@@ -89,7 +89,8 @@ import {
// Layer 5: color-strip sources
import {
showCSSEditor, closeCSSEditorModal, forceCSSEditorClose, saveCSSEditor, deleteColorStrip,
onCSSTypeChange, onAnimationTypeChange, colorCycleAddColor, colorCycleRemoveColor,
onCSSTypeChange, onEffectTypeChange, onAnimationTypeChange, colorCycleAddColor, colorCycleRemoveColor,
applyGradientPreset,
} from './features/color-strips.js';
// Layer 5: calibration
@@ -274,9 +275,11 @@ Object.assign(window, {
saveCSSEditor,
deleteColorStrip,
onCSSTypeChange,
onEffectTypeChange,
onAnimationTypeChange,
colorCycleAddColor,
colorCycleRemoveColor,
applyGradientPreset,
// calibration
showCalibration,

View File

@@ -31,6 +31,13 @@ class CSSEditorModal extends Modal {
animation_speed: document.getElementById('css-editor-animation-speed').value,
cycle_speed: document.getElementById('css-editor-cycle-speed').value,
cycle_colors: JSON.stringify(_colorCycleColors),
effect_type: document.getElementById('css-editor-effect-type').value,
effect_speed: document.getElementById('css-editor-effect-speed').value,
effect_palette: document.getElementById('css-editor-effect-palette').value,
effect_color: document.getElementById('css-editor-effect-color').value,
effect_intensity: document.getElementById('css-editor-effect-intensity').value,
effect_scale: document.getElementById('css-editor-effect-scale').value,
effect_mirror: document.getElementById('css-editor-effect-mirror').checked,
};
}
}
@@ -45,8 +52,11 @@ export function onCSSTypeChange() {
document.getElementById('css-editor-static-section').style.display = type === 'static' ? '' : 'none';
document.getElementById('css-editor-color-cycle-section').style.display = type === 'color_cycle' ? '' : 'none';
document.getElementById('css-editor-gradient-section').style.display = type === 'gradient' ? '' : 'none';
document.getElementById('css-editor-effect-section').style.display = type === 'effect' ? '' : 'none';
// Animation section — shown for static/gradient only (color_cycle is always animating)
if (type === 'effect') onEffectTypeChange();
// Animation section — shown for static/gradient only
const animSection = document.getElementById('css-editor-animation-section');
const animTypeSelect = document.getElementById('css-editor-animation-type');
const noneOpt = `<option value="none">${t('color_strip.animation.type.none')}</option>`;
@@ -119,6 +129,31 @@ function _syncAnimationSpeedState() {
}
}
/* ── Effect type helpers ──────────────────────────────────────── */
export function onEffectTypeChange() {
const et = document.getElementById('css-editor-effect-type').value;
// palette: all except meteor
document.getElementById('css-editor-effect-palette-group').style.display = et !== 'meteor' ? '' : 'none';
// color picker: meteor only
document.getElementById('css-editor-effect-color-group').style.display = et === 'meteor' ? '' : 'none';
// intensity: fire, meteor, aurora
document.getElementById('css-editor-effect-intensity-group').style.display =
['fire', 'meteor', 'aurora'].includes(et) ? '' : 'none';
// scale: plasma, noise, aurora
document.getElementById('css-editor-effect-scale-group').style.display =
['plasma', 'noise', 'aurora'].includes(et) ? '' : 'none';
// mirror: meteor only
document.getElementById('css-editor-effect-mirror-group').style.display = et === 'meteor' ? '' : 'none';
// description
const descEl = document.getElementById('css-editor-effect-type-desc');
if (descEl) {
const desc = t('color_strip.effect.' + et + '.desc') || '';
descEl.textContent = desc;
descEl.style.display = desc ? '' : 'none';
}
}
/* ── Color Cycle helpers ──────────────────────────────────────── */
const _DEFAULT_CYCLE_COLORS = ['#ff0000', '#ffff00', '#00ff00', '#00ffff', '#0000ff', '#ff00ff'];
@@ -197,6 +232,7 @@ export function createColorStripCard(source, pictureSourceMap) {
const isStatic = source.source_type === 'static';
const isGradient = source.source_type === 'gradient';
const isColorCycle = source.source_type === 'color_cycle';
const isEffect = source.source_type === 'effect';
const anim = (isStatic || isGradient) && source.animation && source.animation.enabled ? source.animation : null;
const animBadge = anim
@@ -246,6 +282,15 @@ export function createColorStripCard(source, pictureSourceMap) {
${source.led_count ? `<span class="stream-card-prop" title="${t('color_strip.leds')}">💡 ${source.led_count}</span>` : ''}
${animBadge}
`;
} else if (isEffect) {
const effectLabel = t('color_strip.effect.' + (source.effect_type || 'fire')) || source.effect_type || 'fire';
const paletteLabel = source.palette ? (t('color_strip.palette.' + source.palette) || source.palette) : '';
propsHtml = `
<span class="stream-card-prop">⚡ ${escapeHtml(effectLabel)}</span>
${paletteLabel ? `<span class="stream-card-prop" title="${t('color_strip.effect.palette')}">🎨 ${escapeHtml(paletteLabel)}</span>` : ''}
<span class="stream-card-prop" title="${t('color_strip.effect.speed')}">⏩ ${(source.speed || 1.0).toFixed(1)}×</span>
${source.led_count ? `<span class="stream-card-prop" title="${t('color_strip.leds')}">💡 ${source.led_count}</span>` : ''}
`;
} else {
const srcName = (pictureSourceMap && pictureSourceMap[source.picture_source_id])
? pictureSourceMap[source.picture_source_id].name
@@ -259,8 +304,8 @@ export function createColorStripCard(source, pictureSourceMap) {
`;
}
const icon = isStatic ? '🎨' : isColorCycle ? '🔄' : isGradient ? '🌈' : '🎞️';
const calibrationBtn = (!isStatic && !isGradient && !isColorCycle)
const icon = isStatic ? '🎨' : isColorCycle ? '🔄' : isGradient ? '🌈' : isEffect ? '⚡' : '🎞️';
const calibrationBtn = (!isStatic && !isGradient && !isColorCycle && !isEffect)
? `<button class="btn btn-icon btn-secondary" onclick="showCSSCalibration('${source.id}')" title="${t('calibration.title')}">📐</button>`
: '';
@@ -319,11 +364,24 @@ export async function showCSSEditor(cssId = null) {
} else if (sourceType === 'color_cycle') {
_loadColorCycleState(css);
} else if (sourceType === 'gradient') {
document.getElementById('css-editor-gradient-preset').value = '';
gradientInit(css.stops || [
{ position: 0.0, color: [255, 0, 0] },
{ position: 1.0, color: [0, 0, 255] },
]);
_loadAnimationState(css.animation);
} else if (sourceType === 'effect') {
document.getElementById('css-editor-effect-type').value = css.effect_type || 'fire';
onEffectTypeChange();
document.getElementById('css-editor-effect-speed').value = css.speed ?? 1.0;
document.getElementById('css-editor-effect-speed-val').textContent = parseFloat(css.speed ?? 1.0).toFixed(1);
document.getElementById('css-editor-effect-palette').value = css.palette || 'fire';
document.getElementById('css-editor-effect-color').value = rgbArrayToHex(css.color || [255, 80, 0]);
document.getElementById('css-editor-effect-intensity').value = css.intensity ?? 1.0;
document.getElementById('css-editor-effect-intensity-val').textContent = parseFloat(css.intensity ?? 1.0).toFixed(1);
document.getElementById('css-editor-effect-scale').value = css.scale ?? 1.0;
document.getElementById('css-editor-effect-scale-val').textContent = parseFloat(css.scale ?? 1.0).toFixed(1);
document.getElementById('css-editor-effect-mirror').checked = css.mirror || false;
} else {
sourceSelect.value = css.picture_source_id || '';
@@ -369,7 +427,18 @@ export async function showCSSEditor(cssId = null) {
document.getElementById('css-editor-led-count').value = 0;
_loadAnimationState(null);
_loadColorCycleState(null);
document.getElementById('css-editor-effect-type').value = 'fire';
document.getElementById('css-editor-effect-speed').value = 1.0;
document.getElementById('css-editor-effect-speed-val').textContent = '1.0';
document.getElementById('css-editor-effect-palette').value = 'fire';
document.getElementById('css-editor-effect-color').value = '#ff5000';
document.getElementById('css-editor-effect-intensity').value = 1.0;
document.getElementById('css-editor-effect-intensity-val').textContent = '1.0';
document.getElementById('css-editor-effect-scale').value = 1.0;
document.getElementById('css-editor-effect-scale-val').textContent = '1.0';
document.getElementById('css-editor-effect-mirror').checked = false;
document.getElementById('css-editor-title').textContent = t('color_strip.add');
document.getElementById('css-editor-gradient-preset').value = '';
gradientInit([
{ position: 0.0, color: [255, 0, 0] },
{ position: 1.0, color: [0, 0, 255] },
@@ -438,6 +507,23 @@ export async function saveCSSEditor() {
animation: _getAnimationPayload(),
};
if (!cssId) payload.source_type = 'gradient';
} else if (sourceType === 'effect') {
payload = {
name,
effect_type: document.getElementById('css-editor-effect-type').value,
speed: parseFloat(document.getElementById('css-editor-effect-speed').value),
palette: document.getElementById('css-editor-effect-palette').value,
intensity: parseFloat(document.getElementById('css-editor-effect-intensity').value),
scale: parseFloat(document.getElementById('css-editor-effect-scale').value),
mirror: document.getElementById('css-editor-effect-mirror').checked,
led_count: parseInt(document.getElementById('css-editor-led-count').value) || 0,
};
// Meteor uses a color picker
if (payload.effect_type === 'meteor') {
const hex = document.getElementById('css-editor-effect-color').value;
payload.color = [parseInt(hex.slice(1, 3), 16), parseInt(hex.slice(3, 5), 16), parseInt(hex.slice(5, 7), 16)];
}
if (!cssId) payload.source_type = 'effect';
} else {
payload = {
name,
@@ -596,6 +682,101 @@ export function gradientInit(stops) {
gradientRenderAll();
}
/* ── Presets ──────────────────────────────────────────────────── */
const _GRADIENT_PRESETS = {
rainbow: [
{ position: 0.0, color: [255, 0, 0] },
{ position: 0.17, color: [255, 165, 0] },
{ position: 0.33, color: [255, 255, 0] },
{ position: 0.5, color: [0, 255, 0] },
{ position: 0.67, color: [0, 100, 255] },
{ position: 0.83, color: [75, 0, 130] },
{ position: 1.0, color: [148, 0, 211] },
],
sunset: [
{ position: 0.0, color: [255, 60, 0] },
{ position: 0.3, color: [255, 120, 20] },
{ position: 0.6, color: [200, 40, 80] },
{ position: 0.8, color: [120, 20, 120] },
{ position: 1.0, color: [40, 10, 60] },
],
ocean: [
{ position: 0.0, color: [0, 10, 40] },
{ position: 0.3, color: [0, 60, 120] },
{ position: 0.6, color: [0, 140, 180] },
{ position: 0.8, color: [100, 220, 240] },
{ position: 1.0, color: [200, 240, 255] },
],
forest: [
{ position: 0.0, color: [0, 40, 0] },
{ position: 0.3, color: [0, 100, 20] },
{ position: 0.6, color: [60, 180, 30] },
{ position: 0.8, color: [140, 220, 50] },
{ position: 1.0, color: [220, 255, 80] },
],
fire: [
{ position: 0.0, color: [0, 0, 0] },
{ position: 0.25, color: [80, 0, 0] },
{ position: 0.5, color: [255, 40, 0] },
{ position: 0.75, color: [255, 160, 0] },
{ position: 1.0, color: [255, 255, 60] },
],
lava: [
{ position: 0.0, color: [0, 0, 0] },
{ position: 0.3, color: [120, 0, 0] },
{ position: 0.6, color: [255, 60, 0] },
{ position: 0.8, color: [255, 160, 40] },
{ position: 1.0, color: [255, 255, 120] },
],
aurora: [
{ position: 0.0, color: [0, 20, 40] },
{ position: 0.25, color: [0, 200, 100] },
{ position: 0.5, color: [0, 100, 200] },
{ position: 0.75, color: [120, 0, 200] },
{ position: 1.0, color: [0, 200, 140] },
],
ice: [
{ position: 0.0, color: [255, 255, 255] },
{ position: 0.3, color: [180, 220, 255] },
{ position: 0.6, color: [80, 160, 255] },
{ position: 0.85, color: [20, 60, 180] },
{ position: 1.0, color: [10, 20, 80] },
],
warm: [
{ position: 0.0, color: [255, 255, 80] },
{ position: 0.33, color: [255, 160, 0] },
{ position: 0.67, color: [255, 60, 0] },
{ position: 1.0, color: [160, 0, 0] },
],
cool: [
{ position: 0.0, color: [0, 255, 200] },
{ position: 0.33, color: [0, 120, 255] },
{ position: 0.67, color: [60, 0, 255] },
{ position: 1.0, color: [120, 0, 180] },
],
neon: [
{ position: 0.0, color: [255, 0, 200] },
{ position: 0.25, color: [0, 255, 255] },
{ position: 0.5, color: [0, 255, 50] },
{ position: 0.75, color: [255, 255, 0] },
{ position: 1.0, color: [255, 0, 100] },
],
pastel: [
{ position: 0.0, color: [255, 180, 180] },
{ position: 0.2, color: [255, 220, 160] },
{ position: 0.4, color: [255, 255, 180] },
{ position: 0.6, color: [180, 255, 200] },
{ position: 0.8, color: [180, 200, 255] },
{ position: 1.0, color: [220, 180, 255] },
],
};
export function applyGradientPreset(key) {
if (!key || !_GRADIENT_PRESETS[key]) return;
gradientInit(_GRADIENT_PRESETS[key]);
}
/* ── Render ───────────────────────────────────────────────────── */
export function gradientRenderAll() {

View File

@@ -589,6 +589,21 @@
"color_strip.gradient.position": "Position (0.01.0)",
"color_strip.gradient.bidir.hint": "Add a second color on the right side of this stop to create a hard edge in the gradient.",
"color_strip.gradient.min_stops": "Gradient must have at least 2 stops",
"color_strip.gradient.preset": "Preset:",
"color_strip.gradient.preset.hint": "Load a predefined gradient palette. Selecting a preset replaces the current stops.",
"color_strip.gradient.preset.custom": "— Custom —",
"color_strip.gradient.preset.rainbow": "Rainbow",
"color_strip.gradient.preset.sunset": "Sunset",
"color_strip.gradient.preset.ocean": "Ocean",
"color_strip.gradient.preset.forest": "Forest",
"color_strip.gradient.preset.fire": "Fire",
"color_strip.gradient.preset.lava": "Lava",
"color_strip.gradient.preset.aurora": "Aurora",
"color_strip.gradient.preset.ice": "Ice",
"color_strip.gradient.preset.warm": "Warm",
"color_strip.gradient.preset.cool": "Cool",
"color_strip.gradient.preset.neon": "Neon",
"color_strip.gradient.preset.pastel": "Pastel",
"color_strip.animation": "Animation",
"color_strip.animation.type": "Effect:",
"color_strip.animation.type.hint": "Animation effect to apply.",
@@ -617,5 +632,39 @@
"color_strip.color_cycle.add_color": "+ Add Color",
"color_strip.color_cycle.speed": "Speed:",
"color_strip.color_cycle.speed.hint": "Cycle speed multiplier. 1.0 ≈ one full cycle every 20 seconds; higher values cycle faster.",
"color_strip.color_cycle.min_colors": "Color cycle must have at least 2 colors"
"color_strip.color_cycle.min_colors": "Color cycle must have at least 2 colors",
"color_strip.type.effect": "Effect",
"color_strip.type.effect.hint": "Procedural LED effects (fire, meteor, plasma, noise, aurora) generated in real time.",
"color_strip.effect.type": "Effect Type:",
"color_strip.effect.type.hint": "Choose the procedural algorithm.",
"color_strip.effect.fire": "Fire",
"color_strip.effect.fire.desc": "Cellular automaton simulating rising flames with heat diffusion",
"color_strip.effect.meteor": "Meteor",
"color_strip.effect.meteor.desc": "Bright head travels along the strip with an exponential-decay tail",
"color_strip.effect.plasma": "Plasma",
"color_strip.effect.plasma.desc": "Overlapping sine waves mapped to a palette — classic demo-scene effect",
"color_strip.effect.noise": "Noise",
"color_strip.effect.noise.desc": "Scrolling fractal value noise mapped to a palette",
"color_strip.effect.aurora": "Aurora",
"color_strip.effect.aurora.desc": "Layered noise bands that drift and blend — northern lights style",
"color_strip.effect.speed": "Speed:",
"color_strip.effect.speed.hint": "Speed multiplier for the effect animation (0.1 = very slow, 10.0 = very fast).",
"color_strip.effect.palette": "Palette:",
"color_strip.effect.palette.hint": "Color palette used to map effect values to RGB colors.",
"color_strip.effect.color": "Meteor Color:",
"color_strip.effect.color.hint": "Head color for the meteor effect.",
"color_strip.effect.intensity": "Intensity:",
"color_strip.effect.intensity.hint": "Effect intensity — controls spark rate (fire), tail decay (meteor), or brightness range (aurora).",
"color_strip.effect.scale": "Scale:",
"color_strip.effect.scale.hint": "Spatial scale — wave frequency (plasma), zoom level (noise), or band width (aurora).",
"color_strip.effect.mirror": "Mirror:",
"color_strip.effect.mirror.hint": "Bounce mode — the meteor reverses direction at strip ends instead of wrapping.",
"color_strip.palette.fire": "Fire",
"color_strip.palette.ocean": "Ocean",
"color_strip.palette.lava": "Lava",
"color_strip.palette.forest": "Forest",
"color_strip.palette.rainbow": "Rainbow",
"color_strip.palette.aurora": "Aurora",
"color_strip.palette.sunset": "Sunset",
"color_strip.palette.ice": "Ice"
}

View File

@@ -589,6 +589,21 @@
"color_strip.gradient.position": "Позиция (0.01.0)",
"color_strip.gradient.bidir.hint": "Добавить второй цвет справа от этой остановки для создания резкого перехода в градиенте.",
"color_strip.gradient.min_stops": "Градиент должен содержать не менее 2 остановок",
"color_strip.gradient.preset": "Пресет:",
"color_strip.gradient.preset.hint": "Загрузить готовую палитру градиента. Выбор пресета заменяет текущие остановки.",
"color_strip.gradient.preset.custom": "— Свой —",
"color_strip.gradient.preset.rainbow": "Радуга",
"color_strip.gradient.preset.sunset": "Закат",
"color_strip.gradient.preset.ocean": "Океан",
"color_strip.gradient.preset.forest": "Лес",
"color_strip.gradient.preset.fire": "Огонь",
"color_strip.gradient.preset.lava": "Лава",
"color_strip.gradient.preset.aurora": "Аврора",
"color_strip.gradient.preset.ice": "Лёд",
"color_strip.gradient.preset.warm": "Тёплый",
"color_strip.gradient.preset.cool": "Холодный",
"color_strip.gradient.preset.neon": "Неон",
"color_strip.gradient.preset.pastel": "Пастельный",
"color_strip.animation": "Анимация",
"color_strip.animation.type": "Эффект:",
"color_strip.animation.type.hint": "Эффект анимации.",
@@ -617,5 +632,39 @@
"color_strip.color_cycle.add_color": "+ Добавить цвет",
"color_strip.color_cycle.speed": "Скорость:",
"color_strip.color_cycle.speed.hint": "Множитель скорости смены. 1.0 ≈ один полный цикл за 20 секунд; большие значения ускоряют смену.",
"color_strip.color_cycle.min_colors": "Смена цвета должна содержать не менее 2 цветов"
"color_strip.color_cycle.min_colors": "Смена цвета должна содержать не менее 2 цветов",
"color_strip.type.effect": "Эффект",
"color_strip.type.effect.hint": "Процедурные LED-эффекты (огонь, метеор, плазма, шум, аврора), генерируемые в реальном времени.",
"color_strip.effect.type": "Тип эффекта:",
"color_strip.effect.type.hint": "Выберите процедурный алгоритм.",
"color_strip.effect.fire": "Огонь",
"color_strip.effect.fire.desc": "Клеточный автомат, имитирующий поднимающееся пламя с диффузией тепла",
"color_strip.effect.meteor": "Метеор",
"color_strip.effect.meteor.desc": "Яркая точка движется по ленте с экспоненциально затухающим хвостом",
"color_strip.effect.plasma": "Плазма",
"color_strip.effect.plasma.desc": "Наложение синусоидальных волн с палитрой — классический демо-эффект",
"color_strip.effect.noise": "Шум",
"color_strip.effect.noise.desc": "Прокручиваемый фрактальный шум, отображённый на палитру",
"color_strip.effect.aurora": "Аврора",
"color_strip.effect.aurora.desc": "Наложенные шумовые полосы, дрейфующие и смешивающиеся — в стиле северного сияния",
"color_strip.effect.speed": "Скорость:",
"color_strip.effect.speed.hint": "Множитель скорости анимации эффекта (0.1 = очень медленно, 10.0 = очень быстро).",
"color_strip.effect.palette": "Палитра:",
"color_strip.effect.palette.hint": "Цветовая палитра для отображения значений эффекта в RGB-цвета.",
"color_strip.effect.color": "Цвет метеора:",
"color_strip.effect.color.hint": "Цвет головной точки метеора.",
"color_strip.effect.intensity": "Интенсивность:",
"color_strip.effect.intensity.hint": "Интенсивность эффекта — частота искр (огонь), затухание хвоста (метеор) или диапазон яркости (аврора).",
"color_strip.effect.scale": "Масштаб:",
"color_strip.effect.scale.hint": "Пространственный масштаб — частота волн (плазма), уровень масштабирования (шум) или ширина полос (аврора).",
"color_strip.effect.mirror": "Отражение:",
"color_strip.effect.mirror.hint": "Режим отскока — метеор меняет направление у краёв ленты вместо переноса.",
"color_strip.palette.fire": "Огонь",
"color_strip.palette.ocean": "Океан",
"color_strip.palette.lava": "Лава",
"color_strip.palette.forest": "Лес",
"color_strip.palette.rainbow": "Радуга",
"color_strip.palette.aurora": "Аврора",
"color_strip.palette.sunset": "Закат",
"color_strip.palette.ice": "Лёд"
}