test(compositor): pin blend behaviour to a golden fixture; fix luminosity (#15373)

Blending exists in two implementations - the numpy compositor and the
layerBlend.frag shader that drives the live preview - with nothing holding
them together. They have already diverged once (the safeDiv operand), and a
divergence only shows up to the user as 'the render does not match the
preview'.

Adds compositor_blend_golden.json: every mode, at both endpoints, the
midpoint and inside each epsilon guard. Any implementation of these 26 modes
must reproduce it. compositor_blend_test.py pins the numpy side to it and
additionally spells out the boundary rules by hand, so the guards cannot be
re-broken by regenerating the fixture.

Diffing the shader against the numpy implementation over that grid leaves
exactly one mismatch: luminosity. safe_div guards the denominator and
returns 0, so a luminosity layer over a black or near-black backdrop
disappears. The backdrop has no hue or saturation to preserve there, so the
result should be a neutral grey at the layer's luminance - which is also the
analytic limit of i * lum(l)/lum(i) as the backdrop approaches black. The
matching four-line shader change is proposed on the frontend PR; with both
applied all 26 modes agree.

Also clamps layer opacity to [0, 1]. The layer state round-trips through the
saved workflow and is accepted verbatim on /prompt, so it is untrusted
input; the canvas is only clamped once, after the last layer, so an
out-of-range coverage multiplier changes the blend of every layer above it.
_parse_background already clamps the same field.
This commit is contained in:
Christian Byrne
2026-08-06 19:14:37 -07:00
committed by GitHub
parent 9232943a2c
commit d36bc61a3e
6 changed files with 2603 additions and 3 deletions
+16 -2
View File
@@ -146,8 +146,22 @@ def _blend_color(i: np.ndarray, l: np.ndarray) -> np.ndarray:
def _blend_luminosity(i: np.ndarray, l: np.ndarray) -> np.ndarray:
ratio = safe_div(luminance(l), luminance(i))
return i * ratio[..., None]
# Scale the backdrop so it carries the layer's luminance. Where the backdrop
# has no luminance to scale there is no hue or saturation to preserve either,
# so the result is a neutral grey at the layer's luminance - which is also the
# analytic limit of i * lum(l)/lum(i) as a grey backdrop approaches black.
# Guarding the numerator here instead (returning black) makes a luminosity
# layer disappear over dark backdrops; see tests-unit/comfy_extras_test/
# compositor_blend_golden.json.
lum_i = luminance(i)
lum_l = luminance(l)
degenerate = lum_i <= EPSILON
ratio = np.where(degenerate, 0.0, lum_l / np.where(degenerate, 1.0, lum_i))
return np.where(
degenerate[..., None],
np.broadcast_to(lum_l[..., None], i.shape),
i * ratio[..., None],
)
HSL_BLEND = {
+6 -1
View File
@@ -272,7 +272,12 @@ def _layer_params(entry, natural_w: int, natural_h: int) -> dict:
blend = entry.get("blend")
return {
"visible": bool(entry.get("visible", True)),
"opacity": _number(entry, "opacity", 1.0),
# The layer state is untrusted input: it round-trips through the saved
# workflow and can be posted directly to /prompt. An out-of-range opacity
# would otherwise reach blend_composite as a raw coverage multiplier and
# produce negative or greater-than-white RGB. _parse_background already
# clamps the same field.
"opacity": min(max(_number(entry, "opacity", 1.0), 0.0), 1.0),
"blend": blend if isinstance(blend, str) else "normal",
"x": _number(transform, "x", 0.0),
"y": _number(transform, "y", 0.0),