web: put the mask's column away with the tool, and keep a blown highlight's hue

The column of mask knobs belongs to an armed LINEAR or RADIAL shape, and it
stayed on the stage after the hand had moved on: arm a shape, draw it, click
another chip or another tab, and the strip of mask sliders was still there
belonging to a tool that was no longer in hand. A capture-phase `pointerdown` on
`window`, armed only while `maskTool` is set, now puts the tool down in the same
gesture that reaches for something else. A press on the rail (the tabs) or on
any chip except the shape's own two dismisses; a press inside the column is left
alone, because the column's own chips handle their click themselves, and a press
on the photo is left alone, because dragging on the photo is how the shape is
drawn. It is the dismissal the STRAIGHTEN tool already had, one effect over, so
the tab-switch effect needed no `setMaskTool(null)` of its own.

A RAW's blown highlight came out magenta, and was measured before it was
touched. `example-sony.ARW` through the lab (`rawblow.html`, the same camera
white/black and the same `rgb_cam` the app's develop uses): white 16380, black
512, cam_mul green-normalised to (2.581, 1, 1.553). The pixels the sensor could
not hold — 0.7% of the frame, raw max channel at or past 0.99 of the white level
— average (0.775, 1.416, 1.108) in raw, and per channel 26.3% / 96.0% / 46.7% are
at or over white: green is 1.4x the white level while red is still under it.

The develop shader clamped every channel to 1.0 BEFORE the white-balance gains,
and that one clamp is the whole cast. Green is the channel the gains are
normalised to, so it stopped at 1.0, while red and blue — which need their 2.581
and 1.553 — were already past it and were carried over by the multiply. The
blown area therefore left the matrix at (1.0, 0.53, 0.62) instead of at white:
measured on the develop output, (254.4, 217.0, 242.9) — red and blue 38 above
green, which is magenta. On the stage, pixels with red and blue over 235 and
green under 225 in the same framing: 1191 with the clamp, 33 without it.

The shader now only floors at zero, keeps the channel ratios through the matrix,
and fades whatever ran past white towards white (`mix(rgb / mx, 1, 1 - 1/mx)`,
the desaturate-to-white dcraw uses for the same problem). The blown area comes
out (254.5, 253.9, 246.6): an overflow that stays bright and stops taking a hue,
broken per channel at sd 8.7 / 28.0 / 26.4 today against 5.4 / 3.3 / 17.8 now.
Whole-frame averages move by 0.008/0.278/0.140 of a level — the fade only
touches pixels that were over white, which are the 0.7%.

"cannot be rescued" is the second half of the same fact, and it is now a
measurement rather than a hope: LIGHT's HIGHLIGHT row is a curve over what
develop emitted, and while red and blue were pinned at 255 the curve had nothing
to pull on. The fade leaves a compressed ramp there instead, which is what the
row now pulls. LibRaw's own reconstruction modes are not the answer on this
file: `-H` 1, 2 and 3 hand back byte-identical develop output to `-H` 0
(`pxAt65535` is 0 — the sensor never reached its 65535, only the camera's white
level), so `SETTINGS.highlight` stays 0. What the app cannot do is keep the two
stops above white, because the band still leaves develop as 8-bit JPEG; that
ceiling is named at the point of the fade, for whoever needs RAW highlights
recovered rather than merely correct.

`npm run typecheck` and `npm run build` are clean (bundle `index-BJy_3HCz.js`).

Probes: verify-mask-column (dev server, real photo, arm a shape and draw it,
then reach for another chip and for LIGHT and FX — 8 checks, 8 pass: the column
stands while the shape is armed, survives a press on the photo and on the
shape's own kind chips, and goes on any other chip or tab), rawblow (the real
ARW through the real develop maths in the page, before/after chains side by
side, which is where the magenta and the reconstruction modes were measured),
probe-raw-highlight (the app itself, ARW uploaded, blown pixels counted and the
HIGHLIGHT row driven to both ends).
This commit is contained in:
2026-09-26 20:55:05 +07:00
parent 9164bf3228
commit 432acba9c1
2 changed files with 34 additions and 1 deletions
+15 -1
View File
@@ -56,8 +56,22 @@ float3 encode(float3 x) {
half4 main(float2 pos) {
float4 p = raw.eval(float2(pos.x, pos.y - crop.x));
float3 lin = clamp((p.rgb - black.rgb) * black.a, 0.0, 1.0) * mul.rgb;
// Only the floor here. A photo the sensor could not hold goes over the white
// level in all three channels, and clipping them one by one before the WB gains
// is what tints what is left of the highlight: green — the channel the gains are
// normalised to — stops at 1.0 while red and blue, which need their 2.6x and
// 1.6x, are already past it, so the blown area comes out magenta. Keep the
// channel ratios through the matrix instead and let the overflow fade to white.
float3 lin = max((p.rgb - black.rgb) * black.a, 0.0) * mul.rgb;
float3 rgb = float3(dot(m0.xyz, lin), dot(m1.xyz, lin), dot(m2.xyz, lin));
float mx = max(max(rgb.r, rgb.g), rgb.b);
// The overflow fades towards white instead of being cut, so the trace of a
// sensor that ran past its white level survives as a compressed ramp — which is
// what LIGHT's HIGHLIGHT row then has to pull on. ponytail: the ramp lives in
// [0.5, 1] and the band leaves as 8-bit JPEG, so the two stops above white are
// compressed, not kept; give develop a 16-bit output (or a knee of its own) when
// RAW highlights have to be recovered rather than merely look right.
rgb = mx > 1.0 ? mix(rgb / mx, float3(1.0), 1.0 - 1.0 / mx) : rgb;
return half4(half3(encode(rgb)), 1.0);
}
`;