Render a blown highlight white instead of magenta

Every clipped sky came out bright pink. Measured, not guessed: developing
_MG_8596.CR2 and looking at the export, the subject renders correctly and only
the saturated region is wrong.

A fully clipped pixel reaches the shader as (1, 1, 1) — three photosites that
stopped counting, carrying no colour at all. The as-shot multipliers are not
neutral, so balancing sends it to (1.93, 1.00, 1.68) on this body, and the
camera matrix turns that into R 2.88, G 0.51, B 2.03. Red and blue clip at
one; green, whose matrix row is far less positive-heavy, does not. Red and
blue high with green low is magenta.

Nothing upstream was at fault, which is why the two previous attempts missed
it: the white balance is correct, the matrix is correct, and the sensor
normalisation is correct. The input simply was not a colour, and correct
arithmetic on a non-colour produces a confident wrong answer.

So saturation is detected before the balance is applied — the last 1.5% of
range, smoothstepped rather than switched, because a hard threshold draws a
visible rim around every highlight and a backlit edge on skin is where that
shows. Above it the pixel is pulled to the neutral of its own brightness, so
it keeps its luminance and loses only the cast.

Verified end to end on the file itself: the sky is white, the skin, the black
dresses and the stone are unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-17 13:17:15 +02:00
co-authored by Claude Opus 5
parent 31e20399c8
commit dea826811e
3 changed files with 47 additions and 2 deletions
+27
View File
@@ -347,7 +347,34 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {{
// //
// A non-linear source has already had this applied in-camera; the uniform // A non-linear source has already had this applied in-camera; the uniform
// is neutral there, so this is a multiply by one rather than a branch. // is neutral there, so this is a multiply by one rather than a branch.
// How close this pixel was to saturation before any balance was applied.
// A photosite at its white level carries no colour information — every
// channel simply stopped counting — so the balance below must not be
// allowed to tint it.
let clipped = smoothstep(0.985, 1.0, max(c.r, max(c.g, c.b)));
c = c * u.as_shot_wb.rgb; c = c * u.as_shot_wb.rgb;
// **Highlight desaturation, and without it every blown sky is magenta.**
//
// A fully clipped pixel arrives as (1, 1, 1). The as-shot multipliers are
// not neutral — on a Canon 6D they are (1.93, 1.00, 1.68) — so balancing
// sends it to exactly that, and the camera matrix then produces R 2.88,
// G 0.51, B 2.03. Red and blue clip at one and green does not, which is
// magenta. The balance is correct; the input was not a colour.
//
// So a saturated pixel is pulled back toward the neutral its raw values
// actually represent, fading in over the last 1.5% of range. Smoothly,
// because a hard switch puts a visible edge around every highlight where
// the two treatments meet — a rim light on skin is the worst case, and it
// is the one people notice.
//
// The neutral chosen is the balanced grey of the same brightness, so the
// highlight keeps its luminance and loses only the cast.
if (clipped > 0.0) {{
let neutral = vec3<f32>(max(c.r, max(c.g, c.b)));
c = mix(c, neutral, clipped);
}}
{body} {body}
// Camera space -> linear sRGB. Applied after the adjustments so white // Camera space -> linear sRGB. Applied after the adjustments so white
// balance and exposure act on sensor-native values, which is where they // balance and exposure act on sensor-native values, which is where they
+1 -1
View File
@@ -1036,7 +1036,7 @@ in property <bool> panel-visible: true;
timeline-pan(d) => { root.library-timeline-pan(d); } timeline-pan(d) => { root.library-timeline-pan(d); }
timeline-zoom(d) => { root.library-timeline-zoom(d); } timeline-zoom(d) => { root.library-timeline-zoom(d); }
syncing: root.library-syncing; syncing: root.library-syncing;
cell-size: root.library-cell-size; requested-cell-size: root.library-cell-size;
zoom-cells(d) => { root.library-zoom-cells(d); } zoom-cells(d) => { root.library-zoom-cells(d); }
scroll-to: root.library-scroll-to; scroll-to: root.library-scroll-to;
scroll-token: root.library-scroll-token; scroll-token: root.library-scroll-token;
+19 -1
View File
@@ -926,7 +926,25 @@ export component LibraryGrid inherits Rectangle {
// Zoomable, so the grid serves both jobs: fewer, larger images for // Zoomable, so the grid serves both jobs: fewer, larger images for
// judging one, and more, smaller ones for finding one. Driven from Rust so // judging one, and more, smaller ones for finding one. Driven from Rust so
// the value survives a scope change and the thumbnail class can follow it. // the value survives a scope change and the thumbnail class can follow it.
in property <length> cell-size: 180px; /// The size class the user chose. What is actually drawn is `cell-size`
/// below, which is this capped to what the grid can hold.
in property <length> requested-cell-size: 180px;
/// The drawn cell, never wider than the column it has to sit in.
///
/// `columns` floors to a minimum of one, so a cell larger than the grid
/// still produced a single column — of a cell that did not fit, running
/// off the right-hand edge with no way to scroll to it. That is reachable
/// from the size classes alone on a tablet in portrait: 768 logical pixels
/// wide, less the timeline and any open sidebar, is narrower than the
/// larger classes assume.
///
/// Capping here rather than clamping the size class keeps the user's
/// choice intact — rotate to landscape, or close the sidebar, and the
/// thumbnails grow back to the size that was asked for.
property <length> cell-size:
min(root.requested-cell-size, max(64px, self.width - 2 * Theme.gap));
property <int> columns: max(1, floor((self.width - Theme.gap) / (cell-size + Theme.gap))); property <int> columns: max(1, floor((self.width - Theme.gap) / (cell-size + Theme.gap)));
// Reported out so Rust can place month headings: a heading belongs on a // Reported out so Rust can place month headings: a heading belongs on a
// cell that begins a row, and only the grid knows how wide a row is. // cell that begins a row, and only the grid knows how wide a row is.