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.