From 432acba9c1c631f838b2b45e803f126df12ff82f Mon Sep 17 00:00:00 2001 From: 3dtours Date: Sat, 26 Sep 2026 20:55:05 +0700 Subject: [PATCH] web: put the mask's column away with the tool, and keep a blown highlight's hue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- docker/frontend/src/App.tsx | 19 +++++++++++++++++++ docker/frontend/src/engine/rawDevelop.ts | 16 +++++++++++++++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/docker/frontend/src/App.tsx b/docker/frontend/src/App.tsx index c8ee643..e4cfbf4 100644 --- a/docker/frontend/src/App.tsx +++ b/docker/frontend/src/App.tsx @@ -1949,6 +1949,25 @@ export function Workspace() { return () => window.removeEventListener('pointerdown', dismiss, true); }, [openParam]); + // The mask's column belongs to the armed shape the same way: reaching for another + // chip or tab puts the tool down and takes its column off the stage in one gesture. + // The shape's own chips are left out — toggling one is its own onClick's business — + // and anything inside the column (or on the photo, where the shape is drawn) stays. + useEffect(() => { + if (!maskTool) return; + const dismiss = (e: PointerEvent) => { + const el = e.target as HTMLElement | null; + if ( + !el?.closest('.rail, .chip') || + el.closest('[data-col="mask"], .chip[data-key="linear"], .chip[data-key="radial"]') + ) + return; + setMaskTool(null); + }; + window.addEventListener('pointerdown', dismiss, true); + return () => window.removeEventListener('pointerdown', dismiss, true); + }, [maskTool]); + // Continuous parameter chips: label + live value; amber text when edited, // amber border while its ruler is open. const paramChips = (defs: ContinuousParamDef[]): ChipDef[] => diff --git a/docker/frontend/src/engine/rawDevelop.ts b/docker/frontend/src/engine/rawDevelop.ts index 38f2745..d7a1288 100644 --- a/docker/frontend/src/engine/rawDevelop.ts +++ b/docker/frontend/src/engine/rawDevelop.ts @@ -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); } `;