diff --git a/core/dr-pipeline/src/operation.rs b/core/dr-pipeline/src/operation.rs index 9ec1711..2684689 100644 --- a/core/dr-pipeline/src/operation.rs +++ b/core/dr-pipeline/src/operation.rs @@ -347,7 +347,34 @@ fn main(@builtin(global_invocation_id) gid: vec3) {{ // // 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. + // 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; + + // **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(max(c.r, max(c.g, c.b))); + c = mix(c, neutral, clipped); + }} {body} // Camera space -> linear sRGB. Applied after the adjustments so white // balance and exposure act on sensor-native values, which is where they diff --git a/ui/dr-ui/ui/app.slint b/ui/dr-ui/ui/app.slint index a12db25..9f7129a 100644 --- a/ui/dr-ui/ui/app.slint +++ b/ui/dr-ui/ui/app.slint @@ -1036,7 +1036,7 @@ in property panel-visible: true; timeline-pan(d) => { root.library-timeline-pan(d); } timeline-zoom(d) => { root.library-timeline-zoom(d); } 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); } scroll-to: root.library-scroll-to; scroll-token: root.library-scroll-token; diff --git a/ui/dr-ui/ui/library.slint b/ui/dr-ui/ui/library.slint index bb748bb..acf8125 100644 --- a/ui/dr-ui/ui/library.slint +++ b/ui/dr-ui/ui/library.slint @@ -926,7 +926,25 @@ export component LibraryGrid inherits Rectangle { // Zoomable, so the grid serves both jobs: fewer, larger images for // 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. - in property 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 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 cell-size: + min(root.requested-cell-size, max(64px, self.width - 2 * Theme.gap)); + property 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 // cell that begins a row, and only the grid knows how wide a row is.