Override blend mode, FPS sparkline, fix api_input persistence
New features: - Override composite blend mode: per-pixel alpha from brightness (black=transparent, bright=opaque). Ideal for API input over effects. - API input test preview FPS chart uses shared createFpsSparkline (same look as target card charts) Fixes: - Fix api_input source not surviving server restart: from_dict was still passing removed led_count field to constructor - Fix composite layer brightness/processing selectors not aligned: labels get fixed width, selects fill remaining space - Fix CSPT input selector showing in non-CSPT CSS test mode - Fix test modal LED/FPS controls showing for api_input sources - Server only sends test WS frames when api_input push_generation changes Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -31,7 +31,7 @@ class CompositeLayer(BaseModel):
|
||||
"""A single layer in a composite color strip source."""
|
||||
|
||||
source_id: str = Field(description="ID of the layer's color strip source")
|
||||
blend_mode: str = Field(default="normal", description="Blend mode: normal|add|multiply|screen")
|
||||
blend_mode: str = Field(default="normal", description="Blend mode: normal|add|multiply|screen|override")
|
||||
opacity: float = Field(default=1.0, ge=0.0, le=1.0, description="Layer opacity 0.0-1.0")
|
||||
enabled: bool = Field(default=True, description="Whether this layer is active")
|
||||
brightness_source_id: Optional[str] = Field(None, description="Optional value source ID for dynamic brightness")
|
||||
|
||||
@@ -16,6 +16,7 @@ _BLEND_NORMAL = "normal"
|
||||
_BLEND_ADD = "add"
|
||||
_BLEND_MULTIPLY = "multiply"
|
||||
_BLEND_SCREEN = "screen"
|
||||
_BLEND_OVERRIDE = "override"
|
||||
|
||||
|
||||
class CompositeColorStripStream(ColorStripStream):
|
||||
@@ -300,11 +301,34 @@ class CompositeColorStripStream(ColorStripStream):
|
||||
u16a >>= 8
|
||||
np.copyto(out, u16a, casting="unsafe")
|
||||
|
||||
def _blend_override(self, bottom: np.ndarray, top: np.ndarray, alpha: int,
|
||||
out: np.ndarray) -> None:
|
||||
"""Override blend: per-pixel alpha derived from top brightness.
|
||||
|
||||
Black pixels are fully transparent (bottom shows through),
|
||||
bright pixels fully opaque (top replaces bottom). Layer opacity
|
||||
scales the per-pixel alpha.
|
||||
"""
|
||||
u16a, u16b = self._u16_a, self._u16_b
|
||||
# Per-pixel brightness = max(R, G, B) for each LED
|
||||
per_px_alpha = np.max(top, axis=1, keepdims=True).astype(np.uint16)
|
||||
# Scale by layer opacity
|
||||
per_px_alpha = (per_px_alpha * alpha) >> 8
|
||||
# Lerp: out = (bottom * (256 - per_px_alpha) + top * per_px_alpha) >> 8
|
||||
np.copyto(u16a, bottom, casting="unsafe")
|
||||
np.copyto(u16b, top, casting="unsafe")
|
||||
u16a *= (256 - per_px_alpha)
|
||||
u16b *= per_px_alpha
|
||||
u16a += u16b
|
||||
u16a >>= 8
|
||||
np.copyto(out, u16a, casting="unsafe")
|
||||
|
||||
_BLEND_DISPATCH = {
|
||||
_BLEND_NORMAL: "_blend_normal",
|
||||
_BLEND_ADD: "_blend_add",
|
||||
_BLEND_MULTIPLY: "_blend_multiply",
|
||||
_BLEND_SCREEN: "_blend_screen",
|
||||
_BLEND_OVERRIDE: "_blend_override",
|
||||
}
|
||||
|
||||
# ── Processing loop ─────────────────────────────────────────
|
||||
|
||||
@@ -1516,6 +1516,19 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.composite-layer-brightness-label {
|
||||
flex-shrink: 0;
|
||||
width: 90px;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.composite-layer-brightness,
|
||||
.composite-layer-cspt {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.composite-layer-blend {
|
||||
width: 100px;
|
||||
flex-shrink: 0;
|
||||
|
||||
@@ -685,6 +685,7 @@ function _getCompositeBlendItems() {
|
||||
{ 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: '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') },
|
||||
];
|
||||
}
|
||||
|
||||
@@ -741,6 +742,7 @@ function _compositeRenderList() {
|
||||
<option value="add"${layer.blend_mode === 'add' ? ' selected' : ''}>${t('color_strip.composite.blend_mode.add')}</option>
|
||||
<option value="multiply"${layer.blend_mode === 'multiply' ? ' selected' : ''}>${t('color_strip.composite.blend_mode.multiply')}</option>
|
||||
<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>
|
||||
</div>
|
||||
<div class="composite-layer-row">
|
||||
|
||||
@@ -1131,6 +1131,8 @@
|
||||
"color_strip.composite.blend_mode.multiply.desc": "Darkens by multiplying colors",
|
||||
"color_strip.composite.blend_mode.screen": "Screen",
|
||||
"color_strip.composite.blend_mode.screen.desc": "Brightens, inverse of multiply",
|
||||
"color_strip.composite.blend_mode.override": "Override",
|
||||
"color_strip.composite.blend_mode.override.desc": "Black = transparent, bright = opaque",
|
||||
"color_strip.composite.opacity": "Opacity",
|
||||
"color_strip.composite.brightness": "Brightness",
|
||||
"color_strip.composite.brightness.none": "None (full brightness)",
|
||||
|
||||
@@ -1131,6 +1131,8 @@
|
||||
"color_strip.composite.blend_mode.multiply.desc": "Затемняет, умножая цвета",
|
||||
"color_strip.composite.blend_mode.screen": "Экран",
|
||||
"color_strip.composite.blend_mode.screen.desc": "Осветляет, обратное умножение",
|
||||
"color_strip.composite.blend_mode.override": "Замена",
|
||||
"color_strip.composite.blend_mode.override.desc": "Чёрный = прозрачный, яркий = непрозрачный",
|
||||
"color_strip.composite.opacity": "Непрозрачность",
|
||||
"color_strip.composite.brightness": "Яркость",
|
||||
"color_strip.composite.brightness.none": "Нет (полная яркость)",
|
||||
|
||||
@@ -1131,6 +1131,8 @@
|
||||
"color_strip.composite.blend_mode.multiply.desc": "通过相乘颜色变暗",
|
||||
"color_strip.composite.blend_mode.screen": "滤色",
|
||||
"color_strip.composite.blend_mode.screen.desc": "提亮,正片叠底的反转",
|
||||
"color_strip.composite.blend_mode.override": "覆盖",
|
||||
"color_strip.composite.blend_mode.override.desc": "黑色=透明,亮色=不透明",
|
||||
"color_strip.composite.opacity": "不透明度",
|
||||
"color_strip.composite.brightness": "亮度",
|
||||
"color_strip.composite.brightness.none": "无(全亮度)",
|
||||
|
||||
@@ -227,7 +227,7 @@ class ColorStripSource:
|
||||
return ApiInputColorStripSource(
|
||||
id=sid, name=name, source_type="api_input",
|
||||
created_at=created_at, updated_at=updated_at, description=description,
|
||||
clock_id=clock_id, tags=tags, led_count=data.get("led_count") or 0,
|
||||
clock_id=clock_id, tags=tags,
|
||||
fallback_color=fallback_color,
|
||||
timeout=float(data.get("timeout") or 5.0),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user