feat: add overlay, soft light, hard light, difference, exclusion blend modes to composite
Some checks failed
Lint & Test / test (push) Failing after 27s

Integer-math implementations with pre-allocated scratch buffers.
IconSelect picker updated with 10 blend modes. i18n for en/ru/zh.
This commit is contained in:
2026-03-24 17:24:39 +03:00
parent bbef7e5869
commit 0723c5c68c
6 changed files with 162 additions and 11 deletions

View File

@@ -70,7 +70,7 @@ Need to research HAOS communication options first (WebSocket API, REST API, MQTT
### `composite` ### `composite`
- [ ] Allow nested composites (with cycle detection) - [ ] Allow nested composites (with cycle detection)
- [ ] More blend modes: overlay, soft light, hard light, difference, exclusion - [x] More blend modes: overlay, soft light, hard light, difference, exclusion
- [x] Per-layer LED range masks (optional start/end/reverse on each composite layer) - [x] Per-layer LED range masks (optional start/end/reverse on each composite layer)
### `notification` ### `notification`

View File

@@ -17,6 +17,11 @@ _BLEND_ADD = "add"
_BLEND_MULTIPLY = "multiply" _BLEND_MULTIPLY = "multiply"
_BLEND_SCREEN = "screen" _BLEND_SCREEN = "screen"
_BLEND_OVERRIDE = "override" _BLEND_OVERRIDE = "override"
_BLEND_OVERLAY = "overlay"
_BLEND_SOFT_LIGHT = "soft_light"
_BLEND_HARD_LIGHT = "hard_light"
_BLEND_DIFFERENCE = "difference"
_BLEND_EXCLUSION = "exclusion"
class CompositeColorStripStream(ColorStripStream): class CompositeColorStripStream(ColorStripStream):
@@ -348,12 +353,125 @@ class CompositeColorStripStream(ColorStripStream):
u16a >>= 8 u16a >>= 8
np.copyto(out, u16a, casting="unsafe") np.copyto(out, u16a, casting="unsafe")
def _blend_overlay(self, bottom: np.ndarray, top: np.ndarray, alpha: int,
out: np.ndarray) -> None:
"""Overlay blend: multiply darks, screen lights, then lerp with alpha.
if bottom < 128: blended = 2*bottom*top >> 8
else: blended = 255 - 2*(255-bottom)*(255-top) >> 8
"""
u16a, u16b = self._u16_a, self._u16_b
np.copyto(u16a, bottom, casting="unsafe")
np.copyto(u16b, top, casting="unsafe")
# Multiply path: 2*b*t >> 8
mul = (u16a * u16b) >> 7 # * 2 >> 8 == >> 7
# Screen path: 255 - 2*(255-b)*(255-t) >> 8
scr = 255 - (((255 - u16a) * (255 - u16b)) >> 7)
# Select based on bottom < 128
mask = u16a < 128
blended = np.where(mask, mul, scr)
np.clip(blended, 0, 255, out=blended)
# Lerp: result = (bottom * (256-a) + blended * a) >> 8
np.copyto(u16a, bottom, casting="unsafe")
u16a *= (256 - alpha)
blended *= alpha
u16a += blended
u16a >>= 8
np.copyto(out, u16a, casting="unsafe")
def _blend_soft_light(self, bottom: np.ndarray, top: np.ndarray, alpha: int,
out: np.ndarray) -> None:
"""Soft light blend (Pegtop formula), then lerp with alpha.
blended = (1 - 2*t/255) * b*b/255 + 2*t*b/255
"""
u16a, u16b = self._u16_a, self._u16_b
np.copyto(u16a, bottom, casting="unsafe")
np.copyto(u16b, top, casting="unsafe")
# term1 = (255 - 2*t) * b * b / (255*255)
# term2 = 2 * t * b / 255
# Use intermediate 32-bit to avoid overflow
b32 = u16a.astype(np.uint32)
t32 = u16b.astype(np.uint32)
blended = ((255 - 2 * t32) * b32 * b32 + 2 * t32 * b32 * 255) // (255 * 255)
np.clip(blended, 0, 255, out=blended)
# Lerp
np.copyto(u16a, bottom, casting="unsafe")
u16a *= (256 - alpha)
blended_u16 = blended.astype(np.uint16)
blended_u16 *= alpha
u16a += blended_u16
u16a >>= 8
np.copyto(out, u16a, casting="unsafe")
def _blend_hard_light(self, bottom: np.ndarray, top: np.ndarray, alpha: int,
out: np.ndarray) -> None:
"""Hard light blend: overlay with top/bottom roles swapped.
if top < 128: blended = 2*bottom*top >> 8
else: blended = 255 - 2*(255-bottom)*(255-top) >> 8
"""
u16a, u16b = self._u16_a, self._u16_b
np.copyto(u16a, bottom, casting="unsafe")
np.copyto(u16b, top, casting="unsafe")
mul = (u16a * u16b) >> 7
scr = 255 - (((255 - u16a) * (255 - u16b)) >> 7)
# Select based on top < 128 (differs from overlay)
mask = u16b < 128
blended = np.where(mask, mul, scr)
np.clip(blended, 0, 255, out=blended)
# Lerp
np.copyto(u16a, bottom, casting="unsafe")
u16a *= (256 - alpha)
blended *= alpha
u16a += blended
u16a >>= 8
np.copyto(out, u16a, casting="unsafe")
def _blend_difference(self, bottom: np.ndarray, top: np.ndarray, alpha: int,
out: np.ndarray) -> None:
"""Difference blend: |bottom - top|, then lerp with alpha."""
u16a, u16b = self._u16_a, self._u16_b
np.copyto(u16a, bottom, casting="unsafe")
np.copyto(u16b, top, casting="unsafe")
# abs diff using signed subtraction
blended = np.abs(u16a.astype(np.int16) - u16b.astype(np.int16)).astype(np.uint16)
# Lerp
np.copyto(u16a, bottom, casting="unsafe")
u16a *= (256 - alpha)
blended *= alpha
u16a += blended
u16a >>= 8
np.copyto(out, u16a, casting="unsafe")
def _blend_exclusion(self, bottom: np.ndarray, top: np.ndarray, alpha: int,
out: np.ndarray) -> None:
"""Exclusion blend: bottom + top - 2*bottom*top/255, then lerp with alpha."""
u16a, u16b = self._u16_a, self._u16_b
np.copyto(u16a, bottom, casting="unsafe")
np.copyto(u16b, top, casting="unsafe")
# blended = b + t - 2*b*t/255
blended = u16a + u16b - ((u16a * u16b) >> 7)
np.clip(blended, 0, 255, out=blended)
# Lerp
np.copyto(u16a, bottom, casting="unsafe")
u16a *= (256 - alpha)
blended *= alpha
u16a += blended
u16a >>= 8
np.copyto(out, u16a, casting="unsafe")
_BLEND_DISPATCH = { _BLEND_DISPATCH = {
_BLEND_NORMAL: "_blend_normal", _BLEND_NORMAL: "_blend_normal",
_BLEND_ADD: "_blend_add", _BLEND_ADD: "_blend_add",
_BLEND_MULTIPLY: "_blend_multiply", _BLEND_MULTIPLY: "_blend_multiply",
_BLEND_SCREEN: "_blend_screen", _BLEND_SCREEN: "_blend_screen",
_BLEND_OVERRIDE: "_blend_override", _BLEND_OVERRIDE: "_blend_override",
_BLEND_OVERLAY: "_blend_overlay",
_BLEND_SOFT_LIGHT: "_blend_soft_light",
_BLEND_HARD_LIGHT: "_blend_hard_light",
_BLEND_DIFFERENCE: "_blend_difference",
_BLEND_EXCLUSION: "_blend_exclusion",
} }
# ── Processing loop ───────────────────────────────────────── # ── Processing loop ─────────────────────────────────────────

View File

@@ -51,11 +51,16 @@ export function compositeDestroyEntitySelects() {
function _getCompositeBlendItems() { function _getCompositeBlendItems() {
return [ return [
{ value: 'normal', icon: _icon(P.square), label: t('color_strip.composite.blend_mode.normal'), desc: t('color_strip.composite.blend_mode.normal.desc') }, { value: 'normal', icon: _icon(P.square), label: t('color_strip.composite.blend_mode.normal'), desc: t('color_strip.composite.blend_mode.normal.desc') },
{ value: 'add', icon: _icon(P.sun), label: t('color_strip.composite.blend_mode.add'), desc: t('color_strip.composite.blend_mode.add.desc') }, { value: 'add', icon: _icon(P.sun), label: t('color_strip.composite.blend_mode.add'), desc: t('color_strip.composite.blend_mode.add.desc') },
{ value: 'multiply', icon: _icon(P.eye), label: t('color_strip.composite.blend_mode.multiply'), desc: t('color_strip.composite.blend_mode.multiply.desc') }, { value: 'multiply', icon: _icon(P.moon), label: t('color_strip.composite.blend_mode.multiply'), desc: t('color_strip.composite.blend_mode.multiply.desc') },
{ value: 'screen', icon: _icon(P.monitor), label: t('color_strip.composite.blend_mode.screen'), desc: t('color_strip.composite.blend_mode.screen.desc') }, { value: 'screen', icon: _icon(P.monitor), label: t('color_strip.composite.blend_mode.screen'), desc: t('color_strip.composite.blend_mode.screen.desc') },
{ value: 'override', icon: _icon(P.zap), label: t('color_strip.composite.blend_mode.override'), desc: t('color_strip.composite.blend_mode.override.desc') }, { value: 'overlay', icon: _icon(P.copy), label: t('color_strip.composite.blend_mode.overlay'), desc: t('color_strip.composite.blend_mode.overlay.desc') },
{ value: 'soft_light', icon: _icon(P.sunDim), label: t('color_strip.composite.blend_mode.soft_light'), desc: t('color_strip.composite.blend_mode.soft_light.desc') },
{ value: 'hard_light', icon: _icon(P.zap), label: t('color_strip.composite.blend_mode.hard_light'), desc: t('color_strip.composite.blend_mode.hard_light.desc') },
{ value: 'difference', icon: _icon(P.activity), label: t('color_strip.composite.blend_mode.difference'), desc: t('color_strip.composite.blend_mode.difference.desc') },
{ value: 'exclusion', icon: _icon(P.circleOff), label: t('color_strip.composite.blend_mode.exclusion'), desc: t('color_strip.composite.blend_mode.exclusion.desc') },
{ value: 'override', icon: _icon(P.eye), label: t('color_strip.composite.blend_mode.override'), desc: t('color_strip.composite.blend_mode.override.desc') },
]; ];
} }
@@ -127,11 +132,9 @@ export function compositeRenderList() {
<div class="composite-layer-row"> <div class="composite-layer-row">
<select class="composite-layer-source" data-idx="${i}">${srcOptions}</select> <select class="composite-layer-source" data-idx="${i}">${srcOptions}</select>
<select class="composite-layer-blend" data-idx="${i}"> <select class="composite-layer-blend" data-idx="${i}">
<option value="normal"${layer.blend_mode === 'normal' ? ' selected' : ''}>${t('color_strip.composite.blend_mode.normal')}</option> ${_getCompositeBlendItems().map(b =>
<option value="add"${layer.blend_mode === 'add' ? ' selected' : ''}>${t('color_strip.composite.blend_mode.add')}</option> `<option value="${b.value}"${layer.blend_mode === b.value ? ' selected' : ''}>${b.label}</option>`
<option value="multiply"${layer.blend_mode === 'multiply' ? ' selected' : ''}>${t('color_strip.composite.blend_mode.multiply')}</option> ).join('')}
<option value="screen"${layer.blend_mode === 'screen' ? ' selected' : ''}>${t('color_strip.composite.blend_mode.screen')}</option>
<option value="override"${layer.blend_mode === 'override' ? ' selected' : ''}>${t('color_strip.composite.blend_mode.override')}</option>
</select> </select>
</div> </div>
<div class="composite-layer-row"> <div class="composite-layer-row">

View File

@@ -1183,6 +1183,16 @@
"color_strip.composite.blend_mode.screen": "Screen", "color_strip.composite.blend_mode.screen": "Screen",
"color_strip.composite.blend_mode.screen.desc": "Brightens, inverse of multiply", "color_strip.composite.blend_mode.screen.desc": "Brightens, inverse of multiply",
"color_strip.composite.blend_mode.override": "Override", "color_strip.composite.blend_mode.override": "Override",
"color_strip.composite.blend_mode.overlay": "Overlay",
"color_strip.composite.blend_mode.overlay.desc": "Multiply darks, screen lights",
"color_strip.composite.blend_mode.soft_light": "Soft Light",
"color_strip.composite.blend_mode.soft_light.desc": "Gentle contrast adjustment",
"color_strip.composite.blend_mode.hard_light": "Hard Light",
"color_strip.composite.blend_mode.hard_light.desc": "Strong contrast, vivid colors",
"color_strip.composite.blend_mode.difference": "Difference",
"color_strip.composite.blend_mode.difference.desc": "Absolute color difference",
"color_strip.composite.blend_mode.exclusion": "Exclusion",
"color_strip.composite.blend_mode.exclusion.desc": "Like difference, lower contrast",
"color_strip.composite.blend_mode.override.desc": "Black = transparent, bright = opaque", "color_strip.composite.blend_mode.override.desc": "Black = transparent, bright = opaque",
"color_strip.composite.opacity": "Opacity", "color_strip.composite.opacity": "Opacity",
"color_strip.composite.brightness": "Brightness", "color_strip.composite.brightness": "Brightness",

View File

@@ -1148,6 +1148,16 @@
"color_strip.composite.blend_mode.screen": "Экран", "color_strip.composite.blend_mode.screen": "Экран",
"color_strip.composite.blend_mode.screen.desc": "Осветляет, обратное умножение", "color_strip.composite.blend_mode.screen.desc": "Осветляет, обратное умножение",
"color_strip.composite.blend_mode.override": "Замена", "color_strip.composite.blend_mode.override": "Замена",
"color_strip.composite.blend_mode.overlay": "Наложение",
"color_strip.composite.blend_mode.overlay.desc": "Умножение тёмных, осветление светлых",
"color_strip.composite.blend_mode.soft_light": "Мягкий свет",
"color_strip.composite.blend_mode.soft_light.desc": "Мягкая коррекция контраста",
"color_strip.composite.blend_mode.hard_light": "Жёсткий свет",
"color_strip.composite.blend_mode.hard_light.desc": "Сильный контраст, яркие цвета",
"color_strip.composite.blend_mode.difference": "Разница",
"color_strip.composite.blend_mode.difference.desc": "Абсолютная разница цветов",
"color_strip.composite.blend_mode.exclusion": "Исключение",
"color_strip.composite.blend_mode.exclusion.desc": "Как разница, но мягче",
"color_strip.composite.blend_mode.override.desc": "Чёрный = прозрачный, яркий = непрозрачный", "color_strip.composite.blend_mode.override.desc": "Чёрный = прозрачный, яркий = непрозрачный",
"color_strip.composite.opacity": "Непрозрачность", "color_strip.composite.opacity": "Непрозрачность",
"color_strip.composite.brightness": "Яркость", "color_strip.composite.brightness": "Яркость",

View File

@@ -1148,6 +1148,16 @@
"color_strip.composite.blend_mode.screen": "滤色", "color_strip.composite.blend_mode.screen": "滤色",
"color_strip.composite.blend_mode.screen.desc": "提亮,正片叠底的反转", "color_strip.composite.blend_mode.screen.desc": "提亮,正片叠底的反转",
"color_strip.composite.blend_mode.override": "覆盖", "color_strip.composite.blend_mode.override": "覆盖",
"color_strip.composite.blend_mode.overlay": "叠加",
"color_strip.composite.blend_mode.overlay.desc": "暗部相乘,亮部滤色",
"color_strip.composite.blend_mode.soft_light": "柔光",
"color_strip.composite.blend_mode.soft_light.desc": "柔和对比度调整",
"color_strip.composite.blend_mode.hard_light": "强光",
"color_strip.composite.blend_mode.hard_light.desc": "强对比度,鲜艳色彩",
"color_strip.composite.blend_mode.difference": "差值",
"color_strip.composite.blend_mode.difference.desc": "绝对颜色差异",
"color_strip.composite.blend_mode.exclusion": "排除",
"color_strip.composite.blend_mode.exclusion.desc": "类似差值,对比度更低",
"color_strip.composite.blend_mode.override.desc": "黑色=透明,亮色=不透明", "color_strip.composite.blend_mode.override.desc": "黑色=透明,亮色=不透明",
"color_strip.composite.opacity": "不透明度", "color_strip.composite.opacity": "不透明度",
"color_strip.composite.brightness": "亮度", "color_strip.composite.brightness": "亮度",